CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

Build ES256 verifier for `alg`-less P-256 JWKs

`parse_jwk` only constructed `decoding_key` when the JWK declared
an explicit `alg = ES256` field, but RFC 7517 marks `alg` as
OPTIONAL and real-world DPoP proofs routinely omit it from the
embedded JWK because the JWT header already carries the algorithm.
The fake AS surfaced this as `invalid_dpop_proof` against any
public client whose DPoP proof followed that pattern.

`ParsedJwk::decoding_key` is now `Some` whenever the JWK is on
P-256 and either declares `alg = ES256` or omits `alg`. A P-256
JWK that explicitly declares an unsupported `alg` (e.g. `RS256`)
still produces `decoding_key = None` — the caller's explicit
intent is respected rather than overridden by the curve hint, and
that combination is internally inconsistent anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+46 -17
+10 -3
src/common/CLAUDE.md
··· 1 1 # common 2 2 3 - Last verified: 2026-04-23 3 + Last verified: 2026-04-25 4 4 5 5 ## Purpose 6 6 ··· 87 87 `tests/common/mod.rs`. 88 88 - **Exposes from `oauth::jws`**: 89 89 - Enums: `JwsAlg` (`Es256` | `Es256k`), `JwkUse` (`Sig` | `Other(Arc<str>)`). 90 - - Types: `ParsedJwk` (kid, alg, alg_raw, r#use, pre-built verifier for 91 - ES256 only), plus the error type `JwsError` (`#[derive(Diagnostic)]` 90 + - Types: `ParsedJwk` (kid, alg, alg_raw, r#use, pre-built ES256 91 + verifier), plus the error type `JwsError` (`#[derive(Diagnostic)]` 92 92 with `oauth_client::jws::*` codes). 93 93 - Functions: `parse_jwk`, `sign_jws`, `verify_jws`. 94 94 - `sign_jws`/`verify_jws` only support ES256. ES256K parses but signing 95 95 and verification return `JwsError::UnsupportedOperation` — handled at 96 96 the JWKS stage as a curve-not-modern-EC violation rather than a hard 97 97 structural failure. 98 + - `ParsedJwk::decoding_key` is `Some` whenever the JWK is on P-256 99 + and either declares `alg = ES256` or omits `alg` (RFC 7517 marks 100 + `alg` as optional, and real-world DPoP proofs routinely embed a 101 + JWK with no `alg` because the JWT header already carries it). It 102 + is `None` for secp256k1 or for a P-256 JWK that explicitly 103 + declares an unsupported `alg` (the caller's intent is respected 104 + rather than overridden by the curve hint). 98 105 - **Exposes from `oauth::relying_party`**: 99 106 - Enum: `ClientKind` (`Confidential` | `Public`). 100 107 - Types: `RelyingParty`, `AsDescriptor`, `ParRequest`, `ParResponse`,
+36 -14
src/common/oauth/jws.rs
··· 108 108 /// `token_endpoint_auth_signing_alg` when the JWK omits `alg`. 109 109 pub crv: JwkCrv, 110 110 pub r#use: JwkUse, 111 - /// The verifier, pre-constructed from the JWK material. Only 112 - /// present when `alg == Some(JwsAlg::Es256)` and the x/y 113 - /// coordinates decoded successfully. Always None for ES256K or 114 - /// when `alg` is absent/unsupported. 111 + /// The verifier, pre-constructed from the JWK material. Present 112 + /// whenever the JWK is on the P-256 curve and the x/y coordinates 113 + /// decoded successfully — either with an explicit `alg = ES256` 114 + /// or with `alg` absent (RFC 7517 makes `alg` optional, and many 115 + /// real-world DPoP proofs omit it from the embedded JWK because 116 + /// the JWT header already carries `alg`). Always None for 117 + /// secp256k1 (we do not implement ES256K verification) or when 118 + /// `alg` is present but not `ES256`. 115 119 /// 116 120 /// Note: Point-on-curve validation is deferred to the first 117 121 /// `verify_jws` call; jsonwebtoken validates the point during ··· 334 338 }) 335 339 .unwrap_or(JwkUse::Sig); 336 340 337 - // 6. For `alg == Some(Es256)` AND consistent crv, construct a 338 - // `DecodingKey::from_ec_components(x, y)` via reading `x` and 339 - // `y` base64url-decoded. On decode failure, surface 341 + // 6. Construct a `DecodingKey::from_ec_components(x, y)` whenever 342 + // the JWK is on P-256 and either declares `alg = ES256` or 343 + // omits `alg` entirely. RFC 7517 makes `alg` optional, and 344 + // real-world DPoP proofs routinely omit it from the embedded 345 + // JWK because the JWT header already carries the algorithm. 346 + // On x/y decode failure, surface 340 347 // `JwkMissingField { field: "x" }`. Note: from_ec_components 341 348 // cannot distinguish between x and y failures, so we always map 342 - // to the x field per the design. For any other alg value, 343 - // `decoding_key = None`. 344 - let decoding_key = if alg == Some(JwsAlg::Es256) { 349 + // to the x field per the design. For ES256K (or any other 350 + // alg) on a non-P-256 curve, `decoding_key = None`. 351 + // `alg = None && alg_raw = None` means the field was absent. 352 + // `alg = None && alg_raw = Some(_)` means the field was present 353 + // but neither ES256 nor ES256K — the caller has explicitly tagged 354 + // this key as something other than what we support, so respect 355 + // that intent and do not construct an ES256 verifier even if the 356 + // curve happens to be P-256 (the JWK is internally inconsistent 357 + // anyway). 358 + let key_is_p256_es256_compatible = jwk_crv == JwkCrv::P256 359 + && (alg == Some(JwsAlg::Es256) || (alg.is_none() && alg_raw.is_none())); 360 + let decoding_key = if key_is_p256_es256_compatible { 345 361 let x = obj 346 362 .get("x") 347 363 .and_then(|v| v.as_str()) ··· 619 635 } 620 636 621 637 #[test] 622 - fn permissive_alg_absent() { 638 + fn permissive_alg_absent_p256_builds_decoding_key() { 639 + // RFC 7517 marks `alg` as OPTIONAL on a JWK, and real-world 640 + // DPoP proofs routinely omit it from the embedded JWK because 641 + // the JWT header already carries the algorithm. When the 642 + // curve is P-256, parse_jwk must still construct a usable 643 + // ES256 decoding key. 623 644 let jwk_json = serde_json::json!({ 624 645 "kty": "EC", 625 646 "crv": "P-256", 626 - "use": "sig" 647 + "use": "sig", 648 + "x": "WKn33rT8TiCaO7JJHcoOPsvmwYGR59a9q-SB22SVrao", 649 + "y": "3mNorjTQhWE31PQ-VT0bxmruBarHQr3dAvigeBsDFmg" 627 650 }); 628 651 629 652 let source = Arc::<[u8]>::from(b"test".as_ref()); 630 653 let jwk = parse_jwk(&jwk_json, "test", source).expect("parse_jwk failed"); 631 654 632 - // Verify the absent alg is handled gracefully. 633 655 assert_eq!(jwk.alg, None); 634 656 assert_eq!(jwk.alg_raw, None); 635 - assert!(jwk.decoding_key.is_none()); 657 + assert!(jwk.decoding_key.is_some()); 636 658 } 637 659 638 660 #[test]