jcp-wire: a request presented again gets its first answer, so a lost response is recoverable #349

Merged
dcharlot merged 1 commit from fix/jcp-wire-retry-and-single-writer into main 2026-10-20 03:49:38 -04:00
4 changed files with 202 additions and 58 deletions

View file

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

View file

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

View file

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

View file

@ -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<u8>,
@ -213,6 +216,23 @@ pub fn read_msg<T: serde::de::DeserializeOwned>(r: &mut impl Read) -> Result<T,
/// How far, by default, a request's `now` may stray from the endpoint's clock.
pub const DEFAULT_SKEW_SECS: u64 = 300;
/// A request the endpoint decided, kept while its stamp is in the window.
#[derive(Clone, Debug)]
struct Answered {
/// The request's own stamp, which says when it leaves the window.
stamp: u64,
/// What it was answered, given again to the same request.
response: WireResponse,
}
/// What [`Endpoint::admit`] lets a fresh, authenticated request do.
enum Admission {
/// Be decided: the endpoint has not decided it.
Decide(ContentId),
/// Get the answer it got before: the endpoint decided it already.
Repeat(WireResponse),
}
/// An enforcement point serving `jcp/1` over byte streams: the runtime that
/// decides, and the steward key that seals every decision.
pub struct Endpoint {
@ -223,8 +243,9 @@ pub struct Endpoint {
clock: fn() -> 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<ContentId, u64>,
/// The requests decided within the window, by content id: each one's
/// stamp, and the answer it got.
decided: HashMap<ContentId, Answered>,
/// Where each admitted request and each revocation is recorded first.
journal: Box<dyn Journal + Send>,
}
@ -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<Admission, jcp_schema::DenyReason> {
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<Vec<u8>>,
output: Vec<u8>,
}
impl Read for Loopback {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for Loopback {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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()];