jouleclaw: sign the tree head, and give the transparency log a consumer #167
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/transparency-log"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two problems that turned out to be the same problem seen from either end.
The tree head was signed in name only
The crate's stated promise is that "any party can prove a receipt was included
in the log without trusting the log operator." An inclusion proof delivers that
only against a root the operator is bound to.
TreeHead— whose doc commentread "Signed Tree Head — the public commitment the log operator makes" — had no
signature field, no key, and no verification. An unsigned root can be minted
per-auditor, so two parties shown different roots had nothing to compare and
nobody to attribute it to. The promise was false as written.
TreeHead::sign/SignedTreeHead::verifynow sign over:0x02is a third domain separator, distinct from the leaf (0x00) andnode (
0x01) prefixes, so a tree head cannot be presented as an entry in itsown log.
verifyreturns the root rather than(). A caller handed the root bythis method cannot verify a proof against an unverified head by accident,
which is the mistake the type exists to prevent.
root_hexis aString, so it is now parsed rather than assumed: a head whoseroot is not 32 bytes of hex is refused at signing time and again at verify
time, because a signature over an unparseable root is a signature over nothing
checkable.
The sharpest test is the one an unsigned root cannot pass:
two_signed_heads_at_one_size_are_an_attributable_split_view. Neither head isforged, both carry the operator's signature, and the pair is evidence against
one key. That is what makes "without trusting the operator" mean something.
Nothing consumed the crate
The RFC 6962 proofs merged in #153 were inert — no JouleClaw or JCP decision
could be given an inclusion proof, so an auditor-grade tier existed as an
unreferenced library.
JcpGatewas already accumulating its sealed receipts in a privateVec<Vec<u8>>, which is only as trustworthy as the party holding it. Eachreceipt is now appended to a Merkle log on the same path, and the gate publishes
signed_tree_head,inclusion_proofandconsistency_proof. An auditor withonly the steward's public key can check that a specific decision — allow or deny
— is in the stream the operator published. Stream and log are appended in one
place, so an index into one is an index into the other.
Decisions made through
authorize_named(cascade answers, skill loads) land inthe same log, with a test to pin it: a log holding only tool calls would be a
partial record presented as a whole one.
Fail closed
On that same path,
if let Ok(receipt) = seal_chained(..)swallowed a sealfailure and then returned the verdict — so an allow could proceed with no
receipt and no log entry, the one outcome the log exists to make impossible. A
seal failure now refuses the dispatch. The debit has already landed, so the
caller is charged for a call it does not get; that is the safe direction,
because the opposite is an unrecorded side effect.
authorize_namedreports itas
SEAL_FAILED, a reason distinct from any JCP verdict: the call was notrefused on authority, it was refused because it could not be recorded.
That branch is unreachable through the public API — every field of the JCR-1
wire is a type the canonicaliser accepts — so my first falsification pass found
it untested: reverting the fix passed all 8 tests. An untested refusal is a
comment, not a guarantee, so there is now a
#[cfg(test)]fault seam(
fail_seals, compiled out of every build a consumer sees) and a test. With thefix reverted, that test fails by running the inner tool — the precise
unrecorded side effect.
What this does not prove
Stated in the module docs and the spec rather than left to be inferred: not
completeness. An operator that never records a decision logs nothing, and no
proof about the entries that exist can speak for one that was never written.
What the log does prove is that a receipt is in the tree a signed head commits
to, that the log has only grown between two signed heads, and that an entry
committed to under a published head cannot afterwards be removed or rewritten
without equivocating under the operator's key.
What narrows the gap is where the append happens — before the verdict returns
— so a caller that observed a refusal can demand the entry that refused it.
Spec
New §22 Transparency log (OPTIONAL): the tree, the signing preimage
byte-for-byte, the MUSTs on signing and on refusing an unparseable root, the
proven/not-proven split in plain terms, and conformance class C-TLOG.
References moves to §23; nothing cross-referenced it.
Verification
jouleclaw-transparency-log26 passed,jouleclaw-jcp9 passed, 0 failed. Nonew clippy warnings in either crate. Workspace resolves; nothing else depends on
either crate, so the blast radius is the two.
Eight falsifications from the committed baseline:
verifyevery_field_of_a_head_is_covered_by_the_signatureroot_hexpadded instead of refuseda_head_whose_root_is_not_a_root_is_refused_rather_than_signedHEAD_PREFIXcollided withLEAF_PREFIXhead_preimages_are_domain_separated_from_leaves_and_nodesa_call_that_cannot_be_recorded_is_refused_not_performed— fails by running the tool🤖 Generated with Claude Code