From 2e37fe2e503d43b08db8e273b26b1c0022a11a41 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 13:20:35 -0400 Subject: [PATCH] jcp-bridge: an OAuth access token narrows to the grant's caveats or is not minted, and a delegated grant needs its chain `grant_to_access_token` projects a JCP grant into an RFC 9068 access token that a resource server enforces with ordinary JWT tooling. It carried the capability, the expiry and the joule budget, and dropped everything else. The resource server therefore enforced more authority than the grant conveyed: - A `NotAfter` narrower than the expiry was ignored, and so was any `NotBefore`. - An `ActionIn` did not narrow `scope`. - `PayloadIs`, `MaxCostUj`, `ModelIn`, a flow ceiling and a money ceiling were dropped silently. - A delegated grant was minted from its leaf's signature alone, with the chain that shows it narrows never checked. - `verify_access_token` never checked `nbf`. A projection may narrow, never widen: - **Narrowed.** `NotAfter` lowers `exp`, `NotBefore` raises `nbf`, `ActionIn` intersects `scope`, and a `CallerIs` naming the subject adds nothing. - **Refused.** A condition no claim a resource server is known to enforce can carry gets no token (`Unprojectable`): `PayloadIs`, `MaxCostUj`, `ModelIn`, a flow ceiling, a money ceiling. So does a grant whose conditions leave nothing to permit (`PermitsNothing`): no action, an empty window, or a caller other than the subject. - **Chains.** `grant_to_access_token` refuses a delegated grant (`Delegated`). `chain_to_access_token` projects a chain, verified hop by hop, with every link bounding `exp` and every link's caveats applied. - **Verification.** `verify_access_token` enforces `nbf` as well as `exp`. Checked on the build host: - jcp-bridge: 18 tests pass, three of them new. jcp-rs: 517 pass. - The mint example runs, and clippy is clean. - Mutations each fail tests: dropping NotAfter, dropping PayloadIs, minting a lone delegated grant, and not enforcing nbf. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs | 192 ++++++++++++++++++++-- 1 file changed, 175 insertions(+), 17 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 8ae31915b..5d04fc399 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -22,7 +22,18 @@ //! It is an interop projection, not a second source of truth: the grant is //! verified before minting, the issuer's own key signs the JWT, and a resource //! server enforces `scope`/`aud`/`exp`/`joule_budget_uj` with off-the-shelf JWT -//! tooling. The full JCP enforcement (attenuation chains, revocation, metered +//! tooling. +//! +//! **A projection may narrow, never widen.** A grant's caveats (JCP §4.9) are +//! conditions the token must not lose, because a resource server enforcing the +//! token enforces only what the token says. Those a standard claim can carry +//! are folded into it as a narrowing: `NotAfter` lowers `exp`, `NotBefore` +//! raises `nbf`, `ActionIn` intersects `scope`. For the rest (`PayloadIs`, +//! `MaxCostUj`, `ModelIn`, a flow ceiling, a `CallerIs` naming anyone but the +//! subject) and for a money ceiling, no claim a resource server is known to +//! enforce exists, so no token is minted. A delegated grant is projected only +//! with its chain ([`chain_to_access_token`]), verified hop by hop, so its +//! attenuation is checked rather than taken from the leaf. The full JCP enforcement (attenuation chains, revocation, metered //! debit, receipts) still lives in the JCP runtime; this is the access token //! that gets the grant *through the OAuth front door*. @@ -70,6 +81,20 @@ pub enum OAuthError { /// a mix-up attack, or a misconfiguration that must be treated as one. #[error("authorization response issuer {got} is not the expected {expected}")] AuthorizationIssuerMixUp { expected: String, got: String }, + /// The grant carries a condition no access-token claim can carry, so a + /// token would convey more than the grant: none is minted. + #[error("the grant's {0} has no access-token claim, so no token is minted")] + Unprojectable(&'static str), + /// The grant's conditions leave nothing a token could permit: no action, or + /// no instant at which it is valid. + #[error("the grant permits nothing a token could carry: {0}")] + PermitsNothing(&'static str), + /// A delegated grant is projected only with its chain. + #[error("a delegated grant is projected with its chain (chain_to_access_token)")] + Delegated, + /// `now < nbf`: the token is not yet valid. + #[error("access token not yet valid")] + NotYetValid, } /// The RFC 9207 authorization-response parameter naming the issuing server. @@ -110,40 +135,98 @@ pub fn check_authorization_issuer( } } -/// Project a verified [`SignedGrant`] into an RFC 9068 access-token JWT, signed -/// `EdDSA` by the grant **issuer**. `issued_at` is the `iat`/`nbf` (unix +/// Project a verified root [`SignedGrant`] into an RFC 9068 access-token JWT, +/// signed `EdDSA` by the grant **issuer**. `issued_at` is the `iat` (unix /// seconds; passed in so this stays deterministic). `ttl_secs` caps the token /// lifetime so it never outlives the grant *and* stays short-lived. +/// +/// The grant's caveats narrow the token, or refuse it (see the module docs). A +/// delegated grant is refused here: project it with [`chain_to_access_token`]. pub fn grant_to_access_token( sg: &SignedGrant, issuer_sk: &SigningKey, issued_at: u64, ttl_secs: u64, ) -> Result { + if sg.grant.parent.is_some() { + return Err(OAuthError::Delegated); + } // Only mint a token for a grant that actually verifies. jcp_grant::verify(sg).map_err(|e| OAuthError::Grant(format!("{e:?}")))?; - // The signer must be the grant's issuer (the authorization server). + project(std::slice::from_ref(sg), issuer_sk, issued_at, ttl_secs) +} + +/// Project a delegation chain, root first, into an access token for its leaf, +/// signed by the leaf's issuer. The chain is verified hop by hop (JCP §4.2), +/// every link's expiry bounds `exp`, and every link's caveats narrow the token +/// or refuse it, so the token conveys no more than the chain does. +pub fn chain_to_access_token( + chain: &[SignedGrant], + issuer_sk: &SigningKey, + issued_at: u64, + ttl_secs: u64, +) -> Result { + jcp_grant::verify_chain(chain).map_err(|e| OAuthError::Grant(format!("{e:?}")))?; + project(chain, issuer_sk, issued_at, ttl_secs) +} + +/// The projection itself, over a verified chain (a root alone is a chain of one). +fn project(chain: &[SignedGrant], issuer_sk: &SigningKey, issued_at: u64, ttl_secs: u64) -> Result { + let leaf = &chain.last().ok_or(OAuthError::Grant("empty chain".into()))?.grant; + // The signer must be the leaf's issuer (the authorization server). let issuer = Did::from_verifying_key(&issuer_sk.verifying_key()); - if issuer != sg.grant.issuer { + if issuer != leaf.issuer { return Err(OAuthError::IssuerMismatch); } - let g = &sg.grant; - let grant_exp = if g.expiry == 0 { u64::MAX } else { g.expiry }; - let exp = grant_exp.min(issued_at.saturating_add(ttl_secs)); + let mut exp = issued_at.saturating_add(ttl_secs); + let mut nbf = issued_at; + let mut scope: Vec = leaf.capability.actions.clone(); + for link in chain { + let g = &link.grant; + if g.expiry != 0 { + exp = exp.min(g.expiry); + } + // A money ceiling: no claim a resource server is known to enforce. + if g.budget_funds.is_some() { + return Err(OAuthError::Unprojectable("money ceiling")); + } + for c in &g.caveats { + match c { + jcp_schema::Caveat::NotAfter(t) => exp = exp.min(*t), + jcp_schema::Caveat::NotBefore(t) => nbf = nbf.max(*t), + jcp_schema::Caveat::ActionIn(set) => scope.retain(|a| set.contains(a)), + // The runtime requires the caller to be the subject, and `sub` + // already names the subject: a CallerIs naming it adds nothing, + // and one naming anyone else is a grant no caller can use. + jcp_schema::Caveat::CallerIs(d) if *d == leaf.subject => {} + jcp_schema::Caveat::CallerIs(_) => return Err(OAuthError::PermitsNothing("a caller other than the subject")), + jcp_schema::Caveat::PayloadIs(_) => return Err(OAuthError::Unprojectable("PayloadIs caveat")), + jcp_schema::Caveat::MaxCostUj(_) => return Err(OAuthError::Unprojectable("MaxCostUj caveat")), + jcp_schema::Caveat::ModelIn(_) => return Err(OAuthError::Unprojectable("ModelIn caveat")), + jcp_schema::Caveat::FlowBelow(_) => return Err(OAuthError::Unprojectable("flow ceiling")), + } + } + } + if scope.is_empty() { + return Err(OAuthError::PermitsNothing("no action")); + } + if nbf > exp { + return Err(OAuthError::PermitsNothing("no instant at which it is valid")); + } let header = json!({ "alg": ALG_EDDSA, "typ": TYP_AT_JWT }); let claims = json!({ - "iss": did_key(&g.issuer), - "sub": did_key(&g.subject), - "client_id": did_key(&g.subject), - "aud": g.capability.resource.to_hex(), + "iss": did_key(&leaf.issuer), + "sub": did_key(&leaf.subject), + "client_id": did_key(&leaf.subject), + "aud": leaf.capability.resource.to_hex(), "iat": issued_at, - "nbf": issued_at, + "nbf": nbf, "exp": exp, - "jti": hex(&g.nonce), - "scope": g.capability.actions.join(" "), - CLAIM_JOULE_BUDGET: g.budget_uj, + "jti": hex(&leaf.nonce), + "scope": scope.join(" "), + CLAIM_JOULE_BUDGET: leaf.budget_uj, }); let signing_input = format!( @@ -156,7 +239,7 @@ pub fn grant_to_access_token( } /// Verify an access-token JWT against the issuer's Ed25519 public key and (if -/// `now > 0`) its expiry. Returns the decoded claims on success — a resource +/// `now > 0`) its validity window, `nbf` to `exp`. Returns the decoded claims on success — a resource /// server reads `scope`, `aud`, and `joule_budget_uj` to enforce the grant. pub fn verify_access_token( token: &str, @@ -194,6 +277,9 @@ pub fn verify_access_token( if now > exp { return Err(OAuthError::Expired); } + if now < claims.get("nbf").and_then(Value::as_u64).unwrap_or(0) { + return Err(OAuthError::NotYetValid); + } } Ok(claims) } @@ -432,6 +518,78 @@ mod tests { )); } + fn caveated(sk: &SigningKey, caveats: Vec) -> SignedGrant { + let mut g = signed_grant(sk).grant; + g.caveats = caveats; + issue(sk, g).unwrap() + } + + /// A caveat a standard claim can carry narrows that claim: the token's + /// window shrinks to the caveat's and its scope to the permitted actions. + #[test] + fn caveats_narrow_the_token() { + use jcp_schema::Caveat; + let sk = SigningKey::from_bytes(&[9u8; 32]); + let key = sk.verifying_key().to_bytes(); + let sg = caveated(&sk, vec![Caveat::NotBefore(1_200), Caveat::NotAfter(2_000), Caveat::ActionIn(vec!["read".into()])]); + let tok = grant_to_access_token(&sg, &sk, 1_000, 3_600).unwrap(); + let claims = verify_access_token(&tok, &key, 1_500).unwrap(); + assert_eq!((claims["nbf"].as_u64(), claims["exp"].as_u64()), (Some(1_200), Some(2_000))); + assert_eq!(claims["scope"], "read", "write is not permitted"); + assert!(matches!(verify_access_token(&tok, &key, 1_100), Err(OAuthError::NotYetValid))); + assert!(matches!(verify_access_token(&tok, &key, 2_001), Err(OAuthError::Expired))); + } + + /// A condition no claim can carry refuses the token, as does one that + /// leaves the token nothing to permit. Before, each was minted without it. + #[test] + fn a_condition_no_claim_carries_refuses_the_token() { + use jcp_schema::{Caveat, Funds}; + let sk = SigningKey::from_bytes(&[9u8; 32]); + let other = Did::from_verifying_key(&SigningKey::from_bytes(&[5u8; 32]).verifying_key()); + for c in [Caveat::PayloadIs(ContentId::of_bytes(b"args")), Caveat::MaxCostUj(10), Caveat::ModelIn(vec![ContentId::of_bytes(b"m")])] { + assert!(matches!(grant_to_access_token(&caveated(&sk, vec![c]), &sk, 1_000, 3_600), Err(OAuthError::Unprojectable(_)))); + } + for c in [Caveat::CallerIs(other), Caveat::ActionIn(vec!["delete".into()]), Caveat::NotBefore(9_000)] { + assert!(matches!(grant_to_access_token(&caveated(&sk, vec![c]), &sk, 1_000, 3_600), Err(OAuthError::PermitsNothing(_)))); + } + let mut g = signed_grant(&sk).grant; + g.budget_funds = Some(Funds::new("USD", 100)); + assert!(matches!(grant_to_access_token(&issue(&sk, g).unwrap(), &sk, 1_000, 3_600), Err(OAuthError::Unprojectable(_)))); + let subject = signed_grant(&sk).grant.subject; + assert!(grant_to_access_token(&caveated(&sk, vec![Caveat::CallerIs(subject)]), &sk, 1_000, 3_600).is_ok()); + } + + /// A delegated grant is projected only with its chain, which is verified, + /// and every link bounds the token. + #[test] + fn a_delegated_grant_is_projected_with_its_chain() { + use jcp_schema::Caveat; + let root_sk = SigningKey::from_bytes(&[9u8; 32]); + let holder = SigningKey::from_bytes(&[2u8; 32]); + let root = caveated(&root_sk, vec![Caveat::NotAfter(2_500)]); + let child = jcp_grant::attenuate( + &root, + &holder, + Did::from_verifying_key(&SigningKey::from_bytes(&[6u8; 32]).verifying_key()), + Capability::new(resource(), ["read"]), + 1_000, + 0, + [8u8; 16], + ) + .unwrap(); + assert!(matches!(grant_to_access_token(&child, &holder, 1_000, 3_600), Err(OAuthError::Delegated))); + let tok = chain_to_access_token(&[root.clone(), child.clone()], &holder, 1_000, 3_600).unwrap(); + let claims = verify_access_token(&tok, &holder.verifying_key().to_bytes(), 1_500).unwrap(); + assert_eq!((claims["exp"].as_u64(), claims["scope"].as_str()), (Some(2_500), Some("read"))); + assert_eq!(claims[CLAIM_JOULE_BUDGET], 1_000); + + let mut stripped = child.grant.clone(); + stripped.caveats.clear(); + let stripped = issue(&holder, stripped).unwrap(); + assert!(matches!(chain_to_access_token(&[root, stripped], &holder, 1_000, 3_600), Err(OAuthError::Grant(_)))); + } + #[test] fn minting_with_non_issuer_key_is_refused() { let sk = SigningKey::from_bytes(&[9u8; 32]); -- 2.54.0 (Apple Git-157)