jcp §4.13: a retry is not a second call, where the caller names the call #588
3 changed files with 433 additions and 19 deletions
59
jcp/SPEC.md
59
jcp/SPEC.md
|
|
@ -628,6 +628,65 @@ bounded at each point separately, so `n` points admit up to `n` times the limit.
|
|||
A deployment that needs one bound across points MUST route the grant's calls
|
||||
through one of them, or share its history between them.
|
||||
|
||||
### 4.13 A retry is not a second call (RECOMMENDED where a transport can redeliver)
|
||||
|
||||
§4.11 makes a restart give no budget back. It leaves open the case one layer
|
||||
out: the same call delivered twice.
|
||||
|
||||
An endpoint already recognises a repeat it has seen within its freshness window,
|
||||
by the request's content address, and answers it from the record without
|
||||
debiting again. That catches a redelivery which reproduced the request's bytes
|
||||
exactly — and the bytes include the caller's stamp. So it catches the fast,
|
||||
byte-identical redelivery, and misses the one that matters: a transport whose
|
||||
retry is slow enough that the first stamp has aged out, where the caller must
|
||||
**re-stamp** to be admitted at all. That retry has a different content address,
|
||||
is admitted as a new call, and is debited again. The overspend §4.11 exists to
|
||||
prevent happens anyway, for a reason §4.11 does not reach.
|
||||
|
||||
The cause is that identity is **incidental** — derived from the bytes — rather
|
||||
than declared. A caller therefore **SHOULD** name its call: 32 bytes it chose,
|
||||
the same across every delivery of that call and different between calls, signed
|
||||
with the rest of the request. Where a request carries one, an endpoint **MUST**
|
||||
recognise a repeat by it rather than by the request's bytes, and **MUST** answer
|
||||
a repeat what it answered the first delivery without debiting the grant again.
|
||||
|
||||
Two consequences follow from the id being the caller's.
|
||||
|
||||
- **It is scoped to the caller.** The key is the pair, never the id alone. A
|
||||
global id space would let one caller suppress another's call by guessing its
|
||||
id — a denial with no trace, since a suppressed call looks exactly like a
|
||||
recognised repeat.
|
||||
- **An absent id is not a promise of uniqueness.** Where a request carries none,
|
||||
every delivery is a distinct call and the content-address behaviour above is
|
||||
all an endpoint can offer. A host **MUST NOT** infer an identity the caller
|
||||
did not declare: two calls that happen to be byte-identical may be two calls
|
||||
the caller meant.
|
||||
|
||||
**The guarantee is bounded, and says so.** An endpoint remembers a call id for a
|
||||
declared **horizon**, and a redelivery arriving after it is not recognised. The
|
||||
horizon **MUST** be at least as long as the longest retry of the transport in
|
||||
front of it, or the mechanism does not cover the case it was added for.
|
||||
Remembering every call forever would need state forever; a bounded guarantee
|
||||
that names its bound is honest where an unbounded one would not be true.
|
||||
|
||||
**What this does not decide.** Whether an action *may* be re-dispatched at all
|
||||
is a property of the action, not of the call id, and §4.11 already governs the
|
||||
unresolved case: an endpoint does not silently retry, does not report an
|
||||
in-flight action as completed or failed, and surfaces it naming the party that
|
||||
held it. A declared id is what makes a re-dispatch *decidable* where a
|
||||
dispatcher does classify its actions — "safe to re-dispatch" means nothing until
|
||||
the re-dispatch can be recognised as the same call. Any such classification is
|
||||
the implementing party's declaration, and the decision to act on it is the
|
||||
enforcement point's. **A model is never asked whether an action already ran**:
|
||||
it would be guessing about state it cannot observe, and a guess is not evidence.
|
||||
|
||||
And a recognised repeat is not free of cost, only free of the call's cost. The
|
||||
endpoint did work to recognise it. What it must not do is debit the call twice.
|
||||
|
||||
Conformance class **C29** exercises the re-stamped retry with and without a
|
||||
declared id, two deliberately-distinct calls, the caller scoping, survival
|
||||
across a restart, and the horizon's bound.
|
||||
|
||||
## 5. Energy (REQUIRED)
|
||||
|
||||
Every metered cost is integer microjoules with a provenance tag: `HwShunt`
|
||||
|
|
|
|||
|
|
@ -83,11 +83,100 @@ pub fn checks() -> Vec<Check> {
|
|||
Check { id: "C25 flow: label under ceiling, floor, fail-closed, no debit", run: c25_flow },
|
||||
Check { id: "C27 windows: calls, payloads and joules over a sliding window", run: c27_windows },
|
||||
Check { id: "C28 endpoint evidence: a signed challenge, bound to the answer", run: c28_endpoint_evidence },
|
||||
Check { id: "C29 a retry is not a second call", run: c29_call_identity },
|
||||
Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials },
|
||||
Check { id: "C-neg canonical rejects floats", run: cneg_canonical },
|
||||
]
|
||||
}
|
||||
|
||||
/// C29 — a retry is not a second call (SPEC §4.13).
|
||||
fn c29_call_identity() -> Result<(), String> {
|
||||
use jcp_wire::{Endpoint, JournalRecord, MemoryJournal, WireRequest};
|
||||
fn clock() -> u64 {
|
||||
10
|
||||
}
|
||||
let resource = ContentId::of_bytes(b"c29");
|
||||
let caller = sk(3);
|
||||
let chain = vec![grant(&sk(1), did(&caller), resource, &["read"], 100_000, 1_000)];
|
||||
let ep = || {
|
||||
Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(clock)
|
||||
};
|
||||
let req = |now: u64, who: &SigningKey, call: Option<[u8; 32]>| {
|
||||
let f = frame(chain.last().unwrap(), resource, "read", Trust::Control);
|
||||
WireRequest::sign_parts(f, chain.clone(), who, Energy::estimator(7), now, None, call)
|
||||
.map_err(|e| format!("sign: {e:?}"))
|
||||
};
|
||||
let leaf = chain.last().unwrap().grant.id();
|
||||
|
||||
// Without a declared id, a retry that re-stamped is a second call.
|
||||
let mut anon = ep();
|
||||
anon.handle(&req(10, &caller, None)?);
|
||||
let once = anon.runtime.spent(&leaf);
|
||||
anon.handle(&req(11, &caller, None)?);
|
||||
if anon.runtime.spent(&leaf) != once * 2 {
|
||||
return Err("a re-stamped retry with no declared id should be a second call".into());
|
||||
}
|
||||
|
||||
// With one, it is the same call: paid once, answered what it was answered.
|
||||
let mut named = ep();
|
||||
let first = named.handle(&req(10, &caller, Some([0xC1; 32]))?);
|
||||
let paid = named.runtime.spent(&leaf);
|
||||
let again = named.handle(&req(11, &caller, Some([0xC1; 32]))?);
|
||||
if named.runtime.spent(&leaf) != paid {
|
||||
return Err("a declared repeat was debited twice".into());
|
||||
}
|
||||
if again.receipt != first.receipt {
|
||||
return Err("a declared repeat was answered something else".into());
|
||||
}
|
||||
|
||||
// Two calls that say they differ both happen.
|
||||
let mut distinct = ep();
|
||||
distinct.handle(&req(10, &caller, Some([0xA1; 32]))?);
|
||||
let one = distinct.runtime.spent(&leaf);
|
||||
distinct.handle(&req(10, &caller, Some([0xA2; 32]))?);
|
||||
if distinct.runtime.spent(&leaf) != one * 2 {
|
||||
return Err("a different declared id should be a different call".into());
|
||||
}
|
||||
|
||||
// The id is scoped to its caller: another caller's identical id is not a
|
||||
// repeat of mine, or one caller could suppress another's call.
|
||||
let mut scoped = ep();
|
||||
let mine = req(10, &caller, Some([0xC1; 32]))?;
|
||||
let mine_answer = scoped.handle(&mine);
|
||||
let theirs = req(10, &sk(4), Some([0xC1; 32]))?;
|
||||
if scoped.handle(&theirs).receipt == mine_answer.receipt {
|
||||
return Err("another caller's id was taken for mine".into());
|
||||
}
|
||||
|
||||
// The index survives a restart, or the retry is debited again — the
|
||||
// overspend §4.11 prevents, one layer out.
|
||||
let recorded = [JournalRecord::Decided { at: 10, request: req(10, &caller, Some([0xC1; 32]))? }];
|
||||
let mut restarted = ep()
|
||||
.with_journal(MemoryJournal::default(), &recorded)
|
||||
.map_err(|e| format!("replay: {e:?}"))?;
|
||||
let replayed = restarted.runtime.spent(&leaf);
|
||||
restarted.handle(&req(11, &caller, Some([0xC1; 32]))?);
|
||||
if restarted.runtime.spent(&leaf) != replayed {
|
||||
return Err("a restart forgot a call it had decided".into());
|
||||
}
|
||||
|
||||
// And the guarantee is bounded by the horizon rather than unbounded.
|
||||
fn late() -> u64 {
|
||||
400
|
||||
}
|
||||
let mut past = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
|
||||
.with_clock(late)
|
||||
.with_replay_horizon(60)
|
||||
.with_journal(MemoryJournal::default(), &recorded)
|
||||
.map_err(|e| format!("replay: {e:?}"))?;
|
||||
let before = past.runtime.spent(&leaf);
|
||||
past.handle(&req(400, &caller, Some([0xC1; 32]))?);
|
||||
if past.runtime.spent(&leaf) <= before {
|
||||
return Err("past the horizon a call should no longer be recognised".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sk(s: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[s; 32])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,15 @@ pub struct WireRequest {
|
|||
/// always had.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub challenge: Option<[u8; 32]>,
|
||||
/// The caller's name for *this call* (SPEC §4.13): 32 bytes it chose,
|
||||
/// kept the same across every delivery of the same call and different
|
||||
/// between calls. Where it is present the endpoint recognises a repeat by
|
||||
/// it instead of by the request's bytes, so a retry that re-stamps `now`
|
||||
/// is still the same call rather than a second one. It is signed with the
|
||||
/// rest of the request, and omitted when absent, so a request without one
|
||||
/// has the bytes it always had.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub call_id: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// The borrowed signable view of a request — every field but the signature.
|
||||
|
|
@ -126,6 +135,8 @@ struct SignableRequest<'a> {
|
|||
now: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
challenge: Option<&'a [u8; 32]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
call_id: Option<&'a [u8; 32]>,
|
||||
}
|
||||
|
||||
impl WireRequest {
|
||||
|
|
@ -137,7 +148,7 @@ impl WireRequest {
|
|||
cost: &Energy,
|
||||
now: u64,
|
||||
) -> Result<Vec<u8>, SchemaError> {
|
||||
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None })
|
||||
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None, call_id: None })
|
||||
}
|
||||
|
||||
/// The canonical bytes the caller signs for a request carrying
|
||||
|
|
@ -151,7 +162,52 @@ impl WireRequest {
|
|||
now: u64,
|
||||
challenge: &[u8; 32],
|
||||
) -> Result<Vec<u8>, SchemaError> {
|
||||
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge) })
|
||||
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge), call_id: None })
|
||||
}
|
||||
|
||||
/// The canonical bytes *this* request's signature covers, whichever
|
||||
/// optional fields it carries.
|
||||
///
|
||||
/// One function rather than one per combination of optionals: each is
|
||||
/// skipped when absent, so a request carrying none has the bytes it
|
||||
/// always had, and adding another optional does not double the number of
|
||||
/// ways to ask this question.
|
||||
pub fn signed_bytes(&self) -> Result<Vec<u8>, SchemaError> {
|
||||
canonical_cbor(&SignableRequest {
|
||||
frame: &self.frame,
|
||||
chain: &self.chain,
|
||||
caller: &self.caller,
|
||||
cost: &self.cost,
|
||||
now: self.now,
|
||||
challenge: self.challenge.as_ref(),
|
||||
call_id: self.call_id.as_ref(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build and sign a request, naming each optional field explicitly.
|
||||
/// [`sign`](Self::sign) and [`sign_challenged`](Self::sign_challenged)
|
||||
/// are this with one of them fixed.
|
||||
///
|
||||
/// The argument list is the request's own fields; collapsing the two
|
||||
/// optionals into a struct would hide which of them a call site sets,
|
||||
/// which is the thing a reader of a signing call needs to see.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn sign_parts(
|
||||
frame: Frame,
|
||||
chain: Vec<SignedGrant>,
|
||||
caller_sk: &SigningKey,
|
||||
cost: Energy,
|
||||
now: u64,
|
||||
challenge: Option<[u8; 32]>,
|
||||
call_id: Option<[u8; 32]>,
|
||||
) -> Result<Self, SchemaError> {
|
||||
use ed25519_dalek::Signer;
|
||||
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
|
||||
let mut req =
|
||||
Self { frame, chain, caller, cost, now, signature: Vec::new(), challenge, call_id };
|
||||
let bytes = req.signed_bytes()?;
|
||||
req.signature = caller_sk.sign(&bytes).to_bytes().to_vec();
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
/// Build and sign a request that asks for the endpoint's evidence, bound
|
||||
|
|
@ -164,11 +220,7 @@ impl WireRequest {
|
|||
now: u64,
|
||||
challenge: [u8; 32],
|
||||
) -> Result<Self, SchemaError> {
|
||||
use ed25519_dalek::Signer;
|
||||
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
|
||||
let bytes = Self::challenged_signable_bytes(&frame, &chain, &caller, &cost, now, &challenge)?;
|
||||
let signature = caller_sk.sign(&bytes).to_bytes().to_vec();
|
||||
Ok(Self { frame, chain, caller, cost, now, signature, challenge: Some(challenge) })
|
||||
Self::sign_parts(frame, chain, caller_sk, cost, now, Some(challenge), None)
|
||||
}
|
||||
|
||||
/// Build and sign a request with the caller's key.
|
||||
|
|
@ -179,11 +231,7 @@ impl WireRequest {
|
|||
cost: Energy,
|
||||
now: u64,
|
||||
) -> Result<Self, SchemaError> {
|
||||
use ed25519_dalek::Signer;
|
||||
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
|
||||
let bytes = Self::signable_bytes(&frame, &chain, &caller, &cost, now)?;
|
||||
let signature = caller_sk.sign(&bytes).to_bytes().to_vec();
|
||||
Ok(Self { frame, chain, caller, cost, now, signature, challenge: None })
|
||||
Self::sign_parts(frame, chain, caller_sk, cost, now, None, None)
|
||||
}
|
||||
|
||||
/// Attach an externally produced signature (e.g. a FROST k-of-n aggregate)
|
||||
|
|
@ -196,18 +244,14 @@ impl WireRequest {
|
|||
now: u64,
|
||||
signature: Vec<u8>,
|
||||
) -> Self {
|
||||
Self { frame, chain, caller, cost, now, signature, challenge: None }
|
||||
Self { frame, chain, caller, cost, now, signature, challenge: None, call_id: None }
|
||||
}
|
||||
|
||||
/// Whether `signature` is the caller's, over this request's signable bytes.
|
||||
pub fn verify_caller(&self) -> bool {
|
||||
use ed25519_dalek::Verifier;
|
||||
let Ok(vk) = self.caller.verifying_key() else { return false };
|
||||
let bytes = match &self.challenge {
|
||||
None => Self::signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now),
|
||||
Some(c) => Self::challenged_signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now, c),
|
||||
};
|
||||
let Ok(bytes) = bytes else {
|
||||
let Ok(bytes) = self.signed_bytes() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(sig) = ed25519_dalek::Signature::from_slice(&self.signature) else { return false };
|
||||
|
|
@ -330,6 +374,18 @@ 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 call the endpoint decided, kept for the replay horizon (SPEC §4.13).
|
||||
///
|
||||
/// Retained by **when it was decided**, not by the request's stamp: the whole
|
||||
/// point of a declared call id is to recognise a retry that re-stamps `now`,
|
||||
/// and an entry pruned by the original stamp would already be gone when that
|
||||
/// retry arrives.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Called {
|
||||
decided_at: u64,
|
||||
response: WireResponse,
|
||||
}
|
||||
|
||||
/// A request the endpoint decided, kept while its stamp is in the window.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Answered {
|
||||
|
|
@ -360,6 +416,15 @@ pub struct Endpoint {
|
|||
/// The requests decided within the window, by content id: each one's
|
||||
/// stamp, and the answer it got.
|
||||
decided: HashMap<ContentId, Answered>,
|
||||
/// The calls decided within the replay horizon, by the caller and the
|
||||
/// call id it declared (SPEC §4.13). Scoped to the caller because the id
|
||||
/// is the caller's to choose: were it global, one caller could suppress
|
||||
/// another's call by guessing its id.
|
||||
calls: HashMap<(Did, [u8; 32]), Called>,
|
||||
/// How long a declared call id is remembered. The guarantee is bounded by
|
||||
/// this and says so: recognising a repeat forever would need state
|
||||
/// forever.
|
||||
replay_horizon_secs: u64,
|
||||
/// Where each admitted request and each revocation is recorded first.
|
||||
journal: Box<dyn Journal + Send>,
|
||||
/// Where this endpoint's evidence comes from, if it can give any.
|
||||
|
|
@ -377,6 +442,8 @@ impl Endpoint {
|
|||
clock: system_clock,
|
||||
skew_secs: DEFAULT_SKEW_SECS,
|
||||
decided: HashMap::new(),
|
||||
calls: HashMap::new(),
|
||||
replay_horizon_secs: DEFAULT_SKEW_SECS,
|
||||
journal: Box::new(MemoryJournal::default()),
|
||||
attester: None,
|
||||
}
|
||||
|
|
@ -415,6 +482,16 @@ impl Endpoint {
|
|||
// 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);
|
||||
// The call index is re-established by the same replay, or
|
||||
// a restart would forget which calls it had already
|
||||
// decided and debit their retries again — the overspend
|
||||
// §4.11 exists to prevent, one layer out.
|
||||
if let Some(cid) = request.call_id {
|
||||
self.calls.insert(
|
||||
(request.caller, cid),
|
||||
Called { decided_at: *at, response: response.clone() },
|
||||
);
|
||||
}
|
||||
self.decided.insert(id, Answered { stamp: request.now, response });
|
||||
}
|
||||
JournalRecord::Revoked { revocation, grant } => {
|
||||
|
|
@ -427,6 +504,8 @@ impl Endpoint {
|
|||
let now = (self.clock)();
|
||||
let skew = self.skew_secs;
|
||||
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
|
||||
let horizon = self.replay_horizon_secs;
|
||||
self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now);
|
||||
self.journal = Box::new(journal);
|
||||
Ok(self)
|
||||
}
|
||||
|
|
@ -441,6 +520,18 @@ impl Endpoint {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember a declared call id for `secs` instead of the default
|
||||
/// [`DEFAULT_SKEW_SECS`] (SPEC §4.13).
|
||||
///
|
||||
/// A transport that can redeliver after an outage needs a horizon at
|
||||
/// least as long as its longest retry, or a redelivery arrives after the
|
||||
/// endpoint has forgotten the call and is debited as a new one. The cost
|
||||
/// is state, which is why it is a choice and not a constant.
|
||||
pub fn with_replay_horizon(mut self, secs: u64) -> Self {
|
||||
self.replay_horizon_secs = secs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Decide at `clock` (unix seconds) instead of the system clock.
|
||||
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
|
||||
self.clock = clock;
|
||||
|
|
@ -462,9 +553,20 @@ impl Endpoint {
|
|||
fn admit(&mut self, req: &WireRequest, now: u64) -> Result<Admission, jcp_schema::DenyReason> {
|
||||
let skew = self.skew_secs;
|
||||
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
|
||||
let horizon = self.replay_horizon_secs;
|
||||
self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now);
|
||||
if now.abs_diff(req.now) > skew {
|
||||
return Err(jcp_schema::DenyReason::StaleRequest);
|
||||
}
|
||||
// A declared call id answers first. Recognising a repeat by the
|
||||
// request's bytes only catches a redelivery that reproduced them
|
||||
// exactly, including `now` — so it misses the retry that re-stamped,
|
||||
// which is the one a slow transport actually sends.
|
||||
if let Some(cid) = req.call_id {
|
||||
if let Some(c) = self.calls.get(&(req.caller, cid)) {
|
||||
return Ok(Admission::Repeat(c.response.clone()));
|
||||
}
|
||||
}
|
||||
let id = ContentId::of(req).map_err(|_| jcp_schema::DenyReason::BadSignature)?;
|
||||
Ok(match self.decided.get(&id) {
|
||||
Some(a) => Admission::Repeat(a.response.clone()),
|
||||
|
|
@ -538,6 +640,12 @@ impl Endpoint {
|
|||
let response = self.respond(req, decision);
|
||||
if let (Some(id), None) = (id, refused) {
|
||||
self.decided.insert(id, Answered { stamp: req.now, response: response.clone() });
|
||||
if let Some(cid) = req.call_id {
|
||||
self.calls.insert(
|
||||
(req.caller, cid),
|
||||
Called { decided_at: now, response: response.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
|
@ -840,7 +948,7 @@ mod tests {
|
|||
assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
|
||||
}
|
||||
|
||||
/// A window survives a restart (SPEC §4.12): the endpoint replays each
|
||||
/// A window survives a restart (SPEC §4.13): the endpoint replays each
|
||||
/// decision at the time it made it, so the window a call filled is still
|
||||
/// full afterwards.
|
||||
#[test]
|
||||
|
|
@ -1209,4 +1317,162 @@ mod tests {
|
|||
tampered.now = 99;
|
||||
assert!(!tampered.verify_caller());
|
||||
}
|
||||
|
||||
|
||||
/// A request stamped `now`, carrying the caller's name for the call.
|
||||
fn identified(chain: &[SignedGrant], r: ContentId, cost: u64, now: u64, call: [u8; 32]) -> WireRequest {
|
||||
let frame = request(chain, did(&sk(3)), r, Trust::Control, cost).frame;
|
||||
WireRequest::sign_parts(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), now, None, Some(call))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn spent_on_leaf(ep: &Endpoint, chain: &[SignedGrant]) -> u64 {
|
||||
ep.runtime.spent(&chain.last().unwrap().grant.id())
|
||||
}
|
||||
|
||||
/// A request without a call id is byte-for-byte what it was before the
|
||||
/// field existed, and still verifies against the old signable bytes.
|
||||
#[test]
|
||||
fn a_request_without_a_call_id_has_the_bytes_it_always_had() {
|
||||
let (chain, _, r) = fixture();
|
||||
let plain = stamped(&chain, r, 1, 10);
|
||||
let wire = canonical_cbor(&plain).unwrap();
|
||||
assert!(!wire.windows(7).any(|w| w == b"call_id"), "no call_id field on the wire");
|
||||
use ed25519_dalek::Verifier;
|
||||
let old =
|
||||
WireRequest::signable_bytes(&plain.frame, &plain.chain, &plain.caller, &plain.cost, plain.now)
|
||||
.unwrap();
|
||||
let sig = ed25519_dalek::Signature::from_slice(&plain.signature).unwrap();
|
||||
sk(3).verifying_key().verify(&old, &sig).expect("signed over the bytes it always was");
|
||||
}
|
||||
|
||||
/// The id is inside the signature, so a relay cannot retarget a call by
|
||||
/// editing it — which would let it replay one call as another.
|
||||
#[test]
|
||||
fn the_call_id_is_covered_by_the_signature() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut req = identified(&chain, r, 1, 10, [0xC1; 32]);
|
||||
assert!(req.verify_caller());
|
||||
req.call_id = Some([0xC2; 32]);
|
||||
assert!(!req.verify_caller(), "editing the call id must break the signature");
|
||||
}
|
||||
|
||||
/// The gap this closes. A transport retries; the first stamp has aged, so
|
||||
/// the client re-stamps. Recognising a repeat by the request's bytes
|
||||
/// cannot see that this is the same call, and the grant is debited twice.
|
||||
/// A declared id is seen.
|
||||
#[test]
|
||||
fn a_re_stamped_retry_is_the_same_call_only_when_it_says_so() {
|
||||
let (chain, _, r) = fixture();
|
||||
|
||||
// Without an id: the re-stamped retry is a second call, and pays.
|
||||
let mut anon = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
anon.handle(&stamped(&chain, r, 7, 10));
|
||||
let once = spent_on_leaf(&anon, &chain);
|
||||
anon.handle(&stamped(&chain, r, 7, 11));
|
||||
assert_eq!(spent_on_leaf(&anon, &chain), once * 2, "the retry was debited again");
|
||||
|
||||
// With one: the same call, answered from the record.
|
||||
let mut named = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
let first = named.handle(&identified(&chain, r, 7, 10, [0xC1; 32]));
|
||||
let after_first = spent_on_leaf(&named, &chain);
|
||||
let again = named.handle(&identified(&chain, r, 7, 11, [0xC1; 32]));
|
||||
assert_eq!(spent_on_leaf(&named, &chain), after_first, "a declared repeat pays once");
|
||||
assert_eq!(again.receipt, first.receipt, "and is answered what it was answered");
|
||||
}
|
||||
|
||||
/// Two calls the caller means as distinct are distinct, even when they
|
||||
/// would otherwise be byte-identical. Identity is declared, not inferred.
|
||||
#[test]
|
||||
fn two_calls_that_say_they_differ_both_happen() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
ep.handle(&identified(&chain, r, 7, 10, [0xA1; 32]));
|
||||
let once = spent_on_leaf(&ep, &chain);
|
||||
ep.handle(&identified(&chain, r, 7, 10, [0xA2; 32]));
|
||||
assert_eq!(spent_on_leaf(&ep, &chain), once * 2, "a different id is a different call");
|
||||
}
|
||||
|
||||
/// The id is the caller's to choose, so it is scoped to the caller. Were
|
||||
/// it global, one caller could suppress another's call by guessing its id.
|
||||
#[test]
|
||||
fn a_call_id_is_scoped_to_its_caller() {
|
||||
let (chain, _, r) = fixture();
|
||||
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
|
||||
let mine = identified(&chain, r, 7, 10, [0xC1; 32]);
|
||||
ep.handle(&mine);
|
||||
// The same 32 bytes from a different caller. The grant does not admit
|
||||
// that caller, so what matters is that it is *decided* rather than
|
||||
// answered from my record: a suppressed call would be a repeat.
|
||||
let frame = request(&chain, did(&sk(4)), r, Trust::Control, 7).frame;
|
||||
let theirs = WireRequest::sign_parts(
|
||||
frame,
|
||||
chain.to_vec(),
|
||||
&sk(4),
|
||||
Energy::estimator(7),
|
||||
10,
|
||||
None,
|
||||
Some([0xC1; 32]),
|
||||
)
|
||||
.unwrap();
|
||||
let resp = ep.handle(&theirs);
|
||||
assert_ne!(resp.receipt, ep.handle(&mine).receipt, "another caller's id is not mine");
|
||||
}
|
||||
|
||||
/// The index is re-established by the same replay §4.11 already does, or a
|
||||
/// restart would forget which calls it decided and debit their retries
|
||||
/// again — the overspend §4.11 exists to prevent, one layer out.
|
||||
#[test]
|
||||
fn a_restart_still_knows_which_calls_it_decided() {
|
||||
let (chain, _, r) = fixture();
|
||||
let req = identified(&chain, r, 7, 10, [0xC1; 32]);
|
||||
let mut before = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
|
||||
.with_clock(at_10)
|
||||
.with_journal(MemoryJournal::default(), &[])
|
||||
.unwrap();
|
||||
before.handle(&req);
|
||||
|
||||
let recorded = [JournalRecord::Decided { at: 10, request: req.clone() }];
|
||||
let mut after = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
|
||||
.with_clock(at_10)
|
||||
.with_journal(MemoryJournal::default(), &recorded)
|
||||
.unwrap();
|
||||
let replayed = spent_on_leaf(&after, &chain);
|
||||
// The retry arrives after the restart, re-stamped.
|
||||
after.handle(&identified(&chain, r, 7, 11, [0xC1; 32]));
|
||||
assert_eq!(spent_on_leaf(&after, &chain), replayed, "the retry pays nothing after a restart");
|
||||
}
|
||||
|
||||
/// The guarantee is bounded by the horizon, and says so. Remembering a
|
||||
/// call forever would need state forever, so past it the endpoint no
|
||||
/// longer recognises the repeat — which is a limit to state, not a limit
|
||||
/// to pretend is absent.
|
||||
#[test]
|
||||
fn the_guarantee_is_bounded_by_the_horizon() {
|
||||
let (chain, _, r) = fixture();
|
||||
fn at_400() -> u64 {
|
||||
400
|
||||
}
|
||||
// Decided at 10 and remembered for 60 s; the endpoint now reads 400.
|
||||
let recorded = [JournalRecord::Decided {
|
||||
at: 10,
|
||||
request: identified(&chain_of(), ContentId::of_bytes(b"db"), 7, 10, [0xC1; 32]),
|
||||
}];
|
||||
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
|
||||
.with_clock(at_400)
|
||||
.with_replay_horizon(60)
|
||||
.with_journal(MemoryJournal::default(), &recorded)
|
||||
.unwrap();
|
||||
let replayed = spent_on_leaf(&ep, &chain);
|
||||
// A retry stamped inside the freshness window, but past the horizon.
|
||||
ep.handle(&identified(&chain, r, 7, 400, [0xC1; 32]));
|
||||
assert!(
|
||||
spent_on_leaf(&ep, &chain) > replayed,
|
||||
"past the horizon the call is no longer recognised, and is debited"
|
||||
);
|
||||
}
|
||||
|
||||
fn chain_of() -> Vec<SignedGrant> {
|
||||
fixture().0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue