diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 90b6245bc..0cb3f4efe 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -146,9 +146,18 @@ When a frame is decided against a delegated grant, the enforcement point MUST be presented the **full chain, root first, leaf last**, and MUST verify it hop by hop: the root has no parent; every later grant's `parent` equals the id of the grant before it; each hop is issued by the previous grant's `subject`; each hop -narrows per §4.1; and every signature in the chain verifies. A structurally -invalid chain is denied `chain-broken`; an amplifying one -`attenuation-violation`. +narrows per §4.1, carries every caveat of the grant before it (§4.9) and no +wider money ceiling (§4.8); and every signature in the chain verifies. A +structurally invalid chain is denied `chain-broken`; an amplifying one +`attenuation-violation`. The rules `attenuate` holds when it mints are held +again here, because a chain arrives signed by whoever minted it. + +**Caveats bind on every link.** A chained decision MUST evaluate the caveats of +every grant in the chain, exactly as a single-grant decision evaluates its own +(§4.9); a chain is never a way past a condition its root imposed. In the +two-phase metered path a chain is authorized without debit, cost-dependent +caveats waiting, and then charged with the measured cost, which is debited from +every link. **Energy conservation across siblings.** The cost of an allowed action MUST fit the remaining budget of **every** grant in the chain and MUST be debited from @@ -1145,7 +1154,9 @@ A conforming implementation passes, with real checks: - **C6 — revocation:** only the issuer revokes; a revoked ancestor kills the whole delegation subtree; a forged revocation is never admitted. - **C7 — chain conservation:** a multi-hop chain verifies hop by hop; a spliced - chain is refused; sibling delegations share their root's budget. + chain is refused; sibling delegations share their root's budget; a chained + decision enforces an inherited caveat; and a chain whose child dropped one does + not verify. - **C8 — wire binding:** a request round-trips the `jcp/1` framing canonically and an enforcement endpoint decides + seals over a live socket. - **C9 — energy honesty:** the metering ladder always yields a figure and its diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 09e0e2f08..295af7997 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -17,7 +17,7 @@ //! whole delegation subtree; a forged revocation changes nothing. //! - **C7 — chain conservation:** a multi-hop chain verifies hop by hop, and //! sibling delegations share their root's budget — delegation never mints -//! joules. +//! joules. Caveats bind on every link, and a child cannot drop one. //! - **C8 — wire binding:** a request round-trips the `jcp/1` framing //! canonically and an enforcement endpoint decides + seals over a live socket. //! - **C9 — energy honesty:** the meter ladder always yields a figure and its @@ -48,7 +48,7 @@ pub fn checks() -> Vec { Check { id: "C4 data/control separation", run: c4_trust }, Check { id: "C5 receipt seal + verify", run: c5_receipt }, Check { id: "C6 revocation authority + subtree kill", run: c6_revocation }, - Check { id: "C7 chain verification + budget conservation", run: c7_chain }, + Check { id: "C7 chain verification + budget conservation + inherited caveats", run: c7_chain }, Check { id: "C8 wire binding round-trip + live decide", run: c8_wire }, Check { id: "C9 energy provenance honesty", run: c9_energy }, Check { id: "C10 delegation control (depth narrows, leaf-only holds)", run: c10_delegation_control }, @@ -507,6 +507,28 @@ fn c7_chain() -> Result<(), String> { if !rt.decide_chained(&fb, &chain_b, sub, Energy::estimator(400), 1).is_allow() { return Err("the root's true remainder was refused".into()); } + + // Caveats bind in a chain: a child inherits its parent's one-shot payload, + // and a chained decision over another payload is denied. + let intended = ContentId::of_bytes(b"approved-args"); + let mut g = grant(&issuer, did(&holder), r, &["read"], 1000, 0).grant; + g.caveats = vec![Caveat::PayloadIs(intended)]; + g.nonce = [9; 16]; + let caveated_root = issue(&issuer, g).map_err(|e| e.to_string())?; + let heir = attenuate(&caveated_root, &holder, sub, Capability::new(r, ["read"]), 600, 0, [3; 16]).unwrap(); + let chain_c = vec![caveated_root.clone(), heir.clone()]; + let mut fc = frame(&heir, r, "read", Trust::Control); + fc.payload_hash = ContentId::of_bytes(b"attacker-args"); + if rt.decide_chained(&fc, &chain_c, sub, Energy::estimator(1), 1).verdict != Verdict::Deny(DenyReason::CaveatUnsatisfied) { + return Err("a chained decision ignored an inherited caveat".into()); + } + // A child that dropped the caveat does not verify. + let mut stripped = heir.grant.clone(); + stripped.caveats.clear(); + let stripped = issue(&holder, stripped).map_err(|e| e.to_string())?; + if verify_chain(&[caveated_root, stripped]).is_ok() { + return Err("a chain whose child dropped a caveat verified".into()); + } Ok(()) } diff --git a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs index aaa96af75..2a55c592f 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/lib.rs @@ -301,7 +301,8 @@ impl RevocationSet { /// Checks, per hop: the root has no parent; every later grant's `parent` is the /// id of the grant before it; each child is issued by the previous grant's /// subject (the holder delegated it); each child narrows its parent (capability -/// ⊆, budget ≤, expiry no later); and every signature in the chain verifies. +/// ⊆, budget ≤, expiry no later, every parent caveat carried, money ceiling no +/// wider); and every signature in the chain verifies. /// A chain that passes conveys exactly what its leaf says and never more than /// its root. pub fn verify_chain(chain: &[SignedGrant]) -> Result<(), GrantError> { @@ -347,6 +348,17 @@ pub fn verify_chain(chain: &[SignedGrant]) -> Result<(), GrantError> { if outlives { return Err(GrantError::Amplifies("expiry outlives parent")); } + // Caveats only accumulate (§4.9) and a money ceiling only narrows + // (§4.8). `attenuate` holds both when it mints, but a chain arrives + // minted by whoever signed it, so the verifier holds them too: a child + // that dropped a parent's caveat would otherwise verify and convey the + // authority the caveat withheld. + if !caveats_narrow(&parent.caveats, &child.caveats) { + return Err(GrantError::Amplifies("caveats weaker than parent")); + } + if !funds_narrows(&parent.budget_funds, &child.budget_funds) { + return Err(GrantError::Amplifies("money ceiling exceeds parent")); + } } Ok(()) } @@ -489,6 +501,61 @@ mod tests { Did::from_verifying_key(&k.verifying_key()) } + /// A child minted by hand, outside `attenuate`, with the parent link set. + fn hand_minted_child(parent: &SignedGrant, delegator: &SigningKey, caveats: Vec, budget_funds: Option) -> SignedGrant { + let p = &parent.grant; + issue( + delegator, + Grant { + issuer: did(delegator), + subject: did(&sk(9)), + capability: p.capability.clone(), + budget_uj: p.budget_uj, + budget_funds, + caveats, + expiry: p.expiry, + parent: Some(p.id()), + delegate_depth: None, + nonce: [7; 16], + }, + ) + .unwrap() + } + + /// `attenuate` refuses to drop a caveat or widen a money ceiling, but a + /// chain can be signed by anything; `verify_chain` must refuse both too. + #[test] + fn a_chain_cannot_drop_a_caveat_or_widen_a_money_ceiling() { + let (root_sk, holder) = (sk(1), sk(2)); + let usd = |minor| Some(Funds { currency: "USD".into(), minor }); + let root = issue( + &root_sk, + Grant { + issuer: did(&root_sk), + subject: did(&holder), + capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]), + budget_uj: 1_000, + budget_funds: usd(500), + caveats: vec![Caveat::NotAfter(900)], + expiry: 0, + parent: None, + delegate_depth: None, + nonce: [1; 16], + }, + ) + .unwrap(); + + let faithful = hand_minted_child(&root, &holder, vec![Caveat::NotAfter(900), Caveat::MaxCostUj(10)], usd(400)); + assert!(verify_chain(&[root.clone(), faithful]).is_ok()); + + let dropped = hand_minted_child(&root, &holder, vec![], usd(400)); + assert!(matches!(verify_chain(&[root.clone(), dropped]), Err(GrantError::Amplifies(_)))); + let widened = hand_minted_child(&root, &holder, vec![Caveat::NotAfter(900)], usd(600)); + assert!(matches!(verify_chain(&[root.clone(), widened]), Err(GrantError::Amplifies(_)))); + let unbounded = hand_minted_child(&root, &holder, vec![Caveat::NotAfter(900)], None); + assert!(matches!(verify_chain(&[root, unbounded]), Err(GrantError::Amplifies(_)))); + } + fn root(issuer: &SigningKey, subject: Did) -> SignedGrant { let g = Grant { issuer: did(issuer), diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index b6e01258b..872ea367c 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -714,82 +714,18 @@ impl Runtime { now: u64, facts: Option<&FlowFacts>, ) -> Decision { - let leaf = match chain.last() { - Some(l) => l, - None => { - return Decision { - verdict: Verdict::Deny(DenyReason::ChainBroken), - cost, - budget_remaining_uj: 0, - } - } - }; - let leaf_remaining = leaf.grant.budget_uj.saturating_sub(self.spent(&leaf.grant.id())); + let leaf_remaining = chain + .last() + .map_or(0, |l| l.grant.budget_uj.saturating_sub(self.spent(&l.grant.id()))); let deny = |r: DenyReason| Decision { verdict: Verdict::Deny(r), cost, budget_remaining_uj: leaf_remaining, }; - - // Structure + signatures + attenuation invariants, hop by hop. - if let Err(e) = verify_chain(chain) { - return deny(match e { - GrantError::Amplifies(_) => DenyReason::AttenuationViolation, - GrantError::BadSignature | GrantError::SigShape(_) => DenyReason::BadSignature, - _ => DenyReason::ChainBroken, - }); - } - // A revoked ancestor kills the whole subtree. - if self.revocations.hits_chain(chain) { - return deny(DenyReason::Revoked); - } - // The chain's ROOT issuer must be a configured trust root for the - // resource (SPEC §7) — authority anchors only in keys this enforcement - // point was configured to honor. - if !self.root_honored(&frame.resource, &chain[0].grant.issuer) { - return deny(DenyReason::UntrustedRoot); - } - // No link may be expired at decision time. - if chain.iter().any(|sg| sg.grant.is_expired(now)) { - return deny(DenyReason::Expired); - } - // The leaf-level checks: trust, audience, holder, capability. - if frame.trust == Trust::Data { - return deny(DenyReason::UntrustedControl); - } - if frame.grant_id != leaf.grant.id() { - return deny(DenyReason::WrongResource); - } - if caller != leaf.grant.subject { - return deny(DenyReason::WrongSubject); - } - if frame.resource != leaf.grant.capability.resource { - return deny(DenyReason::WrongResource); - } - if !leaf.grant.capability.actions.iter().any(|a| a == &frame.action) { - return deny(DenyReason::ActionNotGranted); - } - // 6c (chain form): flow against the chain's attenuated ceiling and the - // resource's taint floor. The ceiling is the meet over every link, so a - // chain is at most as permissive as its strictest hop, and a facts-less - // call under a ceilinged chain fails closed — mirroring the leaf gate. - { - let chained = chain_ceiling(chain); - if let Some(f) = facts { - if self.taint_floor.refuses(&frame.resource, &f.label) { - return deny(DenyReason::TaintedFlow); - } - } - match (chained, facts) { - (None, _) => {} - (Some(_), None) => return deny(DenyReason::TaintedFlow), - (Some(ceiling), Some(f)) => { - if !ceiling.admits(&f.label) { - return deny(DenyReason::TaintedFlow); - } - } - } - } + let leaf = match self.check_chained(frame, chain, caller, now, facts, Some(cost.uj())) { + Ok(leaf) => leaf, + Err(r) => return deny(r), + }; // Energy conservation: the cost must fit every link's remaining budget. let c = cost.uj(); for sg in chain { @@ -818,6 +754,138 @@ impl Runtime { budget_remaining_uj: leaf.grant.budget_uj - self.spent(&leaf.grant.id()), } } + + /// Every check of a chained decision except the budget: structure, + /// signatures and attenuation, revocation, the trust root, expiry, the + /// leaf's audience and holder, flow, and every link's caveats. `cost_uj` is + /// `None` in the two-phase metered path, where cost-dependent caveats wait + /// for the debit. Returns the leaf. + fn check_chained<'c>( + &self, + frame: &Frame, + chain: &'c [SignedGrant], + caller: Did, + now: u64, + facts: Option<&FlowFacts>, + cost_uj: Option, + ) -> Result<&'c SignedGrant, DenyReason> { + let leaf = chain.last().ok_or(DenyReason::ChainBroken)?; + // Structure + signatures + attenuation invariants, hop by hop. + if let Err(e) = verify_chain(chain) { + return Err(match e { + GrantError::Amplifies(_) => DenyReason::AttenuationViolation, + GrantError::BadSignature | GrantError::SigShape(_) => DenyReason::BadSignature, + _ => DenyReason::ChainBroken, + }); + } + // A revoked ancestor kills the whole subtree. + if self.revocations.hits_chain(chain) { + return Err(DenyReason::Revoked); + } + // The chain's ROOT issuer must be a configured trust root for the + // resource (SPEC §7) — authority anchors only in keys this enforcement + // point was configured to honor. + if !self.root_honored(&frame.resource, &chain[0].grant.issuer) { + return Err(DenyReason::UntrustedRoot); + } + // No link may be expired at decision time. + if chain.iter().any(|sg| sg.grant.is_expired(now)) { + return Err(DenyReason::Expired); + } + // The leaf-level checks: trust, audience, holder, capability. + if frame.trust == Trust::Data { + return Err(DenyReason::UntrustedControl); + } + if frame.grant_id != leaf.grant.id() { + return Err(DenyReason::WrongResource); + } + if caller != leaf.grant.subject { + return Err(DenyReason::WrongSubject); + } + if frame.resource != leaf.grant.capability.resource { + return Err(DenyReason::WrongResource); + } + if !leaf.grant.capability.actions.iter().any(|a| a == &frame.action) { + return Err(DenyReason::ActionNotGranted); + } + // 6c (chain form): flow against the chain's attenuated ceiling and the + // resource's taint floor. The ceiling is the meet over every link, so a + // chain is at most as permissive as its strictest hop, and a facts-less + // call under a ceilinged chain fails closed — mirroring the leaf gate. + { + let chained = chain_ceiling(chain); + if let Some(f) = facts { + if self.taint_floor.refuses(&frame.resource, &f.label) { + return Err(DenyReason::TaintedFlow); + } + } + match (chained, facts) { + (None, _) => {} + (Some(_), None) => return Err(DenyReason::TaintedFlow), + (Some(ceiling), Some(f)) => { + if !ceiling.admits(&f.label) { + return Err(DenyReason::TaintedFlow); + } + } + } + } + // Caveats (§4.9), on every link. `verify_chain` has refused a child that + // dropped one of its parent's, so the leaf carries them all; evaluating + // each link as well keeps this decision sound on its own. + for sg in chain { + if let Some(r) = caveats_unsatisfied(&sg.grant, frame, caller, cost_uj, now) { + return Err(r); + } + } + Ok(leaf) + } + + /// What a call can spend under `chain` before some link binds: the least + /// any link has left. + fn chain_remaining(&self, chain: &[SignedGrant]) -> u64 { + chain + .iter() + .map(|sg| sg.grant.budget_uj.saturating_sub(self.spent(&sg.grant.id()))) + .min() + .unwrap_or(0) + } + + /// Phase one of metered enforcement over a delegation chain, root first: + /// [`authorize`](Self::authorize) for a delegated grant. Every check of + /// [`decide_chained`](Self::decide_chained) runs, except that the budget + /// test is "every link has budget left" and cost-dependent caveats wait for + /// [`charge_chained`](Self::charge_chained). Debits nothing. The reported + /// `budget_remaining_uj` is the least any link has left. + pub fn authorize_chained(&mut self, frame: &Frame, chain: &[SignedGrant], caller: Did, now: u64) -> Decision { + let remaining = self.chain_remaining(chain); + let verdict = match self.check_chained(frame, chain, caller, now, None, None) { + Err(r) => Verdict::Deny(r), + Ok(_) if remaining == 0 => Verdict::Deny(DenyReason::BudgetExceeded), + Ok(_) => Verdict::Allow, + }; + Decision { verdict, cost: nothing_ran(), budget_remaining_uj: remaining } + } + + /// Phase two over a chain: debit a measured cost against **every** link, + /// each saturating at its own ceiling, so siblings delegated from one + /// parent share it here as they do in [`decide_chained`](Self::decide_chained). + /// A cost-dependent caveat on any link binds now; a breach denies the + /// verdict, and the joules stay debited because they were spent (§4.9). + pub fn charge_chained(&mut self, chain: &[SignedGrant], measured: Energy) -> Decision { + let uj = measured.uj(); + for sg in chain { + let gid = sg.grant.id(); + let spent = self.spent(&gid); + let debited = uj.min(sg.grant.budget_uj.saturating_sub(spent)); + self.spent_uj.insert(gid, spent + debited); + } + let breached = chain.iter().any(|sg| sg.grant.caveats.iter().any(|c| c.cost_breached(uj))); + Decision { + verdict: if breached { Verdict::Deny(DenyReason::CaveatUnsatisfied) } else { Verdict::Allow }, + cost: measured, + budget_remaining_uj: self.chain_remaining(chain), + } + } } #[cfg(test)] @@ -835,6 +903,111 @@ mod tests { Did::from_verifying_key(&k.verifying_key()) } + /// A root over `db` held by `sk(2)`, carrying `caveats`. + fn chain_root(budget_uj: u64, caveats: Vec) -> SignedGrant { + let g = Grant { + issuer: did(&sk(1)), + subject: did(&sk(2)), + capability: Capability::new(ContentId::of_bytes(b"db"), ["read"]), + budget_uj, + budget_funds: None, + caveats, + expiry: 0, + parent: None, + delegate_depth: None, + nonce: [1; 16], + }; + issue(&sk(1), g).unwrap() + } + + /// A chained decision enforces caveats. It used to evaluate none, so a + /// delegated one-shot grant could be exercised with any payload, past its + /// window, or above its per-call cap. + #[test] + fn a_chained_decision_enforces_caveats() { + let r = ContentId::of_bytes(b"db"); + let intended = ContentId::of_bytes(b"approved-args"); + let root = chain_root(1_000, vec![Caveat::PayloadIs(intended), Caveat::NotAfter(50), Caveat::MaxCostUj(100)]); + let child = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500, 0, [2; 16]).unwrap(); + let chain = [root, child]; + let mut rt = Runtime::new(); + let mut f = frame(&chain[1], r, "read", Trust::Control); + let deny = Verdict::Deny(DenyReason::CaveatUnsatisfied); + + f.payload_hash = ContentId::of_bytes(b"attacker-args"); + assert_eq!(rt.decide_chained(&f, &chain, did(&sk(3)), Energy::measured(1), 10).verdict, deny); + f.payload_hash = intended; + assert_eq!(rt.decide_chained(&f, &chain, did(&sk(3)), Energy::measured(1), 60).verdict, deny, "past NotAfter"); + assert_eq!(rt.decide_chained(&f, &chain, did(&sk(3)), Energy::measured(200), 10).verdict, deny, "over MaxCostUj"); + assert_eq!(rt.spent(&chain[1].grant.id()), 0); + assert!(rt.decide_chained(&f, &chain, did(&sk(3)), Energy::measured(1), 10).is_allow()); + } + + /// A child that dropped its parent's caveat does not convey authority. + #[test] + fn a_chain_whose_child_dropped_a_caveat_is_refused() { + let r = ContentId::of_bytes(b"db"); + let root = chain_root(1_000, vec![Caveat::NotAfter(50)]); + let child = Grant { + issuer: did(&sk(2)), + subject: did(&sk(3)), + capability: Capability::new(r, ["read"]), + budget_uj: 500, + budget_funds: None, + caveats: Vec::new(), + expiry: 0, + parent: Some(root.grant.id()), + delegate_depth: None, + nonce: [2; 16], + }; + let chain = [root, issue(&sk(2), child).unwrap()]; + let mut rt = Runtime::new(); + let f = frame(&chain[1], r, "read", Trust::Control); + assert_eq!( + rt.decide_chained(&f, &chain, did(&sk(3)), Energy::measured(1), 60).verdict, + Verdict::Deny(DenyReason::AttenuationViolation) + ); + assert_eq!( + rt.authorize_chained(&f, &chain, did(&sk(3)), 60).verdict, + Verdict::Deny(DenyReason::AttenuationViolation) + ); + } + + /// The two-phase metered path over a chain: authorize debits nothing and + /// defers cost caveats; charge debits every link, so siblings share their + /// parent, and a per-call breach is reported with the joules still debited. + #[test] + fn a_chain_authorizes_without_debit_and_charges_every_link() { + let r = ContentId::of_bytes(b"db"); + let root = chain_root(1_000, Vec::new()); + let capped = jcp_grant::attenuate_caveated( + &root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 600, vec![Caveat::MaxCostUj(300)], 0, [2; 16], + ) + .unwrap(); + let sibling = jcp_grant::attenuate(&root, &sk(2), did(&sk(4)), Capability::new(r, ["read"]), 600, 0, [3; 16]).unwrap(); + let (a, b) = ([root.clone(), capped], [root.clone(), sibling]); + let mut rt = Runtime::new(); + let fa = frame(&a[1], r, "read", Trust::Control); + let fb = frame(&b[1], r, "read", Trust::Control); + + let d = rt.authorize_chained(&fa, &a, did(&sk(3)), 10); + assert!(d.is_allow(), "a cost caveat cannot bind before the cost is known"); + assert_eq!(d.budget_remaining_uj, 600, "the least any link has left"); + assert_eq!(rt.spent(&root.grant.id()), 0); + + let charged = rt.charge_chained(&a, Energy::measured(400)); + assert_eq!(charged.verdict, Verdict::Deny(DenyReason::CaveatUnsatisfied)); + assert_eq!((rt.spent(&root.grant.id()), rt.spent(&a[1].grant.id())), (400, 400)); + + assert_eq!(rt.authorize_chained(&fb, &b, did(&sk(4)), 10).budget_remaining_uj, 600); + assert!(rt.charge_chained(&b, Energy::measured(600)).is_allow()); + assert_eq!(rt.spent(&root.grant.id()), 1_000, "the siblings shared their parent"); + assert_eq!( + rt.authorize_chained(&fb, &b, did(&sk(4)), 10).verdict, + Verdict::Deny(DenyReason::BudgetExceeded) + ); + } + fn setup() -> (SignedGrant, Did, ContentId) { let issuer = sk(1); let holder = sk(2);