jouleclaw: sign the tree head, and give the transparency log a consumer #167
6 changed files with 582 additions and 17 deletions
|
|
@ -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)
|
||||
|
|
|
|||
2
jouleclaw/jouleclaw-rs/Cargo.lock
generated
2
jouleclaw/jouleclaw-rs/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue