jcp: a chained decision enforces caveats, and a chain cannot drop one #300

Merged
dcharlot merged 1 commit from fix/jcp-chained-caveats into main 2026-10-17 16:03:08 -04:00
4 changed files with 322 additions and 49 deletions

View file

@ -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

View file

@ -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> {
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(())
}

View file

@ -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<Caveat>, budget_funds: Option<Funds>) -> 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),

View file

@ -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<u64>,
) -> 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<Caveat>) -> 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);