From ca3de8e3b398a960cbb3e8235ca2911978357611 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 10:26:42 -0400 Subject: [PATCH] jcp-wire: a caller-signed challenge, and endpoint evidence bound to the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep 2026-10-19 item #5, wire side. A WireRequest had no challenge slot and a WireResponse carried no endpoint evidence. Freshness came only from the caller's `now` stamp, so a caller had no way to ask the platform an endpoint runs on to vouch for an answer. jcp-wire (SPEC §11): - WireRequest.challenge: 32 caller-chosen bytes, covered by the caller's signature (challenged_signable_bytes, sign_challenged). It is omitted when absent, so a request without one keeps its bytes and its signature. - WireResponse.evidence { kind, report }, omitted when absent. An endpoint configured with_attester answers an authenticated challenged request with a report over evidence_report_data = BLAKE3-XOF-64(derive_key "jcp/1 wire evidence v1", challenge ‖ BLAKE3(receipt)). The challenge makes it the caller's; the receipt makes it this answer's. - No evidence for a request denied bad-signature, so a forgery cannot make the platform produce reports on demand. A repeat gets its first evidence, and a restart rebuilds it through respond(), so the attester is configured before with_journal. - WireResponse::verify_evidence checks against the challenge the caller SENT, never one read back, through a caller-supplied EvidenceVerifier. jcp-wire stays free of any TEE crate, and a transport's own evidence (an exported authenticator on TLS) is a profile of the same slot. SPEC §11: message shapes, the evidence binding and rules, and conformance class C28 with a C28 check. Tests: 4 new, plus C28. A challenged request gets evidence bound to its answer; evidence comes only where asked and able, with an unchallenged request byte-identical and signed over the old bytes; the challenge is signed, so swapping or stripping it is refused with no evidence; a repeat gets its first evidence and a restart rebuilds it. Mutation testing: 9 mutants, all killed. Verified on the build host: jcp-rs 579 tests (--locked) and every conformance class; clippy -D warnings (jcp-wire, -conformance, -threshold) and rustdoc -D warnings (jcp-wire) clean. Repo checks pass, including lockfiles. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 34 ++- jcp/jcp-rs/Cargo.lock | 1 + jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 77 +++++ jcp/jcp-rs/crates/jcp-wire/Cargo.toml | 1 + jcp/jcp-rs/crates/jcp-wire/src/lib.rs | 279 ++++++++++++++++++- 5 files changed, 383 insertions(+), 9 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 0ccaf08af..0fc88928c 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -1387,8 +1387,8 @@ wire. ``` WireRequest { frame, chain: [signed-grant…], caller: did:key, cost: energy, - now: uint, signature: bytes(64) } -WireResponse { decision, receipt: bytes } + now: uint, signature: bytes(64), challenge?: bytes(32) } +WireResponse { decision, receipt: bytes, evidence?: { kind: text, report: bytes } } ``` `chain` is the full delegation chain, root first (a single un-delegated grant is @@ -1433,6 +1433,35 @@ 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. +**Endpoint evidence (OPTIONAL).** A caller that wants evidence of the platform +the endpoint runs on puts a `challenge` in its request: 32 fresh bytes it chose. +The challenge is covered by `signature` like every other field, and is omitted +when absent, so a request without one has the bytes it always had. An endpoint +that can attest answers such a request with `evidence`: a report of `kind`, for +example a TEE attestation report, whose bound data is + +```text +report_data = BLAKE3-XOF-64(derive_key = "jcp/1 wire evidence v1", + challenge[32] ‖ BLAKE3(receipt)) +``` + +- **What it binds.** The challenge makes the evidence the caller's, made for its + request rather than replayed from another. The receipt makes it this answer's. +- **Checking it.** A caller checks the evidence against the challenge **it sent**, + never one read back from the response, and against the receipt it received, + with a verifier for `kind`. +- **When there is none.** An endpoint that cannot attest omits `evidence`, and a + caller that requires it treats the response as unattested. +- **Unauthenticated requests.** An endpoint MUST NOT give evidence for a request + denied `bad-signature`, which would let anyone have its platform produce + reports on demand. +- **Repeats and restarts.** A repeat of a challenged request gets the evidence it + got first, like the rest of its answer, and an endpoint that restarts rebuilds + it with the response. +- **Transport profiles.** A transport that carries platform evidence of its own, + such as an exported authenticator on a TLS session, is a profile of this slot: + the same challenge, the same binding. + **What the endpoint holds survives it.** An endpoint that can restart is bound by §4.11: it MUST record each request it admits before deciding it, and each revocation it honours before applying it, and on restart MUST replay them @@ -1456,3 +1485,4 @@ gate. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. A priced chained decision applies the parent's money ceiling to its child's spend, and a delegated grant decided without its chain is denied `chain-broken`. - **C26 — one writer per record:** a durable decision record admits one writer. A second holder of the same record is refused rather than opening it, because two would each replay it, each re-establish the same remaining budget and each debit against it — spending it twice while every entry still verifies (§5). The exclusive claim is released when the holder goes away, so a restart can reopen the record it previously held, and a platform that cannot provide exclusivity fails the open rather than proceeding. - **C27 — windows:** a `WithinWindow` caveat bounds a grant's use over any window of its length (§4.12). A call window admits its limit and refuses the next, `window-exceeded`, without debiting it, and admits again once the earliest call has slid out. A distinct-payload window admits a payload already inside the window and refuses a new one beyond the limit. A joule window refuses a one-shot call whose cost would overfill it, and in the two-phase path binds at the debit, where an overrun is denied and stays debited. A parent's window counts its delegates' calls. A window survives a restart replayed through the gate. A runtime restored from a timeless ledger counts every window as full until its history is complete. A zero-second window is refused. +- **C28 — endpoint evidence:** a request's `challenge` is covered by the caller's signature, so swapping or stripping it is denied `bad-signature`, and a request without one keeps its bytes. An endpoint that can attest answers a challenged request with evidence whose bound data is the challenge and the receipt (§11). The evidence verifies against the caller's own challenge and is refused for another challenge or another answer. An unauthenticated request gets none, and a repeat gets the evidence it got first. diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 4f29664be..a0e0e043e 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -768,6 +768,7 @@ dependencies = [ name = "jcp-wire" version = "0.1.0" dependencies = [ + "blake3", "ed25519-dalek", "jcp-grant", "jcp-receipt", diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index d20111a9c..548a93c05 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -82,6 +82,7 @@ pub fn checks() -> Vec { Check { id: "C26 one writer per record: a second holder is refused", run: c26_single_writer }, 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: "C-neg expired / forged / wrong-caller", run: cneg_denials }, Check { id: "C-neg canonical rejects floats", run: cneg_canonical }, ] @@ -336,6 +337,82 @@ fn c23_payload_binding() -> Result<(), String> { Ok(()) } +/// C28 — endpoint evidence (SPEC §11): a caller-signed challenge, and evidence +/// from the endpoint's platform bound to the challenge and the answer. +fn c28_endpoint_evidence() -> Result<(), String> { + use jcp_wire::{Attester, Endpoint, Evidence, EvidenceVerifier, WireRequest}; + struct Signs(SigningKey); + impl Attester for Signs { + fn kind(&self) -> String { + "conformance-ed25519".into() + } + fn report(&self, report_data: &[u8; 64]) -> Result, String> { + use ed25519_dalek::Signer; + Ok([report_data.as_slice(), &self.0.sign(report_data).to_bytes()].concat()) + } + } + struct Checks(ed25519_dalek::VerifyingKey); + impl EvidenceVerifier for Checks { + fn verify(&self, e: &Evidence, report_data: &[u8; 64]) -> Result<(), String> { + use ed25519_dalek::Verifier; + if e.report.len() != 128 || e.report[..64] != report_data[..] { + return Err("does not bind this answer and challenge".into()); + } + let sig = ed25519_dalek::Signature::from_slice(&e.report[64..]).map_err(|e| e.to_string())?; + self.0.verify(report_data, &sig).map_err(|_| "not this platform's".into()) + } + } + let r = ContentId::of_bytes(b"db"); + let holder = did(&sk(2)); + let sg = grant(&sk(1), holder, r, &["read"], 100_000, 0); + let platform = sk(9); + let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward") + .with_clock(|| 1) + .with_attester(Signs(platform.clone())); + let checks = Checks(platform.verifying_key()); + let ours = [0x5A; 32]; + let signed = |challenge: Option<[u8; 32]>| -> Result { + let f = frame(&sg, r, "read", Trust::Control); + match challenge { + Some(c) => WireRequest::sign_challenged(f, vec![sg.clone()], &sk(2), Energy::estimator(10), 1, c), + None => WireRequest::sign(f, vec![sg.clone()], &sk(2), Energy::estimator(10), 1), + } + .map_err(|e| e.to_string()) + }; + + let req = signed(Some(ours))?; + let resp = ep.handle(&req); + if !resp.decision.is_allow() { + return Err(format!("a challenged call was denied: {:?}", resp.decision.verdict)); + } + resp.verify_evidence(&ours, &checks).map_err(|e| format!("genuine evidence refused: {e}"))?; + if resp.verify_evidence(&[0x5B; 32], &checks).is_ok() { + return Err("evidence verified against another caller's challenge".into()); + } + let mut other = resp.clone(); + other.receipt.push(0); + if other.verify_evidence(&ours, &checks).is_ok() { + return Err("evidence verified for another answer".into()); + } + if ep.handle(&req) != resp { + return Err("a repeat did not get its first answer and evidence".into()); + } + + let mut swapped = req.clone(); + swapped.challenge = Some([0x5B; 32]); + let forged = ep.handle(&swapped); + if forged.decision.verdict != Verdict::Deny(DenyReason::BadSignature) || forged.evidence.is_some() { + return Err("a swapped challenge was not refused, or got evidence".into()); + } + + let plain = signed(None)?; + let plain_resp = ep.handle(&plain); + if plain_resp.evidence.is_some() || canonical_cbor(&plain).map_err(|e| e.to_string())?.windows(9).any(|w| w == b"challenge") { + return Err("a request without a challenge changed shape or got evidence".into()); + } + Ok(()) +} + /// C27 — windows (SPEC §4.12): a sliding window over a grant's recent use, /// counted where calls are admitted, binding a joule window at the debit, over /// every link of a chain, and full while its history is unknown. diff --git a/jcp/jcp-rs/crates/jcp-wire/Cargo.toml b/jcp/jcp-rs/crates/jcp-wire/Cargo.toml index 573735548..d99111f99 100644 --- a/jcp/jcp-rs/crates/jcp-wire/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-wire/Cargo.toml @@ -13,6 +13,7 @@ jcp-runtime = { workspace = true } jcp-receipt = { workspace = true } serde = { workspace = true } ed25519-dalek = { workspace = true } +blake3 = { workspace = true } thiserror = { workspace = true } [lints] diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index db9823c54..43bbf43ef 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -63,6 +63,10 @@ pub enum WireError { Schema(#[from] SchemaError), #[error("peer closed the stream")] Closed, + /// The response's endpoint evidence is missing, or does not bind this + /// response and the caller's challenge (SPEC §11). + #[error("endpoint evidence: {0}")] + Evidence(String), } /// Errors from an endpoint's journal: an append it could not make, a @@ -99,8 +103,17 @@ pub struct WireRequest { /// 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`]. + /// Ed25519 (64 bytes) by `caller` over [`WireRequest::signable_bytes`], + /// or [`WireRequest::challenged_signable_bytes`] where the request + /// carries a challenge. pub signature: Vec, + /// The caller's challenge for evidence from the endpoint's platform (SPEC + /// §11): 32 fresh bytes the caller chose, which the endpoint's evidence + /// binds with the response. 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 challenge: Option<[u8; 32]>, } /// The borrowed signable view of a request — every field but the signature. @@ -111,6 +124,8 @@ struct SignableRequest<'a> { caller: &'a Did, cost: &'a Energy, now: u64, + #[serde(skip_serializing_if = "Option::is_none")] + challenge: Option<&'a [u8; 32]>, } impl WireRequest { @@ -122,7 +137,38 @@ impl WireRequest { cost: &Energy, now: u64, ) -> Result, SchemaError> { - canonical_cbor(&SignableRequest { frame, chain, caller, cost, now }) + canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None }) + } + + /// The canonical bytes the caller signs for a request carrying + /// `challenge`: [`signable_bytes`](Self::signable_bytes) with the + /// challenge among them. + pub fn challenged_signable_bytes( + frame: &Frame, + chain: &[SignedGrant], + caller: &Did, + cost: &Energy, + now: u64, + challenge: &[u8; 32], + ) -> Result, SchemaError> { + canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge) }) + } + + /// Build and sign a request that asks for the endpoint's evidence, bound + /// to `challenge` (SPEC §11). + pub fn sign_challenged( + frame: Frame, + chain: Vec, + caller_sk: &SigningKey, + cost: Energy, + now: u64, + challenge: [u8; 32], + ) -> Result { + 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) }) } /// Build and sign a request with the caller's key. @@ -137,7 +183,7 @@ impl WireRequest { 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 }) + Ok(Self { frame, chain, caller, cost, now, signature, challenge: None }) } /// Attach an externally produced signature (e.g. a FROST k-of-n aggregate) @@ -150,14 +196,18 @@ impl WireRequest { now: u64, signature: Vec, ) -> Self { - Self { frame, chain, caller, cost, now, signature } + Self { frame, chain, caller, cost, now, signature, challenge: 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 Ok(bytes) = Self::signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now) else { + 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 { return false; }; let Ok(sig) = ed25519_dalek::Signature::from_slice(&self.signature) else { return false }; @@ -166,11 +216,75 @@ impl WireRequest { } /// The enforcement point's answer: the deterministic decision and the sealed -/// JCR-1 receipt covering it (allow and deny alike). +/// JCR-1 receipt covering it (allow and deny alike), and, for a request that +/// carried a challenge to an endpoint that can attest, evidence from the +/// endpoint's platform. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WireResponse { pub decision: Decision, pub receipt: Vec, + /// Evidence binding this response's receipt and the request's challenge + /// (SPEC §11). Omitted when absent, so a response without it has the bytes + /// it always had. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, +} + +/// Evidence from an endpoint's platform for one response (SPEC §11): a report +/// of `kind` whose bound data is [`evidence_report_data`] of the request's +/// challenge and the response's receipt. What `report` is depends on `kind`, +/// for example a TEE attestation report. Reading it is the verifier's part. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + pub kind: String, + pub report: Vec, +} + +/// The BLAKE3 key-derivation context of [`evidence_report_data`]. +pub const EVIDENCE_CONTEXT: &str = "jcp/1 wire evidence v1"; + +/// The 64 bytes an endpoint's evidence binds (SPEC §11): BLAKE3 in +/// key-derivation mode under [`EVIDENCE_CONTEXT`], extended to 64 bytes, over +/// the caller's 32-byte challenge followed by the BLAKE3 of the receipt. +/// +/// The challenge makes the evidence the caller's, made for its request rather +/// than replayed from another. The receipt makes it this answer's: the +/// decision, the request it was over, and the steward that sealed it. +pub fn evidence_report_data(challenge: &[u8; 32], receipt: &[u8]) -> [u8; 64] { + let mut h = blake3::Hasher::new_derive_key(EVIDENCE_CONTEXT); + h.update(challenge); + h.update(blake3::hash(receipt).as_bytes()); + let mut out = [0u8; 64]; + h.finalize_xof().fill(&mut out); + out +} + +/// A source of evidence from the platform an endpoint runs on, such as a TEE +/// report provider (SPEC §11). +pub trait Attester: Send { + /// The kind of report this produces, so a verifier knows how to read it. + fn kind(&self) -> String; + /// A report binding `report_data`. + fn report(&self, report_data: &[u8; 64]) -> Result, String>; +} + +/// How a caller checks an endpoint's evidence: that `evidence` is a genuine +/// report of its kind, from a platform the caller accepts, binding +/// `report_data`. +pub trait EvidenceVerifier { + fn verify(&self, evidence: &Evidence, report_data: &[u8; 64]) -> Result<(), String>; +} + +impl WireResponse { + /// Check this response's evidence against the challenge the caller sent, + /// held by the caller, never read back from the response. The evidence + /// must be present and bind this response's receipt and that challenge. + pub fn verify_evidence(&self, challenge: &[u8; 32], verifier: &dyn EvidenceVerifier) -> Result<(), WireError> { + let evidence = self.evidence.as_ref().ok_or_else(|| WireError::Evidence("the response carries none".into()))?; + verifier + .verify(evidence, &evidence_report_data(challenge, &self.receipt)) + .map_err(WireError::Evidence) + } } fn system_clock() -> u64 { @@ -248,6 +362,8 @@ pub struct Endpoint { decided: HashMap, /// Where each admitted request and each revocation is recorded first. journal: Box, + /// Where this endpoint's evidence comes from, if it can give any. + attester: Option>, } impl Endpoint { @@ -262,9 +378,19 @@ impl Endpoint { skew_secs: DEFAULT_SKEW_SECS, decided: HashMap::new(), journal: Box::new(MemoryJournal::default()), + attester: None, } } + /// Give evidence from `attester` with each response to an authenticated + /// request that carries a challenge (SPEC §11). Configure it before + /// [`with_journal`](Self::with_journal), so the responses a restart + /// rebuilds carry evidence as the first ones did. + pub fn with_attester(mut self, attester: impl Attester + 'static) -> Self { + self.attester = Some(Box::new(attester)); + self + } + /// Record in `journal`, first replaying `recorded`, the records it already /// holds, through the gate (SPEC §4.11). Each request is decided again at /// the time it was decided, which re-establishes the spend and the memory @@ -359,7 +485,18 @@ impl Endpoint { .ok() }) .unwrap_or_default(); - WireResponse { decision, receipt } + // Evidence for a request that asked for it, from an endpoint that can + // give it. Not for an unauthenticated one: a forged request could + // otherwise have the platform produce reports on demand. + let authenticated = decision.verdict != jcp_schema::Verdict::Deny(jcp_schema::DenyReason::BadSignature); + let evidence = match (&req.challenge, &self.attester) { + (Some(c), Some(a)) if authenticated => a + .report(&evidence_report_data(c, &receipt)) + .ok() + .map(|report| Evidence { kind: a.kind(), report }), + _ => None, + }; + WireResponse { decision, receipt, evidence } } /// Decide one request and seal the receipt — the whole protocol step, @@ -727,6 +864,134 @@ mod tests { assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::WindowExceeded)); } + // ── endpoint evidence (SPEC §11) ───────────────────────────────────────── + + /// A software attester for the tests: its report is the bound data + /// followed by an Ed25519 signature over it. + struct SignedReport(SigningKey); + impl Attester for SignedReport { + fn kind(&self) -> String { + "test-ed25519".into() + } + fn report(&self, report_data: &[u8; 64]) -> Result, String> { + use ed25519_dalek::Signer; + Ok([report_data.as_slice(), &self.0.sign(report_data).to_bytes()].concat()) + } + } + + /// Accepts a [`SignedReport`] by one key that binds the expected data. + struct Accepts(ed25519_dalek::VerifyingKey); + impl EvidenceVerifier for Accepts { + fn verify(&self, evidence: &Evidence, report_data: &[u8; 64]) -> Result<(), String> { + use ed25519_dalek::Verifier; + if evidence.kind != "test-ed25519" || evidence.report.len() != 128 { + return Err("not a report of this kind".into()); + } + if evidence.report[..64] != report_data[..] { + return Err("it binds another response or challenge".into()); + } + let sig = ed25519_dalek::Signature::from_slice(&evidence.report[64..]).map_err(|e| e.to_string())?; + self.0.verify(report_data, &sig).map_err(|_| "not this platform's".into()) + } + } + + fn challenged(chain: &[SignedGrant], r: ContentId, cost: u64, now: u64, challenge: [u8; 32]) -> WireRequest { + let frame = request(chain, did(&sk(3)), r, Trust::Control, cost).frame; + WireRequest::sign_challenged(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), now, challenge).unwrap() + } + + /// A request that carries a challenge gets evidence from the endpoint's + /// platform, bound to this answer and to the challenge the caller sent: + /// it is refused for another caller's challenge, another answer, or + /// another platform. + #[test] + fn a_challenged_request_gets_evidence_bound_to_its_answer() { + let (chain, _, r) = fixture(); + let platform = sk(40); + let mut ep = endpoint(at_10).with_attester(SignedReport(platform.clone())); + let ours = [0xA1; 32]; + let resp = ep.handle(&challenged(&chain, r, 1, 10, ours)); + assert!(resp.decision.is_allow()); + let verifier = Accepts(platform.verifying_key()); + resp.verify_evidence(&ours, &verifier).unwrap(); + assert!(resp.verify_evidence(&[0xA2; 32], &verifier).is_err(), "another caller's challenge"); + let mut other_answer = resp.clone(); + other_answer.receipt.push(0); + assert!(other_answer.verify_evidence(&ours, &verifier).is_err(), "another answer"); + assert!(resp.verify_evidence(&ours, &Accepts(sk(41).verifying_key())).is_err(), "another platform"); + } + + /// Evidence is given where it is asked for and the endpoint can give it, + /// and only there. A request without a challenge is the request it always + /// was, and its response carries none. + #[test] + fn evidence_is_given_only_where_asked_and_able() { + let (chain, _, r) = fixture(); + let verifier = Accepts(sk(40).verifying_key()); + let mut ep = endpoint(at_10).with_attester(SignedReport(sk(40))); + let plain = stamped(&chain, r, 1, 10); + let wire = canonical_cbor(&plain).unwrap(); + assert!(!wire.windows(9).any(|w| w == b"challenge"), "no challenge 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"); + } + let resp = ep.handle(&plain); + assert!(resp.evidence.is_none()); + assert!(!canonical_cbor(&resp).unwrap().windows(8).any(|w| w == b"evidence")); + assert!(matches!(resp.verify_evidence(&[0; 32], &verifier), Err(WireError::Evidence(_)))); + + let mut bare = endpoint(at_10); + assert!(bare.handle(&challenged(&chain, r, 1, 10, [0xA1; 32])).evidence.is_none(), "no attester, no evidence"); + } + + /// The challenge is signed with the request, so it cannot be swapped or + /// stripped in transit, and an unauthenticated request gets no evidence. + #[test] + fn the_challenge_is_signed_with_the_request() { + let (chain, _, r) = fixture(); + let mut ep = endpoint(at_10).with_attester(SignedReport(sk(40))); + let mut swapped = challenged(&chain, r, 1, 10, [0xA1; 32]); + swapped.challenge = Some([0xA2; 32]); + let resp = ep.handle(&swapped); + assert_eq!(resp.decision.verdict, Verdict::Deny(DenyReason::BadSignature)); + assert!(resp.evidence.is_none(), "an unauthenticated request gets no evidence"); + let mut stripped = challenged(&chain, r, 1, 10, [0xA1; 32]); + stripped.challenge = None; + assert_eq!(ep.handle(&stripped).decision.verdict, Verdict::Deny(DenyReason::BadSignature)); + } + + /// A repeat gets the answer it got first, evidence included; a new + /// challenge is a new request; and a restart rebuilds the evidence with + /// the response. + #[test] + fn a_repeat_gets_its_first_evidence_and_a_restart_rebuilds_it() { + let (chain, _, r) = fixture(); + let platform = sk(40); + let journal = Shared::default(); + let req = challenged(&chain, r, 1, 10, [0xA1; 32]); + let mut before = endpoint(at_10) + .with_attester(SignedReport(platform.clone())) + .with_journal(journal.clone(), &[]) + .unwrap(); + let first = before.handle(&req); + assert!(first.evidence.is_some()); + assert_eq!(before.handle(&req), first); + let fresh = before.handle(&challenged(&chain, r, 1, 10, [0xA2; 32])); + assert_ne!(fresh.receipt, first.receipt, "a new challenge is a new request, decided anew"); + drop(before); + let recorded = journal.0.lock().unwrap().clone(); + let mut after = endpoint(at_10) + .with_attester(SignedReport(platform.clone())) + .with_journal(MemoryJournal::default(), &recorded) + .unwrap(); + let again = after.handle(&req); + assert_eq!(again.receipt, first.receipt); + again.verify_evidence(&[0xA1; 32], &Accepts(platform.verifying_key())).unwrap(); + } + /// A revocation honoured through the endpoint is recorded, and still /// stands after a restart. #[test] -- 2.54.0 (Apple Git-157)