jcp: a chain is bounded in money on every link, and a delegated grant is never decided alone #304
3 changed files with 149 additions and 5 deletions
12
jcp/SPEC.md
12
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`.
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue