A better Rust ATProto crate
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

updates to signing algorithms for oauth

Orual 3115ebbf c7cafbcb

+240 -55
+7
CHANGELOG.md
··· 34 34 - `knownValues` generation now aligned with AT Protocol spec and triggers more frequently 35 35 - Improved feature dependency tracking for API crate features 36 36 37 + **Additional signing algorithms** (`jacquard-oauth`) 38 + - Keyset signing now supports ES384 (P-384), ES256K (secp256k1), and EdDSA (Ed25519) in addition to ES256 39 + - `Keyset::create_jwt` now accepts `&[Signing]` (from `jose_jwa`) instead of string-based algorithm names 40 + 41 + **Documentation** (`jacquard-oauth`, `jacquard-identity`) 42 + - Doc comments across all public items in both crates (thanks Claude, but I played editor pretty heavily) 43 + 37 44 ### Fixed 38 45 39 46 **Identity resolution** (`jacquard-identity`)
+15
Cargo.lock
··· 1254 1254 "ff", 1255 1255 "generic-array", 1256 1256 "group", 1257 + "hkdf", 1257 1258 "pem-rfc7468", 1258 1259 "pkcs8", 1259 1260 "rand_core 0.6.4", ··· 1956 1957 ] 1957 1958 1958 1959 [[package]] 1960 + name = "hkdf" 1961 + version = "0.12.4" 1962 + source = "registry+https://github.com/rust-lang/crates.io-index" 1963 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 1964 + dependencies = [ 1965 + "hmac", 1966 + ] 1967 + 1968 + [[package]] 1959 1969 name = "hmac" 1960 1970 version = "0.12.1" 1961 1971 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2615 2625 "bytes", 2616 2626 "chrono", 2617 2627 "dashmap", 2628 + "ed25519-dalek", 2618 2629 "elliptic-curve", 2619 2630 "http", 2620 2631 "jacquard-common", 2621 2632 "jacquard-identity", 2622 2633 "jose-jwa", 2623 2634 "jose-jwk", 2635 + "k256", 2624 2636 "miette", 2625 2637 "n0-future", 2626 2638 "p256", 2639 + "p384", 2627 2640 "rand 0.8.5", 2628 2641 "rouille", 2629 2642 "serde", ··· 3546 3559 source = "registry+https://github.com/rust-lang/crates.io-index" 3547 3560 checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 3548 3561 dependencies = [ 3562 + "ecdsa", 3549 3563 "elliptic-curve", 3550 3564 "primeorder", 3565 + "sha2", 3551 3566 ] 3552 3567 3553 3568 [[package]]
+4 -1
crates/jacquard-oauth/Cargo.toml
··· 32 32 serde_html_form = { workspace = true } 33 33 miette = { workspace = true } 34 34 p256 = { workspace = true, features = ["ecdsa"] } 35 + p384 = { version = "0.13", features = ["ecdsa"] } 36 + k256 = { version = "0.13", features = ["ecdsa"] } 37 + ed25519-dalek = { version = "2", features = ["rand_core"] } 35 38 jose-jwa = "0.1" 36 - jose-jwk = { workspace = true, features = ["p256"] } 39 + jose-jwk = { workspace = true, features = ["p256", "p384"] } 37 40 chrono.workspace = true 38 41 elliptic-curve = "0.13.8" 39 42 http.workspace = true
+2 -2
crates/jacquard-oauth/src/dpop.rs
··· 16 16 17 17 use crate::{ 18 18 jose::{ 19 - create_signed_jwt, 20 19 jws::RegisteredHeader, 21 20 jwt::{Claims, PublicClaims, RegisteredClaims}, 21 + signing, 22 22 }, 23 23 session::DpopDataSource, 24 24 }; ··· 800 800 nonce: nonce, 801 801 }, 802 802 }; 803 - Ok(create_signed_jwt( 803 + Ok(signing::create_signed_jwt_es256( 804 804 SigningKey::from(secret.clone()), 805 805 header.into(), 806 806 claims,
+1 -2
crates/jacquard-oauth/src/jose.rs
··· 2 2 pub mod jws; 3 3 /// JWT (JSON Web Token) claims types. 4 4 pub mod jwt; 5 - /// Signed JWT creation using ES256 keys. 5 + /// Signed JWT creation for supported algorithms (ES256, ES384, ES256K, EdDSA). 6 6 pub mod signing; 7 7 8 8 use serde::{Deserialize, Serialize}; ··· 18 18 Jws(jws::Header<'a>), 19 19 } 20 20 21 - pub use self::signing::create_signed_jwt;
+55 -12
crates/jacquard-oauth/src/jose/signing.rs
··· 1 1 use base64::Engine; 2 2 use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 3 use jacquard_common::CowStr; 4 - use p256::ecdsa::{Signature, SigningKey, signature::Signer}; 5 4 6 5 use super::{Header, jwt::Claims}; 7 6 8 - /// Creates a compact-serialized signed JWT using an ES256 (P-256 ECDSA) key. 9 - pub fn create_signed_jwt( 10 - key: SigningKey, 7 + /// Builds the base64url-encoded `header.payload` signing input. 8 + fn signing_input(header: &Header, claims: &Claims) -> serde_json::Result<(String, String)> { 9 + let h = URL_SAFE_NO_PAD.encode(serde_json::to_string(header)?); 10 + let p = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims)?); 11 + Ok((h, p)) 12 + } 13 + 14 + /// Assembles a compact JWS from pre-encoded parts and raw signature bytes. 15 + fn assemble(header: &str, payload: &str, sig: &[u8]) -> CowStr<'static> { 16 + format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(sig)).into() 17 + } 18 + 19 + /// Creates a compact-serialized signed JWT using ES256 (P-256 ECDSA with SHA-256). 20 + pub fn create_signed_jwt_es256( 21 + key: p256::ecdsa::SigningKey, 22 + header: Header, 23 + claims: Claims, 24 + ) -> serde_json::Result<CowStr<'static>> { 25 + use p256::ecdsa::signature::Signer; 26 + let (h, p) = signing_input(&header, &claims)?; 27 + let sig: p256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 28 + Ok(assemble(&h, &p, &sig.to_bytes())) 29 + } 30 + 31 + /// Creates a compact-serialized signed JWT using ES384 (P-384 ECDSA with SHA-384). 32 + pub fn create_signed_jwt_es384( 33 + key: p384::ecdsa::SigningKey, 34 + header: Header, 35 + claims: Claims, 36 + ) -> serde_json::Result<CowStr<'static>> { 37 + use p384::ecdsa::signature::Signer; 38 + let (h, p) = signing_input(&header, &claims)?; 39 + let sig: p384::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 40 + Ok(assemble(&h, &p, &sig.to_bytes())) 41 + } 42 + 43 + /// Creates a compact-serialized signed JWT using ES256K (secp256k1 ECDSA with SHA-256). 44 + pub fn create_signed_jwt_es256k( 45 + key: k256::ecdsa::SigningKey, 46 + header: Header, 47 + claims: Claims, 48 + ) -> serde_json::Result<CowStr<'static>> { 49 + use k256::ecdsa::signature::Signer; 50 + let (h, p) = signing_input(&header, &claims)?; 51 + let sig: k256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 52 + Ok(assemble(&h, &p, &sig.to_bytes())) 53 + } 54 + 55 + /// Creates a compact-serialized signed JWT using EdDSA (Ed25519). 56 + pub fn create_signed_jwt_eddsa( 57 + key: ed25519_dalek::SigningKey, 11 58 header: Header, 12 59 claims: Claims, 13 60 ) -> serde_json::Result<CowStr<'static>> { 14 - let header = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?); 15 - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims)?); 16 - let signature: Signature = key.sign(format!("{header}.{payload}").as_bytes()); 17 - Ok(format!( 18 - "{header}.{payload}.{}", 19 - URL_SAFE_NO_PAD.encode(signature.to_bytes()) 20 - ) 21 - .into()) 61 + use ed25519_dalek::Signer; 62 + let (h, p) = signing_input(&header, &claims)?; 63 + let sig = key.sign(format!("{h}.{p}").as_bytes()); 64 + Ok(assemble(&h, &p, &sig.to_bytes())) 22 65 }
+148 -36
crates/jacquard-oauth/src/keyset.rs
··· 1 - use crate::jose::create_signed_jwt; 2 1 use crate::jose::jws::RegisteredHeader; 3 2 use crate::jose::jwt::Claims; 4 - use jacquard_common::{CowStr, IntoStatic}; 3 + use crate::jose::signing; 4 + use jacquard_common::CowStr; 5 5 use jose_jwa::{Algorithm, Signing}; 6 - use jose_jwk::{Class, EcCurves, crypto}; 6 + use jose_jwk::{Class, EcCurves, OkpCurves, crypto}; 7 7 use jose_jwk::{Jwk, JwkSet, Key}; 8 8 use std::collections::HashSet; 9 9 use thiserror::Error; ··· 23 23 EmptyKid(usize), 24 24 /// No key in the set matches any of the requested signing algorithms. 25 25 #[error("no signing key found for algorithms: {0:?}")] 26 - NotFound(Vec<CowStr<'static>>), 26 + NotFound(Vec<Signing>), 27 27 /// Only secret (private) keys may be used for signing; a public key was provided. 28 28 #[error("key for signing must be a secret key")] 29 29 PublicKey, 30 + /// The key type or curve is not supported for signing. 31 + #[error("unsupported key type for signing")] 32 + UnsupportedKey, 33 + /// The private key (`d` parameter) is missing from the JWK. 34 + #[error("missing private key material")] 35 + MissingPrivateKey, 30 36 /// An error from the underlying JWK cryptographic operation. 31 37 #[error("crypto error: {0:?}")] 32 38 JwkCrypto(crypto::Error), 39 + /// The raw key bytes have an invalid length or format. 40 + #[error("invalid key material: {0}")] 41 + InvalidKey(String), 33 42 /// JSON serialization of a JWT header or claims payload failed. 34 43 #[error(transparent)] 35 44 SerdeJson(#[from] serde_json::Error), ··· 38 47 /// Convenience result type for keyset operations. 39 48 pub type Result<T> = core::result::Result<T, Error>; 40 49 50 + /// Signing algorithm preference order for AT Protocol OAuth. 51 + /// 52 + /// EdDSA and ES256K are preferred for their security properties, followed by 53 + /// the NIST curves. This order matches common AT Protocol server expectations. 54 + const PREFERRED_SIGNING_ALGORITHMS: [Signing; 4] = [ 55 + Signing::EdDsa, 56 + Signing::Es256K, 57 + Signing::Es256, 58 + Signing::Es384, 59 + ]; 60 + 41 61 /// A validated collection of JWK secret keys used for signing DPoP proofs and client assertions. 42 62 /// 43 - /// Key selection follows a preference order defined in [`PREFERRED_SIGNING_ALGORITHMS`](Self::PREFERRED_SIGNING_ALGORITHMS), 44 - /// though currently only P-256 (ES256) keys are supported. 63 + /// Key selection follows [`PREFERRED_SIGNING_ALGORITHMS`] when multiple keys match. 64 + /// Supported algorithms: EdDSA (Ed25519), ES256K (secp256k1), ES256 (P-256), ES384 (P-384). 45 65 #[derive(Clone, Debug, Default, PartialEq, Eq)] 46 66 pub struct Keyset(Vec<Jwk>); 47 67 48 68 impl Keyset { 49 - const PREFERRED_SIGNING_ALGORITHMS: [&'static str; 9] = [ 50 - "EdDSA", "ES256K", "ES256", "PS256", "PS384", "PS512", "HS256", "HS384", "HS512", 51 - ]; 52 69 /// Returns a [`JwkSet`] containing the public halves of all keys in this keyset. 53 70 pub fn public_jwks(&self) -> JwkSet { 54 71 let mut keys = Vec::with_capacity(self.0.len()); ··· 57 74 Key::Ec(ref mut ec) => { 58 75 ec.d = None; 59 76 } 60 - _ => unimplemented!(), 77 + Key::Okp(ref mut okp) => { 78 + okp.d = None; 79 + } 80 + _ => {} 61 81 } 62 82 keys.push(key); 63 83 } 64 84 JwkSet { keys } 65 85 } 86 + 66 87 /// Signs a JWT with the best available key that matches one of the requested algorithms. 67 88 /// 68 89 /// Returns [`Error::NotFound`] if no key in the keyset supports any of the given algorithms. 69 - pub fn create_jwt(&self, algs: &[CowStr], claims: Claims) -> Result<CowStr<'static>> { 90 + pub fn create_jwt(&self, algs: &[Signing], claims: Claims) -> Result<CowStr<'static>> { 70 91 let Some(jwk) = self.find_key(algs, Class::Signing) else { 71 - return Err(Error::NotFound(algs.to_vec().into_static())); 92 + return Err(Error::NotFound(algs.to_vec())); 72 93 }; 73 94 self.create_jwt_with_key(jwk, claims) 74 95 } 75 - fn find_key(&self, algs: &[CowStr], cls: Class) -> Option<&Jwk> { 96 + 97 + fn find_key(&self, algs: &[Signing], cls: Class) -> Option<&Jwk> { 76 98 let candidates = self 77 99 .0 78 100 .iter() ··· 80 102 if key.prm.cls.is_some_and(|c| c != cls) { 81 103 return None; 82 104 } 83 - let alg = match &key.key { 84 - Key::Ec(ec) => match ec.crv { 85 - EcCurves::P256 => "ES256", 86 - _ => unimplemented!(), 87 - }, 88 - _ => unimplemented!(), 89 - }; 90 - Some((alg, key)).filter(|(alg, _)| algs.contains(&CowStr::Borrowed(&alg))) 105 + let alg = alg_for_key(&key.key)?; 106 + Some((alg, key)).filter(|(alg, _)| algs.contains(alg)) 91 107 }) 92 108 .collect::<Vec<_>>(); 93 - for pref_alg in Self::PREFERRED_SIGNING_ALGORITHMS { 109 + for pref_alg in PREFERRED_SIGNING_ALGORITHMS { 94 110 for (alg, key) in &candidates { 95 - if alg == &pref_alg { 111 + if *alg == pref_alg { 96 112 return Some(key); 97 113 } 98 114 } 99 115 } 100 116 None 101 117 } 118 + 102 119 fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> { 103 120 let kid = key.prm.kid.clone().unwrap(); 104 - match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 105 - crypto::Key::P256(crypto::Kind::Secret(secret_key)) => { 106 - let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 107 - header.kid = Some(kid.into()); 108 - Ok(create_signed_jwt(secret_key.into(), header.into(), claims)?) 121 + match &key.key { 122 + Key::Ec(ec) => { 123 + let d = ec.d.as_ref().ok_or(Error::MissingPrivateKey)?; 124 + let d_bytes: &[u8] = d.as_ref(); 125 + match ec.crv { 126 + EcCurves::P256 => { 127 + let signing_key = p256::ecdsa::SigningKey::from_bytes(d_bytes.into()) 128 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 129 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 130 + header.kid = Some(kid.into()); 131 + Ok(signing::create_signed_jwt_es256( 132 + signing_key, 133 + header.into(), 134 + claims, 135 + )?) 136 + } 137 + EcCurves::P384 => { 138 + let signing_key = p384::ecdsa::SigningKey::from_bytes(d_bytes.into()) 139 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 140 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es384)); 141 + header.kid = Some(kid.into()); 142 + Ok(signing::create_signed_jwt_es384( 143 + signing_key, 144 + header.into(), 145 + claims, 146 + )?) 147 + } 148 + EcCurves::P256K => { 149 + let signing_key = k256::ecdsa::SigningKey::from_bytes(d_bytes.into()) 150 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 151 + let mut header = 152 + RegisteredHeader::from(Algorithm::Signing(Signing::Es256K)); 153 + header.kid = Some(kid.into()); 154 + Ok(signing::create_signed_jwt_es256k( 155 + signing_key, 156 + header.into(), 157 + claims, 158 + )?) 159 + } 160 + _ => Err(Error::UnsupportedKey), 161 + } 109 162 } 110 - _ => unimplemented!(), 163 + Key::Okp(okp) => match okp.crv { 164 + OkpCurves::Ed25519 => { 165 + let d = okp.d.as_ref().ok_or(Error::MissingPrivateKey)?; 166 + let d_bytes: &[u8] = d.as_ref(); 167 + let signing_key = ed25519_dalek::SigningKey::try_from(d_bytes) 168 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 169 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::EdDsa)); 170 + header.kid = Some(kid.into()); 171 + Ok(signing::create_signed_jwt_eddsa( 172 + signing_key, 173 + header.into(), 174 + claims, 175 + )?) 176 + } 177 + _ => Err(Error::UnsupportedKey), 178 + }, 179 + _ => Err(Error::UnsupportedKey), 111 180 } 112 181 } 113 182 } 114 183 184 + /// Returns the signing algorithm for the given JWK key type, if supported. 185 + fn alg_for_key(key: &Key) -> Option<Signing> { 186 + match key { 187 + Key::Ec(ec) => match ec.crv { 188 + EcCurves::P256 => Some(Signing::Es256), 189 + EcCurves::P384 => Some(Signing::Es384), 190 + EcCurves::P256K => Some(Signing::Es256K), 191 + _ => None, 192 + }, 193 + Key::Okp(okp) => match okp.crv { 194 + OkpCurves::Ed25519 => Some(Signing::EdDsa), 195 + _ => None, 196 + }, 197 + _ => None, 198 + } 199 + } 200 + 201 + /// Parses a string-based algorithm name into a [`Signing`] variant, if it maps to 202 + /// an algorithm this crate supports. 203 + pub fn parse_signing_alg(s: &str) -> Option<Signing> { 204 + match s { 205 + "ES256" => Some(Signing::Es256), 206 + "ES384" => Some(Signing::Es384), 207 + "ES256K" => Some(Signing::Es256K), 208 + "EdDSA" => Some(Signing::EdDsa), 209 + _ => None, 210 + } 211 + } 212 + 115 213 impl TryFrom<Vec<Jwk>> for Keyset { 116 214 type Error = Error; 117 215 ··· 127 225 return Err(Error::DuplicateKid(kid)); 128 226 } 129 227 hs.insert(kid); 130 - // ensure that the key is a secret key 131 - if match crypto::Key::try_from(&key.key).map_err(Error::JwkCrypto)? { 132 - crypto::Key::P256(crypto::Kind::Public(_)) => true, 133 - crypto::Key::P256(crypto::Kind::Secret(_)) => false, 134 - _ => unimplemented!(), 135 - } { 136 - return Err(Error::PublicKey); 228 + 229 + // Validate that the key has private material and is a supported type. 230 + match &key.key { 231 + Key::Ec(ec) => { 232 + if ec.d.is_none() { 233 + return Err(Error::PublicKey); 234 + } 235 + if alg_for_key(&key.key).is_none() { 236 + return Err(Error::UnsupportedKey); 237 + } 238 + } 239 + Key::Okp(okp) => { 240 + if okp.d.is_none() { 241 + return Err(Error::PublicKey); 242 + } 243 + if alg_for_key(&key.key).is_none() { 244 + return Err(Error::UnsupportedKey); 245 + } 246 + } 247 + _ => return Err(Error::UnsupportedKey), 137 248 } 249 + 138 250 v.push(key); 139 251 } else { 140 252 return Err(Error::EmptyKid(i));
+8 -2
crates/jacquard-oauth/src/request.rs
··· 15 15 use serde_json::Value; 16 16 use smol_str::ToSmolStr; 17 17 18 + use jose_jwa::Signing; 19 + 18 20 use crate::{ 19 21 FALLBACK_ALG, 20 22 atproto::atproto_client_metadata, ··· 886 888 .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => 887 889 { 888 890 if let Some(keyset) = &keyset { 889 - let mut algs = server_metadata 891 + let mut alg_strs = server_metadata 890 892 .token_endpoint_auth_signing_alg_values_supported 891 893 .clone() 892 894 .unwrap_or(vec![FALLBACK_ALG.into()]); 893 - algs.sort_by(compare_algos); 895 + alg_strs.sort_by(compare_algos); 896 + let algs: Vec<Signing> = alg_strs 897 + .iter() 898 + .filter_map(|s| crate::keyset::parse_signing_alg(s)) 899 + .collect(); 894 900 let iat = Utc::now().timestamp(); 895 901 return Ok(ClientAuth { 896 902 client_id: client_id.clone(),