diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 0cb3f4efe..e1144ccba 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -152,12 +152,20 @@ 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. +A delegated grant (one with a `parent`) MUST NOT be decided on its own. Alone, +nothing shows that it narrows what its issuer held, so its budget, caveats and +expiry would be taken at their word, as a root's are. A single-grant decision +over a delegated grant is denied `chain-broken`. + **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. +every link. A priced chained decision applies both ceilings of §4.8 to **every** +link: the joule ceilings first, then the money ceiling of each link that has +one. An allow debits both dimensions from every link, so siblings share their +parent's money as they share its joules; a deny debits neither. **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 @@ -1251,4 +1259,4 @@ gate. - **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one. - **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; `ModelIn` admits only a frame naming one of its models, and denies a frame naming another or none; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. - **C25 — flow:** a value's propagated label must sit at or below the grant's ceiling and clear the resource's taint floor; an argument whose provenance was **not computed** fails closed against a ceilinged grant rather than passing as clean; several ceilings in a chain compose to the strictest and attenuation never widens one; a flow denial debits nothing, so a tainted call costs zero joules; and a grant with no flow caveat encodes byte-identically to one minted before flow existed. -- **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. +- **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. A priced chained decision applies the parent's money ceiling to its child's spend, and a delegated grant decided without its chain is denied `chain-broken`. diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 295af7997..3d73f9ca1 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -256,6 +256,32 @@ fn c22_dual_ceilings() -> Result<(), String> { if child.grant.budget_funds != Some(Funds::new("USD", 500)) { return Err("a plain attenuation must inherit the parent's money ceiling".into()); } + + // A chain is bounded in money on every link (§4.2): the parent's ceiling + // binds its child's spend, and a deny debits neither dimension. + let heir = attenuate(&sg, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 50, 0, [4; 16]) + .map_err(|e| e.to_string())?; + let chain = [sg.clone(), heir.clone()]; + let mut crt = Runtime::new(); + let fc = frame(&heir, r, "read", Trust::Control); + let sub = did(&sk(3)); + if !crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("USD", 300), 1).is_allow() { + return Err("a chained priced call within both ceilings was denied".into()); + } + let d = crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("USD", 300), 1); + if d.verdict != Verdict::Deny(DenyReason::FundsExceeded) || crt.spent(&sg.grant.id()) != 1 { + return Err("a chained call past the parent's money ceiling was not refused cleanly".into()); + } + let d = crt.decide_chained_priced(&fc, &chain, sub, Energy::measured(1), &Price::new("EUR", 1), 1); + if d.verdict != Verdict::Deny(DenyReason::CurrencyMismatch) { + return Err("a chained call in another currency was converted rather than refused".into()); + } + // A delegated grant is decided only with its chain: alone, its money + // ceiling is not shown to narrow its parent's. + let d = crt.decide_priced(&fc, &heir, sub, Energy::measured(1), &Price::new("USD", 1), 1); + if d.verdict != Verdict::Deny(DenyReason::ChainBroken) { + return Err("a delegated grant was decided without its chain".into()); + } Ok(()) } diff --git a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs index 872ea367c..a3e76ef79 100644 --- a/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-runtime/src/lib.rs @@ -476,6 +476,12 @@ impl Runtime { if verify(sg).is_err() { return Some(DenyReason::BadSignature); } + // 4b. A delegated grant is decided only with its chain (§4.2). Alone, + // nothing shows that it narrows what its issuer held: its budget, + // caveats and expiry would be taken at their word, as a root's are. + if g.parent.is_some() { + return Some(DenyReason::ChainBroken); + } // 5. Not expired. if g.is_expired(now) { return Some(DenyReason::Expired); @@ -687,7 +693,25 @@ impl Runtime { ) -> Decision { // No flow facts: a chain whose links carry a flow ceiling fails closed // (the un-migrated path), exactly as `decide` does with `check(.., None)`. - self.decide_chained_inner(frame, chain, caller, cost, now, None) + self.decide_chained_inner(frame, chain, caller, cost, now, None, None) + } + + /// [`decide_priced`](Self::decide_priced) over a delegation chain: both + /// ceilings on **every** link (§4.8, §4.2). The joule ceiling is checked + /// first on every link, then the money ceiling of every link that has + /// one, refusing a cost in another currency. On allow both dimensions are + /// debited from every link, so siblings share their parent's money as they + /// share its joules; on deny neither is debited. + pub fn decide_chained_priced( + &mut self, + frame: &Frame, + chain: &[SignedGrant], + caller: Did, + cost: Energy, + price: &Price, + now: u64, + ) -> Decision { + self.decide_chained_inner(frame, chain, caller, cost, now, None, Some(price)) } /// The flow-aware [`decide_chained`](Self::decide_chained): the chain path with @@ -702,9 +726,10 @@ impl Runtime { now: u64, facts: &FlowFacts, ) -> Decision { - self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts)) + self.decide_chained_inner(frame, chain, caller, cost, now, Some(facts), None) } + #[allow(clippy::too_many_arguments)] fn decide_chained_inner( &mut self, frame: &Frame, @@ -713,6 +738,7 @@ impl Runtime { cost: Energy, now: u64, facts: Option<&FlowFacts>, + price: Option<&Price>, ) -> Decision { let leaf_remaining = chain .last() @@ -734,11 +760,31 @@ impl Runtime { return deny(DenyReason::BudgetExceeded); } } - // Allow — debit every link, so sibling delegations share their root. + // The money ceiling of every link that has one (§4.8), checked after + // joules so that when both bind the receipt names energy. A link with + // no money ceiling is bounded by joules alone. + if let Some(price) = price { + for sg in chain { + if let Some(funds) = &sg.grant.budget_funds { + if !funds.same_currency(&price.currency) { + return deny(DenyReason::CurrencyMismatch); + } + if self.spent_minor(&sg.grant.id()).saturating_add(price.minor) > funds.minor { + return deny(DenyReason::FundsExceeded); + } + } + } + } + // Allow — debit every link, so sibling delegations share their root, + // in money as in joules. for sg in chain { let gid = sg.grant.id(); let s = self.spent(&gid) + c; self.spent_uj.insert(gid, s); + if let (Some(price), Some(_)) = (price, &sg.grant.budget_funds) { + let m = self.spent_minor(&gid) + price.minor; + self.spent_minor.insert(gid, m); + } } // A tainted chained call also depletes each link's tainted sub-budget. if facts.map(|f| !f.is_bottom()).unwrap_or(false) { @@ -920,6 +966,70 @@ mod tests { issue(&sk(1), g).unwrap() } + /// A delegated grant is decided only with its chain (§4.2). Alone, its + /// budget, caveats and expiry would be taken at their word, as a root's + /// are, and nothing would show that they narrow what its issuer held. + #[test] + fn a_delegated_grant_alone_is_refused() { + let r = ContentId::of_bytes(b"db"); + let root = chain_root(1_000, Vec::new()); + let child = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500, 0, [2; 16]).unwrap(); + let mut rt = Runtime::new(); + let f = frame(&child, r, "read", Trust::Control); + let broken = Verdict::Deny(DenyReason::ChainBroken); + assert_eq!(rt.decide(&f, &child, did(&sk(3)), Energy::measured(1), 10).verdict, broken); + assert_eq!(rt.authorize(&f, &child, did(&sk(3)), 10).verdict, broken); + assert_eq!(rt.decide_priced(&f, &child, did(&sk(3)), Energy::measured(1), &Price::new("USD", 1), 10).verdict, broken); + assert!(rt.decide_chained(&f, &[root, child], did(&sk(3)), Energy::measured(1), 10).is_allow()); + } + + /// Money binds on every link of a chain (§4.8): siblings share their + /// parent's money as they share its joules, a deny debits neither + /// dimension, a foreign currency is refused, and joules are checked first. + #[test] + fn a_chain_is_bounded_in_money_on_every_link() { + let r = ContentId::of_bytes(b"db"); + let mut g = chain_root(1_000_000, Vec::new()).grant; + g.budget_funds = Some(Funds::new("USD", 1_000)); + let root = issue(&sk(1), g).unwrap(); + let usd = |m| Some(Funds::new("USD", m)); + let a = jcp_grant::attenuate_priced(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500_000, usd(600), 0, [2; 16]).unwrap(); + let b = jcp_grant::attenuate_priced(&root, &sk(2), did(&sk(4)), Capability::new(r, ["read"]), 500_000, usd(600), 0, [3; 16]).unwrap(); + let (ca, cb) = ([root.clone(), a.clone()], [root.clone(), b.clone()]); + let mut rt = Runtime::new(); + let (fa, fb) = (frame(&a, r, "read", Trust::Control), frame(&b, r, "read", Trust::Control)); + let usd_cost = |m| Price::new("USD", m); + + assert!(rt.decide_chained_priced(&fa, &ca, did(&sk(3)), Energy::measured(10), &usd_cost(500), 10).is_allow()); + assert_eq!((rt.spent_minor(&root.grant.id()), rt.spent_minor(&a.grant.id())), (500, 500)); + + let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &usd_cost(600), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::FundsExceeded), "the parent has 500 left, though b has 600"); + assert_eq!((rt.spent_minor(&b.grant.id()), rt.spent(&b.grant.id()), rt.spent(&root.grant.id())), (0, 0, 10)); + + let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &Price::new("EUR", 1), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::CurrencyMismatch)); + let d = rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(600_000), &usd_cost(1), 10); + assert_eq!(d.verdict, Verdict::Deny(DenyReason::BudgetExceeded), "joules bind first"); + + assert!(rt.decide_chained_priced(&fb, &cb, did(&sk(4)), Energy::measured(10), &usd_cost(500), 10).is_allow()); + assert_eq!(rt.spent_minor(&root.grant.id()), 1_000); + } + + /// A chain with no money ceiling is bounded by joules alone, whatever the + /// price or its currency, as a single grant is. + #[test] + fn a_chain_with_no_money_ceiling_ignores_the_price() { + let r = ContentId::of_bytes(b"db"); + let root = chain_root(1_000, Vec::new()); + let child = jcp_grant::attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 500, 0, [2; 16]).unwrap(); + let mut rt = Runtime::new(); + let f = frame(&child, r, "read", Trust::Control); + let chain = [root, child]; + assert!(rt.decide_chained_priced(&f, &chain, did(&sk(3)), Energy::measured(1), &Price::new("JPY", 1 << 40), 10).is_allow()); + assert_eq!(rt.spent_minor(&chain[0].grant.id()), 0); + } + /// 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.