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