From 4e71d90fc861743bf67680c609bc1cd4fafc0daa Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Tue, 30 Sep 2025 19:13:02 +0000 Subject: [PATCH 1/5] feat(jose-jwk): JWK thumbprint Implement RFC 7638 JWK thumbprint support in the `jose-jwk` crate. --- Cargo.lock | 27 +++ jose-jwk/Cargo.toml | 1 + jose-jwk/src/lib.rs | 2 + jose-jwk/src/thumbprint.rs | 286 ++++++++++++++++++++++++++++ jose-jwk/tests/thumbprint.rs | 351 +++++++++++++++++++++++++++++++++++ 5 files changed, 667 insertions(+) create mode 100644 jose-jwk/src/thumbprint.rs create mode 100644 jose-jwk/tests/thumbprint.rs diff --git a/Cargo.lock b/Cargo.lock index 837bfc8..712540d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.7.0-rc.4" @@ -225,6 +234,7 @@ dependencies = [ "serde", "serde_json", "serdect 0.2.0", + "sha2", "subtle", "url", "zeroize", @@ -255,6 +265,12 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + [[package]] name = "libm" version = "0.2.15" @@ -464,6 +480,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signature" version = "3.0.0-rc.3" diff --git a/jose-jwk/Cargo.toml b/jose-jwk/Cargo.toml index f76c0ba..99e9f3b 100644 --- a/jose-jwk/Cargo.toml +++ b/jose-jwk/Cargo.toml @@ -26,6 +26,7 @@ legacy = ["dep:base64ct", "dep:elliptic-curve", "dep:serde_json", "dep:serdect", jose-b64 = { version = "=0.2.0-pre", default-features = false, features = ["secret"] } jose-jwa = { version = "=0.2.0-pre" } serde = { version = "1", default-features = false, features = ["alloc", "derive"] } +sha2 = { version = "=0.11.0-rc.2", default-features = false } zeroize = { version = "1.8.1", default-features = false, features = ["alloc"] } # optional dependencies diff --git a/jose-jwk/src/lib.rs b/jose-jwk/src/lib.rs index 178c5f7..f770c8f 100644 --- a/jose-jwk/src/lib.rs +++ b/jose-jwk/src/lib.rs @@ -23,12 +23,14 @@ extern crate alloc; pub mod crypto; pub mod legacy; +pub mod thumbprint; mod key; mod prm; pub use key::*; pub use prm::{Class, Operations, Parameters, Thumbprint}; +pub use thumbprint::{JwkThumbprint, ThumbprintError}; pub use jose_b64; pub use jose_jwa; diff --git a/jose-jwk/src/thumbprint.rs b/jose-jwk/src/thumbprint.rs new file mode 100644 index 0000000..b76916d --- /dev/null +++ b/jose-jwk/src/thumbprint.rs @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: 2025 Phantom Technologies, Inc. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! JWK Thumbprint implementation as defined in RFC 7638. +//! +//! This module provides methods for computing JWK Thumbprints, which are +//! cryptographic hash values computed over the required members of a JWK. + +use alloc::fmt::Write; +use alloc::string::String; +use alloc::vec::Vec; + +use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; +use sha2::{Digest, Sha256}; + +use crate::key::{Ec, EcCurves, Oct, Okp, OkpCurves, Rsa}; +use crate::{Jwk, Key}; + +/// Trait for computing JWK thumbprints. +/// +/// This trait is implemented for each key type to compute the thumbprint +/// according to RFC 7638 requirements. +pub trait JwkThumbprint { + /// Compute the JWK thumbprint using SHA-256. + /// + /// Returns the base64url-encoded thumbprint string. + fn jwk_thumbprint(&self) -> Result; + + /// Compute the JWK thumbprint using a custom hash function. + /// + /// Returns the base64url-encoded thumbprint string. + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest; +} + +/// Errors that can occur during thumbprint computation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ThumbprintError { + /// Failed to format the JSON representation. + JsonFormatError, +} + +impl core::fmt::Display for ThumbprintError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ThumbprintError::JsonFormatError => write!(f, "failed to format JSON for thumbprint"), + } + } +} + +/// Helper function to build the canonical JSON representation for thumbprint computation. +fn build_canonical_json( + kty: &str, + required_fields: &[(&str, &str)], +) -> Result { + let mut json = String::with_capacity(256); + + // Start with opening brace + json.push('{'); + + // Create a vector of all fields including kty, then sort lexicographically + let mut all_fields = Vec::with_capacity(required_fields.len() + 1); + all_fields.push(("kty", kty)); + all_fields.extend_from_slice(required_fields); + all_fields.sort_by_key(|(key, _)| *key); + + // Write each field + for (i, (key, value)) in all_fields.iter().enumerate() { + if i > 0 { + json.push(','); + } + write!(json, "\"{key}\":\"{value}\"").map_err(|_| ThumbprintError::JsonFormatError)?; + } + + // Close with closing brace + json.push('}'); + + Ok(json) +} + +/// Compute thumbprint from canonical JSON. +fn compute_thumbprint_from_json(json: &str) -> String +where + D: Digest, +{ + let hash = D::digest(json.as_bytes()); + Base64UrlUnpadded::encode_string(&hash) +} + +impl JwkThumbprint for Ec { + fn jwk_thumbprint(&self) -> Result { + self.jwk_thumbprint_with_hash::() + } + + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + let crv = match self.crv { + EcCurves::P256 => "P-256", + EcCurves::P384 => "P-384", + EcCurves::P521 => "P-521", + EcCurves::P256K => "secp256k1", + }; + + let x = Base64UrlUnpadded::encode_string(&self.x); + let y = Base64UrlUnpadded::encode_string(&self.y); + + let required_fields = &[("crv", crv), ("x", x.as_str()), ("y", y.as_str())]; + + let json = build_canonical_json("EC", required_fields)?; + Ok(compute_thumbprint_from_json::(&json)) + } +} + +impl JwkThumbprint for Jwk { + /// Compute the JWK thumbprint using SHA-256 as defined in RFC 7638. + /// + /// This method computes a cryptographic hash over the required members + /// of the JWK and returns the base64url-encoded result. + /// + /// # Examples + /// + /// ``` + /// # use jose_jwk::{Jwk, JwkThumbprint, Key, Rsa}; + /// let jwk = Jwk { + /// key: Key::Rsa(Rsa { + /// e: vec![1, 0, 1].into(), + /// n: vec![0xAB, 0xCD, 0xEF].into(), + /// prv: None, + /// }), + /// prm: Default::default(), + /// }; + /// + /// let thumbprint = jwk.jwk_thumbprint().unwrap(); + /// assert!(!thumbprint.is_empty()); + /// ``` + fn jwk_thumbprint(&self) -> Result { + self.key.jwk_thumbprint() + } + + /// Compute the JWK thumbprint using a custom hash function as defined in RFC 7638. + /// + /// This method allows using a different hash function than the default SHA-256. + /// + /// # Examples + /// + /// ``` + /// # use jose_jwk::{Jwk, JwkThumbprint, Key, Rsa}; + /// # use sha2::Sha512; + /// let jwk = Jwk { + /// key: Key::Rsa(Rsa { + /// e: vec![1, 0, 1].into(), + /// n: vec![0xAB, 0xCD, 0xEF].into(), + /// prv: None, + /// }), + /// prm: Default::default(), + /// }; + /// + /// let thumbprint = jwk.jwk_thumbprint_with_hash::().unwrap(); + /// assert!(!thumbprint.is_empty()); + /// ``` + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + self.key.jwk_thumbprint_with_hash::() + } +} + +impl JwkThumbprint for Rsa { + fn jwk_thumbprint(&self) -> Result { + self.jwk_thumbprint_with_hash::() + } + + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + let e = Base64UrlUnpadded::encode_string(&self.e); + let n = Base64UrlUnpadded::encode_string(&self.n); + + let required_fields = &[("e", e.as_str()), ("n", n.as_str())]; + + let json = build_canonical_json("RSA", required_fields)?; + Ok(compute_thumbprint_from_json::(&json)) + } +} + +impl JwkThumbprint for Oct { + fn jwk_thumbprint(&self) -> Result { + self.jwk_thumbprint_with_hash::() + } + + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + let k = Base64UrlUnpadded::encode_string(&self.k); + + let required_fields = &[("k", k.as_str())]; + + let json = build_canonical_json("oct", required_fields)?; + Ok(compute_thumbprint_from_json::(&json)) + } +} + +impl JwkThumbprint for Okp { + fn jwk_thumbprint(&self) -> Result { + self.jwk_thumbprint_with_hash::() + } + + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + let crv = match self.crv { + OkpCurves::Ed25519 => "Ed25519", + OkpCurves::Ed448 => "Ed448", + OkpCurves::X25519 => "X25519", + OkpCurves::X448 => "X448", + }; + + let x = Base64UrlUnpadded::encode_string(&self.x); + + let required_fields = &[("crv", crv), ("x", x.as_str())]; + + let json = build_canonical_json("OKP", required_fields)?; + Ok(compute_thumbprint_from_json::(&json)) + } +} + +impl JwkThumbprint for Key { + fn jwk_thumbprint(&self) -> Result { + match self { + Key::Ec(ec) => ec.jwk_thumbprint(), + Key::Rsa(rsa) => rsa.jwk_thumbprint(), + Key::Oct(oct) => oct.jwk_thumbprint(), + Key::Okp(okp) => okp.jwk_thumbprint(), + } + } + + fn jwk_thumbprint_with_hash(&self) -> Result + where + D: Digest, + { + match self { + Key::Ec(ec) => ec.jwk_thumbprint_with_hash::(), + Key::Rsa(rsa) => rsa.jwk_thumbprint_with_hash::(), + Key::Oct(oct) => oct.jwk_thumbprint_with_hash::(), + Key::Okp(okp) => okp.jwk_thumbprint_with_hash::(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Rsa; + use alloc::vec; + + #[test] + fn test_build_canonical_json() { + let result = + build_canonical_json("RSA", &[("e", "AQAB"), ("n", "test")]).expect("canonical JSON"); + assert_eq!(result, r#"{"e":"AQAB","kty":"RSA","n":"test"}"#); + } + + #[test] + fn test_rsa_thumbprint_json_format() { + let rsa = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xAB, 0xCD, 0xEF].into(), + prv: None, + }; + + let result = rsa.jwk_thumbprint().expect("canonical JSON"); + + // Should be base64url encoded + assert!(!result.is_empty()); + assert!(!result.contains('=')); + assert!(!result.contains('/')); + assert!(!result.contains('+')); + } +} diff --git a/jose-jwk/tests/thumbprint.rs b/jose-jwk/tests/thumbprint.rs new file mode 100644 index 0000000..3086730 --- /dev/null +++ b/jose-jwk/tests/thumbprint.rs @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: 2025 Phantom Technologies, Inc. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! JWK Thumbprint tests including RFC 7638 examples. + +#![allow(clippy::indexing_slicing)] // usage is always valid + +use jose_jwk::thumbprint::{JwkThumbprint, ThumbprintError}; +use jose_jwk::*; +use sha2::{Sha256, Sha384, Sha512}; + +/// Test the RSA key example from RFC 7638 Section 3.1. +#[test] +fn test_rfc7638_rsa_example() { + // This is the RSA key from RFC 7638 Section 3.1 + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), // "AQAB" in base64url + n: vec![ + 210, 252, 123, 106, 10, 30, 108, 103, 16, 74, 235, 143, 136, 178, 87, 102, 155, 77, + 246, 121, 221, 173, 9, 155, 92, 74, 108, 217, 168, 128, 21, 181, 161, 51, 191, 11, 133, + 108, 120, 113, 182, 223, 0, 11, 85, 79, 206, 179, 194, 237, 81, 43, 182, 143, 20, 92, + 110, 132, 52, 117, 47, 171, 82, 161, 207, 193, 36, 64, 143, 121, 181, 138, 69, 120, + 193, 100, 40, 133, 87, 137, 247, 162, 73, 227, 132, 203, 45, 159, 174, 45, 103, 253, + 150, 251, 146, 108, 25, 142, 7, 115, 153, 253, 200, 21, 192, 175, 9, 125, 222, 90, 173, + 239, 244, 77, 231, 14, 130, 127, 72, 120, 67, 36, 57, 191, 238, 185, 96, 104, 208, 71, + 79, 197, 13, 109, 144, 191, 58, 152, 223, 175, 16, 64, 200, 156, 2, 214, 146, 171, 59, + 60, 40, 150, 96, 157, 134, 253, 115, 183, 116, 206, 7, 64, 100, 124, 238, 234, 163, 16, + 189, 18, 249, 133, 168, 235, 159, 89, 253, 212, 38, 206, 165, 178, 18, 15, 79, 42, 52, + 188, 171, 118, 75, 126, 108, 84, 214, 132, 2, 56, 188, 196, 5, 135, 165, 158, 102, 237, + 31, 51, 137, 69, 119, 99, 92, 71, 10, 247, 92, 249, 44, 32, 209, 218, 67, 225, 191, + 196, 25, 226, 34, 166, 240, 208, 187, 53, 140, 94, 56, 249, 203, 5, 10, 234, 254, 144, + 72, 20, 241, 172, 26, 164, 156, 202, 158, 160, 202, 131, + ] + .into(), + prv: None, + }; + + let jwk = Jwk { + key: Key::Rsa(rsa_key), + prm: Parameters::default(), + }; + + let thumbprint = jwk.jwk_thumbprint().unwrap(); + + // Expected result from RFC 7638 Section 3.1 + assert_eq!(thumbprint, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); +} + +/// Test all key types produce valid base64url thumbprints. +#[test] +fn test_all_key_types_thumbprint() { + let test_keys = vec![ + Key::Ec(Ec { + crv: EcCurves::P256, + x: vec![ + 48, 160, 66, 76, 210, 28, 41, 68, 131, 138, 45, 117, 201, 43, 55, 231, 110, 162, + 13, 159, 0, 137, 58, 59, 78, 238, 138, 60, 10, 175, 236, 62, + ] + .into(), + y: vec![ + 224, 75, 101, 233, 36, 86, 217, 136, 139, 82, 179, 121, 189, 251, 213, 30, 232, + 105, 239, 31, 15, 198, 91, 102, 89, 105, 91, 108, 206, 8, 23, 35, + ] + .into(), + d: None, + }), + Key::Oct(Oct { + k: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16].into(), + }), + Key::Okp(Okp { + crv: OkpCurves::Ed25519, + x: vec![ + 215, 90, 152, 1, 130, 177, 10, 183, 213, 75, 254, 211, 201, 100, 7, 58, 14, 225, + 114, 243, 218, 166, 35, 37, 175, 2, 26, 104, 247, 7, 81, 26, + ] + .into(), + d: None, + }), + ]; + + for key in test_keys { + let jwk = Jwk { + key, + prm: Parameters::default(), + }; + let thumbprint = jwk.jwk_thumbprint().unwrap(); + + assert!(!thumbprint.is_empty()); + assert!(!thumbprint.contains('=')); + assert!(!thumbprint.contains('/')); + assert!(!thumbprint.contains('+')); + } +} + +/// Test that private and public key representations produce the same thumbprint. +#[test] +fn test_private_public_key_same_thumbprint() { + // EC test + let ec_public = Ec { + crv: EcCurves::P256, + x: vec![1; 32].into(), + y: vec![2; 32].into(), + d: None, + }; + let ec_private = Ec { + crv: ec_public.crv, + x: ec_public.x.clone(), + y: ec_public.y.clone(), + d: Some(vec![100; 32].into()), + }; + assert_eq!( + ec_public.jwk_thumbprint().unwrap(), + ec_private.jwk_thumbprint().unwrap() + ); + + // OKP test + let okp_public = Okp { + crv: OkpCurves::Ed25519, + x: vec![1; 32].into(), + d: None, + }; + let okp_private = Okp { + crv: okp_public.crv, + x: okp_public.x.clone(), + d: Some(vec![100; 32].into()), + }; + assert_eq!( + okp_public.jwk_thumbprint().unwrap(), + okp_private.jwk_thumbprint().unwrap() + ); + + // RSA test + let rsa_public = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0x12, 0x34].into(), + prv: None, + }; + let rsa_private = Rsa { + e: rsa_public.e.clone(), + n: rsa_public.n.clone(), + prv: Some(RsaPrivate { + d: vec![0xAB].into(), + opt: None, + }), + }; + assert_eq!( + rsa_public.jwk_thumbprint().unwrap(), + rsa_private.jwk_thumbprint().unwrap() + ); +} + +/// Test different hash algorithms produce different results with expected lengths. +#[test] +fn test_hash_algorithms() { + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xAB, 0xCD, 0xEF, 0x12].into(), + prv: None, + }; + + let jwk = Jwk { + key: Key::Rsa(rsa_key), + prm: Parameters::default(), + }; + + let sha256_tp = jwk.jwk_thumbprint_with_hash::().unwrap(); + let sha384_tp = jwk.jwk_thumbprint_with_hash::().unwrap(); + let sha512_tp = jwk.jwk_thumbprint_with_hash::().unwrap(); + + // Verify expected lengths + assert_eq!(sha256_tp.len(), 43); // 256 bits + assert_eq!(sha384_tp.len(), 64); // 384 bits + assert_eq!(sha512_tp.len(), 86); // 512 bits + + // All should be different + assert_ne!(sha256_tp, sha384_tp); + assert_ne!(sha256_tp, sha512_tp); + assert_ne!(sha384_tp, sha512_tp); + + // All should be valid base64url + for tp in [&sha256_tp, &sha384_tp, &sha512_tp] { + assert!(!tp.contains('=')); + assert!(!tp.contains('+')); + assert!(!tp.contains('/')); + } +} + +/// Test that different curves produce different thumbprints. +#[test] +fn test_curve_differentiation() { + // EC curves + let ec_curves = [ + EcCurves::P256, + EcCurves::P384, + EcCurves::P521, + EcCurves::P256K, + ]; + + let mut ec_thumbprints = Vec::new(); + for curve in ec_curves { + let ec = Ec { + crv: curve, + x: vec![1; 32].into(), + y: vec![2; 32].into(), + d: None, + }; + ec_thumbprints.push(ec.jwk_thumbprint().unwrap()); + } + + // All EC thumbprints should be different + for i in 0..ec_thumbprints.len() { + for j in (i + 1)..ec_thumbprints.len() { + assert_ne!(ec_thumbprints[i], ec_thumbprints[j]); + } + } + + // OKP curves + let okp_curves = [ + OkpCurves::Ed25519, + OkpCurves::Ed448, + OkpCurves::X25519, + OkpCurves::X448, + ]; + + let mut okp_thumbprints = Vec::new(); + for curve in okp_curves { + let okp = Okp { + crv: curve, + x: vec![1; 32].into(), + d: None, + }; + okp_thumbprints.push(okp.jwk_thumbprint().unwrap()); + } + + // All OKP thumbprints should be different + for i in 0..okp_thumbprints.len() { + for j in (i + 1)..okp_thumbprints.len() { + assert_ne!(okp_thumbprints[i], okp_thumbprints[j]); + } + } +} + +/// Test thumbprint consistency and determinism. +#[test] +fn test_thumbprint_determinism() { + let rsa_key = Rsa { + e: vec![1, 0, 1].into(), + n: vec![0xDE, 0xAD, 0xBE, 0xEF].into(), + prv: None, + }; + + // Test Jwk vs Key consistency + let key_tp = rsa_key.jwk_thumbprint().unwrap(); + let jwk_tp = Jwk { + key: Key::Rsa(rsa_key.clone()), + prm: Parameters::default(), + } + .jwk_thumbprint() + .unwrap(); + assert_eq!(key_tp, jwk_tp); + + // Test determinism across multiple computations + let results: Vec = (0..100) + .map(|_| rsa_key.jwk_thumbprint().unwrap()) + .collect(); + for result in &results { + assert_eq!(&key_tp, result); + } +} + +/// Test various key sizes and edge cases. +#[test] +fn test_key_size_edge_cases() { + // RSA with different sizes + let test_keys = vec![ + Key::Rsa(Rsa { + e: vec![1].into(), + n: vec![3].into(), + prv: None, + }), + Key::Rsa(Rsa { + e: vec![0x00, 0x01, 0x00, 0x01].into(), // Leading zeros + n: vec![0xFF; 256].into(), // Large modulus + prv: None, + }), + Key::Ec(Ec { + crv: EcCurves::P256, + x: vec![1].into(), + y: vec![2].into(), + d: None, + }), + Key::Ec(Ec { + crv: EcCurves::P521, + x: vec![0xFF; 66].into(), + y: vec![0xEE; 66].into(), + d: None, + }), + Key::Oct(Oct { + k: vec![0x42].into(), + }), + Key::Oct(Oct { + k: vec![0x5A; 1024].into(), + }), + Key::Okp(Okp { + crv: OkpCurves::Ed25519, + x: vec![1].into(), + d: None, + }), + ]; + + for key in test_keys { + let thumbprint = key.jwk_thumbprint().unwrap(); + assert!(!thumbprint.is_empty()); + assert!(!thumbprint.contains('=')); + } +} + +/// Test that different key types produce different thumbprints. +#[test] +fn test_key_type_differentiation() { + let same_bytes = vec![0x01, 0x02, 0x03, 0x04]; + + let oct_tp = Oct { + k: same_bytes.clone().into(), + } + .jwk_thumbprint() + .unwrap(); + + let okp_tp = Okp { + crv: OkpCurves::Ed25519, + x: same_bytes.into(), + d: None, + } + .jwk_thumbprint() + .unwrap(); + + assert_ne!(oct_tp, okp_tp); +} + +/// Test error type implementation. +#[test] +fn test_error_types() { + let error = ThumbprintError::JsonFormatError; + + let error_str = format!("{error}"); + assert!(!error_str.is_empty()); + assert!(error_str.contains("JSON")); + + let error2 = error.clone(); + assert_eq!(error, error2); +} From 801733e11259fbcda4ad4272ed5d4f6674ad0e2e Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Wed, 1 Oct 2025 02:03:20 +0000 Subject: [PATCH 2/5] presort --- jose-jwk/src/thumbprint.rs | 47 ++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/jose-jwk/src/thumbprint.rs b/jose-jwk/src/thumbprint.rs index b76916d..80c7927 100644 --- a/jose-jwk/src/thumbprint.rs +++ b/jose-jwk/src/thumbprint.rs @@ -8,7 +8,6 @@ use alloc::fmt::Write; use alloc::string::String; -use alloc::vec::Vec; use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; use sha2::{Digest, Sha256}; @@ -50,30 +49,19 @@ impl core::fmt::Display for ThumbprintError { } /// Helper function to build the canonical JSON representation for thumbprint computation. -fn build_canonical_json( - kty: &str, - required_fields: &[(&str, &str)], -) -> Result { +/// Fields must be provided in lexicographic order. +fn build_canonical_json(required_fields: &[(&str, &str)]) -> Result { let mut json = String::with_capacity(256); - // Start with opening brace json.push('{'); - // Create a vector of all fields including kty, then sort lexicographically - let mut all_fields = Vec::with_capacity(required_fields.len() + 1); - all_fields.push(("kty", kty)); - all_fields.extend_from_slice(required_fields); - all_fields.sort_by_key(|(key, _)| *key); - - // Write each field - for (i, (key, value)) in all_fields.iter().enumerate() { + for (i, (key, value)) in required_fields.iter().enumerate() { if i > 0 { json.push(','); } write!(json, "\"{key}\":\"{value}\"").map_err(|_| ThumbprintError::JsonFormatError)?; } - // Close with closing brace json.push('}'); Ok(json) @@ -107,9 +95,15 @@ impl JwkThumbprint for Ec { let x = Base64UrlUnpadded::encode_string(&self.x); let y = Base64UrlUnpadded::encode_string(&self.y); - let required_fields = &[("crv", crv), ("x", x.as_str()), ("y", y.as_str())]; + // Required members in lexicographic order: crv, kty, x, y + let required_fields = &[ + ("crv", crv), + ("kty", "EC"), + ("x", x.as_str()), + ("y", y.as_str()), + ]; - let json = build_canonical_json("EC", required_fields)?; + let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) } } @@ -181,9 +175,10 @@ impl JwkThumbprint for Rsa { let e = Base64UrlUnpadded::encode_string(&self.e); let n = Base64UrlUnpadded::encode_string(&self.n); - let required_fields = &[("e", e.as_str()), ("n", n.as_str())]; + // Required members in lexicographic order: e, kty, n + let required_fields = &[("e", e.as_str()), ("kty", "RSA"), ("n", n.as_str())]; - let json = build_canonical_json("RSA", required_fields)?; + let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) } } @@ -199,9 +194,10 @@ impl JwkThumbprint for Oct { { let k = Base64UrlUnpadded::encode_string(&self.k); - let required_fields = &[("k", k.as_str())]; + // Required members in lexicographic order: k, kty + let required_fields = &[("k", k.as_str()), ("kty", "oct")]; - let json = build_canonical_json("oct", required_fields)?; + let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) } } @@ -224,9 +220,10 @@ impl JwkThumbprint for Okp { let x = Base64UrlUnpadded::encode_string(&self.x); - let required_fields = &[("crv", crv), ("x", x.as_str())]; + // Required members in lexicographic order: crv, kty, x + let required_fields = &[("crv", crv), ("kty", "OKP"), ("x", x.as_str())]; - let json = build_canonical_json("OKP", required_fields)?; + let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) } } @@ -262,8 +259,8 @@ mod tests { #[test] fn test_build_canonical_json() { - let result = - build_canonical_json("RSA", &[("e", "AQAB"), ("n", "test")]).expect("canonical JSON"); + let result = build_canonical_json(&[("e", "AQAB"), ("kty", "RSA"), ("n", "test")]) + .expect("canonical JSON"); assert_eq!(result, r#"{"e":"AQAB","kty":"RSA","n":"test"}"#); } From 9e37b3da057bfd02392affe5db407a502d60385d Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Wed, 1 Oct 2025 02:13:24 +0000 Subject: [PATCH 3/5] display --- jose-jwk/src/key/ec.rs | 35 +++++++++++++++++++++++++++++++++++ jose-jwk/src/key/okp.rs | 35 +++++++++++++++++++++++++++++++++++ jose-jwk/src/thumbprint.rs | 24 ++++++------------------ 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/jose-jwk/src/key/ec.rs b/jose-jwk/src/key/ec.rs index bfa8e4a..d266be0 100644 --- a/jose-jwk/src/key/ec.rs +++ b/jose-jwk/src/key/ec.rs @@ -44,3 +44,38 @@ pub enum EcCurves { #[serde(rename = "secp256k1")] P256K, } + +impl core::fmt::Display for EcCurves { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EcCurves::P256 => write!(f, "P-256"), + EcCurves::P384 => write!(f, "P-384"), + EcCurves::P521 => write!(f, "P-521"), + EcCurves::P256K => write!(f, "secp256k1"), + } + } +} + +impl core::str::FromStr for EcCurves { + type Err = ParseEcCurveError; + + fn from_str(s: &str) -> Result { + match s { + "P-256" => Ok(EcCurves::P256), + "P-384" => Ok(EcCurves::P384), + "P-521" => Ok(EcCurves::P521), + "secp256k1" => Ok(EcCurves::P256K), + _ => Err(ParseEcCurveError), + } + } +} + +/// Error returned when parsing an EC curve name fails. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParseEcCurveError; + +impl core::fmt::Display for ParseEcCurveError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "invalid EC curve name") + } +} diff --git a/jose-jwk/src/key/okp.rs b/jose-jwk/src/key/okp.rs index 00f7edd..dac82a6 100644 --- a/jose-jwk/src/key/okp.rs +++ b/jose-jwk/src/key/okp.rs @@ -39,3 +39,38 @@ pub enum OkpCurves { /// X448 X448, } + +impl core::fmt::Display for OkpCurves { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + OkpCurves::Ed25519 => write!(f, "Ed25519"), + OkpCurves::Ed448 => write!(f, "Ed448"), + OkpCurves::X25519 => write!(f, "X25519"), + OkpCurves::X448 => write!(f, "X448"), + } + } +} + +impl core::str::FromStr for OkpCurves { + type Err = ParseOkpCurveError; + + fn from_str(s: &str) -> Result { + match s { + "Ed25519" => Ok(OkpCurves::Ed25519), + "Ed448" => Ok(OkpCurves::Ed448), + "X25519" => Ok(OkpCurves::X25519), + "X448" => Ok(OkpCurves::X448), + _ => Err(ParseOkpCurveError), + } + } +} + +/// Error returned when parsing an OKP curve name fails. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParseOkpCurveError; + +impl core::fmt::Display for ParseOkpCurveError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "invalid OKP curve name") + } +} diff --git a/jose-jwk/src/thumbprint.rs b/jose-jwk/src/thumbprint.rs index 80c7927..09cb818 100644 --- a/jose-jwk/src/thumbprint.rs +++ b/jose-jwk/src/thumbprint.rs @@ -7,12 +7,12 @@ //! cryptographic hash values computed over the required members of a JWK. use alloc::fmt::Write; -use alloc::string::String; +use alloc::string::{String, ToString}; use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; use sha2::{Digest, Sha256}; -use crate::key::{Ec, EcCurves, Oct, Okp, OkpCurves, Rsa}; +use crate::key::{Ec, Oct, Okp, Rsa}; use crate::{Jwk, Key}; /// Trait for computing JWK thumbprints. @@ -85,19 +85,13 @@ impl JwkThumbprint for Ec { where D: Digest, { - let crv = match self.crv { - EcCurves::P256 => "P-256", - EcCurves::P384 => "P-384", - EcCurves::P521 => "P-521", - EcCurves::P256K => "secp256k1", - }; - + let crv = self.crv.to_string(); let x = Base64UrlUnpadded::encode_string(&self.x); let y = Base64UrlUnpadded::encode_string(&self.y); // Required members in lexicographic order: crv, kty, x, y let required_fields = &[ - ("crv", crv), + ("crv", crv.as_str()), ("kty", "EC"), ("x", x.as_str()), ("y", y.as_str()), @@ -211,17 +205,11 @@ impl JwkThumbprint for Okp { where D: Digest, { - let crv = match self.crv { - OkpCurves::Ed25519 => "Ed25519", - OkpCurves::Ed448 => "Ed448", - OkpCurves::X25519 => "X25519", - OkpCurves::X448 => "X448", - }; - + let crv = self.crv.to_string(); let x = Base64UrlUnpadded::encode_string(&self.x); // Required members in lexicographic order: crv, kty, x - let required_fields = &[("crv", crv), ("kty", "OKP"), ("x", x.as_str())]; + let required_fields = &[("crv", crv.as_str()), ("kty", "OKP"), ("x", x.as_str())]; let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) From 367fd949da84d1ed8f29a3038d13ffb84c0032d8 Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Wed, 1 Oct 2025 02:29:02 +0000 Subject: [PATCH 4/5] as_str --- jose-jwk/src/key/ec.rs | 38 ++++++++------------------------------ jose-jwk/src/key/okp.rs | 38 ++++++++------------------------------ jose-jwk/src/thumbprint.rs | 12 +++++++----- 3 files changed, 23 insertions(+), 65 deletions(-) diff --git a/jose-jwk/src/key/ec.rs b/jose-jwk/src/key/ec.rs index d266be0..4149255 100644 --- a/jose-jwk/src/key/ec.rs +++ b/jose-jwk/src/key/ec.rs @@ -45,37 +45,15 @@ pub enum EcCurves { P256K, } -impl core::fmt::Display for EcCurves { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +impl EcCurves { + /// Returns the string representation of the curve. + #[must_use] + pub const fn as_str(&self) -> &'static str { match self { - EcCurves::P256 => write!(f, "P-256"), - EcCurves::P384 => write!(f, "P-384"), - EcCurves::P521 => write!(f, "P-521"), - EcCurves::P256K => write!(f, "secp256k1"), + EcCurves::P256 => "P-256", + EcCurves::P384 => "P-384", + EcCurves::P521 => "P-521", + EcCurves::P256K => "secp256k1", } } } - -impl core::str::FromStr for EcCurves { - type Err = ParseEcCurveError; - - fn from_str(s: &str) -> Result { - match s { - "P-256" => Ok(EcCurves::P256), - "P-384" => Ok(EcCurves::P384), - "P-521" => Ok(EcCurves::P521), - "secp256k1" => Ok(EcCurves::P256K), - _ => Err(ParseEcCurveError), - } - } -} - -/// Error returned when parsing an EC curve name fails. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ParseEcCurveError; - -impl core::fmt::Display for ParseEcCurveError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "invalid EC curve name") - } -} diff --git a/jose-jwk/src/key/okp.rs b/jose-jwk/src/key/okp.rs index dac82a6..97cb46c 100644 --- a/jose-jwk/src/key/okp.rs +++ b/jose-jwk/src/key/okp.rs @@ -40,37 +40,15 @@ pub enum OkpCurves { X448, } -impl core::fmt::Display for OkpCurves { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +impl OkpCurves { + /// Returns the string representation of the curve. + #[must_use] + pub const fn as_str(&self) -> &'static str { match self { - OkpCurves::Ed25519 => write!(f, "Ed25519"), - OkpCurves::Ed448 => write!(f, "Ed448"), - OkpCurves::X25519 => write!(f, "X25519"), - OkpCurves::X448 => write!(f, "X448"), + OkpCurves::Ed25519 => "Ed25519", + OkpCurves::Ed448 => "Ed448", + OkpCurves::X25519 => "X25519", + OkpCurves::X448 => "X448", } } } - -impl core::str::FromStr for OkpCurves { - type Err = ParseOkpCurveError; - - fn from_str(s: &str) -> Result { - match s { - "Ed25519" => Ok(OkpCurves::Ed25519), - "Ed448" => Ok(OkpCurves::Ed448), - "X25519" => Ok(OkpCurves::X25519), - "X448" => Ok(OkpCurves::X448), - _ => Err(ParseOkpCurveError), - } - } -} - -/// Error returned when parsing an OKP curve name fails. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ParseOkpCurveError; - -impl core::fmt::Display for ParseOkpCurveError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "invalid OKP curve name") - } -} diff --git a/jose-jwk/src/thumbprint.rs b/jose-jwk/src/thumbprint.rs index 09cb818..f22b1eb 100644 --- a/jose-jwk/src/thumbprint.rs +++ b/jose-jwk/src/thumbprint.rs @@ -7,7 +7,7 @@ //! cryptographic hash values computed over the required members of a JWK. use alloc::fmt::Write; -use alloc::string::{String, ToString}; +use alloc::string::String; use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; use sha2::{Digest, Sha256}; @@ -85,13 +85,12 @@ impl JwkThumbprint for Ec { where D: Digest, { - let crv = self.crv.to_string(); let x = Base64UrlUnpadded::encode_string(&self.x); let y = Base64UrlUnpadded::encode_string(&self.y); // Required members in lexicographic order: crv, kty, x, y let required_fields = &[ - ("crv", crv.as_str()), + ("crv", self.crv.as_str()), ("kty", "EC"), ("x", x.as_str()), ("y", y.as_str()), @@ -205,11 +204,14 @@ impl JwkThumbprint for Okp { where D: Digest, { - let crv = self.crv.to_string(); let x = Base64UrlUnpadded::encode_string(&self.x); // Required members in lexicographic order: crv, kty, x - let required_fields = &[("crv", crv.as_str()), ("kty", "OKP"), ("x", x.as_str())]; + let required_fields = &[ + ("crv", self.crv.as_str()), + ("kty", "OKP"), + ("x", x.as_str()), + ]; let json = build_canonical_json(required_fields)?; Ok(compute_thumbprint_from_json::(&json)) From 5702b0e25f0d9fa7a985fc5eeec9d8352da1ad2e Mon Sep 17 00:00:00 2001 From: Jacob Lee Date: Thu, 2 Oct 2025 19:57:16 +0000 Subject: [PATCH 5/5] inline --- jose-jwk/src/thumbprint.rs | 125 ++++++++++--------------------------- 1 file changed, 33 insertions(+), 92 deletions(-) diff --git a/jose-jwk/src/thumbprint.rs b/jose-jwk/src/thumbprint.rs index f22b1eb..6bafb96 100644 --- a/jose-jwk/src/thumbprint.rs +++ b/jose-jwk/src/thumbprint.rs @@ -6,7 +6,6 @@ //! This module provides methods for computing JWK Thumbprints, which are //! cryptographic hash values computed over the required members of a JWK. -use alloc::fmt::Write; use alloc::string::String; use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; @@ -48,34 +47,6 @@ impl core::fmt::Display for ThumbprintError { } } -/// Helper function to build the canonical JSON representation for thumbprint computation. -/// Fields must be provided in lexicographic order. -fn build_canonical_json(required_fields: &[(&str, &str)]) -> Result { - let mut json = String::with_capacity(256); - - json.push('{'); - - for (i, (key, value)) in required_fields.iter().enumerate() { - if i > 0 { - json.push(','); - } - write!(json, "\"{key}\":\"{value}\"").map_err(|_| ThumbprintError::JsonFormatError)?; - } - - json.push('}'); - - Ok(json) -} - -/// Compute thumbprint from canonical JSON. -fn compute_thumbprint_from_json(json: &str) -> String -where - D: Digest, -{ - let hash = D::digest(json.as_bytes()); - Base64UrlUnpadded::encode_string(&hash) -} - impl JwkThumbprint for Ec { fn jwk_thumbprint(&self) -> Result { self.jwk_thumbprint_with_hash::() @@ -85,19 +56,18 @@ impl JwkThumbprint for Ec { where D: Digest, { - let x = Base64UrlUnpadded::encode_string(&self.x); - let y = Base64UrlUnpadded::encode_string(&self.y); - // Required members in lexicographic order: crv, kty, x, y - let required_fields = &[ - ("crv", self.crv.as_str()), - ("kty", "EC"), - ("x", x.as_str()), - ("y", y.as_str()), - ]; - - let json = build_canonical_json(required_fields)?; - Ok(compute_thumbprint_from_json::(&json)) + let buf = D::new() + .chain_update(r#"{"crv":""#) + .chain_update(self.crv.as_str()) + .chain_update(r#"","kty":"EC","x":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.x)) + .chain_update(r#"","y":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.y)) + .chain_update(r#""}"#) + .finalize(); + + Ok(Base64UrlUnpadded::encode_string(&buf)) } } @@ -165,14 +135,16 @@ impl JwkThumbprint for Rsa { where D: Digest, { - let e = Base64UrlUnpadded::encode_string(&self.e); - let n = Base64UrlUnpadded::encode_string(&self.n); - // Required members in lexicographic order: e, kty, n - let required_fields = &[("e", e.as_str()), ("kty", "RSA"), ("n", n.as_str())]; + let buf = D::new() + .chain_update(r#"{"e":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.e)) + .chain_update(r#"","kty":"RSA","n":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.n)) + .chain_update(r#""}"#) + .finalize(); - let json = build_canonical_json(required_fields)?; - Ok(compute_thumbprint_from_json::(&json)) + Ok(Base64UrlUnpadded::encode_string(&buf)) } } @@ -185,13 +157,14 @@ impl JwkThumbprint for Oct { where D: Digest, { - let k = Base64UrlUnpadded::encode_string(&self.k); - // Required members in lexicographic order: k, kty - let required_fields = &[("k", k.as_str()), ("kty", "oct")]; + let buf = D::new() + .chain_update(r#"{"k":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.k)) + .chain_update(r#"","kty":"oct"}"#) + .finalize(); - let json = build_canonical_json(required_fields)?; - Ok(compute_thumbprint_from_json::(&json)) + Ok(Base64UrlUnpadded::encode_string(&buf)) } } @@ -204,17 +177,16 @@ impl JwkThumbprint for Okp { where D: Digest, { - let x = Base64UrlUnpadded::encode_string(&self.x); - // Required members in lexicographic order: crv, kty, x - let required_fields = &[ - ("crv", self.crv.as_str()), - ("kty", "OKP"), - ("x", x.as_str()), - ]; + let buf = D::new() + .chain_update(r#"{"crv":""#) + .chain_update(self.crv.as_str()) + .chain_update(r#"","kty":"OKP","x":""#) + .chain_update(Base64UrlUnpadded::encode_string(&self.x)) + .chain_update(r#""}"#) + .finalize(); - let json = build_canonical_json(required_fields)?; - Ok(compute_thumbprint_from_json::(&json)) + Ok(Base64UrlUnpadded::encode_string(&buf)) } } @@ -240,34 +212,3 @@ impl JwkThumbprint for Key { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::key::Rsa; - use alloc::vec; - - #[test] - fn test_build_canonical_json() { - let result = build_canonical_json(&[("e", "AQAB"), ("kty", "RSA"), ("n", "test")]) - .expect("canonical JSON"); - assert_eq!(result, r#"{"e":"AQAB","kty":"RSA","n":"test"}"#); - } - - #[test] - fn test_rsa_thumbprint_json_format() { - let rsa = Rsa { - e: vec![1, 0, 1].into(), - n: vec![0xAB, 0xCD, 0xEF].into(), - prv: None, - }; - - let result = rsa.jwk_thumbprint().expect("canonical JSON"); - - // Should be base64url encoded - assert!(!result.is_empty()); - assert!(!result.contains('=')); - assert!(!result.contains('/')); - assert!(!result.contains('+')); - } -}