jcp: a chained decision enforces caveats, and a chain cannot drop one #300
4 changed files with 322 additions and 49 deletions
19
jcp/SPEC.md
19
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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue