jouleclaw: sign the tree head, and give the transparency log a consumer #167

Merged
dcharlot merged 1 commit from feat/transparency-log into main 2026-10-06 17:28:15 -04:00
6 changed files with 582 additions and 17 deletions

View file

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

View file

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

View file

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

View file

@ -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<Vec<u8>>,
/// 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<Vec<u8>, 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<u8>) {
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<SignedTreeHead, LogError> {
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<InclusionProof, LogError> {
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<jouleclaw_transparency_log::ConsistencyProof, LogError> {
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);
}
}

View file

@ -13,3 +13,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
blake3 = { workspace = true }
ed25519-dalek = { workspace = true }

View file

@ -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<Vec<u8>, 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<SignedTreeHead, LogError> {
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<u8>,
}
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()
});
}
}