Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 84 additions & 14 deletions crates/config/src/sections/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,7 +128,11 @@ impl From<Key> 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 the key’s RFC 7638 JWK Thumbprint.
#[serde(skip_serializing_if = "Option::is_none")]
kid: Option<String>,

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

let kid = match self.kid.clone() {
Some(kid) => kid,
None => private_key.thumbprint_sha256_base64(),
};

Ok(JsonWebKey::new(private_key)
.with_kid(self.kid.clone())
.with_kid(kid)
.with_use(mas_iana::jose::JsonWebKeyUse::Sig))
}
}
Expand Down Expand Up @@ -322,7 +327,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let rsa_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
Expand All @@ -338,7 +343,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let ec_p256_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
Expand All @@ -354,7 +359,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let ec_p384_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
Expand All @@ -370,7 +375,7 @@ impl SecretsConfig {
.await
.context("could not join blocking task")?;
let ec_k256_key = KeyConfig {
kid: Alphanumeric.sample_string(&mut rng, 10),
kid: None,
password: None,
key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};
Expand All @@ -383,7 +388,7 @@ impl SecretsConfig {

pub(crate) fn test() -> Self {
let rsa_key = KeyConfig {
kid: "abcdef".to_owned(),
kid: None,
password: None,
key: Key::Value(
indoc::indoc! {r"
Expand All @@ -402,7 +407,7 @@ impl SecretsConfig {
),
};
let ecdsa_key = KeyConfig {
kid: "ghijkl".to_owned(),
kid: None,
password: None,
key: Key::Value(
indoc::indoc! {r"
Expand All @@ -422,3 +427,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::<SecretsConfig>("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("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
});

Ok(())
});
})
.await
.unwrap();
}
}
23 changes: 23 additions & 0 deletions crates/jose/src/jwk/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -239,6 +240,28 @@ impl<P> JsonWebKey<P> {
}
}

/// 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<P: Thumbprint> Thumbprint for JsonWebKey<P> {
fn thumbprint_prehashed(&self) -> String {
self.parameters.thumbprint_prehashed()
}
}

impl<P> Constrainable for JsonWebKey<P>
where
P: ParametersInfo,
Expand Down
46 changes: 45 additions & 1 deletion crates/jose/src/jwk/public_parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
);
}
}
8 changes: 7 additions & 1 deletion crates/keystore/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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;
Expand Down Expand Up @@ -599,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
Expand Down
4 changes: 1 addition & 3 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 the key’s RFC 7638 JWK Thumbprint.",
"type": "string"
},
"password_file": {
Expand Down
41 changes: 9 additions & 32 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-----
Expand Down Expand Up @@ -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:
Expand All @@ -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]: <https://datatracker.ietf.org/doc/html/rfc7517#section-4.5>

## `passwords`

Settings related to the local password database
Expand Down
Loading