diff --git a/jcp/SPEC.md b/jcp/SPEC.md index e1144ccba..af0661243 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -1166,7 +1166,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. + and an enforcement endpoint decides + seals over a live socket; a replayed + request is refused `replayed`, and one stamped outside the window + `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 @@ -1231,10 +1233,16 @@ WireResponse { decision, receipt: bytes } ``` `chain` is the full delegation chain, root first (a single un-delegated grant is -a one-link chain). `now` makes decision time an explicit input — a decision is -replayable, not ambient. The response carries the deterministic decision and the +a one-link chain). The response carries the deterministic decision and the sealed JCR-1 receipt, deny and allow alike. +**Time is the enforcement point's.** The endpoint MUST decide at its own clock. +`now` is the time the caller signed the request, a freshness stamp: the endpoint +MUST deny a request whose `now` differs from its clock by more than its +freshness window as `stale-request`, and MUST NOT decide any request as of the +time it names. A caller that chose the decision time could stamp a request +before its grant expired, or inside a time caveat it has since left. + **Caller authentication.** `signature` is Ed25519 by `caller` over the canonical bytes of every other field. The endpoint MUST verify it before deciding; a request that fails is denied `bad-signature` and sealed like any @@ -1242,10 +1250,20 @@ other decision — the refused impersonation is evidence too. A threshold actor (§2.2) signs the same field with a k-of-n aggregate, indistinguishable on the wire. -The wire never carries a secret: grants are signed public objects, and a -captured request replays into the same deterministic decision and the same -budget accounting it produced the first time. The endpoint behind the -enforcement point is invoked only on Allow. +**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. + +**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 +figure was measured. The requester MUST therefore be the party that metered the +action: the grant's holder is the enforcement point that ran it, as a steward is +in Mesh, rather than the agent the grant governs reporting its own consumption. +The endpoint behind the enforcement point is invoked only on Allow. ## 12. Non-goals diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 3d73f9ca1..60281eb7e 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -49,7 +49,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", run: c8_wire }, + Check { id: "C8 wire binding round-trip + live decide + no replay, 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 }, @@ -583,7 +583,7 @@ fn c8_wire() -> Result<(), String> { let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; let addr = listener.local_addr().map_err(|e| e.to_string())?; let server = std::thread::spawn(move || { - let mut ep = jcp_wire::Endpoint::new(Runtime::new(), steward_sk, "did:example:steward"); + let mut ep = jcp_wire::Endpoint::new(Runtime::new(), steward_sk, "did:example:steward").with_clock(|| 1); let (mut stream, _) = listener.accept().unwrap(); let _ = ep.serve_stream(&mut stream); }); @@ -594,8 +594,28 @@ 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. + 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)); + } drop(client); server.join().map_err(|_| "wire server panicked".to_string())?; + + // The endpoint decides at its own clock: a request stamped outside its + // window is refused rather than decided as of the time it names. + let mut ep = jcp_wire::Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(|| 1_000).with_skew(60); + if ep.handle(&req).decision.verdict != Verdict::Deny(DenyReason::StaleRequest) { + return Err("a wire request stamped outside the window was decided".into()); + } + // Inside the window, the clock still decides: a grant that expires between + // the request's stamp and the endpoint's clock is expired. + let short = grant(&issuer, holder, r, &["read"], 100_000, 999); + let late = jcp_wire::WireRequest::sign(frame(&short, r, "read", Trust::Control), vec![short.clone()], &sk(2), Energy::estimator(1), 998) + .map_err(|e| e.to_string())?; + if ep.handle(&late).decision.verdict != Verdict::Deny(DenyReason::Expired) { + return Err("a wire request was decided at its own stamp rather than the endpoint's clock".into()); + } Ok(()) } @@ -854,7 +874,7 @@ fn c13_signed_caller() -> Result<(), String> { // two can — and the endpoint treats it as any signed caller let sig = team.sign(&bytes, &[1, 3], &mut rng).map_err(|e| e.to_string())?; let req = jcp_wire::WireRequest::with_signature(f.clone(), chain.clone(), team.group_did(), cost, 1, sig); - let mut ep = jcp_wire::Endpoint::new(Runtime::new(), sk(7), "did:example:steward"); + let mut ep = jcp_wire::Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(|| 1); if !ep.handle(&req).decision.is_allow() { return Err("a properly threshold-signed request was denied".into()); } diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index 35b6ccfd9..72812a0da 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -602,6 +602,13 @@ pub enum DenyReason { /// (SPEC §4.10) — a tighter wallet for work that has touched an untrusted /// source, distinct from the cumulative `BudgetExceeded`. TaintBudgetExceeded, + /// A wire request stamped with a `now` outside the enforcement point's + /// freshness window (SPEC §11). The point decides at its own clock, and a + /// 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. + Replayed, } impl DenyReason { @@ -623,6 +630,8 @@ impl DenyReason { DenyReason::UntrustedRoot => "untrusted-root", DenyReason::TaintedFlow => "tainted-flow", DenyReason::TaintBudgetExceeded => "taint-budget-exceeded", + DenyReason::StaleRequest => "stale-request", + DenyReason::Replayed => "replayed", } } } diff --git a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs index b7cf439f3..644c4704c 100644 --- a/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-threshold/src/lib.rs @@ -292,7 +292,8 @@ mod tests { jcp_runtime::Runtime::new(), ed25519_dalek::SigningKey::from_bytes(&[7; 32]), "did:example:steward", - ); + ) + .with_clock(|| 5); let resp = ep.handle(&req); assert!(resp.decision.is_allow()); } diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index ef4c90c68..e21e0b7eb 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -15,10 +15,18 @@ //! //! The decision stays on the enforcement side: the endpoint behind the server //! is invoked only on Allow. The wire never carries a secret — grants are -//! signed public objects, and a captured request replays into the same -//! deterministic decision (and the same budget accounting) it would have -//! produced the first time. +//! 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. +//! +//! 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 +//! (SPEC §11): an endpoint takes no figure from a party it does not hold to +//! account for it. +use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream, ToSocketAddrs}; #[cfg(unix)] @@ -26,7 +34,7 @@ use std::os::unix::net::{UnixListener, UnixStream}; use ed25519_dalek::SigningKey; use jcp_runtime::{Decision, Runtime}; -use jcp_schema::{canonical_cbor, from_canonical_cbor, Did, Energy, Frame, SchemaError, SignedGrant}; +use jcp_schema::{canonical_cbor, from_canonical_cbor, ContentId, Did, Energy, Frame, SchemaError, SignedGrant}; use serde::{Deserialize, Serialize}; /// Cap on a single wire message. Generous for grant chains, hostile to a @@ -60,8 +68,9 @@ pub struct WireRequest { pub chain: Vec, pub caller: Did, pub cost: Energy, - /// Decision-time seconds; the enforcement point treats time as input, not - /// ambient state, so a decision is replayable. + /// When the caller signed the request, unix seconds: a freshness stamp. The + /// enforcement point decides at its own clock and refuses a request whose + /// stamp is outside its window, so this cannot choose the decision time. pub now: u64, /// Ed25519 (64 bytes) by `caller` over [`WireRequest::signable_bytes`]. pub signature: Vec, @@ -137,6 +146,12 @@ pub struct WireResponse { pub receipt: Vec, } +fn system_clock() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + /// Write one length-prefixed canonical-CBOR message. pub fn write_msg(w: &mut impl Write, msg: &T) -> Result<(), WireError> { let body = canonical_cbor(msg)?; @@ -171,17 +186,63 @@ 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, } impl Endpoint { + /// An endpoint deciding at the system clock, with a + /// [`DEFAULT_SKEW_SECS`] freshness window. pub fn new(runtime: Runtime, steward_sk: SigningKey, steward: impl Into) -> Self { - Self { runtime, steward_sk, steward: steward.into() } + Self { + runtime, + steward_sk, + steward: steward.into(), + clock: system_clock, + skew_secs: DEFAULT_SKEW_SECS, + decided: HashMap::new(), + } + } + + /// Decide at `clock` (unix seconds) instead of the system clock. + pub fn with_clock(mut self, clock: fn() -> u64) -> Self { + self.clock = clock; + self + } + + /// Accept a request whose `now` is within `secs` of the clock. + pub fn with_skew(mut self, secs: u64) -> Self { + self.skew_secs = secs; + 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> { + let skew = self.skew_secs; + self.decided.retain(|_, stamp| 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(()) } /// Decide one request and seal the receipt — the whole protocol step, @@ -189,18 +250,19 @@ impl Endpoint { /// unauthenticated request is denied (`bad-signature`) and sealed like any /// other decision — the refused impersonation is evidence too. pub fn handle(&mut self, req: &WireRequest) -> WireResponse { - let decision = if req.verify_caller() { - self.runtime.decide_chained(&req.frame, &req.chain, req.caller, req.cost, req.now) - } else { - Decision { - verdict: jcp_schema::Verdict::Deny(jcp_schema::DenyReason::BadSignature), + let now = (self.clock)(); + let refused = if req.verify_caller() { self.admit(req, now).err() } else { Some(jcp_schema::DenyReason::BadSignature) }; + let decision = match refused { + None => self.runtime.decide_chained(&req.frame, &req.chain, req.caller, req.cost, now), + Some(reason) => Decision { + verdict: jcp_schema::Verdict::Deny(reason), cost: req.cost, budget_remaining_uj: req .chain .last() .map(|l| l.grant.budget_uj.saturating_sub(self.runtime.spent(&l.grant.id()))) .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). @@ -318,6 +380,86 @@ mod tests { (vec![root, leaf], did(&sk(3)), r) } + /// The fixture's requests are stamped 10; the existing tests decide there. + fn at_10() -> u64 { + 10 + } + + /// A request stamped `now`, signed by the fixture's caller. + fn stamped(chain: &[SignedGrant], r: ContentId, cost: u64, now: u64) -> WireRequest { + let frame = request(chain, did(&sk(3)), r, Trust::Control, cost).frame; + WireRequest::sign(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), now).unwrap() + } + + /// The fixture's chain with a root and leaf that expire at `at`. + fn expiring(at: u64) -> (Vec, ContentId) { + let (chain, _, r) = fixture(); + let mut g = chain[0].grant.clone(); + g.expiry = at; + let root = issue(&sk(1), g).unwrap(); + let leaf = attenuate(&root, &sk(2), did(&sk(3)), Capability::new(r, ["read"]), 50_000, at, [1; 16]).unwrap(); + (vec![root, leaf], r) + } + + fn at_100() -> u64 { + 100 + } + + /// The endpoint decides at its own clock. A caller used to choose the + /// decision time by stamping its request, so a grant that had expired was + /// decided as of a moment it had not. + #[test] + fn the_endpoint_decides_at_its_own_clock() { + // Expires at 99. A request stamped 97 is inside the window, and was + // valid at its stamp, but the endpoint's clock reads 100. + let (chain, r) = expiring(99); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_100).with_skew(5); + let fresh = ep.handle(&stamped(&chain, r, 1, 97)); + assert_eq!(fresh.decision.verdict, Verdict::Deny(DenyReason::Expired), "decided at 100, not at the stamp 97"); + let backdated = ep.handle(&stamped(&chain, r, 1, 40)); + assert_eq!(backdated.decision.verdict, Verdict::Deny(DenyReason::StaleRequest), "stamped outside the window"); + assert!(!backdated.receipt.is_empty(), "sealed like any other denial"); + assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 0); + + // The same chain at an endpoint whose clock is before the expiry: allowed. + let (chain, r) = expiring(200); + let mut ok = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_100).with_skew(5); + 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. + #[test] + fn a_captured_request_is_decided_once() { + let (chain, _, r) = fixture(); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); + let req = stamped(&chain, r, 10_000, 10); + assert!(ep.handle(&req).decision.is_allow()); + let again = ep.handle(&req); + assert_eq!(again.decision.verdict, Verdict::Deny(DenyReason::Replayed)); + assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 10_000, "debited once"); + assert!(ep.handle(&stamped(&chain, r, 10_000, 11)).decision.is_allow()); + assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 20_000); + } + + static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1_000); + fn moving() -> u64 { + CLOCK.load(std::sync::atomic::Ordering::SeqCst) + } + + /// The endpoint forgets a request once its stamp leaves the window, and a + /// replay after that is stale rather than fresh again. + #[test] + fn a_replay_after_the_window_is_stale() { + let (chain, _, r) = fixture(); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(moving).with_skew(30); + let req = stamped(&chain, r, 1, 1_000); + assert!(ep.handle(&req).decision.is_allow()); + CLOCK.store(1_031, std::sync::atomic::Ordering::SeqCst); + assert_eq!(ep.handle(&req).decision.verdict, Verdict::Deny(DenyReason::StaleRequest)); + assert!(ep.decided.is_empty(), "nothing outside the window is remembered"); + } + // the fixture's caller is sk(3) — sign as them fn request(chain: &[SignedGrant], _caller: Did, r: ContentId, trust: Trust, cost: u64) -> WireRequest { let frame = Frame { @@ -350,7 +492,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { - let mut ep = Endpoint::new(Runtime::new(), steward_sk, "did:example:steward"); + let mut ep = Endpoint::new(Runtime::new(), steward_sk, "did:example:steward").with_clock(at_10); let (mut stream, _) = listener.accept().unwrap(); ep.serve_stream(&mut stream).unwrap(); }); @@ -385,7 +527,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let server = std::thread::spawn(move || { - let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward"); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); for _ in 0..2 { let (mut stream, _) = listener.accept().unwrap(); ep.serve_stream(&mut stream).unwrap(); @@ -419,7 +561,7 @@ mod tests { // the issuer revoked the ROOT before this endpoint came up let sr = revoke(&sk(1), &root_grant, [9; 16]).unwrap(); rt.apply_revocation(&sr, &root_grant).unwrap(); - let mut ep = Endpoint::new(rt, sk(7), "did:example:steward"); + let mut ep = Endpoint::new(rt, sk(7), "did:example:steward").with_clock(at_10); let (mut stream, _) = listener.accept().unwrap(); ep.serve_stream(&mut stream).unwrap(); }); @@ -443,7 +585,7 @@ mod tests { #[test] fn an_impersonated_caller_is_denied_and_sealed() { let (chain, caller, r) = fixture(); - let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward"); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10); // a request claiming the caller's did, signed by someone else let frame = request(&chain, caller, r, Trust::Control, 1).frame; let bytes = WireRequest::signable_bytes(&frame, &chain, &caller, &Energy::estimator(1), 10).unwrap();