diff --git a/jcp/SPEC.md b/jcp/SPEC.md index d7c796774..4c75b2d94 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -1245,9 +1245,9 @@ A conforming implementation passes, with real checks: 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; a replayed - request is refused `replayed`, and one stamped outside the window - `stale-request`. + and an enforcement endpoint decides + seals over a live socket; a request + presented again gets its first response unchanged and is not debited again, + and one stamped outside the window is refused `stale-request`. - **C9 — energy honesty:** the metering ladder always yields a figure and its provenance is internally consistent. - **C10 — delegation control:** depth narrows per hop; a leaf-only grant cannot @@ -1333,10 +1333,19 @@ wire. **A request is decided once.** The wire never carries a secret: grants are signed public objects, and a request is a signed public object too. The endpoint MUST remember every request it decided while that request's stamp is inside the -window, and MUST deny one presented again as `replayed`, so a captured request -is neither decided nor debited a second time. Once a request's stamp has left -the window, a replay of it is stale. Two byte-identical requests are one -request, so a caller that means the same action twice stamps the second later. +window, together with its response. A request presented again MUST get that same +response, the same decision and the same receipt, and MUST NOT be decided, +debited or recorded a second time. That holds across a restart, which replays +the record (below) and so recovers each response: the decision is deterministic +and so is the seal. A captured request is therefore worth nothing beyond what +its first exchange carried. A caller whose response was lost recovers it by +sending the same request again. Refusing the repeat instead would leave that +caller only one way to learn the outcome: stamp the request again, which is a +new request and decides and debits the same action a second time. A request +refused before it was decided (stale, unauthenticated, or `unrecorded`) is not +remembered. Once a request's stamp has left the window, a repeat of it is stale. +Two byte-identical requests are one request, so a caller that means the same +action twice stamps the second later. **The cost is the holder's measurement.** `cost` is the requester's own figure, and the endpoint debits it as given, so a budget binds only as honestly as that diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 60281eb7e..f3ab5626e 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -19,7 +19,9 @@ //! sibling delegations share their root's budget — delegation never mints //! 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. +//! canonically and an enforcement endpoint decides + seals over a live +//! socket; the same request again gets its first answer, not a second +//! decision. //! - **C9 — energy honesty:** the meter ladder always yields a figure and its //! provenance is internally consistent (HwShunt ⇒ measured present; //! Estimator ⇒ measured absent). @@ -49,7 +51,7 @@ pub fn checks() -> Vec { 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 + inherited caveats", run: c7_chain }, - Check { id: "C8 wire binding round-trip + live decide + no replay, endpoint clock", run: c8_wire }, + Check { id: "C8 wire binding round-trip + live decide + decided once, endpoint clock", 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 }, Check { id: "C11 sharded budgets conserve the root at mint", run: c11_shard }, @@ -594,10 +596,11 @@ fn c8_wire() -> Result<(), String> { } jcp_receipt::verify(&req.frame, &sg.grant, &resp.decision, &resp.receipt, "did:example:steward", &steward_vk.to_bytes()) .map_err(|e| format!("wire receipt failed to verify: {e}"))?; - // The same request again is refused, not decided and debited twice. + // The same request again gets the answer it got, not a second decision + // and debit: the same decision, the same budget left, the same receipt. let again = client.call(&req).map_err(|e| e.to_string())?; - if again.decision.verdict != Verdict::Deny(DenyReason::Replayed) { - return Err(format!("a replayed wire request was decided again: {:?}", again.decision.verdict)); + if again.decision != resp.decision || again.receipt != resp.receipt { + return Err(format!("a wire request presented again was decided again: {:?}", again.decision)); } drop(client); server.join().map_err(|_| "wire server panicked".to_string())?; diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index 90514e094..d10c5a263 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -607,7 +607,11 @@ pub enum DenyReason { /// request signed for another time is not decided. StaleRequest, /// A wire request the enforcement point has already decided, presented - /// again (SPEC §11). It is decided once, and debited once. + /// again. The reference endpoint no longer refuses one. A request is still + /// decided and debited once, but a repeat now gets its first answer, + /// unchanged, so that a caller whose response was lost can recover it + /// (SPEC §11). The reason is kept so that receipts sealed with it, and + /// endpoints that still refuse, keep decoding. Replayed, /// The enforcement point could not record the request before deciding it /// (SPEC §4.11), so it does not decide it: a decision it could not record diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index 53dc9f8a5..d7bd59c69 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -18,8 +18,10 @@ //! signed public objects. The endpoint decides at **its own clock**: a //! request's `now` is a freshness stamp, and one outside the window //! ([`Endpoint::with_skew`]) is denied `stale-request`. Within the window the -//! endpoint remembers what it decided, so a captured request presented again -//! is denied `replayed` rather than decided, and debited, a second time. +//! endpoint remembers what it decided and what it answered, so a request +//! presented again gets that same answer, the same decision and the same +//! receipt, and is neither decided nor debited a second time. A caller whose +//! response was lost recovers it by sending the same request again. //! //! The `cost` a request declares is the requester's own figure, so the //! requester is the grant's holder and the party that metered the action @@ -30,7 +32,8 @@ //! request is recorded before it is decided, each revocation before it is //! applied, and [`Endpoint::with_journal`] replays them through the gate at the //! times they were decided, so a restart gives no budget back, unrevokes -//! nothing, and forgets no request that may still be shown again. +//! nothing, and forgets no request that may still be shown again, nor the +//! answer it got. mod journal; pub use journal::{FileJournal, Journal, JournalRecord, MemoryJournal}; @@ -164,7 +167,7 @@ impl WireRequest { /// The enforcement point's answer: the deterministic decision and the sealed /// JCR-1 receipt covering it (allow and deny alike). -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WireResponse { pub decision: Decision, pub receipt: Vec, @@ -213,6 +216,23 @@ pub fn read_msg(r: &mut impl Read) -> Result u64, /// How far a request's `now` may stray from `clock`. skew_secs: u64, - /// The requests decided within the window, by content id, with their stamp. - decided: HashMap, + /// The requests decided within the window, by content id: each one's + /// stamp, and the answer it got. + decided: HashMap, /// Where each admitted request and each revocation is recorded first. journal: Box, } @@ -259,10 +280,16 @@ impl Endpoint { return Err(inconsistent(i, "the caller's signature does not verify")); } let id = ContentId::of(request).map_err(|e| inconsistent(i, &e.to_string()))?; - if self.decided.insert(id, request.now).is_some() { + if self.decided.contains_key(&id) { return Err(inconsistent(i, "the request was decided twice")); } - self.runtime.decide_chained(&request.frame, &request.chain, request.caller, request.cost, *at); + let decision = + self.runtime.decide_chained(&request.frame, &request.chain, request.caller, request.cost, *at); + // The decision is deterministic and so is the seal, so + // this is the answer the request got before the restart, + // byte for byte, and a repeat of it gets that again. + let response = self.respond(request, decision); + self.decided.insert(id, Answered { stamp: request.now, response }); } JournalRecord::Revoked { revocation, grant } => { self.runtime @@ -273,7 +300,7 @@ impl Endpoint { } let now = (self.clock)(); let skew = self.skew_secs; - self.decided.retain(|_, stamp| stamp.saturating_add(skew) >= now); + self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now); self.journal = Box::new(journal); Ok(self) } @@ -300,35 +327,63 @@ impl Endpoint { self } - /// Whether `req` may be decided now: fresh, and not already decided. A - /// request that may be is remembered until its stamp leaves the window, - /// after which a replay of it is stale. - fn admit(&mut self, req: &WireRequest, now: u64) -> Result<(), jcp_schema::DenyReason> { + /// What may be done with `req` now. A stale request is refused. A fresh + /// one is decided, unless the endpoint decided it already, in which case + /// it gets the answer it got then: a request is decided once, and a + /// caller whose response was lost recovers it by sending the same request + /// again. A request decided is remembered until its stamp leaves the + /// window, after which a repeat of it is stale. + fn admit(&mut self, req: &WireRequest, now: u64) -> Result { let skew = self.skew_secs; - self.decided.retain(|_, stamp| stamp.saturating_add(skew) >= now); + self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now); if now.abs_diff(req.now) > skew { return Err(jcp_schema::DenyReason::StaleRequest); } let id = ContentId::of(req).map_err(|_| jcp_schema::DenyReason::BadSignature)?; - if self.decided.insert(id, req.now).is_some() { - return Err(jcp_schema::DenyReason::Replayed); - } - Ok(()) + Ok(match self.decided.get(&id) { + Some(a) => Admission::Repeat(a.response.clone()), + None => Admission::Decide(id), + }) + } + + /// Seal `decision` for `req`. The receipt names the leaf grant and — for a + /// multi-hop chain — every debited link, so the receipt stream replays + /// exactly (SPEC §8.1). + fn respond(&self, req: &WireRequest, decision: Decision) -> WireResponse { + let chain_ids: Vec<_> = req.chain.iter().map(|sg| sg.grant.id()).collect(); + let receipt = req + .chain + .last() + .and_then(|leaf| { + jcp_receipt::seal_chained(&req.frame, &chain_ids, &leaf.grant, &decision, &self.steward_sk, &self.steward) + .ok() + }) + .unwrap_or_default(); + WireResponse { decision, receipt } } /// Decide one request and seal the receipt — the whole protocol step, /// transport-free. The caller's signature is verified first: an /// unauthenticated request is denied (`bad-signature`) and sealed like any /// other decision — the refused impersonation is evidence too. + /// + /// A request the endpoint decided already, inside the window, gets the + /// answer it got then, unchanged: not decided again, not debited again, + /// and not sealed again. It carries nothing the first answer did not, to + /// whoever presents it. A request refused before it was decided (stale, + /// unauthenticated, or unrecorded) is not remembered, and is decided when + /// it is presented again. pub fn handle(&mut self, req: &WireRequest) -> WireResponse { let now = (self.clock)(); - let mut refused = if req.verify_caller() { self.admit(req, now).err() } else { Some(jcp_schema::DenyReason::BadSignature) }; + let admitted = if req.verify_caller() { self.admit(req, now) } else { Err(jcp_schema::DenyReason::BadSignature) }; + let (id, mut refused) = match admitted { + Ok(Admission::Repeat(response)) => return response, + Ok(Admission::Decide(id)) => (Some(id), None), + Err(reason) => (None, Some(reason)), + }; // Recorded before it is decided: a decision that is lost on a restart // would take its debit with it (SPEC §4.11). if refused.is_none() && self.journal.append(&JournalRecord::Decided { at: now, request: req.clone() }).is_err() { - if let Ok(id) = ContentId::of(req) { - self.decided.remove(&id); - } refused = Some(jcp_schema::DenyReason::Unrecorded); } let decision = match refused { @@ -343,18 +398,11 @@ impl Endpoint { .unwrap_or(0), }, }; - // The receipt names the leaf grant and — for a multi-hop chain — every - // debited link, so the receipt stream replays exactly (SPEC §8.1). - let chain_ids: Vec<_> = req.chain.iter().map(|sg| sg.grant.id()).collect(); - let receipt = req - .chain - .last() - .and_then(|leaf| { - jcp_receipt::seal_chained(&req.frame, &chain_ids, &leaf.grant, &decision, &self.steward_sk, &self.steward) - .ok() - }) - .unwrap_or_default(); - WireResponse { decision, receipt } + let response = self.respond(req, decision); + if let (Some(id), None) = (id, refused) { + self.decided.insert(id, Answered { stamp: req.now, response: response.clone() }); + } + response } /// Serve one connection: read requests, answer decisions, until the peer @@ -506,21 +554,99 @@ mod tests { assert!(ok.handle(&stamped(&chain, r, 1, 97)).decision.is_allow()); } - /// A captured request presented again is refused, not decided and debited - /// a second time; a request stamped differently is a new request. + /// A request presented again is decided once: it gets the answer it got + /// the first time, decision and receipt alike, and is debited once. A + /// caller whose response was lost therefore recovers it, where it used to + /// be refused `replayed` and could only stamp the request again, which + /// decided and debited the same action a second time. A request stamped + /// differently is a new request. #[test] - fn a_captured_request_is_decided_once() { + fn a_request_presented_again_gets_its_first_answer() { let (chain, _, r) = fixture(); - let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + let journal = Shared::default(); + let mut ep = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap(); let req = stamped(&chain, r, 10_000, 10); - assert!(ep.handle(&req).decision.is_allow()); + let first = ep.handle(&req); + assert!(first.decision.is_allow()); let again = ep.handle(&req); - assert_eq!(again.decision.verdict, Verdict::Deny(DenyReason::Replayed)); + assert_eq!(again, first, "the same decision and the same receipt bytes"); assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000, "debited once"); + assert_eq!(journal.0.lock().unwrap().len(), 1, "and recorded once"); assert!(ep.handle(&stamped(&chain, r, 10_000, 11)).decision.is_allow()); assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 20_000); } + /// A refusal is an answer too: a request denied by the gate gets the same + /// denial again, and one refused before it was decided is decided when + /// presented again. + #[test] + fn a_denial_is_answered_again_and_an_undecided_request_is_not_remembered() { + let (chain, _, r) = fixture(); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + let over = stamped(&chain, r, 60_000, 10); + let first = ep.handle(&over); + assert_eq!(first.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded)); + assert_eq!(ep.handle(&over), first); + + let mut forged = stamped(&chain, r, 1, 10); + forged.signature[0] ^= 1; + assert_eq!(ep.handle(&forged).decision.verdict, Verdict::Deny(DenyReason::BadSignature)); + assert_eq!(ep.decided.len(), 1, "only the request the gate decided is remembered"); + } + + /// What a repeat gets is the answer to that request, made when it was + /// decided: a revocation since binds the next request, and does not turn + /// an earlier answer into a new decision. + #[test] + fn a_repeat_after_a_revocation_is_the_earlier_answer_and_a_new_request_is_refused() { + let (chain, _, r) = fixture(); + let mut ep = endpoint(at_10); + let req = stamped(&chain, r, 10_000, 10); + let first = ep.handle(&req); + assert!(first.decision.is_allow()); + let of_root = revoke(&sk(1), &chain[0].grant, [9; 16]).unwrap(); + ep.revoke(&of_root, &chain[0].grant).unwrap(); + assert_eq!(ep.handle(&req), first, "the answer it got"); + assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000, "and nothing more"); + assert_eq!(ep.handle(&stamped(&chain, r, 1, 11)).decision.verdict, Verdict::Deny(DenyReason::Revoked)); + } + + /// Over a live stream: the caller's first response never arrives, and it + /// sends the same request again and gets that response. + #[test] + fn a_lost_response_is_recovered_by_sending_the_same_request() { + let (chain, _, r) = fixture(); + let mut ep = endpoint(at_10); + let req = stamped(&chain, r, 10_000, 10); + let lost = ep.handle(&req); + let mut wire = Vec::new(); + write_msg(&mut wire, &req).unwrap(); + let mut stream = Loopback { input: std::io::Cursor::new(wire), output: Vec::new() }; + ep.serve_stream(&mut stream).unwrap(); + let got: WireResponse = read_msg(&mut std::io::Cursor::new(stream.output)).unwrap(); + assert_eq!(got, lost); + assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000); + } + + /// A stream whose reads come from `input` and whose writes go to `output`. + struct Loopback { + input: std::io::Cursor>, + output: Vec, + } + impl Read for Loopback { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.input.read(buf) + } + } + impl Write for Loopback { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.output.write(buf) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1_000); fn moving() -> u64 { CLOCK.load(std::sync::atomic::Ordering::SeqCst) @@ -594,19 +720,21 @@ mod tests { assert_eq!(after.handle(&stamped(&chain, r, 1, 10)).decision.verdict, Verdict::Deny(DenyReason::Revoked)); } - /// The endpoint remembers what it decided across a restart, so a request - /// still inside the window is refused, not decided a second time. + /// The endpoint remembers what it decided, and what it answered, across a + /// restart: a request still inside the window gets its first answer, byte + /// for byte, rather than being decided a second time. #[test] - fn a_request_decided_before_a_restart_is_still_refused() { + fn a_request_decided_before_a_restart_gets_the_same_answer_after_it() { let (chain, _, r) = fixture(); let journal = Shared::default(); let req = stamped(&chain, r, 10_000, 10); let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap(); - assert!(before.handle(&req).decision.is_allow()); + let first = before.handle(&req); + assert!(first.decision.is_allow()); drop(before); let recorded = journal.0.lock().unwrap().clone(); let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap(); - assert_eq!(after.handle(&req).decision.verdict, Verdict::Deny(DenyReason::Replayed)); + assert_eq!(after.handle(&req), first, "the answer it got before the restart"); assert_eq!(after.runtime.spent(&chain[1].grant.id()), 10_000); let twice = [recorded[0].clone(), recorded[0].clone()];