diff --git a/CHANGELOG.md b/CHANGELOG.md index 9367543e..e1513094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ - `knownValues` generation now aligned with AT Protocol spec and triggers more frequently - Improved feature dependency tracking for API crate features +**Additional signing algorithms** (`jacquard-oauth`) +- Keyset signing now supports ES384 (P-384), ES256K (secp256k1), and EdDSA (Ed25519) in addition to ES256 +- `Keyset::create_jwt` now accepts `&[Signing]` (from `jose_jwa`) instead of string-based algorithm names + +**Documentation** (`jacquard-oauth`, `jacquard-identity`) +- Doc comments across all public items in both crates (thanks Claude, but I played editor pretty heavily) + ### Fixed **Identity resolution** (`jacquard-identity`) diff --git a/Cargo.lock b/Cargo.lock index 38bf1fb9..398eeba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,6 +1254,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1955,6 +1956,15 @@ dependencies = [ "tracing 0.1.44 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2615,15 +2625,18 @@ dependencies = [ "bytes", "chrono", "dashmap", + "ed25519-dalek", "elliptic-curve", "http", "jacquard-common", "jacquard-identity", "jose-jwa", "jose-jwk", + "k256", "miette", "n0-future", "p256", + "p384", "rand 0.8.5", "rouille", "serde", @@ -3546,8 +3559,10 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2", ] [[package]] diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml index 14a1195c..46ffa836 100644 --- a/crates/jacquard-oauth/Cargo.toml +++ b/crates/jacquard-oauth/Cargo.toml @@ -32,8 +32,11 @@ thiserror = { workspace = true } serde_html_form = { workspace = true } miette = { workspace = true } p256 = { workspace = true, features = ["ecdsa"] } +p384 = { version = "0.13", features = ["ecdsa"] } +k256 = { version = "0.13", features = ["ecdsa"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } jose-jwa = "0.1" -jose-jwk = { workspace = true, features = ["p256"] } +jose-jwk = { workspace = true, features = ["p256", "p384"] } chrono.workspace = true elliptic-curve = "0.13.8" http.workspace = true diff --git a/crates/jacquard-oauth/src/dpop.rs b/crates/jacquard-oauth/src/dpop.rs index 93d9ed56..97c20899 100644 --- a/crates/jacquard-oauth/src/dpop.rs +++ b/crates/jacquard-oauth/src/dpop.rs @@ -16,9 +16,9 @@ use smol_str::SmolStr; use crate::{ jose::{ - create_signed_jwt, jws::RegisteredHeader, jwt::{Claims, PublicClaims, RegisteredClaims}, + signing, }, session::DpopDataSource, }; @@ -800,7 +800,7 @@ pub fn build_dpop_proof<'s>( nonce: nonce, }, }; - Ok(create_signed_jwt( + Ok(signing::create_signed_jwt_es256( SigningKey::from(secret.clone()), header.into(), claims, diff --git a/crates/jacquard-oauth/src/jose.rs b/crates/jacquard-oauth/src/jose.rs index 1b2f2973..00944c39 100644 --- a/crates/jacquard-oauth/src/jose.rs +++ b/crates/jacquard-oauth/src/jose.rs @@ -2,7 +2,7 @@ pub mod jws; /// JWT (JSON Web Token) claims types. pub mod jwt; -/// Signed JWT creation using ES256 keys. +/// Signed JWT creation for supported algorithms (ES256, ES384, ES256K, EdDSA). pub mod signing; use serde::{Deserialize, Serialize}; @@ -18,4 +18,3 @@ pub enum Header<'a> { Jws(jws::Header<'a>), } -pub use self::signing::create_signed_jwt; diff --git a/crates/jacquard-oauth/src/jose/signing.rs b/crates/jacquard-oauth/src/jose/signing.rs index 2074a623..99fa943b 100644 --- a/crates/jacquard-oauth/src/jose/signing.rs +++ b/crates/jacquard-oauth/src/jose/signing.rs @@ -1,22 +1,65 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use jacquard_common::CowStr; -use p256::ecdsa::{Signature, SigningKey, signature::Signer}; use super::{Header, jwt::Claims}; -/// Creates a compact-serialized signed JWT using an ES256 (P-256 ECDSA) key. -pub fn create_signed_jwt( - key: SigningKey, +/// Builds the base64url-encoded `header.payload` signing input. +fn signing_input(header: &Header, claims: &Claims) -> serde_json::Result<(String, String)> { + let h = URL_SAFE_NO_PAD.encode(serde_json::to_string(header)?); + let p = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims)?); + Ok((h, p)) +} + +/// Assembles a compact JWS from pre-encoded parts and raw signature bytes. +fn assemble(header: &str, payload: &str, sig: &[u8]) -> CowStr<'static> { + format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(sig)).into() +} + +/// Creates a compact-serialized signed JWT using ES256 (P-256 ECDSA with SHA-256). +pub fn create_signed_jwt_es256( + key: p256::ecdsa::SigningKey, + header: Header, + claims: Claims, +) -> serde_json::Result> { + use p256::ecdsa::signature::Signer; + let (h, p) = signing_input(&header, &claims)?; + let sig: p256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); + Ok(assemble(&h, &p, &sig.to_bytes())) +} + +/// Creates a compact-serialized signed JWT using ES384 (P-384 ECDSA with SHA-384). +pub fn create_signed_jwt_es384( + key: p384::ecdsa::SigningKey, + header: Header, + claims: Claims, +) -> serde_json::Result> { + use p384::ecdsa::signature::Signer; + let (h, p) = signing_input(&header, &claims)?; + let sig: p384::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); + Ok(assemble(&h, &p, &sig.to_bytes())) +} + +/// Creates a compact-serialized signed JWT using ES256K (secp256k1 ECDSA with SHA-256). +pub fn create_signed_jwt_es256k( + key: k256::ecdsa::SigningKey, + header: Header, + claims: Claims, +) -> serde_json::Result> { + use k256::ecdsa::signature::Signer; + let (h, p) = signing_input(&header, &claims)?; + let sig: k256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); + Ok(assemble(&h, &p, &sig.to_bytes())) +} + +/// Creates a compact-serialized signed JWT using EdDSA (Ed25519). +pub fn create_signed_jwt_eddsa( + key: ed25519_dalek::SigningKey, header: Header, claims: Claims, ) -> serde_json::Result> { - let header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?); - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims)?); - let signature: Signature = key.sign(format!("{header}.{payload}").as_bytes()); - Ok(format!( - "{header}.{payload}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ) - .into()) + use ed25519_dalek::Signer; + let (h, p) = signing_input(&header, &claims)?; + let sig = key.sign(format!("{h}.{p}").as_bytes()); + Ok(assemble(&h, &p, &sig.to_bytes())) } diff --git a/crates/jacquard-oauth/src/keyset.rs b/crates/jacquard-oauth/src/keyset.rs index 2fd4c2c6..4bfdb8d6 100644 --- a/crates/jacquard-oauth/src/keyset.rs +++ b/crates/jacquard-oauth/src/keyset.rs @@ -1,9 +1,9 @@ -use crate::jose::create_signed_jwt; use crate::jose::jws::RegisteredHeader; use crate::jose::jwt::Claims; -use jacquard_common::{CowStr, IntoStatic}; +use crate::jose::signing; +use jacquard_common::CowStr; use jose_jwa::{Algorithm, Signing}; -use jose_jwk::{Class, EcCurves, crypto}; +use jose_jwk::{Class, EcCurves, OkpCurves, crypto}; use jose_jwk::{Jwk, JwkSet, Key}; use std::collections::HashSet; use thiserror::Error; @@ -23,13 +23,22 @@ pub enum Error { EmptyKid(usize), /// No key in the set matches any of the requested signing algorithms. #[error("no signing key found for algorithms: {0:?}")] - NotFound(Vec>), + NotFound(Vec), /// Only secret (private) keys may be used for signing; a public key was provided. #[error("key for signing must be a secret key")] PublicKey, + /// The key type or curve is not supported for signing. + #[error("unsupported key type for signing")] + UnsupportedKey, + /// The private key (`d` parameter) is missing from the JWK. + #[error("missing private key material")] + MissingPrivateKey, /// An error from the underlying JWK cryptographic operation. #[error("crypto error: {0:?}")] JwkCrypto(crypto::Error), + /// The raw key bytes have an invalid length or format. + #[error("invalid key material: {0}")] + InvalidKey(String), /// JSON serialization of a JWT header or claims payload failed. #[error(transparent)] SerdeJson(#[from] serde_json::Error), @@ -38,17 +47,25 @@ pub enum Error { /// Convenience result type for keyset operations. pub type Result = core::result::Result; +/// Signing algorithm preference order for AT Protocol OAuth. +/// +/// EdDSA and ES256K are preferred for their security properties, followed by +/// the NIST curves. This order matches common AT Protocol server expectations. +const PREFERRED_SIGNING_ALGORITHMS: [Signing; 4] = [ + Signing::EdDsa, + Signing::Es256K, + Signing::Es256, + Signing::Es384, +]; + /// A validated collection of JWK secret keys used for signing DPoP proofs and client assertions. /// -/// Key selection follows a preference order defined in [`PREFERRED_SIGNING_ALGORITHMS`](Self::PREFERRED_SIGNING_ALGORITHMS), -/// though currently only P-256 (ES256) keys are supported. +/// Key selection follows [`PREFERRED_SIGNING_ALGORITHMS`] when multiple keys match. +/// Supported algorithms: EdDSA (Ed25519), ES256K (secp256k1), ES256 (P-256), ES384 (P-384). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Keyset(Vec); impl Keyset { - const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [ - "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", - ]; /// Returns a [`JwkSet`] containing the public halves of all keys in this keyset. pub fn public_jwks(&self) -> JwkSet { let mut keys = Vec::with_capacity(self.0.len()); @@ -57,22 +74,27 @@ impl Keyset { Key::Ec(ref mut ec) => { ec.d = None; } - _ => unimplemented!(), + Key::Okp(ref mut okp) => { + okp.d = None; + } + _ => {} } keys.push(key); } JwkSet { keys } } + /// Signs a JWT with the best available key that matches one of the requested algorithms. /// /// Returns [`Error::NotFound`] if no key in the keyset supports any of the given algorithms. - pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result> { + pub fn create_jwt(&self, algs: &[Signing], claims: Claims) -> Result> { let Some(jwk) = self.find_key(algs, Class::Signing) else { - return Err(Error::NotFound(algs.to_vec().into_static())); + return Err(Error::NotFound(algs.to_vec())); }; self.create_jwt_with_key(jwk, claims) } - fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> { + + fn find_key(&self, algs: &[Signing], cls: Class) -> Option<&Jwk> { let candidates = self .0 .iter() @@ -80,38 +102,114 @@ impl Keyset { if key.prm.cls.is_some_and(|c| c != cls) { return None; } - let alg = match &key.key { - Key::Ec(ec) => match ec.crv { - EcCurves::P256 => "ES256", - _ => unimplemented!(), - }, - _ => unimplemented!(), - }; - Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg))) + let alg = alg_for_key(&key.key)?; + Some((alg, key)).filter(|(alg, _)| algs.contains(alg)) }) .collect::>(); - for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { + for pref_alg in PREFERRED_SIGNING_ALGORITHMS { for (alg, key) in &candidates { - if alg == &pref_alg { + if *alg == pref_alg { return Some(key); } } } None } + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result> { let kid = key.prm.kid.clone().unwrap(); - match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { - crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { - let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); - header.kid = Some(kid.into()); - Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) + match &key.key { + Key::Ec(ec) => { + let d = ec.d.as_ref().ok_or(Error::MissingPrivateKey)?; + let d_bytes: &[u8] = d.as_ref(); + match ec.crv { + EcCurves::P256 => { + let signing_key = p256::ecdsa::SigningKey::from_bytes(d_bytes.into()) + .map_err(|e| Error::InvalidKey(e.to_string()))?; + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); + header.kid = Some(kid.into()); + Ok(signing::create_signed_jwt_es256( + signing_key, + header.into(), + claims, + )?) + } + EcCurves::P384 => { + let signing_key = p384::ecdsa::SigningKey::from_bytes(d_bytes.into()) + .map_err(|e| Error::InvalidKey(e.to_string()))?; + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es384)); + header.kid = Some(kid.into()); + Ok(signing::create_signed_jwt_es384( + signing_key, + header.into(), + claims, + )?) + } + EcCurves::P256K => { + let signing_key = k256::ecdsa::SigningKey::from_bytes(d_bytes.into()) + .map_err(|e| Error::InvalidKey(e.to_string()))?; + let mut header = + RegisteredHeader::from(Algorithm::Signing(Signing::Es256K)); + header.kid = Some(kid.into()); + Ok(signing::create_signed_jwt_es256k( + signing_key, + header.into(), + claims, + )?) + } + _ => Err(Error::UnsupportedKey), + } } - _ => unimplemented!(), + Key::Okp(okp) => match okp.crv { + OkpCurves::Ed25519 => { + let d = okp.d.as_ref().ok_or(Error::MissingPrivateKey)?; + let d_bytes: &[u8] = d.as_ref(); + let signing_key = ed25519_dalek::SigningKey::try_from(d_bytes) + .map_err(|e| Error::InvalidKey(e.to_string()))?; + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::EdDsa)); + header.kid = Some(kid.into()); + Ok(signing::create_signed_jwt_eddsa( + signing_key, + header.into(), + claims, + )?) + } + _ => Err(Error::UnsupportedKey), + }, + _ => Err(Error::UnsupportedKey), } } } +/// Returns the signing algorithm for the given JWK key type, if supported. +fn alg_for_key(key: &Key) -> Option { + match key { + Key::Ec(ec) => match ec.crv { + EcCurves::P256 => Some(Signing::Es256), + EcCurves::P384 => Some(Signing::Es384), + EcCurves::P256K => Some(Signing::Es256K), + _ => None, + }, + Key::Okp(okp) => match okp.crv { + OkpCurves::Ed25519 => Some(Signing::EdDsa), + _ => None, + }, + _ => None, + } +} + +/// Parses a string-based algorithm name into a [`Signing`] variant, if it maps to +/// an algorithm this crate supports. +pub fn parse_signing_alg(s: &str) -> Option { + match s { + "ES256" => Some(Signing::Es256), + "ES384" => Some(Signing::Es384), + "ES256K" => Some(Signing::Es256K), + "EdDSA" => Some(Signing::EdDsa), + _ => None, + } +} + impl TryFrom> for Keyset { type Error = Error; @@ -127,14 +225,28 @@ impl TryFrom> for Keyset { return Err(Error::DuplicateKid(kid)); } hs.insert(kid); - // ensure that the key is a secret key - if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { - crypto::Key::P256(crypto::Kind::Public(_)) => true, - crypto::Key::P256(crypto::Kind::Secret(_)) => false, - _ => unimplemented!(), - } { - return Err(Error::PublicKey); + + // Validate that the key has private material and is a supported type. + match &key.key { + Key::Ec(ec) => { + if ec.d.is_none() { + return Err(Error::PublicKey); + } + if alg_for_key(&key.key).is_none() { + return Err(Error::UnsupportedKey); + } + } + Key::Okp(okp) => { + if okp.d.is_none() { + return Err(Error::PublicKey); + } + if alg_for_key(&key.key).is_none() { + return Err(Error::UnsupportedKey); + } + } + _ => return Err(Error::UnsupportedKey), } + v.push(key); } else { return Err(Error::EmptyKid(i)); diff --git a/crates/jacquard-oauth/src/request.rs b/crates/jacquard-oauth/src/request.rs index 53570fc0..e211d8c3 100644 --- a/crates/jacquard-oauth/src/request.rs +++ b/crates/jacquard-oauth/src/request.rs @@ -15,6 +15,8 @@ use serde::Serialize; use serde_json::Value; use smol_str::ToSmolStr; +use jose_jwa::Signing; + use crate::{ FALLBACK_ALG, atproto::atproto_client_metadata, @@ -886,11 +888,15 @@ fn build_auth<'a>( .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => { if let Some(keyset) = &keyset { - let mut algs = server_metadata + let mut alg_strs = server_metadata .token_endpoint_auth_signing_alg_values_supported .clone() .unwrap_or(vec![FALLBACK_ALG.into()]); - algs.sort_by(compare_algos); + alg_strs.sort_by(compare_algos); + let algs: Vec = alg_strs + .iter() + .filter_map(|s| crate::keyset::parse_signing_alg(s)) + .collect(); let iat = Utc::now().timestamp(); return Ok(ClientAuth { client_id: client_id.clone(),