From 32956be09f00cc8b851526056566fa28ac466d5f Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 18 Aug 2025 12:08:47 -0600 Subject: [PATCH 1/4] jose-jwk: import legacy impl from the `elliptic-curve` crate The `elliptic-curve` crate previously shipped with its own JWK impl: http://docs.rs/elliptic-curve/0.13.8/elliptic_curve/struct.JwkEcKey.html To keep the `elliptic-curve` crate simpler and avoid duplication within the project, this extracts the implementation into the `jose-jwk` crate. Ideally we could consolidate into a single implementation, however the legacy implementation offers some features the existing implementation does not like encoding support. Extracting the implementation out provides an easy migration path for existing users until we can consolidate into a single implementation which covers all bases. --- .github/workflows/jose-jwk.yml | 4 +- Cargo.lock | 69 +++- jose-jwk/Cargo.toml | 15 +- jose-jwk/src/legacy.rs | 663 +++++++++++++++++++++++++++++++++ jose-jwk/src/lib.rs | 1 + 5 files changed, 734 insertions(+), 18 deletions(-) create mode 100644 jose-jwk/src/legacy.rs diff --git a/.github/workflows/jose-jwk.yml b/.github/workflows/jose-jwk.yml index cd91d54..096289d 100644 --- a/.github/workflows/jose-jwk.yml +++ b/.github/workflows/jose-jwk.yml @@ -24,7 +24,7 @@ jobs: minimal-versions: uses: RustCrypto/actions/.github/workflows/minimal-versions.yml@master with: - working-directory: ${{ github.workflow }} + working-directory: ${{ github.workflow }} no_std: runs-on: ubuntu-latest @@ -57,6 +57,7 @@ jobs: # Test no features, individual features and all features. - "" - crypto + - legacy - p256 - p384 - rsa @@ -70,6 +71,7 @@ jobs: - p384,rsa - rsa,p256 - rsa,p384 + # TODO(tarcieri): switch to cargo-hack steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master diff --git a/Cargo.lock b/Cargo.lock index 9b8dd1e..d570ee8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -102,6 +103,9 @@ dependencies = [ "ff", "generic-array", "group", + "hex-literal", + "pem-rfc7468", + "pkcs8", "rand_core", "sec1", "subtle", @@ -149,6 +153,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "idna" version = "0.4.0" @@ -192,6 +202,8 @@ version = "0.0.0" name = "jose-jwk" version = "0.1.2" dependencies = [ + "base64ct", + "elliptic-curve", "jose-b64", "jose-jwa", "k256", @@ -201,6 +213,8 @@ dependencies = [ "rsa", "serde", "serde_json", + "serdect", + "subtle", "url", "zeroize", ] @@ -246,6 +260,12 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -325,6 +345,15 @@ dependencies = [ "primeorder", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -369,18 +398,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.66" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -446,24 +475,25 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] [[package]] name = "serde" -version = "1.0.185" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9b6f69f1dfd54c3b568ffa45c310d6973a5e5148fd40cf515acaf38cf5bc31" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.185" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc59dfdcbad1437773485e0367fea4b090a2e0a16d9ffc46af47764536a298ec" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -472,15 +502,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.105" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "signature" version = "2.1.0" @@ -515,15 +556,15 @@ dependencies = [ [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.29" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", diff --git a/jose-jwk/Cargo.toml b/jose-jwk/Cargo.toml index 2a485bd..9ce9e69 100644 --- a/jose-jwk/Cargo.toml +++ b/jose-jwk/Cargo.toml @@ -20,6 +20,7 @@ rust-version = "1.65" [features] default = ["crypto"] crypto = ["p256", "p384", "p521", "k256", "rsa"] +legacy = ["dep:base64ct", "dep:elliptic-curve", "dep:serde_json", "dep:serdect", "dep:subtle"] [dependencies] jose-b64 = { version = "0.1", default-features = false, features = ["secret"], path = "../jose-b64" } @@ -30,13 +31,21 @@ zeroize = { version = "1.7.0", default-features = false, features = ["alloc"] } # optional dependencies p256 = { version = "0.13.2", default-features = false, optional = true, features = ["arithmetic"] } p384 = { version = "0.13.1", default-features = false, optional = true, features = ["arithmetic"] } -p521 = { version = "0.13.3", default-features = false, optional = true, features = ["arithmetic"]} -k256 = { version = "0.13.4", default-features = false, optional = true, features = ["arithmetic"]} +p521 = { version = "0.13.3", default-features = false, optional = true, features = ["arithmetic"] } +k256 = { version = "0.13.4", default-features = false, optional = true, features = ["arithmetic"] } rsa = { version = "0.9", default-features = false, optional = true } url = { version = "2.4.1", default-features = false, optional = true, features = ["serde"] } +# legacy dependencies +base64ct = { version = "1", optional = true, default-features = false, features = ["alloc"] } +elliptic-curve = { version = "0.13", optional = true, default-features = false, features = ["arithmetic", "sec1"] } +serdect = { version = "0.3", optional = true, default-features = false, features = ["alloc"] } +serde_json = { version = "1.0.121", optional = true, default-features = false, features = ["alloc"] } +subtle = { version = "2.6", optional = true, default-features = false } + [dev-dependencies] -serde_json = "1.0.96" +elliptic-curve = { version = "0.13", default-features = false, features = ["dev"] } +serde_json = "1.0.121" [package.metadata.docs.rs] all-features = true diff --git a/jose-jwk/src/legacy.rs b/jose-jwk/src/legacy.rs new file mode 100644 index 0000000..5df88f1 --- /dev/null +++ b/jose-jwk/src/legacy.rs @@ -0,0 +1,663 @@ +//! Legacy JSON Web Key (JWK) implementation originally from the `elliptic-curve` crate. + +#![cfg(feature = "legacy")] + +// TODO(tarcieri): unify this with the other implementation when they reach feature parity. + +use alloc::{ + borrow::ToOwned, + format, + string::{String, ToString}, +}; +use base64ct::{Base64UrlUnpadded as Base64Url, Encoding}; +use core::{ + fmt::{self, Debug}, + marker::PhantomData, + str::{self, FromStr}, +}; +use elliptic_curve::{ + sec1::{Coordinates, EncodedPoint, ModulusSize, ValidatePublicKey}, + sec1::{FromEncodedPoint, ToEncodedPoint}, + AffinePoint, Curve, CurveArithmetic, Error, FieldBytes, FieldBytesSize, PublicKey, Result, + SecretKey, +}; +use serdect::serde::{de, ser, Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Key Type (`kty`) for elliptic curve keys. +pub const EC_KTY: &str = "EC"; + +/// Deserialization error message. +const DE_ERROR_MSG: &str = "struct JwkEcKey with 5 elements"; + +/// Name of the JWK type +const JWK_TYPE_NAME: &str = "JwkEcKey"; + +/// Field names +const FIELDS: &[&str] = &["kty", "crv", "x", "y", "d"]; + +/// Elliptic curve parameters used by JSON Web Keys. +pub trait JwkParameters: Curve { + /// The `crv` parameter which identifies a particular elliptic curve + /// as defined in RFC 7518 Section 6.2.1.1: + /// + /// + /// Curve values are registered in the IANA "JSON Web Key Elliptic Curve" + /// registry defined in RFC 7518 Section 7.6: + /// + const CRV: &'static str; +} + +/// JSON Web Key (JWK) with a `kty` of `"EC"` (elliptic curve). +/// +/// Specified in [RFC 7518 Section 6: Cryptographic Algorithms for Keys][1]. +/// +/// This type can represent either a public/private keypair, or just a +/// public key, depending on whether or not the `d` parameter is present. +/// +/// [1]: https://tools.ietf.org/html/rfc7518#section-6 +// TODO(tarcieri): eagerly decode or validate `x`, `y`, and `d` as Base64 +#[derive(Clone)] +pub struct JwkEcKey { + /// The `crv` parameter which identifies a particular elliptic curve + /// as defined in RFC 7518 Section 6.2.1.1: + /// + crv: String, + + /// The x-coordinate of the elliptic curve point which is the public key + /// value associated with this JWK as defined in RFC 7518 6.2.1.2: + /// + x: String, + + /// The y-coordinate of the elliptic curve point which is the public key + /// value associated with this JWK as defined in RFC 7518 6.2.1.3: + /// + y: String, + + /// The `d` ECC private key parameter as described in RFC 7518 6.2.2.1: + /// + /// + /// Value is optional and if omitted, this JWK represents a private key. + /// + /// Inner value is encoded according to the `Integer-to-Octet-String` + /// conversion as defined in SEC1 section 2.3.7: + /// + d: Option, +} + +impl JwkEcKey { + /// Get the `crv` parameter for this JWK. + pub fn crv(&self) -> &str { + &self.crv + } + + /// Is this JWK a keypair that includes a private key? + pub fn is_keypair(&self) -> bool { + self.d.is_some() + } + + /// Does this JWK contain only a public key? + pub fn is_public_key(&self) -> bool { + self.d.is_none() + } + + /// Decode a JWK into a [`PublicKey`]. + pub fn to_public_key(&self) -> Result> + where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, + { + PublicKey::from_sec1_bytes(self.to_encoded_point::()?.as_bytes()) + } + + /// Create a JWK from a SEC1 [`EncodedPoint`]. + pub fn from_encoded_point(point: &EncodedPoint) -> Option + where + C: Curve + JwkParameters, + FieldBytesSize: ModulusSize, + { + match point.coordinates() { + Coordinates::Uncompressed { x, y } => Some(JwkEcKey { + crv: C::CRV.to_owned(), + x: Base64Url::encode_string(x), + y: Base64Url::encode_string(y), + d: None, + }), + _ => None, + } + } + + /// Get the public key component of this JWK as a SEC1 [`EncodedPoint`]. + pub fn to_encoded_point(&self) -> Result> + where + C: Curve + JwkParameters, + FieldBytesSize: ModulusSize, + { + if self.crv != C::CRV { + return Err(Error); + } + + let x = decode_base64url_fe::(&self.x)?; + let y = decode_base64url_fe::(&self.y)?; + Ok(EncodedPoint::::from_affine_coordinates(&x, &y, false)) + } + + /// Decode a JWK into a [`SecretKey`]. + pub fn to_secret_key(&self) -> Result> + where + C: Curve + JwkParameters + ValidatePublicKey, + FieldBytesSize: ModulusSize, + { + self.try_into() + } +} + +impl FromStr for JwkEcKey { + type Err = Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(|_| Error) + } +} + +#[allow(clippy::to_string_trait_impl)] +impl ToString for JwkEcKey { + fn to_string(&self) -> String { + serde_json::to_string(self).expect("JWK encoding error") + } +} + +impl TryFrom for SecretKey +where + C: Curve + JwkParameters + ValidatePublicKey, + FieldBytesSize: ModulusSize, +{ + type Error = Error; + + fn try_from(jwk: JwkEcKey) -> Result> { + (&jwk).try_into() + } +} + +impl TryFrom<&JwkEcKey> for SecretKey +where + C: Curve + JwkParameters + ValidatePublicKey, + FieldBytesSize: ModulusSize, +{ + type Error = Error; + + fn try_from(jwk: &JwkEcKey) -> Result> { + if let Some(d_base64) = &jwk.d { + let pk = jwk.to_encoded_point::()?; + let mut d_bytes = decode_base64url_fe::(d_base64)?; + let result = SecretKey::from_slice(&d_bytes); + d_bytes.zeroize(); + + result.and_then(|secret_key| { + C::validate_public_key(&secret_key, &pk)?; + Ok(secret_key) + }) + } else { + Err(Error) + } + } +} + +impl From> for JwkEcKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + fn from(sk: SecretKey) -> JwkEcKey { + (&sk).into() + } +} + +impl From<&SecretKey> for JwkEcKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + fn from(sk: &SecretKey) -> JwkEcKey { + let mut jwk = JwkEcKey::from(sk.public_key()); + let mut d = sk.to_bytes(); + jwk.d = Some(Base64Url::encode_string(&d)); + d.zeroize(); + jwk + } +} + +impl TryFrom for PublicKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + type Error = Error; + + fn try_from(jwk: JwkEcKey) -> Result> { + (&jwk).try_into() + } +} + +impl TryFrom<&JwkEcKey> for PublicKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + type Error = Error; + + fn try_from(jwk: &JwkEcKey) -> Result> { + PublicKey::from_sec1_bytes(jwk.to_encoded_point::()?.as_bytes()) + } +} + +impl From> for JwkEcKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + fn from(pk: PublicKey) -> JwkEcKey { + (&pk).into() + } +} + +impl From<&PublicKey> for JwkEcKey +where + C: CurveArithmetic + JwkParameters, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + fn from(pk: &PublicKey) -> JwkEcKey { + Self::from_encoded_point::(&pk.to_encoded_point(false)).expect("JWK encoding error") + } +} + +impl Debug for JwkEcKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let d = if self.d.is_some() { + "Some(...)" + } else { + "None" + }; + + // NOTE: this implementation omits the `d` private key parameter + f.debug_struct(JWK_TYPE_NAME) + .field("crv", &self.crv) + .field("x", &self.x) + .field("y", &self.y) + .field("d", &d) + .finish() + } +} + +impl PartialEq for JwkEcKey { + fn eq(&self, other: &Self) -> bool { + use subtle::ConstantTimeEq; + + // Compare private key in constant time + let d_eq = match &self.d { + Some(d1) => match &other.d { + Some(d2) => d1.as_bytes().ct_eq(d2.as_bytes()).into(), + None => other.d.is_none(), + }, + None => other.d.is_none(), + }; + + self.crv == other.crv && self.x == other.x && self.y == other.y && d_eq + } +} + +impl Eq for JwkEcKey {} + +impl ZeroizeOnDrop for JwkEcKey {} + +impl Drop for JwkEcKey { + fn drop(&mut self) { + self.zeroize(); + } +} + +impl Zeroize for JwkEcKey { + fn zeroize(&mut self) { + if let Some(d) = &mut self.d { + d.zeroize(); + } + } +} + +impl<'de> Deserialize<'de> for JwkEcKey { + fn deserialize(deserializer: D) -> core::result::Result + where + D: de::Deserializer<'de>, + { + /// Field positions + enum Field { + Kty, + Crv, + X, + Y, + D, + } + + /// Field visitor + struct FieldVisitor; + + impl de::Visitor<'_> for FieldVisitor { + type Value = Field; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Formatter::write_str(formatter, "field identifier") + } + + fn visit_u64(self, value: u64) -> core::result::Result + where + E: de::Error, + { + match value { + 0 => Ok(Field::Kty), + 1 => Ok(Field::Crv), + 2 => Ok(Field::X), + 3 => Ok(Field::Y), + 4 => Ok(Field::D), + _ => Err(de::Error::invalid_value( + de::Unexpected::Unsigned(value), + &"field index 0 <= i < 5", + )), + } + } + + fn visit_str(self, value: &str) -> core::result::Result + where + E: de::Error, + { + self.visit_bytes(value.as_bytes()) + } + + fn visit_bytes(self, value: &[u8]) -> core::result::Result + where + E: de::Error, + { + match value { + b"kty" => Ok(Field::Kty), + b"crv" => Ok(Field::Crv), + b"x" => Ok(Field::X), + b"y" => Ok(Field::Y), + b"d" => Ok(Field::D), + _ => Err(de::Error::unknown_field( + &String::from_utf8_lossy(value), + FIELDS, + )), + } + } + } + + impl<'de> Deserialize<'de> for Field { + #[inline] + fn deserialize(__deserializer: D) -> core::result::Result + where + D: de::Deserializer<'de>, + { + de::Deserializer::deserialize_identifier(__deserializer, FieldVisitor) + } + } + + struct Visitor<'de> { + marker: PhantomData, + lifetime: PhantomData<&'de ()>, + } + + impl<'de> de::Visitor<'de> for Visitor<'de> { + type Value = JwkEcKey; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Formatter::write_str(formatter, "struct JwkEcKey") + } + + #[inline] + fn visit_seq(self, mut seq: A) -> core::result::Result + where + A: de::SeqAccess<'de>, + { + let kty = de::SeqAccess::next_element::(&mut seq)? + .ok_or_else(|| de::Error::invalid_length(0, &DE_ERROR_MSG))?; + + if kty != EC_KTY { + return Err(de::Error::custom(format!("unsupported JWK kty: {kty:?}"))); + } + + let crv = de::SeqAccess::next_element::(&mut seq)? + .ok_or_else(|| de::Error::invalid_length(1, &DE_ERROR_MSG))?; + + let x = de::SeqAccess::next_element::(&mut seq)? + .ok_or_else(|| de::Error::invalid_length(2, &DE_ERROR_MSG))?; + + let y = de::SeqAccess::next_element::(&mut seq)? + .ok_or_else(|| de::Error::invalid_length(3, &DE_ERROR_MSG))?; + + let d = de::SeqAccess::next_element::>(&mut seq)? + .ok_or_else(|| de::Error::invalid_length(4, &DE_ERROR_MSG))?; + + Ok(JwkEcKey { crv, x, y, d }) + } + + #[inline] + fn visit_map(self, mut map: A) -> core::result::Result + where + A: de::MapAccess<'de>, + { + let mut kty: Option = None; + let mut crv: Option = None; + let mut x: Option = None; + let mut y: Option = None; + let mut d: Option = None; + + while let Some(key) = de::MapAccess::next_key::(&mut map)? { + match key { + Field::Kty => { + if kty.is_none() { + kty = Some(de::MapAccess::next_value::(&mut map)?); + } else { + return Err(de::Error::duplicate_field(FIELDS[0])); + } + } + Field::Crv => { + if crv.is_none() { + crv = Some(de::MapAccess::next_value::(&mut map)?); + } else { + return Err(de::Error::duplicate_field(FIELDS[1])); + } + } + Field::X => { + if x.is_none() { + x = Some(de::MapAccess::next_value::(&mut map)?); + } else { + return Err(de::Error::duplicate_field(FIELDS[2])); + } + } + Field::Y => { + if y.is_none() { + y = Some(de::MapAccess::next_value::(&mut map)?); + } else { + return Err(de::Error::duplicate_field(FIELDS[3])); + } + } + Field::D => { + if d.is_none() { + d = de::MapAccess::next_value::>(&mut map)?; + } else { + return Err(de::Error::duplicate_field(FIELDS[4])); + } + } + } + } + + let kty = kty.ok_or_else(|| de::Error::missing_field("kty"))?; + + if kty != EC_KTY { + return Err(de::Error::custom(format!("unsupported JWK kty: {kty}"))); + } + + let crv = crv.ok_or_else(|| de::Error::missing_field("crv"))?; + let x = x.ok_or_else(|| de::Error::missing_field("x"))?; + let y = y.ok_or_else(|| de::Error::missing_field("y"))?; + + Ok(JwkEcKey { crv, x, y, d }) + } + } + + de::Deserializer::deserialize_struct( + deserializer, + JWK_TYPE_NAME, + FIELDS, + Visitor { + marker: PhantomData::, + lifetime: PhantomData, + }, + ) + } +} + +impl Serialize for JwkEcKey { + fn serialize(&self, serializer: S) -> core::result::Result + where + S: ser::Serializer, + { + use ser::SerializeStruct; + + let mut state = serializer.serialize_struct(JWK_TYPE_NAME, 5)?; + + for (i, field) in [EC_KTY, &self.crv, &self.x, &self.y].iter().enumerate() { + state.serialize_field(FIELDS[i], field)?; + } + + if let Some(d) = &self.d { + state.serialize_field("d", d)?; + } + + SerializeStruct::end(state) + } +} + +/// Decode a Base64url-encoded field element +fn decode_base64url_fe(s: &str) -> Result> { + let mut result = FieldBytes::::default(); + Base64Url::decode(s, &mut result).map_err(|_| Error)?; + Ok(result) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + use super::*; + use elliptic_curve::dev::MockCurve; + + impl JwkParameters for MockCurve { + const CRV: &'static str = "P-256"; + } + + /// Example private key. From RFC 7518 Appendix C: + /// + const JWK_PRIVATE_KEY: &str = r#" + { + "kty":"EC", + "crv":"P-256", + "x":"gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0", + "y":"SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps", + "d":"0_NxaRPUMQoAJt50Gz8YiTr8gRTwyEaCumd-MToTmIo" + } + "#; + + /// Example public key. + const JWK_PUBLIC_KEY: &str = r#" + { + "kty":"EC", + "crv":"P-256", + "x":"gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0", + "y":"SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps" + } + "#; + + /// Example unsupported JWK (RSA key) + const UNSUPPORTED_JWK: &str = r#" + { + "kty":"RSA", + "kid":"cc34c0a0-bd5a-4a3c-a50d-a2a7db7643df", + "use":"sig", + "n":"pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w", + "e":"AQAB", + "d":"ksDmucdMJXkFGZxiomNHnroOZxe8AmDLDGO1vhs-POa5PZM7mtUPonxwjVmthmpbZzla-kg55OFfO7YcXhg-Hm2OWTKwm73_rLh3JavaHjvBqsVKuorX3V3RYkSro6HyYIzFJ1Ek7sLxbjDRcDOj4ievSX0oN9l-JZhaDYlPlci5uJsoqro_YrE0PRRWVhtGynd-_aWgQv1YzkfZuMD-hJtDi1Im2humOWxA4eZrFs9eG-whXcOvaSwO4sSGbS99ecQZHM2TcdXeAs1PvjVgQ_dKnZlGN3lTWoWfQP55Z7Tgt8Nf1q4ZAKd-NlMe-7iqCFfsnFwXjSiaOa2CRGZn-Q", + "p":"4A5nU4ahEww7B65yuzmGeCUUi8ikWzv1C81pSyUKvKzu8CX41hp9J6oRaLGesKImYiuVQK47FhZ--wwfpRwHvSxtNU9qXb8ewo-BvadyO1eVrIk4tNV543QlSe7pQAoJGkxCia5rfznAE3InKF4JvIlchyqs0RQ8wx7lULqwnn0", + "q":"ven83GM6SfrmO-TBHbjTk6JhP_3CMsIvmSdo4KrbQNvp4vHO3w1_0zJ3URkmkYGhz2tgPlfd7v1l2I6QkIh4Bumdj6FyFZEBpxjE4MpfdNVcNINvVj87cLyTRmIcaGxmfylY7QErP8GFA-k4UoH_eQmGKGK44TRzYj5hZYGWIC8", + "dp":"lmmU_AG5SGxBhJqb8wxfNXDPJjf__i92BgJT2Vp4pskBbr5PGoyV0HbfUQVMnw977RONEurkR6O6gxZUeCclGt4kQlGZ-m0_XSWx13v9t9DIbheAtgVJ2mQyVDvK4m7aRYlEceFh0PsX8vYDS5o1txgPwb3oXkPTtrmbAGMUBpE", + "dq":"mxRTU3QDyR2EnCv0Nl0TCF90oliJGAHR9HJmBe__EjuCBbwHfcT8OG3hWOv8vpzokQPRl5cQt3NckzX3fs6xlJN4Ai2Hh2zduKFVQ2p-AF2p6Yfahscjtq-GY9cB85NxLy2IXCC0PF--Sq9LOrTE9QV988SJy_yUrAjcZ5MmECk", + "qi":"ldHXIrEmMZVaNwGzDF9WG8sHj2mOZmQpw9yrjLK9hAsmsNr5LTyqWAqJIYZSwPTYWhY4nu2O0EY9G9uYiqewXfCKw_UngrJt8Xwfq1Zruz0YY869zPN4GiE9-9rzdZB33RBw8kIOquY3MK74FMwCihYx_LiU2YTHkaoJ3ncvtvg" + } + "#; + + #[test] + fn parse_private_key() { + let jwk = JwkEcKey::from_str(JWK_PRIVATE_KEY).unwrap(); + assert_eq!(jwk.crv, "P-256"); + assert_eq!(jwk.x, "gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0"); + assert_eq!(jwk.y, "SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps"); + assert_eq!( + jwk.d.as_ref().unwrap(), + "0_NxaRPUMQoAJt50Gz8YiTr8gRTwyEaCumd-MToTmIo" + ); + } + + #[test] + fn parse_public_key() { + let jwk = JwkEcKey::from_str(JWK_PUBLIC_KEY).unwrap(); + assert_eq!(jwk.crv, "P-256"); + assert_eq!(jwk.x, "gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0"); + assert_eq!(jwk.y, "SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps"); + assert_eq!(jwk.d, None); + } + + #[test] + fn parse_unsupported() { + assert_eq!(JwkEcKey::from_str(UNSUPPORTED_JWK), Err(Error)); + } + + #[test] + fn serialize_private_key() { + let actual = JwkEcKey::from_str(JWK_PRIVATE_KEY).unwrap().to_string(); + let expected: String = JWK_PRIVATE_KEY.split_whitespace().collect(); + assert_eq!(actual, expected); + } + + #[test] + fn serialize_public_key() { + let actual = JwkEcKey::from_str(JWK_PUBLIC_KEY).unwrap().to_string(); + let expected: String = JWK_PUBLIC_KEY.split_whitespace().collect(); + assert_eq!(actual, expected); + } + + #[test] + fn jwk_into_encoded_point() { + let jwk = JwkEcKey::from_str(JWK_PUBLIC_KEY).unwrap(); + let point = jwk.to_encoded_point::().unwrap(); + let (x, y) = match point.coordinates() { + Coordinates::Uncompressed { x, y } => (x, y), + other => panic!("unexpected coordinates: {other:?}"), + }; + + assert_eq!(&decode_base64url_fe::(&jwk.x).unwrap(), x); + assert_eq!(&decode_base64url_fe::(&jwk.y).unwrap(), y); + } + + #[test] + fn encoded_point_into_jwk() { + let jwk = JwkEcKey::from_str(JWK_PUBLIC_KEY).unwrap(); + let point = jwk.to_encoded_point::().unwrap(); + let jwk2 = JwkEcKey::from_encoded_point::(&point).unwrap(); + assert_eq!(jwk, jwk2); + } +} diff --git a/jose-jwk/src/lib.rs b/jose-jwk/src/lib.rs index 48d5e10..178c5f7 100644 --- a/jose-jwk/src/lib.rs +++ b/jose-jwk/src/lib.rs @@ -22,6 +22,7 @@ extern crate alloc; pub mod crypto; +pub mod legacy; mod key; mod prm; From d9ac26b6ed2ca62ab62e00e903937f02eef63d2f Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 18 Aug 2025 12:23:44 -0600 Subject: [PATCH 2/4] Use clippy v1.89 --- .github/workflows/workspace.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/workspace.yml b/.github/workflows/workspace.yml index 3797dfb..e881681 100644 --- a/.github/workflows/workspace.yml +++ b/.github/workflows/workspace.yml @@ -5,7 +5,7 @@ on: paths-ignore: - README.md push: - branches: + branches: - master paths-ignore: - README.md @@ -22,7 +22,7 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.65.0 + - uses: dtolnay/rust-toolchain@1.89 with: components: clippy - run: cargo clippy --all-features --all-targets -- -D warnings @@ -41,5 +41,5 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly with: - components: rustfmt + components: rustfmt - run: cargo fmt --all -- --check From 8ff069eaf7b7da3be77fcf48bfcbfbf0c9e07a81 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 18 Aug 2025 12:48:56 -0600 Subject: [PATCH 3/4] Downgrade serdect to v0.2 --- jose-jwk/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose-jwk/Cargo.toml b/jose-jwk/Cargo.toml index 9ce9e69..443bab6 100644 --- a/jose-jwk/Cargo.toml +++ b/jose-jwk/Cargo.toml @@ -39,7 +39,7 @@ url = { version = "2.4.1", default-features = false, optional = true, features = # legacy dependencies base64ct = { version = "1", optional = true, default-features = false, features = ["alloc"] } elliptic-curve = { version = "0.13", optional = true, default-features = false, features = ["arithmetic", "sec1"] } -serdect = { version = "0.3", optional = true, default-features = false, features = ["alloc"] } +serdect = { version = "0.2", optional = true, default-features = false, features = ["alloc"] } serde_json = { version = "1.0.121", optional = true, default-features = false, features = ["alloc"] } subtle = { version = "2.6", optional = true, default-features = false } From 30dadfcac6af3011e83990f580543b32abe6b9d2 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Mon, 18 Aug 2025 12:49:03 -0600 Subject: [PATCH 4/4] Downgrade serdect to v0.2 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d570ee8..e5cd9d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,9 +514,9 @@ dependencies = [ [[package]] name = "serdect" -version = "0.3.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ "base16ct", "serde",