From 71a0e035fc816091e20f13ac14cde36e161af1ce Mon Sep 17 00:00:00 2001 From: Joonas Bergius Date: Thu, 6 Jun 2024 18:38:52 -0500 Subject: [PATCH] feat: Add support for converting KeyPairs into JWKs Signed-off-by: Joonas Bergius --- Cargo.toml | 6 ++ src/error.rs | 3 + src/jwk.rs | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 76 +++++++++++++++++--------- 4 files changed, 211 insertions(+), 25 deletions(-) create mode 100644 src/jwk.rs diff --git a/Cargo.toml b/Cargo.toml index eb78817..8125514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ cli = [ xkeys = ["dep:crypto_box"] +jwk = ["serde", "serde_json", "sha2"] + [[bin]] name = "nk" required-features = ["cli"] @@ -47,6 +49,10 @@ exitfailure = { version = "0.5.1", optional = true } env_logger = { version = "0.9", optional = true } serde_json = { version = "1.0", optional = true } +# jwk dependencies +serde = { version = "1.0", optional = true, features = ["derive"] } +sha2 = { version = "0.10", optional = true } + [target.'cfg(target_arch = "wasm32")'.dependencies] # NOTE: We need this due to an underlying dependency being pulled in by # `ed25519-dalek`. Even if we exclude `rand`, that crate pulls it in. `rand` pulls in the low level diff --git a/src/error.rs b/src/error.rs index 974c3ca..e8445ab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -42,6 +42,8 @@ pub enum ErrorKind { IncorrectKeyType, /// Payload not valid (or failed to be decrypted) InvalidPayload, + /// Thumbprint could not be calculated over the provided public key value + ThumbprintCalculationFailure, } /// A handy macro borrowed from the `signatory` crate that lets library-internal code generate @@ -70,6 +72,7 @@ impl ErrorKind { ErrorKind::SignatureError => "Signature failure", ErrorKind::IncorrectKeyType => "Incorrect key type", ErrorKind::InvalidPayload => "Invalid payload", + ErrorKind::ThumbprintCalculationFailure => "Thumbprint calculation failure", } } } diff --git a/src/jwk.rs b/src/jwk.rs new file mode 100644 index 0000000..131bc79 --- /dev/null +++ b/src/jwk.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeMap; + +use crate::{err, from_public_key, from_seed, KeyPair, KeyPairType, Result}; +use data_encoding::BASE64URL_NOPAD; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +// We hard code the value here, because we're using Edwards-curve keys, which OKP represents: +// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-2 +const JWK_KEY_TYPE: &str = "OKP"; +// https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#section-3.1 +const JWK_ALGORITHM: &str = "EdDSA"; +const JWK_SUBTYPE: &str = "Ed25519"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonWebKey { + /// Intended use of the JWK, this is based on the KeyPairType of the KeyPair the JWK is based on, using "enc" for KeyPairType::Curve, otherwise "sig" + #[serde(rename = "use")] + intended_use: String, + /// Key type, which we default to OKP (Octet Key Pair) to represent Edwards-curve keys + #[serde(rename = "kty")] + key_type: String, + /// Key ID, which will be represented by the thumbprint calculated over the subtype (crv), key_type (kty) and public_key (x) components of the JWK. + /// See https://datatracker.ietf.org/doc/html/rfc7639 for more details. + #[serde(rename = "kid")] + key_id: String, + /// Algorithm used for the JWK, defaults to EdDSA + #[serde(rename = "alg")] + algorithm: String, + /// Subtype of the key (from the "JSON Web Elliptic Curve" registry) + #[serde(rename = "crv")] + subtype: String, + // Public key value encoded using base64url encoding + #[serde(rename = "x")] + public_key: String, + // Private key value, if provided, encoded using base64url encoding + #[serde(rename = "d", skip_serializing_if = "Option::is_none")] + private_key: Option, +} + +impl JsonWebKey { + pub fn from_seed(source: &str) -> Result { + let (prefix, seed) = from_seed(source)?; + let sk = SigningKey::from_bytes(&seed); + let kp_type = &KeyPairType::from(prefix); + let public_key = BASE64URL_NOPAD.encode(sk.verifying_key().as_bytes()); + let thumbprint = Self::calculate_thumbprint(&public_key)?; + + Ok(JsonWebKey { + intended_use: Self::intended_use_for_key_pair_type(kp_type), + key_id: thumbprint, + public_key, + private_key: Some(BASE64URL_NOPAD.encode(sk.as_bytes())), + ..Default::default() + }) + } + + pub fn from_public_key(source: &str) -> Result { + let (prefix, bytes) = from_public_key(source)?; + let vk = VerifyingKey::from_bytes(&bytes)?; + let public_key = BASE64URL_NOPAD.encode(vk.as_bytes()); + let thumbprint = Self::calculate_thumbprint(&public_key)?; + + Ok(JsonWebKey { + intended_use: Self::intended_use_for_key_pair_type(&KeyPairType::from(prefix)), + key_id: thumbprint, + public_key, + ..Default::default() + }) + } + + fn intended_use_for_key_pair_type(typ: &KeyPairType) -> String { + match typ { + KeyPairType::Server + | KeyPairType::Cluster + | KeyPairType::Operator + | KeyPairType::Account + | KeyPairType::User + | KeyPairType::Module + | KeyPairType::Service => "sig".to_owned(), + KeyPairType::Curve => "enc".to_owned(), + } + } + + /// For details on how fingerprints are calculated, see: https://datatracker.ietf.org/doc/html/rfc7638#section-3.1 + /// For OKP specific details, see https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3 + pub fn calculate_thumbprint(public_key: &str) -> Result { + // We use BTreeMap here, because the order needs to be lexicographically sorted: + // https://datatracker.ietf.org/doc/html/rfc7638#section-3.3 + let components = BTreeMap::from([ + ("crv", JWK_SUBTYPE), + ("kty", JWK_KEY_TYPE), + ("x", public_key), + ]); + let value = json!(components); + let mut bytes: Vec = Vec::new(); + serde_json::to_writer(&mut bytes, &value).map_err(|_| { + err!( + ThumbprintCalculationFailure, + "unable to serialize public key" + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(&*bytes); + let hash = hasher.finalize(); + Ok(BASE64URL_NOPAD.encode(&hash)) + } +} + +impl Default for JsonWebKey { + fn default() -> Self { + Self { + intended_use: Default::default(), + key_type: JWK_KEY_TYPE.to_string(), + key_id: Default::default(), + algorithm: JWK_ALGORITHM.to_string(), + subtype: JWK_SUBTYPE.to_string(), + public_key: Default::default(), + private_key: None, + } + } +} + +impl TryFrom for JsonWebKey { + type Error = crate::error::Error; + + fn try_from(value: KeyPair) -> Result { + if let Ok(seed) = value.seed() { + Ok(Self::from_seed(&seed)?) + } else { + Ok(Self::from_public_key(&value.public_key())?) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Using the example values from https://datatracker.ietf.org/doc/html/draft-ietf-jose-cfrg-curves-06#appendix-A.3 + #[test] + fn calculate_thumbprint_provides_correct_thumbprint() { + let input_public_key = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; + let expected_thumbprint = "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"; + let actual_thumbprint = JsonWebKey::calculate_thumbprint(input_public_key).unwrap(); + + assert_eq!(expected_thumbprint, actual_thumbprint); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2c27f1f..b48c9a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,6 +58,12 @@ mod xkeys; #[cfg(feature = "xkeys")] pub use xkeys::XKey; +#[cfg(feature = "jwk")] +mod jwk; + +#[cfg(feature = "jwk")] +pub use jwk::JsonWebKey; + const ENCODED_SEED_LENGTH: usize = 58; const ENCODED_PUBKEY_LENGTH: usize = 56; @@ -287,33 +293,17 @@ impl KeyPair { /// Attempts to produce a public-only key pair from the given encoded public key string pub fn from_public_key(source: &str) -> Result { - if source.len() != ENCODED_PUBKEY_LENGTH { - let l = source.len(); - return Err(err!(InvalidKeyLength, "Bad key length: {}", l)); - } + let (prefix, bytes) = from_public_key(source)?; - let source_bytes = source.as_bytes(); - let mut raw = decode_raw(source_bytes)?; + let pk = VerifyingKey::from_bytes(&bytes) + .map_err(|_| err!(VerifyError, "Could not read public key"))?; - let prefix = raw[0]; - if !valid_public_key_prefix(prefix) { - Err(err!( - InvalidPrefix, - "Not a valid public key prefix: {}", - raw[0] - )) - } else { - raw.remove(0); - match VerifyingKey::try_from(&raw[..]) { - Ok(pk) => Ok(KeyPair { - kp_type: KeyPairType::from(prefix), - pk, - sk: None, - signing_key: None, - }), - Err(_) => Err(err!(VerifyError, "Could not read public key")), - } - } + Ok(KeyPair { + kp_type: KeyPairType::from(prefix), + pk, + sk: None, + signing_key: None, + }) } /// Attempts to produce a full key pair from the given encoded seed string @@ -348,6 +338,32 @@ fn decode_raw(raw: &[u8]) -> Result> { } } +/// Returns the prefix byte and the underlying public key bytes +fn from_public_key(source: &str) -> Result<(u8, [u8; 32])> { + if source.len() != ENCODED_PUBKEY_LENGTH { + let l = source.len(); + return Err(err!(InvalidKeyLength, "Bad key length: {}", l)); + } + + let source_bytes = source.as_bytes(); + let mut raw = decode_raw(source_bytes)?; + + let prefix = raw[0]; + if !valid_public_key_prefix(prefix) { + return Err(err!( + InvalidPrefix, + "Not a valid public key prefix: {}", + raw[0] + )); + } + raw.remove(0); + + let mut public_key = [0u8; 32]; + public_key.copy_from_slice(&raw[..]); + + Ok((prefix, public_key)) +} + /// Returns the type and the seed fn from_seed(source: &str) -> Result<(u8, [u8; 32])> { if source.len() != ENCODED_SEED_LENGTH { @@ -521,6 +537,16 @@ mod tests { } } + #[test] + fn from_public_key_rejects_bad_prefix() { + let public_key = "ZCO4XYNKEN7ZFQ42BHYCBYI3K7USOGG43C2DIJZYWSQ2YEMBOZWN6PYH"; + let pair = KeyPair::from_public_key(public_key); + assert!(pair.is_err()); + if let Err(e) = pair { + assert_eq!(e.kind(), ErrorKind::InvalidPrefix); + } + } + #[test] fn public_key_round_trip() { let account =