From 3b1b3ff2356645f1b3cda14ac81ce971e1c6f7b8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 21:44:58 -0400 Subject: [PATCH] jouleclaw: sign the tree head, and give the transparency log a consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, and they were the same problem seen from either end. The crate's own promise is that "any party can prove a receipt was included in the log without trusting the log operator". An inclusion proof delivers that only against a root the operator is bound to, and `TreeHead` — documented as a "Signed Tree Head" — had no signature, no key, and no verification. An unsigned root can be minted per-auditor, so two parties shown different roots had nothing to compare and nobody to attribute it to. The promise was false as written. And nothing consumed the crate. The RFC 6962 proofs merged in #153 were inert: no JouleClaw or JCP decision could be given an inclusion proof, so the auditor-grade tier existed as an unreferenced library. **Signed tree head.** `TreeHead::sign` / `SignedTreeHead::verify` over `0x02 || size || root || timestamp`, every field fixed-width so no two heads share a preimage, and `0x02` a third domain separator so a head cannot be presented as an entry in its own log. `verify` returns the *root* rather than `()`, so a caller cannot check a proof against an unverified head by accident. `root_hex` is a `String`, so it is now parsed rather than assumed: a head whose root is not 32 bytes of hex is refused at signing time and again at verify time, because a signature over an unparseable root is a signature over nothing checkable. Tests cover each field being load-bearing, the wrong key, malformed signature bytes, and — the property an unsigned root cannot have — two signed heads at one size as attributable equivocation. **A consumer.** `JcpGate` already accumulated its sealed receipts in a private `Vec`, which is only as trustworthy as the party holding it. Each receipt is now appended to a Merkle log on the same path, and the gate publishes `signed_tree_head`, `inclusion_proof` and `consistency_proof`. An auditor with only the steward's public key can check that a specific decision — allow or deny — is in the stream the operator published. Stream and log are appended in one place so an index into one is an index into the other. **Fail closed.** On that path, `if let Ok(receipt) = seal_chained(..)` swallowed a seal failure and then returned the verdict, so an *allow* could proceed with no receipt and no log entry — the one outcome the log exists to make impossible. A seal failure now refuses the dispatch. The debit has already landed, so the caller is charged for a call it does not get; that is the safe direction, because the opposite is an unrecorded side effect. **What this does not prove**, stated in the module docs and the spec rather than left to be inferred: not completeness. An operator that never records a decision logs nothing, and no proof about the entries that exist can speak for one never written. What narrows the gap is where the append happens — before the verdict returns, so a caller that observed a refusal can demand the entry that refused it. SPEC gains §22 (Transparency log, OPTIONAL): the tree, the signing preimage byte-for-byte, the MUSTs on signing and on refusing an unparseable root, the proven/not-proven split in plain terms, and conformance class C-TLOG. References moves to §23; nothing cross-referenced it. Co-Authored-By: Claude Opus 5 --- jouleclaw/SPEC.md | 80 ++++- jouleclaw/jouleclaw-rs/Cargo.lock | 2 + .../crates/jouleclaw-jcp/Cargo.toml | 1 + .../crates/jouleclaw-jcp/src/lib.rs | 288 +++++++++++++++++- .../jouleclaw-transparency-log/Cargo.toml | 1 + .../jouleclaw-transparency-log/src/lib.rs | 227 +++++++++++++- 6 files changed, 582 insertions(+), 17 deletions(-) diff --git a/jouleclaw/SPEC.md b/jouleclaw/SPEC.md index fe7018e92..93b5dd539 100644 --- a/jouleclaw/SPEC.md +++ b/jouleclaw/SPEC.md @@ -1086,7 +1086,85 @@ the span. This is normative. --- -## 22. References +## 22. Transparency log (OPTIONAL) + +A receipt proves what a runtime decided. It does not prove that the set of +receipts you were shown is the set that exists: a stream held privately by the +party that produced it can be pruned, re-ordered, or served differently to two +auditors, and nothing in §2 or §10 detects that. A conforming runtime **MAY** +publish its receipts into an append-only Merkle log, which closes the part of +that gap a log can close. + +### 22.1 Tree + +The log is an RFC 6962 Merkle tree over receipt bytes, with BLAKE3 in place of +SHA-256 — JouleClaw is BLAKE3-native — and the RFC's domain separators +preserved so a BLAKE3-shimmed CT verifier can use the same proof shapes: + +- leaf hash: `BLAKE3(0x00 || receipt_bytes)` +- internal node: `BLAKE3(0x01 || left || right)` +- a tree of size `N` is built by the RFC 6962 method: the root is the node hash + of the largest power-of-two subtree on the left and the recursive tree of the + remainder on the right. + +The leaf is the receipt's own bytes, so an auditor checks a proof against the +receipt it holds rather than against a digest the operator computed for it. + +### 22.2 Signed tree head + +A **tree head** is `(size, root, timestamp)`. A head that crosses a trust +boundary **MUST** be signed, and the signature **MUST** cover all three fields +over exactly these bytes: + +``` +0x02 || size (8 bytes, big-endian) || root (32 raw bytes) || timestamp (8 bytes, big-endian) +``` + +`0x02` is a third domain separator, distinct from the leaf and node prefixes, so +a tree head cannot be presented as an entry in its own log. Every field is +fixed-width, so no two distinct heads share a preimage. + +The key **MUST** be the same principal that seals the receipts (§8): a head +signed by anyone else commits a party that did not make the decisions. + +An unsigned root **MUST NOT** be used to verify a proof. This is the whole +premise — an inclusion proof against an unsigned root proves only that the +operator can produce a consistent story on demand, which it always can. A +signed root makes two different roots at one size **attributable +equivocation**: both carry the operator's signature, and the pair is evidence +against one key. + +A verifier **MUST** reject a head whose root is not 32 bytes of hex rather than +treating it as absent or empty. + +### 22.3 What the log proves, and what it does not + +A runtime that publishes this log **SHOULD** state the boundary plainly, because +the two halves are easy to conflate: + +- **Proven.** A given receipt is in the tree a given signed head commits to; the + log has only grown between two signed heads (consistency proof); a receipt + committed to under a published head cannot afterwards be removed or rewritten + without equivocating under the operator's key. +- **Not proven.** *Completeness.* An operator that never records a decision logs + nothing, and no proof about the entries that exist can speak for one that was + never written. + +What narrows the gap is **where** the append happens. The entry **SHOULD** be +written on the same code path that produces the verdict, before the verdict is +returned to the caller. A caller that observed a refusal can then demand the +proof of the entry that refused it, and an implementation that cannot record a +decision **MUST** refuse the action rather than perform it unrecorded — an +unrecordable allow is the one outcome the log exists to make impossible. + +Conformance class **C-TLOG**: a runtime claiming it signs its head over all +three fields, refuses an unparseable root, rejects a head signed by another key, +and serves an inclusion proof that closes against the signed root for every +entry — allow and deny alike. + +--- + +## 23. References - [JouleClaw Charter](../CHARTER.md) — stewardship pattern (shared across all five open standards) diff --git a/jouleclaw/jouleclaw-rs/Cargo.lock b/jouleclaw/jouleclaw-rs/Cargo.lock index 6e906c56c..186388cc8 100644 --- a/jouleclaw/jouleclaw-rs/Cargo.lock +++ b/jouleclaw/jouleclaw-rs/Cargo.lock @@ -3993,6 +3993,7 @@ dependencies = [ "jouleclaw-program", "jouleclaw-skill", "jouleclaw-stack", + "jouleclaw-transparency-log", "thiserror 2.0.20", "tokio", ] @@ -5090,6 +5091,7 @@ name = "jouleclaw-transparency-log" version = "0.1.0" dependencies = [ "blake3", + "ed25519-dalek", "serde", "serde_json", "thiserror 2.0.20", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/Cargo.toml index 38bb0c433..ed879542c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/Cargo.toml @@ -12,6 +12,7 @@ jouleclaw-mcp = { path = "../jouleclaw-mcp" } jouleclaw-stack = { path = "../jouleclaw-stack" } jouleclaw-cascade = { path = "../jouleclaw-cascade" } jouleclaw-skill = { path = "../jouleclaw-skill" } +jouleclaw-transparency-log = { path = "../jouleclaw-transparency-log" } jcp-schema = { path = "../../../../jcp/jcp-rs/crates/jcp-schema" } jcp-grant = { path = "../../../../jcp/jcp-rs/crates/jcp-grant" } jcp-runtime = { path = "../../../../jcp/jcp-rs/crates/jcp-runtime" } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs index 6d8230ded..02ff2f534 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-jcp/src/lib.rs @@ -14,6 +14,26 @@ //! budget, and the decision — allow and deny alike — seals as a JCR-1 receipt //! byte-compatible with the rest of the family's verifiers. //! +//! ## The receipt stream is a log, not a bag +//! +//! Every decision seals, but a sealed receipt in a private `Vec` is only as +//! trustworthy as the party holding the `Vec`. Each receipt is therefore also +//! appended to a Merkle transparency log on the same code path that produces +//! the verdict, and the gate publishes a [`SignedTreeHead`] over it. Any party +//! can then be handed one receipt plus an inclusion proof and check, against a +//! root the operator's key is bound to, that this decision is in the stream the +//! operator published — and that the stream has only ever grown. +//! +//! What that does **not** prove is worth stating, because the two are easy to +//! confuse: it binds the operator to the entries it has committed to, so a +//! receipt cannot be removed or rewritten after a head is published, and two +//! roots at one size are attributable equivocation. It does not prove the log +//! is *complete* — an operator that never decides anything logs nothing. What +//! closes the gap for the decisions that matter is that the append is on the +//! verdict's own path: a caller that observed a deny can demand the proof of +//! the entry that denied it, because the entry was written before the verdict +//! was returned. +//! //! Composition is unchanged downstream: a `GatedTool` **is** a `MeteredTool`, //! so `jouleclaw_mcp::dispatch_metered` drives it exactly as before — the //! energy bracket and `ToolTouch` accounting happen outside the gate, the @@ -30,6 +50,7 @@ use ed25519_dalek::SigningKey; use jcp_runtime::Runtime; use jcp_schema::{ContentId, Did, Energy, Frame, FrameKind, SignedGrant, Trust}; use jouleclaw_mcp::{McpError, MeteredTool}; +use jouleclaw_transparency_log::{InclusionProof, LogError, MerkleTree, SignedTreeHead, TreeHead}; /// The JCP resource of an MCP tool: the content address of its stable /// `tool_id` (e.g. `"mcp:filesystem/read"`). A grant scoped to this resource @@ -47,6 +68,52 @@ struct GateInner { steward: String, now: u64, receipts: Vec>, + /// The same receipts, as leaves. Index `i` here is index `i` of + /// [`JcpGate::receipts`]; the two are appended together or not at all. + log: MerkleTree, + /// Force the next seal to fail. + /// + /// A seal cannot fail through the public API today: every field of the + /// JCR-1 wire is a type the canonicaliser accepts, so `seal_chained`'s + /// `Err` arm is unreachable from here. It is kept because the arm encodes a + /// policy — never allow what cannot be recorded — that must hold if the + /// wire ever gains a field that can fail, and an untested refusal is a + /// comment rather than a guarantee. This seam is the only way to reach it, + /// and it is compiled out of every build a consumer sees. + #[cfg(test)] + fail_seals: bool, +} + +/// The deny label a caller sees when the decision could not be sealed. A +/// distinct reason from any JCP verdict: the call was not refused on authority, +/// it was refused because it could not be recorded. +pub const SEAL_FAILED: &str = "receipt-seal-failed"; + +impl GateInner { + /// Seal one decision, honouring the test-only fault seam. One place, so + /// the two call sites cannot disagree about when a seal has failed. + fn seal( + &self, + frame: &Frame, + chain_ids: &[ContentId], + leaf: &jcp_schema::Grant, + decision: &jcp_runtime::Decision, + ) -> Result, String> { + #[cfg(test)] + if self.fail_seals { + return Err("forced seal failure".to_string()); + } + jcp_receipt::seal_chained(frame, chain_ids, leaf, decision, &self.steward_sk, &self.steward) + .map_err(|e| e.to_string()) + } + + /// Append a sealed receipt to the stream and the log together, so an index + /// into one is an index into the other. Doing this in one place is the + /// point — two call sites appending to two containers is how they drift. + fn record(&mut self, receipt: Vec) { + self.log.append(receipt.clone()); + self.receipts.push(receipt); + } } /// A shared JCP enforcement point for one agent session: the runtime that @@ -74,10 +141,19 @@ impl JcpGate { steward: steward.into(), now: 0, receipts: Vec::new(), + log: MerkleTree::new(), + #[cfg(test)] + fail_seals: false, }), }) } + /// Make every subsequent seal fail — see `GateInner::fail_seals`. + #[cfg(test)] + fn break_sealing(&self) { + self.inner.lock().expect("gate lock").fail_seals = true; + } + /// Set decision time (seconds). Time is an input, not ambient state. pub fn set_now(&self, now: u64) { self.inner.lock().expect("gate lock").now = now; @@ -109,6 +185,41 @@ impl JcpGate { self.inner.lock().expect("gate lock").receipts.clone() } + /// How many decisions the log holds. Equal to `receipts().len()`. + pub fn log_size(&self) -> u64 { + self.inner.lock().expect("gate lock").log.size() + } + + /// The operator's signed commitment to the log as it now stands, bound to + /// the steward key that seals the receipts — the same principal that made + /// the decisions, which is what makes the head worth checking. + /// + /// `timestamp_unix` is supplied rather than read from the clock, in keeping + /// with [`JcpGate::set_now`]: time is an input here, not ambient state. + pub fn signed_tree_head(&self, timestamp_unix: u64) -> Result { + let inner = self.inner.lock().expect("gate lock"); + TreeHead::of(&inner.log, timestamp_unix).sign(&inner.steward_sk) + } + + /// A proof that decision `index` is in the log the head commits to. + /// + /// Hand an auditor `receipts()[index]`, this proof, and a + /// [`JcpGate::signed_tree_head`] taken at the same size: it verifies the + /// head under the steward's public key, gets the root back, and checks the + /// receipt against it without taking the operator's word for any of it. + pub fn inclusion_proof(&self, index: u64) -> Result { + self.inner.lock().expect("gate lock").log.inclusion_proof(index) + } + + /// A proof that the log has only grown since it held `old_size` entries — + /// what an auditor holding an older head checks before trusting a new one. + pub fn consistency_proof( + &self, + old_size: u64, + ) -> Result { + self.inner.lock().expect("gate lock").log.consistency_proof(old_size) + } + /// Decide an arbitrary (action, resource) pair against the chain — the /// general form behind tool dispatch, cascade answers, and skill loads. /// On allow the cost is debited from every link; either way the @@ -135,10 +246,15 @@ impl JcpGate { let chain = inner.chain.clone(); let decision = inner.runtime.decide_chained(&frame, &chain, caller, cost, now); let chain_ids: Vec<_> = chain.iter().map(|sg| sg.grant.id()).collect(); - if let Ok(receipt) = - jcp_receipt::seal_chained(&frame, &chain_ids, &leaf, &decision, &inner.steward_sk, &inner.steward) - { - inner.receipts.push(receipt); + match inner.seal(&frame, &chain_ids, &leaf, &decision) { + Ok(receipt) => inner.record(receipt), + // Fail closed. A seal failure used to be swallowed, which let an + // *allow* through with no receipt and no log entry — the one + // outcome this crate exists to make impossible. The debit has + // already landed, so the caller is charged for a call it does not + // get; that is the safe direction, because the opposite is an + // unrecorded side effect. + Err(_) => return Err(SEAL_FAILED), } match decision.verdict { jcp_schema::Verdict::Allow => Ok(()), @@ -164,10 +280,11 @@ impl JcpGate { let chain = inner.chain.clone(); let decision = inner.runtime.decide_chained(&frame, &chain, caller, cost, now); let chain_ids: Vec<_> = chain.iter().map(|sg| sg.grant.id()).collect(); - if let Ok(receipt) = - jcp_receipt::seal_chained(&frame, &chain_ids, &leaf, &decision, &inner.steward_sk, &inner.steward) - { - inner.receipts.push(receipt); + match inner.seal(&frame, &chain_ids, &leaf, &decision) { + Ok(receipt) => inner.record(receipt), + // See `authorize_named`: an undecidable *record* is not a licence + // to perform the action, so a seal failure refuses the dispatch. + Err(e) => return Err(McpError::Tool(format!("jcp receipt could not be sealed: {e}"))), } match decision.verdict { jcp_schema::Verdict::Allow => Ok(()), @@ -294,7 +411,156 @@ mod tests { assert!(err.to_string().contains("budget-exceeded")); assert_eq!(gate.spent(), 40_000); // the denied call was not debited } -} + // ─── the receipt stream is an auditable log ────────────────────── -pub mod stack; -pub mod skill; + #[tokio::test] + async fn an_auditor_proves_one_decision_without_trusting_the_gate() { + // The end-to-end shape the transparency log exists for: receipt + + // inclusion proof + operator-signed head, checked by a party that has + // only the steward's public key. + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let steward = sk(7); + let gate = JcpGate::new(chain, caller, steward.clone(), "did:example:jouleclaw"); + let tool = gate.wrap(EchoTool("mcp:filesystem/read"), 10_000); + for path in ["/a", "/b", "/c"] { + tool.dispatch(path.as_bytes()).await.expect("granted call runs"); + } + + let receipts = gate.receipts(); + assert_eq!(receipts.len(), 3); + assert_eq!(gate.log_size(), 3, "the log and the stream hold the same entries"); + + let sth = gate.signed_tree_head(1_700_000_000).expect("head signs"); + // The auditor holds only the public key. + let root = sth.verify(&steward.verifying_key()).expect("head is the operator's"); + for (i, r) in receipts.iter().enumerate() { + gate.inclusion_proof(i as u64) + .expect("proof exists") + .verify(r, &root) + .expect("receipt i is in the published log"); + } + + // A receipt that was never logged does not get a proof: swapping the + // bytes under a real proof fails against the same root. + let mut forged = receipts[1].clone(); + *forged.last_mut().unwrap() ^= 0xff; + assert!(gate.inclusion_proof(1).unwrap().verify(&forged, &root).is_err()); + // Nor does a proof for one entry carry another. + assert!(gate.inclusion_proof(0).unwrap().verify(&receipts[2], &root).is_err()); + } + + #[tokio::test] + async fn a_denial_is_in_the_log_before_the_caller_learns_of_it() { + // The property that makes the log more than an operator's scrapbook: + // the append is on the verdict's own path, so a caller that observed a + // refusal can demand the entry that refused it. + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let steward = sk(7); + let gate = JcpGate::new(chain, caller, steward.clone(), "did:example:jouleclaw"); + let denied = gate.wrap(MustNotRun("mcp:filesystem/delete"), 10_000); + let err = denied.dispatch(b"/etc/passwd").await.expect_err("ungranted call is refused"); + assert!(format!("{err}").contains("jcp denied"), "unexpected error: {err}"); + + assert_eq!(gate.log_size(), 1, "the refusal itself is an entry"); + let root = gate + .signed_tree_head(1_700_000_000) + .unwrap() + .verify(&steward.verifying_key()) + .unwrap(); + gate.inclusion_proof(0) + .unwrap() + .verify(&gate.receipts()[0], &root) + .expect("the deny receipt is provably in the log"); + } + + #[tokio::test] + async fn the_log_only_grows_and_the_proof_says_so() { + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let steward = sk(7); + let gate = JcpGate::new(chain, caller, steward.clone(), "did:example:jouleclaw"); + let tool = gate.wrap(EchoTool("mcp:filesystem/read"), 1_000); + for _ in 0..2 { + tool.dispatch(b"/x").await.unwrap(); + } + let early = gate.signed_tree_head(1).unwrap().verify(&steward.verifying_key()).unwrap(); + for _ in 0..5 { + tool.dispatch(b"/y").await.unwrap(); + } + let later = gate.signed_tree_head(2).unwrap().verify(&steward.verifying_key()).unwrap(); + assert_ne!(early, later); + gate.consistency_proof(2) + .expect("proof between the two sizes") + .verify(&early, &later) + .expect("an auditor holding the early head accepts the later one"); + } + + #[tokio::test] + async fn an_index_past_the_end_is_an_error_not_a_proof() { + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let gate = JcpGate::new(chain, caller, sk(7), "did:example:jouleclaw"); + assert!(gate.inclusion_proof(0).is_err(), "an empty log proves nothing"); + gate.wrap(EchoTool("mcp:filesystem/read"), 1_000).dispatch(b"/x").await.unwrap(); + assert!(gate.inclusion_proof(1).is_err()); + assert!(gate.inclusion_proof(u64::MAX).is_err()); + } + + #[tokio::test] + async fn authorize_named_decisions_land_in_the_same_log() { + // Cascade answers and skill loads go through `authorize_named`, not + // tool dispatch. They are decisions too, and a log that held only tool + // calls would be a partial record presented as a whole one. + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let steward = sk(7); + let gate = JcpGate::new(chain, caller, steward.clone(), "did:example:jouleclaw"); + gate.authorize_named( + "mcp:filesystem/read", + tool_resource("mcp:filesystem/read"), + 5_000, + ContentId::of_bytes(b"payload"), + ) + .expect("granted"); + let refused = gate.authorize_named( + "mcp:filesystem/delete", + tool_resource("mcp:filesystem/delete"), + 5_000, + ContentId::of_bytes(b"payload"), + ); + assert!(refused.is_err()); + assert_eq!(gate.log_size(), 2, "both the allow and the deny are entries"); + let root = gate.signed_tree_head(1).unwrap().verify(&steward.verifying_key()).unwrap(); + for (i, r) in gate.receipts().iter().enumerate() { + gate.inclusion_proof(i as u64).unwrap().verify(r, &root).unwrap(); + } + } + #[tokio::test] + async fn a_call_that_cannot_be_recorded_is_refused_not_performed() { + // The fail-closed policy. `if let Ok(receipt) = ..` used to swallow a + // seal failure and then return the verdict, so an *allow* could proceed + // with no receipt and no log entry — exactly the outcome the log exists + // to make impossible. + let (chain, caller) = grant_for("mcp:filesystem/read", 100_000); + let gate = JcpGate::new(chain, caller, sk(7), "did:example:jouleclaw"); + gate.break_sealing(); + let tool = gate.wrap(MustNotRun("mcp:filesystem/read"), 10_000); + let err = tool + .dispatch(b"/etc/hosts") + .await + .expect_err("an unrecordable call must not reach the tool"); + assert!(format!("{err}").contains("could not be sealed"), "unexpected error: {err}"); + assert_eq!(gate.receipts().len(), 0); + assert_eq!(gate.log_size(), 0, "nothing is logged, and nothing happened"); + // The same on the general path, with its own distinct reason: the call + // was not refused on authority, it was refused because it could not be + // recorded. + assert_eq!( + gate.authorize_named( + "mcp:filesystem/read", + tool_resource("mcp:filesystem/read"), + 1_000, + ContentId::of_bytes(b"p"), + ), + Err(SEAL_FAILED) + ); + assert_eq!(gate.log_size(), 0); + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/Cargo.toml index ed16c4652..6d2d8edbe 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/Cargo.toml @@ -13,3 +13,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } blake3 = { workspace = true } +ed25519-dalek = { workspace = true } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs index 40b285d4f..321e92bf3 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-transparency-log/src/lib.rs @@ -28,6 +28,7 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; // ───────────────────────────────────────────────────────────────────── @@ -54,6 +55,18 @@ pub enum LogError { /// Invalid sizes passed to consistency proof (M > N or M == 0). #[error("invalid consistency-proof sizes: {0} → {1}")] InvalidConsistencySizes(u64, u64), + /// A tree head whose `root_hex` is not 32 bytes of hex. Checked rather + /// than assumed: the field is a `String`, so nothing but this stops an + /// operator publishing a head whose root is unparseable — and a signature + /// over an unparseable root is a signature over nothing checkable. + #[error("tree head root is not 32 bytes of hex: {0:?}")] + MalformedRoot(String), + /// Signature bytes that are not a 64-byte Ed25519 signature. + #[error("tree-head signature is {0} bytes, not 64")] + SigShape(usize), + /// A tree head whose signature did not verify under the offered key. + #[error("tree-head signature did not verify under the offered key")] + BadSignature, } // ───────────────────────────────────────────────────────────────────── @@ -62,6 +75,13 @@ pub enum LogError { const LEAF_PREFIX: u8 = 0x00; const NODE_PREFIX: u8 = 0x01; +/// Domain separator for the bytes a log operator signs over a tree head. +/// +/// Distinct from the leaf and node prefixes on purpose: without a third tag, a +/// signature over a tree head and a hash of a leaf or an internal node are +/// drawn from overlapping byte-spaces, and a tree head could be presented as +/// though it were an entry in its own log. +const HEAD_PREFIX: u8 = 0x02; /// Compute the RFC 6962-style leaf hash: /// `BLAKE3(0x00 || leaf_bytes)`. @@ -414,16 +434,24 @@ fn subproof(m: usize, d: &[[u8; 32]], b: bool) -> Vec<[u8; 32]> { // Signed Tree Head // ───────────────────────────────────────────────────────────────────── -/// Signed Tree Head — the public commitment the log operator -/// makes. Auditors verify inclusion proofs against THIS root -/// hash and consistency proofs against previous STHs. +/// A tree head — the state an operator publishes: how many entries the log +/// holds, the root over them, and when it said so. +/// +/// On its own this is a *claim*, not a commitment, and the distinction is the +/// whole premise of a transparency log. The crate's promise is that a receipt +/// can be proven included "without trusting the log operator", and an +/// inclusion proof delivers that only against a root the operator is **bound +/// to**: an unsigned root can be minted per-auditor, so two parties shown +/// different roots have nothing to compare and nobody to attribute it to. +/// [`TreeHead::sign`] produces the binding; [`SignedTreeHead`] is what should +/// cross a trust boundary. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TreeHead { - /// Tree size at this STH. + /// Tree size at this head. pub size: u64, /// Root hash (BLAKE3 hex). pub root_hex: String, - /// Unix-seconds timestamp the STH was sealed (caller-supplied + /// Unix-seconds timestamp the head was sealed (caller-supplied /// for determinism in tests). pub timestamp_unix: u64, } @@ -437,6 +465,72 @@ impl TreeHead { timestamp_unix, } } + + /// The root as 32 raw bytes, or [`LogError::MalformedRoot`]. + pub fn root(&self) -> Result<[u8; 32], LogError> { + let mut out = [0u8; 32]; + if self.root_hex.len() != 64 { + return Err(LogError::MalformedRoot(self.root_hex.clone())); + } + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(&self.root_hex[i * 2..i * 2 + 2], 16) + .map_err(|_| LogError::MalformedRoot(self.root_hex.clone()))?; + } + Ok(out) + } + + /// The exact bytes an operator signs: `0x02 || size || root || timestamp`, + /// sizes big-endian and the root as 32 **raw** bytes. + /// + /// Written out rather than delegated to a serializer so a second + /// implementation can reproduce it, and fixed-width throughout so no two + /// distinct heads can produce the same preimage by rearranging where one + /// field ends and the next begins. + pub fn signing_bytes(&self) -> Result, LogError> { + let root = self.root()?; + let mut out = Vec::with_capacity(1 + 8 + 32 + 8); + out.push(HEAD_PREFIX); + out.extend_from_slice(&self.size.to_be_bytes()); + out.extend_from_slice(&root); + out.extend_from_slice(&self.timestamp_unix.to_be_bytes()); + Ok(out) + } + + /// Bind this head to a key — the operator's commitment. + pub fn sign(self, sk: &SigningKey) -> Result { + let sig = sk.sign(&self.signing_bytes()?).to_bytes().to_vec(); + Ok(SignedTreeHead { head: self, signature: sig }) + } +} + +/// A tree head and the log operator's signature over it — the only form in +/// which a root should be believed by a party that is not the operator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedTreeHead { + /// The head that was signed. + pub head: TreeHead, + /// Ed25519 signature over [`TreeHead::signing_bytes`]. + pub signature: Vec, +} + +impl SignedTreeHead { + /// Check the signature and hand back the root to verify proofs against. + /// + /// Returning the root rather than `()` is deliberate: a caller that is + /// handed the root by this method cannot verify a proof against an + /// *unverified* head by accident, which is the mistake the type exists to + /// prevent. + pub fn verify(&self, vk: &VerifyingKey) -> Result<[u8; 32], LogError> { + let bytes: [u8; 64] = self + .signature + .as_slice() + .try_into() + .map_err(|_| LogError::SigShape(self.signature.len()))?; + let sig = Signature::from_bytes(&bytes); + vk.verify(&self.head.signing_bytes()?, &sig) + .map_err(|_| LogError::BadSignature)?; + self.head.root() + } } // ───────────────────────────────────────────────────────────────────── @@ -733,4 +827,127 @@ mod tests { let same_bytes = blake3::hash(&[NODE_PREFIX, b'x']).to_hex().to_string(); assert_ne!(to_hex(&l), same_bytes); } + // ─── the tree head is a commitment, not a claim ────────────────── + + fn operator() -> SigningKey { + SigningKey::from_bytes(&[7u8; 32]) + } + + fn log_of(n: usize) -> MerkleTree { + let mut t = MerkleTree::new(); + for i in 0..n { + t.append(format!("receipt-{i}").into_bytes()); + } + t + } + + #[test] + fn a_signed_head_hands_back_the_root_to_verify_proofs_against() { + let sk = operator(); + let t = log_of(9); + let sth = TreeHead::of(&t, 1_700_000_000).sign(&sk).unwrap(); + let root = sth.verify(&sk.verifying_key()).unwrap(); + assert_eq!(root, t.root()); + // And the proof closes against exactly that root — the whole chain is + // operator-signed head → root → inclusion. + let proof = t.inclusion_proof(4).unwrap(); + proof.verify(b"receipt-4", &root).unwrap(); + } + + #[test] + fn every_field_of_a_head_is_covered_by_the_signature() { + let sk = operator(); + let vk = sk.verifying_key(); + let t = log_of(5); + let good = TreeHead::of(&t, 1_700_000_000).sign(&sk).unwrap(); + + // Size: an operator shrinking the claimed size to hide entries. + let mut h = good.clone(); + h.head.size = 4; + assert!(matches!(h.verify(&vk), Err(LogError::BadSignature))); + + // Root: the split-view forgery this type exists to stop. + let mut h = good.clone(); + h.head.root_hex = log_of(5).root_hex().replace('a', "b"); + assert!(h.verify(&vk).is_err()); + + // Timestamp: back-dating a head to claim an entry predates a dispute. + let mut h = good.clone(); + h.head.timestamp_unix = 1_600_000_000; + assert!(matches!(h.verify(&vk), Err(LogError::BadSignature))); + + // Another key's signature over the same head. + let other = SigningKey::from_bytes(&[8u8; 32]); + let forged = TreeHead::of(&t, 1_700_000_000).sign(&other).unwrap(); + assert!(matches!(forged.verify(&vk), Err(LogError::BadSignature))); + // It verifies under its own key, so the refusal is about *who* signed. + forged.verify(&other.verifying_key()).unwrap(); + } + + #[test] + fn a_head_whose_root_is_not_a_root_is_refused_rather_than_signed() { + let sk = operator(); + for bad in ["", "deadbeef", &"z".repeat(64), &"ab".repeat(33)] { + let h = TreeHead { size: 1, root_hex: bad.to_string(), timestamp_unix: 1 }; + assert!( + matches!(h.clone().sign(&sk), Err(LogError::MalformedRoot(_))), + "signed a head with root {bad:?}" + ); + // And a head smuggled past signing is still refused on verify. + let smuggled = SignedTreeHead { head: h, signature: vec![0u8; 64] }; + assert!(matches!( + smuggled.verify(&sk.verifying_key()), + Err(LogError::MalformedRoot(_)) + )); + } + } + + #[test] + fn malformed_signature_bytes_are_a_shape_error_not_a_panic() { + let sk = operator(); + let t = log_of(3); + let head = TreeHead::of(&t, 1); + for len in [0usize, 63, 65, 128] { + let sth = SignedTreeHead { head: head.clone(), signature: vec![0u8; len] }; + assert!(matches!(sth.verify(&sk.verifying_key()), Err(LogError::SigShape(n)) if n == len)); + } + } + + #[test] + fn head_preimages_are_domain_separated_from_leaves_and_nodes() { + let t = log_of(2); + let bytes = TreeHead::of(&t, 1).signing_bytes().unwrap(); + assert_eq!(bytes[0], HEAD_PREFIX); + assert_ne!(bytes[0], LEAF_PREFIX); + assert_ne!(bytes[0], NODE_PREFIX); + assert_eq!(bytes.len(), 1 + 8 + 32 + 8, "fixed-width, so no two heads share a preimage"); + // A head of the same tree at a different time is a different preimage. + assert_ne!(bytes, TreeHead::of(&t, 2).signing_bytes().unwrap()); + } + + #[test] + fn two_signed_heads_at_one_size_are_an_attributable_split_view() { + // The property an unsigned root cannot have. An operator serving two + // auditors different roots for the same size must sign both, and the + // two signatures are evidence against one key — which is what makes + // "without trusting the operator" mean something. + let sk = operator(); + let vk = sk.verifying_key(); + let a = TreeHead::of(&log_of(4), 1_700_000_000).sign(&sk).unwrap(); + let mut forked = log_of(3); + forked.append(b"receipt-rewritten".to_vec()); + let b = TreeHead::of(&forked, 1_700_000_000).sign(&sk).unwrap(); + + let (ra, rb) = (a.verify(&vk).unwrap(), b.verify(&vk).unwrap()); + assert_eq!(a.head.size, b.head.size); + assert_ne!(ra, rb, "the two views must differ for this to be a split view"); + // Neither is forged; both are signed. That is the point: the equivocation + // is provable *because* each root is bound to the operator's key. + assert_ne!(a.signature, b.signature); + // And a consistency proof cannot launder one into the other. + assert!(forked.consistency_proof(4).is_err() || { + let p = log_of(4).consistency_proof(3).unwrap(); + p.verify(&log_of(3).root(), &rb).is_err() + }); + } } -- 2.54.0 (Apple Git-157)