From 968968bdbb5bd9b15450fcde4bb06220986cd8b2 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 6 Aug 2025 17:56:40 +0200 Subject: [PATCH 1/4] Auto-generate kid if not given --- Cargo.toml | 2 +- crates/config/src/sections/secrets.rs | 98 ++++++++++++++++++++++++--- crates/keystore/src/lib.rs | 24 ++++++- docs/config.schema.json | 4 +- docs/reference/configuration.md | 41 +++-------- 5 files changed, 124 insertions(+), 45 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 904dea39a..a4d56a376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -474,7 +474,7 @@ features = ["std"] # PKCS#8 encoding [workspace.dependencies.pkcs8] version = "0.10.2" -features = ["std", "pkcs5", "encryption"] +features = ["alloc", "std", "pkcs5", "encryption"] # Public Suffix List [workspace.dependencies.psl] diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 7886e9a57..6df4e63a2 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -132,7 +132,12 @@ impl From for KeyRaw { #[serde_as] #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] pub struct KeyConfig { - kid: String, + /// The key ID `kid` of the key as used by JWKs. + /// + /// If not given, `kid` will be derived from the key by hex-encoding the + /// first four bytes of the key’s fingerprint. + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option, #[schemars(with = "PasswordRaw")] #[serde_as(as = "serde_with::TryFromInto")] @@ -178,12 +183,24 @@ impl KeyConfig { None => PrivateKey::load(&key)?, }; + let kid = match self.kid.clone() { + Some(kid) => kid, + None => kid_from_key(&private_key)?, + }; + Ok(JsonWebKey::new(private_key) - .with_kid(self.kid.clone()) + .with_kid(kid) .with_use(mas_iana::jose::JsonWebKeyUse::Sig)) } } +/// Returns a kid derived from the given key. +fn kid_from_key(private_key: &PrivateKey) -> anyhow::Result { + let fingerprint = private_key.fingerprint()?; + let head = fingerprint.first_chunk::<4>().unwrap(); + Ok(hex::encode(head)) +} + /// Encryption config option. #[derive(Debug, Clone)] pub enum Encryption { @@ -322,7 +339,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let rsa_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: Some(Alphanumeric.sample_string(&mut rng, 10)), password: None, key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -338,7 +355,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_p256_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: Some(Alphanumeric.sample_string(&mut rng, 10)), password: None, key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -354,7 +371,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_p384_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: Some(Alphanumeric.sample_string(&mut rng, 10)), password: None, key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -370,7 +387,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_k256_key = KeyConfig { - kid: Alphanumeric.sample_string(&mut rng, 10), + kid: Some(Alphanumeric.sample_string(&mut rng, 10)), password: None, key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -383,7 +400,7 @@ impl SecretsConfig { pub(crate) fn test() -> Self { let rsa_key = KeyConfig { - kid: "abcdef".to_owned(), + kid: Some("abcdef".to_owned()), password: None, key: Key::Value( indoc::indoc! {r" @@ -402,7 +419,7 @@ impl SecretsConfig { ), }; let ecdsa_key = KeyConfig { - kid: "ghijkl".to_owned(), + kid: Some("ghijkl".to_owned()), password: None, key: Key::Value( indoc::indoc! {r" @@ -422,3 +439,68 @@ impl SecretsConfig { } } } + +#[cfg(test)] +mod tests { + use figment::{ + Figment, Jail, + providers::{Format, Yaml}, + }; + use mas_jose::constraints::Constrainable; + use tokio::{runtime::Handle, task}; + + use super::*; + + #[tokio::test] + async fn load_config_inline_secrets() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + indoc::indoc! {r" + secrets: + encryption: >- + 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff + keys: + - kid: lekid0 + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49 + AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl + fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw== + -----END EC PRIVATE KEY----- + - key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49 + AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9 + h27LAir5RqxByHvua2XsP46rSTChof78uw== + -----END EC PRIVATE KEY----- + "}, + )?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("secrets")?; + + Handle::current().block_on(async move { + assert_eq!( + config.encryption().await.unwrap(), + [ + 0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136, + 136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255, + 255 + ] + ); + + let key_store = config.key_store().await.unwrap(); + assert!(key_store.iter().any(|k| k.kid() == Some("lekid0"))); + assert!(key_store.iter().any(|k| k.kid() == Some("040b0ab8"))); + }); + + Ok(()) + }); + }) + .await + .unwrap(); + } +} diff --git a/crates/keystore/src/lib.rs b/crates/keystore/src/lib.rs index 66de683f0..02ca98d1a 100644 --- a/crates/keystore/src/lib.rs +++ b/crates/keystore/src/lib.rs @@ -9,7 +9,11 @@ use std::{ops::Deref, sync::Arc}; use der::{Decode, Encode, EncodePem, zeroize::Zeroizing}; -use elliptic_curve::{pkcs8::EncodePrivateKey, sec1::ToEncodedPoint}; +use elliptic_curve::{ + pkcs8::{EncodePrivateKey, EncodePublicKey}, + sec1::ToEncodedPoint, +}; +use k256::sha2::{Digest, Sha256}; use mas_iana::jose::{JsonWebKeyType, JsonWebSignatureAlg}; pub use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_jose::{ @@ -179,6 +183,24 @@ impl PrivateKey { } } + /// Returns the fingerprint of the private key. + /// + /// The fingerprint is calculated as the SHA256 sum over the PKCS#8 ASN.1 + /// DER-encoded bytes of the private key’s corresponding public key. + /// + /// # Errors + /// + /// Errors if the DER representation of the public key can’t be derived. + pub fn fingerprint(&self) -> Result<[u8; 32], pkcs8::Error> { + let bytes = match self { + PrivateKey::Rsa(key) => key.to_public_key().to_public_key_der()?, + PrivateKey::EcP256(key) => key.public_key().to_public_key_der()?, + PrivateKey::EcP384(key) => key.public_key().to_public_key_der()?, + PrivateKey::EcK256(key) => key.public_key().to_public_key_der()?, + }; + Ok(Sha256::digest(bytes).into()) + } + /// Serialize the key as a DER document /// /// It will use the most common format depending on the key type: PKCS1 for diff --git a/docs/config.schema.json b/docs/config.schema.json index 6c4fadfcd..fd111ced1 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1553,11 +1553,9 @@ "KeyConfig": { "description": "A single key with its key ID and optional password.", "type": "object", - "required": [ - "kid" - ], "properties": { "kid": { + "description": "The key ID `kid` of the key as used by JWKs.\n\nIf not given, `kid` will be derived from the key by hex-encoding the first four bytes of the key’s fingerprint.", "type": "string" }, "password_file": { diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a4e7dcaaf..94cb2c5e2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -197,35 +197,7 @@ secrets: # Signing keys keys: # It needs at least an RSA key to work properly - - kid: "ahM2bien" - key: | - -----BEGIN RSA PRIVATE KEY----- - MIIEowIBAAKCAQEAuf28zPUp574jDRdX6uN0d7niZCIUpACFo+Po/13FuIGsrpze - yMX6CYWVPalgXW9FCrhxL+4toJRy5npjkgsLFsknL5/zXbWKFgt69cMwsWJ9Ra57 - bonSlI7SoCuHhtw7j+sAlHAlqTOCAVz6P039Y/AGvO6xbC7f+9XftWlbbDcjKFcb - pQilkN9qtkdEH7TLayMAFOsgNvBlwF9+oj9w5PIk3veRTdBXI4GlHjhhzqGZKiRp - oP9HnycHHveyT+C33vuhQso5a3wcUNuvDVOixSqR4kvSt4UVWNK/KmEQmlWU1/m9 - ClIwrs8Q79q0xkGaSa0iuG60nvm7tZez9TFkxwIDAQABAoIBAHA5YkppQ7fJSm0D - wNDCHeyABNJWng23IuwZAOXVNxB1bjSOAv8yNgS4zaw/Hx5BnW8yi1lYZb+W0x2u - i5X7g91j0nkyEi5g88kJdFAGTsM5ok0BUwkHsEBjTUPIACanjGjya48lfBP0OGWK - LJU2Acbjda1aeUPFpPDXw/w6bieEthQwroq3DHCMnk6i9bsxgIOXeN04ij9XBmsH - KPCP2hAUnZSlx5febYfHK7/W95aJp22qa//eHS8cKQZCJ0+dQuZwLhlGosTFqLUm - qhPlt/b1EvPPY0cq5rtUc2W31L0YayVEHVOQx1fQIkH2VIUNbAS+bfVy+o6WCRk6 - s1XDhsECgYEA30tykVTN5LncY4eQIww2mW8v1j1EG6ngVShN3GuBTuXXaEOB8Duc - yT7yJt1ZhmaJwMk4agmZ1/f/ZXBtfLREGVzVvuwqRZ+LHbqIyhi0wQJA0aezPote - uTQnFn+IveHGtpQNDYGL/UgkexuCxbc2HOZG51JpunCK0TdtVfO/9OUCgYEA1TuS - 2WAXzNudRG3xd/4OgtkLD9AvfSvyjw2LkwqCMb3A5UEqw7vubk/xgnRvqrAgJRWo - jndgRrRnikHCavDHBO0GAO/kzrFRfw+e+r4jcLl0Yadke8ndCc7VTnx4wQCrMi5H - 7HEeRwaZONoj5PAPyA5X+N/gT0NNDA7KoQT45DsCgYBt+QWa6A5jaNpPNpPZfwlg - 9e60cAYcLcUri6cVOOk9h1tYoW7cdy+XueWfGIMf+1460Z90MfhP8ncZaY6yzUGA - 0EUBO+Tx10q3wIfgKNzU9hwgZZyU4CUtx668mOEqy4iHoVDwZu4gNyiobPsyDzKa - dxtSkDc8OHNV6RtzKpJOtQKBgFoRGcwbnLH5KYqX7eDDPRnj15pMU2LJx2DJVeU8 - ERY1kl7Dke6vWNzbg6WYzPoJ/unrJhFXNyFmXj213QsSvN3FyD1pFvp/R28mB/7d - hVa93vzImdb3wxe7d7n5NYBAag9+IP8sIJ/bl6i9619uTxwvgtUqqzKPuOGY9dnh - oce1AoGBAKZyZc/NVgqV2KgAnnYlcwNn7sRSkM8dcq0/gBMNuSZkfZSuEd4wwUzR - iFlYp23O2nHWggTkzimuBPtD7Kq4jBey3ZkyGye+sAdmnKkOjNILNbpIZlT6gK3z - fBaFmJGRJinKA+BJeH79WFpYN6SBZ/c3s5BusAbEU7kE5eInyazP - -----END RSA PRIVATE KEY----- + - key_file: keys/rsa_key - kid: "iv1aShae" key: | -----BEGIN EC PRIVATE KEY----- @@ -260,9 +232,7 @@ The following key types are supported: - ECDSA with the P-384 (`secp384r1`) curve - ECDSA with the K-256 (`secp256k1`) curve -Each entry must have a unique `kid`, plus the key itself. -The `kid` can be any case-sensitive string value as long as it is unique to this list; -a key’s `kid` value must be stable across restarts. +Each entry in the list corresponds to one signing key used by MAS. The key can either be specified inline (with the `key` property), or loaded from a file (with the `key_file` property). The following key formats are supported: @@ -271,8 +241,15 @@ The following key formats are supported: - PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not - SEC1 PEM or DER-encoded ECDSA private key +A [JWK Key ID] is automatically derived from each key. +To override this default, set `kid` to a custom value. +The `kid` can be any case-sensitive string value as long as it is unique to this list; +a key’s `kid` value must be stable across restarts. + For PKCS#8 encoded keys, the `password` or `password_file` properties can be used to decrypt the key. +[JWK Key ID]: + ## `passwords` Settings related to the local password database From 3f816ba9c41c2dd18502eb55adb412d05947c533 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Fri, 8 Aug 2025 12:04:56 +0200 Subject: [PATCH 2/4] Improve clarity of return type --- crates/keystore/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/keystore/src/lib.rs b/crates/keystore/src/lib.rs index 02ca98d1a..29678a8c3 100644 --- a/crates/keystore/src/lib.rs +++ b/crates/keystore/src/lib.rs @@ -191,7 +191,7 @@ impl PrivateKey { /// # Errors /// /// Errors if the DER representation of the public key can’t be derived. - pub fn fingerprint(&self) -> Result<[u8; 32], pkcs8::Error> { + pub fn fingerprint(&self) -> pkcs8::spki::Result<[u8; 32]> { let bytes = match self { PrivateKey::Rsa(key) => key.to_public_key().to_public_key_der()?, PrivateKey::EcP256(key) => key.public_key().to_public_key_der()?, From 7f7f9918b04274a9707e4656f7eaf2bafbc95f95 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 20 Aug 2025 18:04:39 +0200 Subject: [PATCH 3/4] Remove kid from test config and config generation --- crates/config/src/sections/secrets.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 6df4e63a2..57f4facf5 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -339,7 +339,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let rsa_key = KeyConfig { - kid: Some(Alphanumeric.sample_string(&mut rng, 10)), + kid: None, password: None, key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -355,7 +355,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_p256_key = KeyConfig { - kid: Some(Alphanumeric.sample_string(&mut rng, 10)), + kid: None, password: None, key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -371,7 +371,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_p384_key = KeyConfig { - kid: Some(Alphanumeric.sample_string(&mut rng, 10)), + kid: None, password: None, key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -387,7 +387,7 @@ impl SecretsConfig { .await .context("could not join blocking task")?; let ec_k256_key = KeyConfig { - kid: Some(Alphanumeric.sample_string(&mut rng, 10)), + kid: None, password: None, key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; @@ -400,7 +400,7 @@ impl SecretsConfig { pub(crate) fn test() -> Self { let rsa_key = KeyConfig { - kid: Some("abcdef".to_owned()), + kid: None, password: None, key: Key::Value( indoc::indoc! {r" @@ -419,7 +419,7 @@ impl SecretsConfig { ), }; let ecdsa_key = KeyConfig { - kid: Some("ghijkl".to_owned()), + kid: None, password: None, key: Key::Value( indoc::indoc! {r" From 897d6de5725f59730e5163162054b2d7d38a04a5 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 20 Aug 2025 18:07:52 +0200 Subject: [PATCH 4/4] Switch to JWK Thumbprints --- Cargo.toml | 2 +- crates/config/src/sections/secrets.rs | 22 +++--------- crates/jose/src/jwk/mod.rs | 23 ++++++++++++ crates/jose/src/jwk/public_parameters.rs | 46 +++++++++++++++++++++++- crates/keystore/src/lib.rs | 32 +++++------------ docs/config.schema.json | 2 +- 6 files changed, 83 insertions(+), 44 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a4d56a376..904dea39a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -474,7 +474,7 @@ features = ["std"] # PKCS#8 encoding [workspace.dependencies.pkcs8] version = "0.10.2" -features = ["alloc", "std", "pkcs5", "encryption"] +features = ["std", "pkcs5", "encryption"] # Public Suffix List [workspace.dependencies.psl] diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 57f4facf5..1e0dd621e 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -9,13 +9,9 @@ use std::borrow::Cow; use anyhow::{Context, bail}; use camino::Utf8PathBuf; use futures_util::future::{try_join, try_join_all}; -use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; +use mas_jose::jwk::{JsonWebKey, JsonWebKeySet, Thumbprint}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; -use rand::{ - Rng, SeedableRng, - distributions::{Alphanumeric, DistString, Standard}, - prelude::Distribution as _, -}; +use rand::{Rng, SeedableRng, distributions::Standard, prelude::Distribution as _}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_with::serde_as; @@ -134,8 +130,7 @@ impl From for KeyRaw { pub struct KeyConfig { /// The key ID `kid` of the key as used by JWKs. /// - /// If not given, `kid` will be derived from the key by hex-encoding the - /// first four bytes of the key’s fingerprint. + /// If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint. #[serde(skip_serializing_if = "Option::is_none")] kid: Option, @@ -185,7 +180,7 @@ impl KeyConfig { let kid = match self.kid.clone() { Some(kid) => kid, - None => kid_from_key(&private_key)?, + None => private_key.thumbprint_sha256_base64(), }; Ok(JsonWebKey::new(private_key) @@ -194,13 +189,6 @@ impl KeyConfig { } } -/// Returns a kid derived from the given key. -fn kid_from_key(private_key: &PrivateKey) -> anyhow::Result { - let fingerprint = private_key.fingerprint()?; - let head = fingerprint.first_chunk::<4>().unwrap(); - Ok(hex::encode(head)) -} - /// Encryption config option. #[derive(Debug, Clone)] pub enum Encryption { @@ -494,7 +482,7 @@ mod tests { let key_store = config.key_store().await.unwrap(); assert!(key_store.iter().any(|k| k.kid() == Some("lekid0"))); - assert!(key_store.iter().any(|k| k.kid() == Some("040b0ab8"))); + assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o"))); }); Ok(()) diff --git a/crates/jose/src/jwk/mod.rs b/crates/jose/src/jwk/mod.rs index 98d40fa7a..f794620ba 100644 --- a/crates/jose/src/jwk/mod.rs +++ b/crates/jose/src/jwk/mod.rs @@ -13,6 +13,7 @@ use mas_iana::jose::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; +use sha2::{Digest, Sha256}; use url::Url; use crate::{ @@ -239,6 +240,28 @@ impl

