jcp-wire: the endpoint decides at its own clock, and a captured request is decided once #322

Merged
dcharlot merged 1 commit from fix/jcp-wire-endpoint-clock-and-replay into main 2026-10-18 19:02:35 -04:00
5 changed files with 218 additions and 28 deletions

View file

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

View file

@ -49,7 +49,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", 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());
}

View file

@ -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",
}
}
}

View file

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

View file

@ -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<SignedGrant>,
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<u8>,
@ -137,6 +146,12 @@ pub struct WireResponse {
pub receipt: Vec<u8>,
}
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<T: Serialize>(w: &mut impl Write, msg: &T) -> Result<(), WireError> {
let body = canonical_cbor(msg)?;
@ -171,17 +186,63 @@ pub fn read_msg<T: serde::de::DeserializeOwned>(r: &mut impl Read) -> Result<T,
Ok(from_canonical_cbor(&body)?)
}
/// How far, by default, a request's `now` may stray from the endpoint's clock.
pub const DEFAULT_SKEW_SECS: u64 = 300;
/// An enforcement point serving `jcp/1` over byte streams: the runtime that
/// decides, and the steward key that seals every decision.
pub struct Endpoint {
pub runtime: Runtime,
steward_sk: SigningKey,
steward: String,
/// Unix seconds: the time every decision is made at.
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>,
}
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<String>) -> 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<SignedGrant>, 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();