CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

at main 553 lines 22 kB view raw
1//! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for 2//! atproto service-auth. 3//! 4//! This module exists to avoid pulling a full JWT library for a handful of 5//! tightly-scoped use cases: minting self-mint JWTs for labeler conformance 6//! tests, and decoding them in tests to verify round-trip correctness. 7//! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 8//! encoding, unpadded base64url segments, UTF-8 JSON payloads. 9 10// pattern: Functional Core 11 12use base64::Engine; 13use base64::engine::general_purpose::URL_SAFE_NO_PAD; 14use serde::{Deserialize, Serialize}; 15use thiserror::Error; 16 17use crate::common::identity::AnySigningKey; 18 19#[cfg(test)] 20use crate::common::identity::{AnySignature, AnySignatureError, AnyVerifyingKey}; 21 22/// Compact JWS header for atproto service-auth tokens. 23/// 24/// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT 25/// rename without updating `serde` attributes — atproto wire format 26/// requires exactly `alg` and `typ`. 27#[derive(Debug, Clone, Serialize, Deserialize)] 28pub struct JwtHeader { 29 /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256). 30 pub alg: String, 31 /// Token type; always "JWT". 32 pub typ: String, 33} 34 35impl JwtHeader { 36 /// Build a header for the given signing key, setting `alg` to match the 37 /// curve and `typ` to `"JWT"`. 38 pub fn for_signing_key(key: &AnySigningKey) -> Self { 39 Self { 40 alg: key.jwt_alg().to_string(), 41 typ: "JWT".to_string(), 42 } 43 } 44} 45 46/// Atproto service-auth JWT claims. 47/// 48/// Fields match the atproto inter-service authentication spec: 49/// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is 50/// deliberately omitted — the spec does not require it and some servers 51/// reject unexpected claims. 52/// 53/// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`, 54/// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT 55/// rename without adding `#[serde(rename = "...")]` attributes. 56#[derive(Debug, Clone, Serialize, Deserialize)] 57pub struct JwtClaims { 58 /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`). 59 pub iss: String, 60 /// Audience — the target service's DID, bare (no `#fragment`). 61 pub aud: String, 62 /// Expiration, UNIX seconds. 63 pub exp: i64, 64 /// Issued-at, UNIX seconds. 65 pub iat: i64, 66 /// Lexicon method NSID the token authorizes (e.g., 67 /// `com.atproto.moderation.createReport`). 68 pub lxm: String, 69 /// Random nonce to prevent replay — hex string, 32 chars (16 bytes). 70 pub jti: String, 71} 72 73/// Errors from JWT encode. 74/// 75/// **Not user-rendered:** these errors only surface inside tests and 76/// library helpers. They deliberately do NOT derive `miette::Diagnostic` 77/// with stable codes — the stage converts any failure into a 78/// `CreateReportStageError::Transport` or a specific check SpecViolation 79/// before rendering. If a future caller needs one of these variants 80/// rendered to the user, they must wrap it in a stage-local diagnostic 81/// with a proper `code = "labeler::..."` string. 82#[derive(Debug, Error)] 83pub(crate) enum EncodeError { 84 /// JSON serialization of header or claims failed (should not happen for 85 /// well-formed structs). 86 #[error("JSON encode failed")] 87 JsonEncode(serde_json::Error), 88} 89 90/// Errors from JWT decode. 91/// 92/// **Not user-rendered:** these errors only surface inside tests and 93/// library helpers. They deliberately do NOT derive `miette::Diagnostic` 94/// with stable codes — the stage converts any failure into a 95/// `CreateReportStageError::Transport` or a specific check SpecViolation 96/// before rendering. If a future caller needs one of these variants 97/// rendered to the user, they must wrap it in a stage-local diagnostic 98/// with a proper `code = "labeler::..."` string. 99#[cfg(test)] 100#[derive(Debug, Error)] 101pub(crate) enum JwtError { 102 /// Compact form was not three `.`-separated base64url segments. 103 #[error("malformed compact JWT: expected three segments")] 104 MalformedCompact, 105 /// A base64url segment failed to decode. 106 #[error("base64url decode failed for {segment}")] 107 Base64Decode { 108 /// Which segment failed: "header", "claims", or "signature". 109 segment: &'static str, 110 /// Underlying base64 error. 111 #[source] 112 source: base64::DecodeError, 113 }, 114 /// A segment decoded to valid bytes but invalid JSON. 115 #[error("JSON decode failed for {segment}")] 116 JsonDecode { 117 /// Which segment failed: "header" or "claims". 118 segment: &'static str, 119 /// Underlying serde_json error. 120 #[source] 121 source: serde_json::Error, 122 }, 123 /// Signature was not exactly 64 bytes. 124 #[error("signature was {actual} bytes; expected 64")] 125 SignatureLength { 126 /// Actual length in bytes. 127 actual: usize, 128 }, 129 /// Signature had the correct length but invalid scalar values (e.g., r or s 130 /// is 0 or exceeds the curve order). 131 #[error("signature has invalid scalar values")] 132 InvalidSignatureScalar, 133 /// The algorithm identifier in the header is not recognized. 134 #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")] 135 UnsupportedAlg { 136 /// The unrecognized algorithm string. 137 alg: String, 138 }, 139 /// Underlying ECDSA verification failure (e.g., curve mismatch). 140 #[error("signature verification failed")] 141 SignatureVerify(#[from] AnySignatureError), 142} 143 144/// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`. 145/// 146/// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 147/// prehash under the supplied key. Returns the full compact token string. 148pub(crate) fn encode_compact( 149 header: &JwtHeader, 150 claims: &JwtClaims, 151 signer: &AnySigningKey, 152) -> Result<String, EncodeError> { 153 let header_json = serde_json::to_vec(header).map_err(EncodeError::JsonEncode)?; 154 let claims_json = serde_json::to_vec(claims).map_err(EncodeError::JsonEncode)?; 155 let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); 156 let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json); 157 let signing_input = format!("{header_b64}.{claims_b64}"); 158 let sig = signer.sign(signing_input.as_bytes()); 159 let sig_bytes = sig.to_jws_bytes(); 160 let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); 161 Ok(format!("{header_b64}.{claims_b64}.{sig_b64}")) 162} 163 164/// Decode a compact JWT into `(header, claims, signature_bytes)`. 165/// 166/// Does NOT verify the signature — use `verify_compact` for that. This helper 167/// is primarily for test round-tripping and for negative-test assertions 168/// (e.g., "the minted token has the expected `alg` header"). 169#[cfg(test)] 170fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 171 let parts: Vec<&str> = token.split('.').collect(); 172 if parts.len() != 3 { 173 return Err(JwtError::MalformedCompact); 174 } 175 let header_b64 = parts[0]; 176 let claims_b64 = parts[1]; 177 let sig_b64 = parts[2]; 178 let header_bytes = 179 URL_SAFE_NO_PAD 180 .decode(header_b64) 181 .map_err(|source| JwtError::Base64Decode { 182 segment: "header", 183 source, 184 })?; 185 let claims_bytes = 186 URL_SAFE_NO_PAD 187 .decode(claims_b64) 188 .map_err(|source| JwtError::Base64Decode { 189 segment: "claims", 190 source, 191 })?; 192 let sig_bytes = URL_SAFE_NO_PAD 193 .decode(sig_b64) 194 .map_err(|source| JwtError::Base64Decode { 195 segment: "signature", 196 source, 197 })?; 198 let header: JwtHeader = 199 serde_json::from_slice(&header_bytes).map_err(|source| JwtError::JsonDecode { 200 segment: "header", 201 source, 202 })?; 203 let claims: JwtClaims = 204 serde_json::from_slice(&claims_bytes).map_err(|source| JwtError::JsonDecode { 205 segment: "claims", 206 source, 207 })?; 208 Ok((header, claims, sig_bytes)) 209} 210 211/// Verify a compact JWT against the given verifying key. Does NOT check 212/// claim values (exp/aud/lxm) — that is the labeler's job in production, 213/// or the stage's assertion job in tests. Only verifies the signature. 214#[cfg(test)] 215pub(crate) fn verify_compact( 216 token: &str, 217 vkey: &AnyVerifyingKey, 218) -> Result<(JwtHeader, JwtClaims), JwtError> { 219 let (header, claims, sig_bytes) = decode_compact(token)?; 220 let expected_alg = match vkey { 221 AnyVerifyingKey::K256(_) => "ES256K", 222 AnyVerifyingKey::P256(_) => "ES256", 223 }; 224 if header.alg != expected_alg { 225 return Err(JwtError::UnsupportedAlg { 226 alg: header.alg.clone(), 227 }); 228 } 229 if sig_bytes.len() != 64 { 230 return Err(JwtError::SignatureLength { 231 actual: sig_bytes.len(), 232 }); 233 } 234 let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above"); 235 let any_sig = match vkey { 236 AnyVerifyingKey::K256(_) => { 237 let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into()) 238 .map_err(|_| JwtError::InvalidSignatureScalar)?; 239 AnySignature::K256(sig) 240 } 241 AnyVerifyingKey::P256(_) => { 242 let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into()) 243 .map_err(|_| JwtError::InvalidSignatureScalar)?; 244 AnySignature::P256(sig) 245 } 246 }; 247 // Recompute the signing input and verify. 248 let dot = token 249 .rfind('.') 250 .expect("three-segment token has a last dot"); 251 let signing_input = &token[..dot]; 252 use sha2::{Digest, Sha256}; 253 let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into(); 254 vkey.verify_prehash(&prehash, &any_sig)?; 255 Ok((header, claims)) 256} 257 258#[cfg(test)] 259mod tests { 260 use super::*; 261 use k256::ecdsa::SigningKey as K256SigningKey; 262 use p256::ecdsa::SigningKey as P256SigningKey; 263 264 #[test] 265 fn encode_decode_roundtrip_k256() { 266 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 267 let vkey = key.verifying_key(); 268 let header = JwtHeader::for_signing_key(&key); 269 let claims = JwtClaims { 270 iss: "did:web:127.0.0.1%3A5000".to_string(), 271 aud: "did:plc:test".to_string(), 272 exp: 2000000000, 273 iat: 1700000000, 274 lxm: "com.atproto.moderation.createReport".to_string(), 275 jti: "0123456789abcdef".to_string(), 276 }; 277 278 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 279 let (decoded_header, decoded_claims) = 280 verify_compact(&token, &vkey).expect("verify succeeds"); 281 282 assert_eq!(decoded_header.alg, "ES256K"); 283 assert_eq!(decoded_claims.iss, claims.iss); 284 assert_eq!(decoded_claims.aud, claims.aud); 285 } 286 287 #[test] 288 fn encode_decode_roundtrip_p256() { 289 let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 290 let vkey = key.verifying_key(); 291 let header = JwtHeader::for_signing_key(&key); 292 let claims = JwtClaims { 293 iss: "did:web:example.com".to_string(), 294 aud: "did:plc:test".to_string(), 295 exp: 2000000000, 296 iat: 1700000000, 297 lxm: "com.atproto.moderation.createReport".to_string(), 298 jti: "fedcba9876543210".to_string(), 299 }; 300 301 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 302 let (decoded_header, decoded_claims) = 303 verify_compact(&token, &vkey).expect("verify succeeds"); 304 305 assert_eq!(decoded_header.alg, "ES256"); 306 assert_eq!(decoded_claims.aud, claims.aud); 307 } 308 309 #[test] 310 fn encode_decode_roundtrip_tampered_claims_fails() { 311 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 312 let vkey = key.verifying_key(); 313 let header = JwtHeader::for_signing_key(&key); 314 let claims = JwtClaims { 315 iss: "did:web:127.0.0.1%3A5000".to_string(), 316 aud: "did:plc:test".to_string(), 317 exp: 2000000000, 318 iat: 1700000000, 319 lxm: "com.atproto.moderation.createReport".to_string(), 320 jti: "0123456789abcdef".to_string(), 321 }; 322 323 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 324 let parts: Vec<&str> = token.split('.').collect(); 325 assert_eq!(parts.len(), 3); 326 327 // Tamper with claims segment. 328 let tampered = format!("{}.YWJj.{}", parts[0], parts[2]); 329 let result = verify_compact(&tampered, &vkey); 330 assert!(result.is_err()); 331 } 332 333 #[test] 334 fn decode_compact_malformed_two_segments() { 335 let result = decode_compact("header.claims"); 336 assert!(matches!(result, Err(JwtError::MalformedCompact))); 337 } 338 339 #[test] 340 fn decode_compact_malformed_four_segments() { 341 // Tokens with four or more segments are malformed. 342 let result = decode_compact("YQ.Yg.Yw.ZA"); 343 assert!(matches!(result, Err(JwtError::MalformedCompact))); 344 } 345 346 #[test] 347 fn decode_compact_invalid_base64() { 348 let result = decode_compact("!!!.claims.sig"); 349 assert!(matches!( 350 result, 351 Err(JwtError::Base64Decode { 352 segment: "header", 353 .. 354 }) 355 )); 356 } 357 358 #[test] 359 fn verify_compact_curve_mismatch() { 360 let k256_key = 361 AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 362 let p256_key = 363 AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 364 365 let header = JwtHeader::for_signing_key(&k256_key); 366 let claims = JwtClaims { 367 iss: "did:web:test".to_string(), 368 aud: "did:plc:test".to_string(), 369 exp: 2000000000, 370 iat: 1700000000, 371 lxm: "com.atproto.moderation.createReport".to_string(), 372 jti: "0123456789abcdef".to_string(), 373 }; 374 375 let token = encode_compact(&header, &claims, &k256_key).expect("encode succeeds"); 376 let p256_vkey = p256_key.verifying_key(); 377 378 // Trying to verify a K256-signed token with a P256 key should fail. 379 let result = verify_compact(&token, &p256_vkey); 380 assert!(result.is_err()); 381 } 382 383 #[test] 384 fn encode_compact_produces_valid_structure() { 385 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 386 let header = JwtHeader::for_signing_key(&key); 387 let claims = JwtClaims { 388 iss: "did:web:test".to_string(), 389 aud: "did:plc:test".to_string(), 390 exp: 2000000000, 391 iat: 1700000000, 392 lxm: "com.atproto.moderation.createReport".to_string(), 393 jti: "0123456789abcdef".to_string(), 394 }; 395 396 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 397 398 // Token must have exactly 3 segments. 399 let parts: Vec<&str> = token.split('.').collect(); 400 assert_eq!(parts.len(), 3); 401 402 // Each segment must decode as valid base64url. 403 for (i, segment) in parts.iter().enumerate() { 404 let segment_name = ["header", "claims", "signature"][i]; 405 let result = URL_SAFE_NO_PAD.decode(segment); 406 assert!( 407 result.is_ok(), 408 "segment {segment_name} failed to decode as base64url" 409 ); 410 } 411 } 412 413 #[test] 414 fn verify_compact_invalid_signature_scalar_k256() { 415 let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 416 let vkey = key.verifying_key(); 417 let header = JwtHeader::for_signing_key(&key); 418 let claims = JwtClaims { 419 iss: "did:web:127.0.0.1%3A5000".to_string(), 420 aud: "did:plc:test".to_string(), 421 exp: 2000000000, 422 iat: 1700000000, 423 lxm: "com.atproto.moderation.createReport".to_string(), 424 jti: "0123456789abcdef".to_string(), 425 }; 426 427 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 428 let parts: Vec<&str> = token.split('.').collect(); 429 assert_eq!(parts.len(), 3); 430 431 // Replace the signature with all zeros (64 bytes, base64url-encoded). 432 let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]); 433 let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig); 434 435 let result = verify_compact(&tampered, &vkey); 436 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 437 } 438 439 #[test] 440 fn verify_compact_invalid_signature_scalar_p256() { 441 let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 442 let vkey = key.verifying_key(); 443 let header = JwtHeader::for_signing_key(&key); 444 let claims = JwtClaims { 445 iss: "did:web:example.com".to_string(), 446 aud: "did:plc:test".to_string(), 447 exp: 2000000000, 448 iat: 1700000000, 449 lxm: "com.atproto.moderation.createReport".to_string(), 450 jti: "fedcba9876543210".to_string(), 451 }; 452 453 let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 454 let parts: Vec<&str> = token.split('.').collect(); 455 assert_eq!(parts.len(), 3); 456 457 // Replace the signature with all zeros (64 bytes, base64url-encoded). 458 let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]); 459 let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig); 460 461 let result = verify_compact(&tampered, &vkey); 462 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 463 } 464 465 // Property-based roundtrip tests pinning the invariant that 466 // `verify_compact(encode_compact(claims, key), key.verifying_key())` 467 // recovers the same claim payload, for every well-formed claim set 468 // and every signing key generated from a 32-byte seed. 469 mod pbt { 470 use super::*; 471 use proptest::prelude::*; 472 use rand_chacha::ChaCha20Rng; 473 use rand_core::SeedableRng; 474 475 // 16 hex chars matches the format atproto labelers expect for 476 // `jti` and is what `RelyingParty::new_jti` produces. 477 const JTI_REGEX: &str = "[0-9a-f]{16}"; 478 479 proptest! { 480 #![proptest_config(ProptestConfig::with_cases(32))] 481 482 #[test] 483 fn encode_verify_compact_roundtrip_k256( 484 seed in any::<[u8; 32]>(), 485 jti in JTI_REGEX, 486 iat in 1_500_000_000i64..2_500_000_000i64, 487 exp_offset in 1i64..86_400i64, 488 ) { 489 let mut rng = ChaCha20Rng::from_seed(seed); 490 let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 491 let vkey = signing.verifying_key(); 492 let header = JwtHeader::for_signing_key(&signing); 493 let claims = JwtClaims { 494 iss: "did:web:test".to_string(), 495 aud: "did:plc:test".to_string(), 496 exp: iat + exp_offset, 497 iat, 498 lxm: "com.atproto.moderation.createReport".to_string(), 499 jti, 500 }; 501 502 let token = encode_compact(&header, &claims, &signing) 503 .expect("encode_compact must succeed"); 504 let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 505 .expect("verify_compact must accept a freshly produced token"); 506 507 prop_assert_eq!(decoded_header.alg, "ES256K"); 508 prop_assert_eq!(decoded_header.typ, header.typ); 509 prop_assert_eq!(decoded_claims.iss, claims.iss); 510 prop_assert_eq!(decoded_claims.aud, claims.aud); 511 prop_assert_eq!(decoded_claims.exp, claims.exp); 512 prop_assert_eq!(decoded_claims.iat, claims.iat); 513 prop_assert_eq!(decoded_claims.lxm, claims.lxm); 514 prop_assert_eq!(decoded_claims.jti, claims.jti); 515 } 516 517 #[test] 518 fn encode_verify_compact_roundtrip_p256( 519 seed in any::<[u8; 32]>(), 520 jti in JTI_REGEX, 521 iat in 1_500_000_000i64..2_500_000_000i64, 522 exp_offset in 1i64..86_400i64, 523 ) { 524 let mut rng = ChaCha20Rng::from_seed(seed); 525 let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 526 let vkey = signing.verifying_key(); 527 let header = JwtHeader::for_signing_key(&signing); 528 let claims = JwtClaims { 529 iss: "did:web:example.com".to_string(), 530 aud: "did:plc:test".to_string(), 531 exp: iat + exp_offset, 532 iat, 533 lxm: "com.atproto.moderation.createReport".to_string(), 534 jti, 535 }; 536 537 let token = encode_compact(&header, &claims, &signing) 538 .expect("encode_compact must succeed"); 539 let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 540 .expect("verify_compact must accept a freshly produced token"); 541 542 prop_assert_eq!(decoded_header.alg, "ES256"); 543 prop_assert_eq!(decoded_header.typ, header.typ); 544 prop_assert_eq!(decoded_claims.iss, claims.iss); 545 prop_assert_eq!(decoded_claims.aud, claims.aud); 546 prop_assert_eq!(decoded_claims.exp, claims.exp); 547 prop_assert_eq!(decoded_claims.iat, claims.iat); 548 prop_assert_eq!(decoded_claims.lxm, claims.lxm); 549 prop_assert_eq!(decoded_claims.jti, claims.jti); 550 } 551 } 552 } 553}