JsonWebKey

{ } } +/// Methods to calculate RFC 7638 JWK Thumbprints. +pub trait Thumbprint { + /// Returns the RFC 7638 JWK Thumbprint JSON string. + fn thumbprint_prehashed(&self) -> String; + + /// Returns the RFC 7638 SHA256-hashed JWK Thumbprint. + fn thumbprint_sha256(&self) -> [u8; 32] { + Sha256::digest(self.thumbprint_prehashed()).into() + } + + /// Returns the RFC 7638 SHA256-hashed JWK Thumbprint as base64url string. + fn thumbprint_sha256_base64(&self) -> String { + Base64UrlNoPad::new(self.thumbprint_sha256().into()).encode() + } +} + +impl Thumbprint for JsonWebKey

{ + fn thumbprint_prehashed(&self) -> String { + self.parameters.thumbprint_prehashed() + } +} + impl

Constrainable for JsonWebKey

where P: ParametersInfo, diff --git a/crates/jose/src/jwk/public_parameters.rs b/crates/jose/src/jwk/public_parameters.rs index 1bc29604d..f4b57c532 100644 --- a/crates/jose/src/jwk/public_parameters.rs +++ b/crates/jose/src/jwk/public_parameters.rs @@ -11,7 +11,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::ParametersInfo; -use crate::base64::Base64UrlNoPad; +use crate::{base64::Base64UrlNoPad, jwk::Thumbprint}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kty")] @@ -52,6 +52,22 @@ impl JsonWebKeyPublicParameters { } } +impl Thumbprint for JsonWebKeyPublicParameters { + fn thumbprint_prehashed(&self) -> String { + match self { + JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e }) => { + format!("{{\"e\":\"{e}\",\"kty\":\"RSA\",\"n\":\"{n}\"}}") + } + JsonWebKeyPublicParameters::Ec(EcPublicParameters { crv, x, y }) => { + format!("{{\"crv\":\"{crv}\",\"kty\":\"EC\",\"x\":\"{x}\",\"y\":\"{y}\"}}") + } + JsonWebKeyPublicParameters::Okp(OkpPublicParameters { crv, x }) => { + format!("{{\"crv\":\"{crv}\",\"kty\":\"OKP\",\"x\":\"{x}\"}}") + } + } + } +} + impl ParametersInfo for JsonWebKeyPublicParameters { fn kty(&self) -> JsonWebKeyType { match self { @@ -300,3 +316,31 @@ mod ec_impls { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_thumbprint_rfc_example() { + // From https://www.rfc-editor.org/rfc/rfc7638.html#section-3.1 + let n = Base64UrlNoPad::parse( + "\ + 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt\ + VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6\ + 4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD\ + W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9\ + 1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH\ + aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + ) + .unwrap(); + let e = Base64UrlNoPad::parse("AQAB").unwrap(); + + let jwkpps = JsonWebKeyPublicParameters::Rsa(RsaPublicParameters { n, e }); + + assert_eq!( + jwkpps.thumbprint_sha256_base64(), + "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" + ); + } +} diff --git a/crates/keystore/src/lib.rs b/crates/keystore/src/lib.rs index 29678a8c3..fa9d305bb 100644 --- a/crates/keystore/src/lib.rs +++ b/crates/keystore/src/lib.rs @@ -9,16 +9,12 @@ use std::{ops::Deref, sync::Arc}; use der::{Decode, Encode, EncodePem, zeroize::Zeroizing}; -use elliptic_curve::{ - pkcs8::{EncodePrivateKey, EncodePublicKey}, - sec1::ToEncodedPoint, -}; -use k256::sha2::{Digest, Sha256}; +use elliptic_curve::{pkcs8::EncodePrivateKey, sec1::ToEncodedPoint}; use mas_iana::jose::{JsonWebKeyType, JsonWebSignatureAlg}; pub use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_jose::{ jwa::{AsymmetricSigningKey, AsymmetricVerifyingKey}, - jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet}, + jwk::{JsonWebKeyPublicParameters, ParametersInfo, PublicJsonWebKeySet, Thumbprint}, }; use pem_rfc7468::PemLabel; use pkcs1::EncodeRsaPrivateKey; @@ -183,24 +179,6 @@ impl PrivateKey { } } - /// Returns the fingerprint of the private key. - /// - /// The fingerprint is calculated as the SHA256 sum over the PKCS#8 ASN.1 - /// DER-encoded bytes of the private key’s corresponding public key. - /// - /// # Errors - /// - /// Errors if the DER representation of the public key can’t be derived. - pub fn fingerprint(&self) -> pkcs8::spki::Result<[u8; 32]> { - let bytes = match self { - PrivateKey::Rsa(key) => key.to_public_key().to_public_key_der()?, - PrivateKey::EcP256(key) => key.public_key().to_public_key_der()?, - PrivateKey::EcP384(key) => key.public_key().to_public_key_der()?, - PrivateKey::EcK256(key) => key.public_key().to_public_key_der()?, - }; - Ok(Sha256::digest(bytes).into()) - } - /// Serialize the key as a DER document /// /// It will use the most common format depending on the key type: PKCS1 for @@ -621,6 +599,12 @@ impl ParametersInfo for PrivateKey { } } +impl Thumbprint for PrivateKey { + fn thumbprint_prehashed(&self) -> String { + JsonWebKeyPublicParameters::from(self).thumbprint_prehashed() + } +} + /// A structure to store a list of [`PrivateKey`]. The keys are held in an /// [`Arc`] to ensure they are only loaded once in memory and allow cheap /// cloning diff --git a/docs/config.schema.json b/docs/config.schema.json index fd111ced1..a9437f6d9 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1555,7 +1555,7 @@ "type": "object", "properties": { "kid": { - "description": "The key ID `kid` of the key as used by JWKs.\n\nIf not given, `kid` will be derived from the key by hex-encoding the first four bytes of the key’s fingerprint.", + "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.", "type": "string" }, "password_file": {