Skip to content

Commit 6eac7ed

Browse files
authored
Automatically derive the kid from the key fingerprint if missing (#4876)
2 parents dd714ba + a65c9af commit 6eac7ed

File tree

6 files changed

+169
-51
lines changed

6 files changed

+169
-51
lines changed

crates/config/src/sections/secrets.rs

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,9 @@ use std::borrow::Cow;
99
use anyhow::{Context, bail};
1010
use camino::Utf8PathBuf;
1111
use futures_util::future::{try_join, try_join_all};
12-
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
12+
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet, Thumbprint};
1313
use mas_keystore::{Encrypter, Keystore, PrivateKey};
14-
use rand::{
15-
Rng, SeedableRng,
16-
distributions::{Alphanumeric, DistString, Standard},
17-
prelude::Distribution as _,
18-
};
14+
use rand::{Rng, SeedableRng, distributions::Standard, prelude::Distribution as _};
1915
use schemars::JsonSchema;
2016
use serde::{Deserialize, Serialize};
2117
use serde_with::serde_as;
@@ -132,7 +128,11 @@ impl From<Key> for KeyRaw {
132128
#[serde_as]
133129
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
134130
pub struct KeyConfig {
135-
kid: String,
131+
/// The key ID `kid` of the key as used by JWKs.
132+
///
133+
/// If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint.
134+
#[serde(skip_serializing_if = "Option::is_none")]
135+
kid: Option<String>,
136136

137137
#[schemars(with = "PasswordRaw")]
138138
#[serde_as(as = "serde_with::TryFromInto<PasswordRaw>")]
@@ -178,8 +178,13 @@ impl KeyConfig {
178178
None => PrivateKey::load(&key)?,
179179
};
180180

181+
let kid = match self.kid.clone() {
182+
Some(kid) => kid,
183+
None => private_key.thumbprint_sha256_base64(),
184+
};
185+
181186
Ok(JsonWebKey::new(private_key)
182-
.with_kid(self.kid.clone())
187+
.with_kid(kid)
183188
.with_use(mas_iana::jose::JsonWebKeyUse::Sig))
184189
}
185190
}
@@ -322,7 +327,7 @@ impl SecretsConfig {
322327
.await
323328
.context("could not join blocking task")?;
324329
let rsa_key = KeyConfig {
325-
kid: Alphanumeric.sample_string(&mut rng, 10),
330+
kid: None,
326331
password: None,
327332
key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
328333
};
@@ -338,7 +343,7 @@ impl SecretsConfig {
338343
.await
339344
.context("could not join blocking task")?;
340345
let ec_p256_key = KeyConfig {
341-
kid: Alphanumeric.sample_string(&mut rng, 10),
346+
kid: None,
342347
password: None,
343348
key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
344349
};
@@ -354,7 +359,7 @@ impl SecretsConfig {
354359
.await
355360
.context("could not join blocking task")?;
356361
let ec_p384_key = KeyConfig {
357-
kid: Alphanumeric.sample_string(&mut rng, 10),
362+
kid: None,
358363
password: None,
359364
key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
360365
};
@@ -370,7 +375,7 @@ impl SecretsConfig {
370375
.await
371376
.context("could not join blocking task")?;
372377
let ec_k256_key = KeyConfig {
373-
kid: Alphanumeric.sample_string(&mut rng, 10),
378+
kid: None,
374379
password: None,
375380
key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
376381
};
@@ -383,7 +388,7 @@ impl SecretsConfig {
383388

384389
pub(crate) fn test() -> Self {
385390
let rsa_key = KeyConfig {
386-
kid: "abcdef".to_owned(),
391+
kid: None,
387392
password: None,
388393
key: Key::Value(
389394
indoc::indoc! {r"
@@ -402,7 +407,7 @@ impl SecretsConfig {
402407
),
403408
};
404409
let ecdsa_key = KeyConfig {
405-
kid: "ghijkl".to_owned(),
410+
kid: None,
406411
password: None,
407412
key: Key::Value(
408413
indoc::indoc! {r"
@@ -422,3 +427,68 @@ impl SecretsConfig {
422427
}
423428
}
424429
}
430+
431+
#[cfg(test)]
432+
mod tests {
433+
use figment::{
434+
Figment, Jail,
435+
providers::{Format, Yaml},
436+
};
437+
use mas_jose::constraints::Constrainable;
438+
use tokio::{runtime::Handle, task};
439+
440+
use super::*;
441+
442+
#[tokio::test]
443+
async fn load_config_inline_secrets() {
444+
task::spawn_blocking(|| {
445+
Jail::expect_with(|jail| {
446+
jail.create_file(
447+
"config.yaml",
448+
indoc::indoc! {r"
449+
secrets:
450+
encryption: >-
451+
0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
452+
keys:
453+
- kid: lekid0
454+
key: |
455+
-----BEGIN EC PRIVATE KEY-----
456+
MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
457+
AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
458+
fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
459+
-----END EC PRIVATE KEY-----
460+
- key: |
461+
-----BEGIN EC PRIVATE KEY-----
462+
MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
463+
AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
464+
h27LAir5RqxByHvua2XsP46rSTChof78uw==
465+
-----END EC PRIVATE KEY-----
466+
"},
467+
)?;
468+
469+
let config = Figment::new()
470+
.merge(Yaml::file("config.yaml"))
471+
.extract_inner::<SecretsConfig>("secrets")?;
472+
473+
Handle::current().block_on(async move {
474+
assert_eq!(
475+
config.encryption().await.unwrap(),
476+
[
477+
0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
478+
136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
479+
255
480+
]
481+
);
482+
483+
let key_store = config.key_store().await.unwrap();
484+
assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
485+
assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
486+
});
487+
488+
Ok(())
489+
});
490+
})
491+
.await
492+
.unwrap();
493+
}
494+
}

crates/jose/src/jwk/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use mas_iana::jose::{
1313
use schemars::JsonSchema;
1414
use serde::{Deserialize, Serialize};
1515
use serde_with::skip_serializing_none;
16+
use sha2::{Digest, Sha256};
1617
use url::Url;
1718

1819
use crate::{
@@ -239,6 +240,28 @@ impl<P> JsonWebKey<P> {
239240
}
240241
}
241242

243+
/// Methods to calculate RFC 7638 JWK Thumbprints.
244+
pub trait Thumbprint {
245+
/// Returns the RFC 7638 JWK Thumbprint JSON string.
246+
fn thumbprint_prehashed(&self) -> String;
247+
248+
/// Returns the RFC 7638 SHA256-hashed JWK Thumbprint.
249+
fn thumbprint_sha256(&self) -> [u8; 32] {
250+
Sha256::digest(self.thumbprint_prehashed()).into()
251+
}
252+
253+
/// Returns the RFC 7638 SHA256-hashed JWK Thumbprint as base64url string.
254+
fn thumbprint_sha256_base64(&self) -> String {
255+
Base64UrlNoPad::new(self.thumbprint_sha256().into()).encode()
256+
}
257+
}
258+
259+
impl<P: Thumbprint> Thumbprint for JsonWebKey<P> {
260+
fn thumbprint_prehashed(&self) -> String {
261+
self.parameters.thumbprint_prehashed()
262+
}
263+
}
264+
242265
impl<P> Constrainable for JsonWebKey<P>
243266
where
244267
P: ParametersInfo,

crates/jose/src/jwk/public_parameters.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use schemars::JsonSchema;
1111
use serde::{Deserialize, Serialize};
1212

1313
use super::ParametersInfo;
14-
use crate::base64::Base64UrlNoPad;
14+
use crate::{base64::Base64UrlNoPad, jwk::Thumbprint};
1515

1616
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1717
#[serde(tag = "kty")]
@@ -52,6 +52,22 @@ impl JsonWebKeyPublicParameters {
5252
}
5353
}
5454

55+
impl Thumbprint for JsonWebKeyPublicParameters {
56+
fn thumbprint_prehashed(&self) -> String {
57+
match self {
58+
JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e }) => {
59+
format!("{{\"e\":\"{e}\",\"kty\":\"RSA\",\"n\":\"{n}\"}}")
60+
}
61+
JsonWebKeyPublicParameters::Ec(EcPublicParameters { crv, x, y }) => {
62+
format!("{{\"crv\":\"{crv}\",\"kty\":\"EC\",\"x\":\"{x}\",\"y\":\"{y}\"}}")
63+
}
64+
JsonWebKeyPublicParameters::Okp(OkpPublicParameters { crv, x }) => {
65+
format!("{{\"crv\":\"{crv}\",\"kty\":\"OKP\",\"x\":\"{x}\"}}")
66+
}
67+
}
68+
}
69+
}
70+
5571
impl ParametersInfo for JsonWebKeyPublicParameters {
5672
fn kty(&self) -> JsonWebKeyType {
5773
match self {
@@ -300,3 +316,31 @@ mod ec_impls {
300316
}
301317
}
302318
}
319+
320+
#[cfg(test)]
321+
mod tests {
322+
use super::*;
323+
324+
#[test]
325+
fn test_thumbprint_rfc_example() {
326+
// From https://www.rfc-editor.org/rfc/rfc7638.html#section-3.1
327+
let n = Base64UrlNoPad::parse(
328+
"\
329+
0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt\
330+
VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6\
331+
4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD\
332+
W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9\
333+
1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH\
334+
aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
335+
)
336+
.unwrap();
337+
let e = Base64UrlNoPad::parse("AQAB").unwrap();
338+
339+
let jwkpps = JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e });
340+
341+
assert_eq!(
342+
jwkpps.thumbprint_sha256_base64(),
343+
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
344+
);
345+
}
346+
}

crates/keystore/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use mas_iana::jose::{JsonWebKeyType, JsonWebSignatureAlg};
1414
pub use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
1515
use mas_jose::{
1616
jwa::{AsymmetricSigningKey, AsymmetricVerifyingKey},
17-
jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet},
17+
jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet, Thumbprint},
1818
};
1919
use pem_rfc7468::PemLabel;
2020
use pkcs1::EncodeRsaPrivateKey;
@@ -599,6 +599,12 @@ impl ParametersInfo for PrivateKey {
599599
}
600600
}
601601

602+
impl Thumbprint for PrivateKey {
603+
fn thumbprint_prehashed(&self) -> String {
604+
JsonWebKeyPublicParameters::from(self).thumbprint_prehashed()
605+
}
606+
}
607+
602608
/// A structure to store a list of [`PrivateKey`]. The keys are held in an
603609
/// [`Arc`] to ensure they are only loaded once in memory and allow cheap
604610
/// cloning

docs/config.schema.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,11 +1553,9 @@
15531553
"KeyConfig": {
15541554
"description": "A single key with its key ID and optional password.",
15551555
"type": "object",
1556-
"required": [
1557-
"kid"
1558-
],
15591556
"properties": {
15601557
"kid": {
1558+
"description": "The key ID `kid` of the key as used by JWKs.\n\nIf not given, `kid` will be the key’s RFC 7638 JWK Thumbprint.",
15611559
"type": "string"
15621560
},
15631561
"password_file": {

docs/reference/configuration.md

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -197,35 +197,7 @@ secrets:
197197
# Signing keys
198198
keys:
199199
# It needs at least an RSA key to work properly
200-
- kid: "ahM2bien"
201-
key: |
202-
-----BEGIN RSA PRIVATE KEY-----
203-
MIIEowIBAAKCAQEAuf28zPUp574jDRdX6uN0d7niZCIUpACFo+Po/13FuIGsrpze
204-
yMX6CYWVPalgXW9FCrhxL+4toJRy5npjkgsLFsknL5/zXbWKFgt69cMwsWJ9Ra57
205-
bonSlI7SoCuHhtw7j+sAlHAlqTOCAVz6P039Y/AGvO6xbC7f+9XftWlbbDcjKFcb
206-
pQilkN9qtkdEH7TLayMAFOsgNvBlwF9+oj9w5PIk3veRTdBXI4GlHjhhzqGZKiRp
207-
oP9HnycHHveyT+C33vuhQso5a3wcUNuvDVOixSqR4kvSt4UVWNK/KmEQmlWU1/m9
208-
ClIwrs8Q79q0xkGaSa0iuG60nvm7tZez9TFkxwIDAQABAoIBAHA5YkppQ7fJSm0D
209-
wNDCHeyABNJWng23IuwZAOXVNxB1bjSOAv8yNgS4zaw/Hx5BnW8yi1lYZb+W0x2u
210-
i5X7g91j0nkyEi5g88kJdFAGTsM5ok0BUwkHsEBjTUPIACanjGjya48lfBP0OGWK
211-
LJU2Acbjda1aeUPFpPDXw/w6bieEthQwroq3DHCMnk6i9bsxgIOXeN04ij9XBmsH
212-
KPCP2hAUnZSlx5febYfHK7/W95aJp22qa//eHS8cKQZCJ0+dQuZwLhlGosTFqLUm
213-
qhPlt/b1EvPPY0cq5rtUc2W31L0YayVEHVOQx1fQIkH2VIUNbAS+bfVy+o6WCRk6
214-
s1XDhsECgYEA30tykVTN5LncY4eQIww2mW8v1j1EG6ngVShN3GuBTuXXaEOB8Duc
215-
yT7yJt1ZhmaJwMk4agmZ1/f/ZXBtfLREGVzVvuwqRZ+LHbqIyhi0wQJA0aezPote
216-
uTQnFn+IveHGtpQNDYGL/UgkexuCxbc2HOZG51JpunCK0TdtVfO/9OUCgYEA1TuS
217-
2WAXzNudRG3xd/4OgtkLD9AvfSvyjw2LkwqCMb3A5UEqw7vubk/xgnRvqrAgJRWo
218-
jndgRrRnikHCavDHBO0GAO/kzrFRfw+e+r4jcLl0Yadke8ndCc7VTnx4wQCrMi5H
219-
7HEeRwaZONoj5PAPyA5X+N/gT0NNDA7KoQT45DsCgYBt+QWa6A5jaNpPNpPZfwlg
220-
9e60cAYcLcUri6cVOOk9h1tYoW7cdy+XueWfGIMf+1460Z90MfhP8ncZaY6yzUGA
221-
0EUBO+Tx10q3wIfgKNzU9hwgZZyU4CUtx668mOEqy4iHoVDwZu4gNyiobPsyDzKa
222-
dxtSkDc8OHNV6RtzKpJOtQKBgFoRGcwbnLH5KYqX7eDDPRnj15pMU2LJx2DJVeU8
223-
ERY1kl7Dke6vWNzbg6WYzPoJ/unrJhFXNyFmXj213QsSvN3FyD1pFvp/R28mB/7d
224-
hVa93vzImdb3wxe7d7n5NYBAag9+IP8sIJ/bl6i9619uTxwvgtUqqzKPuOGY9dnh
225-
oce1AoGBAKZyZc/NVgqV2KgAnnYlcwNn7sRSkM8dcq0/gBMNuSZkfZSuEd4wwUzR
226-
iFlYp23O2nHWggTkzimuBPtD7Kq4jBey3ZkyGye+sAdmnKkOjNILNbpIZlT6gK3z
227-
fBaFmJGRJinKA+BJeH79WFpYN6SBZ/c3s5BusAbEU7kE5eInyazP
228-
-----END RSA PRIVATE KEY-----
200+
- key_file: keys/rsa_key
229201
- kid: "iv1aShae"
230202
key: |
231203
-----BEGIN EC PRIVATE KEY-----
@@ -260,9 +232,7 @@ The following key types are supported:
260232
- ECDSA with the P-384 (`secp384r1`) curve
261233
- ECDSA with the K-256 (`secp256k1`) curve
262234

263-
Each entry must have a unique `kid`, plus the key itself.
264-
The `kid` can be any case-sensitive string value as long as it is unique to this list;
265-
a key’s `kid` value must be stable across restarts.
235+
Each entry in the list corresponds to one signing key used by MAS.
266236
The key can either be specified inline (with the `key` property),
267237
or loaded from a file (with the `key_file` property).
268238
The following key formats are supported:
@@ -271,8 +241,15 @@ The following key formats are supported:
271241
- PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not
272242
- SEC1 PEM or DER-encoded ECDSA private key
273243

244+
A [JWK Key ID] is automatically derived from each key.
245+
To override this default, set `kid` to a custom value.
246+
The `kid` can be any case-sensitive string value as long as it is unique to this list;
247+
a key’s `kid` value must be stable across restarts.
248+
274249
For PKCS#8 encoded keys, the `password` or `password_file` properties can be used to decrypt the key.
275250

251+
[JWK Key ID]: <https://datatracker.ietf.org/doc/html/rfc7517#section-4.5>
252+
276253
## `passwords`
277254

278255
Settings related to the local password database

0 commit comments

Comments
 (0)