Part IV — Engineering Practice · Chapter 32
The Reference Verifier (Seven-Step Protocol)
The Reference Verifier (Seven-Step Protocol)
The verifier is the spec, executable. If your verifier disagrees with someone else's verifier on a single byte of a single attestation, one of you is wrong, and the spec settles which.
ℹPrerequisites▼
Before reading this chapter, you should be comfortable with: Chapters 5–7, 16 (Hashing, Signatures, Canonicalization, Primitives). The verifier re-implements the three-step verification protocol from scratch.
The first time you read about Canon's seven-step protocol — Chapter 1, Chapter 2, Chapter 4 — it is an idea. A recipient with ordinary network access can run the protocol and reach a definitive verdict without the issuer's cooperation. The idea has been load-bearing for the entire book.
This chapter is where the idea becomes code, twice: once in Python
(meridian/canon/walk.py, which you have already read), and once in a second language of your choice (Go, in the lab). The purpose is not engineering practice. Canonicalization byte-exactness is a property you understand only by discovering it disagree with itself across implementations. A second-language verifier is the most honest test of whether the first one was correct. > For non-technical readers (lawyers, judges, and paralegals): The reference verifier produces a one-word verdict — valid or invalid — and a step-by-step report showing which of the seven checks passed or failed. A valid verdict means the attestation's bytes are intact and the signature verifies against the declared public key. It does not mean the underlying evidence is authentic — that still requires foundational testimony. If you are evaluating a Canon attestation in a legal proceeding, the single most useful thing you can do is run meridian-canon verify <attestation.json> and read the output: valid with seven green checkmarks means the document has not been altered; invalid at step N means the specific check named at step N failed, and the attestation should not be relied on without investigation. The rest of this chapter explains how the verifier is built, for those who need to understand or reproduce it. ## At a glance - A conformant verifier implements the Canon §14 falsification protocol literally. Meridian-Cannon ships meridian-canon walk <attestation.json>; a standalone PyPI package (nora-canon-verifier) is on the roadmap. - The seven steps are deterministic for steps 1–6 and informational for step 7 (declined-coverage review). Steps 1–3, 5, and 6 produce a single pass/fail verdict; step 4 (witness content re-hashing) produces a count of verified and failed entries — zero failures is the passing condition. - v0.2.0 adds a DSSE (Dead Simple Signing Envelope) verification path alongside the v0.1.x chain-hash path. The verifier detects the version from the payload_type in dsse_envelope and routes accordingly. A conformant v0.2.0 verifier must support both paths. - Lab 25 is to write the verifier in a second language (Go) and prove it produces byte-identical results to the Python reference on a corpus of fixtures. ## Learning objectives By the end of this chapter you should be able to: 1. Describe all seven steps of the Canon falsification protocol — what each step checks, what input it operates on, and whether its verdict is binary or informational. 2. Implement Step 3 (chain hash recompute) in any language: canonicalize the attestation with the seal field excluded under RFC 8785, SHA-256 the canonical bytes, and compare to seal.chain_hash. 3. Explain what a chain_hash mismatch at Step 3 proves (the attestation was modified after sealing) versus what it does not prove (whether the modification was malicious or a transmission error). 4. Use the Go verifier to cross-validate a Python-emitted attestation: run both binaries against the same fixture, compare the full JSON verdict output, and diagnose any step-level disagreement. ## Why two implementations Software engineers pride themselves on writing each thing once. This chapter asks you to write the verifier twice, in different languages, and that asymmetry is the point. The seven steps are simple to describe and treacherous to implement. Step 3 (recompute the chain hash) requires byte-identical RFC 8785 canonicalization across implementations. The four bug classes from Chapter 7 — UTF-16 vs UTF-8 sort, ECMA-262 number formatting, lone surrogate handling, numeric edges — are silent. Your Python verifier might be wrong in a way you cannot detect until a Go verifier disagrees. > ▼ Why It Matters. A Canon attestation issued by a 2026 system > may be verified by a 2030 system in a language that did not exist > when the attestation was signed. The promise of long-archive > verifiability (Chapter 4) is the promise that every conformant > implementation produces the same verdict on every artifact. Lab 25 > is how you confirm your implementation is conformant. > ◆ Going Deeper — The interop test as the spec's authority. > > Russ Cox's Transparent Logs for Skeptical Clients article > (Chapter 5 further reading) argues that the test suite is the > spec: any natural-language specification will admit > interpretations that diverge in implementation, and only a shared > test corpus closes the divergence. RFC 8785 ships ~286,000 oracle > vectors for exactly this reason. > > The Canon ecosystem will, over time, accumulate a similar shared > test corpus. Today the cyberphone vectors cover canonicalization; > the conformance suite this textbook ships under > nora-canon-conformance-suite will cover the seven-step protocol.
Your Lab 25 verifier, if it passes the suite, is conformant. The suite is small now and will grow.
The seven steps, walked
You read these in Chapter 4. Here they are again with the implementation pointers:
# meridian/canon/walk.py — the reference Python implementation.
def walk(attestation):
if attestation.get("canon_version") not in CANON_VERSION_SUPPORTED:
return {"verdict": "invalid", "steps": {"step0_canon_version": "fail: unsupported"}}
seal = attestation.get("seal")
if not seal:
return {"verdict": "invalid", "steps": {"step0_seal_present": "fail: no seal"}}
s1_msg, pem = _step1_public_key_fetch(seal)
s2 = _step2_signature_verify(pem, seal) if pem else "fail: no public key"
s3 = _step3_chain_hash_recompute(attestation)
s4 = _step4_witness_content_hashes(attestation)
s5 = _step5_supports_resolution(attestation)
s6 = _step6_refutation_targets(attestation)
s7 = _step7_coverage_assessment(attestation)
binary_steps = [s1_msg, s2, s3, s5, s6]
valid = all(s == "pass" for s in binary_steps) and s4["failed"] == 0
return {
"verdict": "valid" if valid else "invalid",
"canon_version": attestation["canon_version"],
"attestation_id": attestation["attestation_id"],
"steps": { ... }
}
Each underscore-prefixed function is one step. Read them in
meridian/canon/walk.py; each is short. Below, what each step decides: ### Step 1 — Public key fetch and fingerprint check Fetch the PEM file at seal.public_key_url. SHA-256-hash the bytes. Compare to seal.public_key_fingerprint. Mismatch → fail. > ◆ Going Deeper — Why fingerprint, not URL identity? A URL can > be hijacked. A DNS record can be MITM'd. A web server can be > compromised. The fingerprint is what prevents an attacker from > swapping the public key under a stable URL and forging > attestations going forward. The fingerprint is signed inside the > seal; substituting it requires the issuer's private key. ### Step 2 — Signature verification Parse the PEM as an Ed25519 public key. Verify the signature in seal.signature against the UTF-8 bytes of the chain hash string, inclusive of the sha256: prefix. The prefix matters: a verifier that signs the hex bytes alone would mismatch. v0.2.0 DSSE path. When attestation.dsse_envelope is present, Step 2 uses the DSSE verification path instead of the legacy chain-hash path. The three sub-steps are: 1. base64url_decode(dsse_envelope.payload) → canonical_bytes 2. SHA-256(canonical_bytes) must equal chain_hash (field-level integrity) 3. Ed25519.verify(sig, PAE(payload_type, canonical_bytes)) using the public
key fetched in Step 1
The Pre-Authentication Encoding (PAE) formula:
PAE(payload_type, payload) =
"DSSEv1\n"
+ len_le8(payload_type) + "\n" + payload_type + "\n"
+ len_le8(payload) + "\n" + payload
len_le8(s) = 8-byte little-endian encoding of len(s)
The sig value is dsse_envelope.signatures[0].sig (base64url-encoded). The Python helper is meridian.canon.signing.verify_dsse:
from meridian.canon.signing import verify_dsse, CANON_PAYLOAD_TYPE
ok = verify_dsse(public_key, canonical_bytes, sig_b64url)
Version routing. The payload_type string encodes the Canon version: "application/vnd.nora.canon.attestation+json; version=0.2.0". A verifier that sees version=0.1.1 in the payload_type (or no dsse_envelope at all) must fall back to the v0.1.x legacy path: verify seal.signature over the UTF-8 bytes of seal.chain_hash. The legacy path will be deprecated in a future release; the v0.2.0 DSSE path is the forward direction. The Python walk.py reference implementation checks for dsse_envelope first and routes to the appropriate sub-verifier. In the Go lab, this routing must be replicated exactly. > ◆ Going Deeper — Why DSSE over the earlier construction? > > The v0.1.x construction signs the chain hash string directly. This is > technically sound but non-standard: a verifier must know that the signed > message is "sha256:" + hex_digest, not the raw digest bytes. DSSE > (from the SLSA provenance specification) formalizes the signed message > as PAE(payload_type, payload). The payload_type acts as domain > separation: a signature made for application/vnd.nora.canon.attestation > cannot be mistaken for a signature made for a different payload type, > even if the payload bytes are identical. Domain separation is the standard > defense against cross-protocol signature confusion attacks. ### Step 3 — Chain hash recompute Canonicalize the attestation (with the seal field excluded) under RFC 8785. SHA-256 the canonical bytes. Compare to seal.chain_hash. Mismatch → fail. This is where Chapter 7's discipline pays off. A buggy canonicalizer fails this step silently: the verifier produces a hash that doesn't match, the verdict is invalid, and the engineer has no idea whether the attestation was tampered with or the canonicalizer was wrong. Lab 25's interop test isolates the canonicalizer. ### Step 4 — Witness content re-hash For each Witness entry, retrieve the content (via content_inline or content_ref). SHA-256 it. Compare to content_hash. Count verified vs failed. Zero failures is the passing condition. This step catches post-issuance content substitution. An attacker who replaces the bytes at content_ref fails the recipient's recomputed hash. The artifact's seal remains cryptographically valid (the seal commits to the content's hash, not the bytes), but the recipient knows the content has been altered. ### Step 5 — Supports closure (R3) For every Claim, verify that every entry in supports resolves to either an observation_id in this attestation's Witness block or a claim_id defined earlier in this Findings block. Forward references are prohibited. Cycles are prohibited. Unresolved references → fail. This step checks structural integrity. A Claim whose supports entries don't resolve is not making a defensible inference. ### Step 6 — Refutation targets resolve For every Challenge, verify that every entry in targets resolves to a claim_id defined in the Findings block. A challenge against a non-existent claim is a malformed artifact → fail. ### Step 7 — Coverage assessment (informational) Surface the coverage.declined inventory. Report it. The verifier does not
judge whether the declines are acceptable; the recipient does. Step 7 never
causes the verdict to flip.
§ For the Record — Canon §14 closing line.
"Step 7 is explicitly informational: Canon requires that the declined-challenges inventory be present, not that the recipient find it acceptable. The verifier surfaces it for the recipient's judgement."
What the verifier returns
{
"verdict": "valid" | "invalid",
"canon_version": "0.2.0",
"attestation_id": "01JABC...",
"steps": {
"step1_public_key_fetch": "pass" | "fail: <reason>",
"step2_signature_verify": "pass" | "fail: <reason>",
"step2_dsse_verify": "pass" | "fail: <reason>" | "not_applicable",
"step3_chain_hash_recompute": "pass" | "fail: <reason>",
"step4_witness_content_hashes": { "verified": N, "failed": M },
"step5_supports_resolution": "pass" | "fail: <reason>",
"step6_refutation_targets": "pass" | "fail: <reason>",
"step7_coverage_assessment": "informational: N declined challenge type(s)"
}
}
For v0.1.x attestations (no dsse_envelope), step2_dsse_verify is "not_applicable" and the legacy step2_signature_verify carries the verdict. For v0.2.0 attestations, both step2_signature_verify and step2_dsse_verify are reported; both must pass for the overall verdict to be valid. A valid verdict requires steps 1–3, 5, 6 to be "pass" and step 4 to have zero failures. Step 7 is reported but never causes invalidation. verdict: valid confirms that the attestation's chain hash, signature, and internal references are intact. It does not authenticate the underlying evidence or establish that the acquisition circumstances are what the attestation claims. A court receiving a Canon attestation with verdict: valid still requires foundational testimony — typically from the issuer — establishing how the source bytes were obtained. The verifier answers the integrity question; the issuer's testimony answers the authenticity question. > ☉ In the Wild — When verifiers disagree at scale. > > Certificate Transparency (RFC 6962) was deployed at internet scale > in the early 2010s and immediately produced disagreement: Chrome's > verifier and Firefox's verifier had subtly different views of the > same logs. The problem was traced to a single edge case in > precertificate handling. Mozilla published a gossip protocol in > 2017 to ensure that disagreement, when it appeared, was visible — > the bug had been silent for two years before anyone noticed. > > A Canon ecosystem that ships only one verifier is similarly > vulnerable. The fix is the same as CT's fix: multiple > implementations, a shared test corpus, and a discipline of > publishing disagreement when it surfaces. Lab 25 is the smallest > useful version of that discipline. ## Lab 25 — Build a second-language verifier The lab is in labs/ch25_verifier/. Two deliverables. ### Lab 25.1 — Implement the verifier in Go Build a Go program verifier that: - Reads a Canon attestation JSON from a path. - Implements all seven steps. - Outputs the same JSON verdict shape as the Python reference. - Returns exit code 0 on valid, 1 on invalid. The lab provides: - A working Go reference solution under reference/main.go (~250 lines; the Go standard library has crypto/ed25519, crypto/sha256, and encoding/json so no third-party dependencies are required for the cryptographic and structural steps; an RFC 8785 canonicalization function is provided as a helper). - A skeleton at student/main.go for you to fill in. - A fixtures directory (fixtures/) with seven attestations: a known-valid one, and one for each step's failure mode. - A pytest harness (test_lab.py) that runs both the Python reference and your Go binary against each fixture and compares their verdict outputs byte-for-byte. ### Lab 25.2 — Find a disagreement After your Go verifier passes the fixture corpus, try to break it. Construct an attestation that the Python reference and your Go verifier disagree on. Disagreement is acceptable for two reasons — either you found a bug in your Go verifier, or you found a bug in the Python reference (or in rfc8785's Python package, or in Go's JSON library). All three have happened in production systems. Document your finding in student/findings.md. If you cannot construct a disagreement after thirty minutes, that is also a finding — write it up, with the inputs you tried. ## Acceptance criteria - pytest test_lab.py passes (both verifiers agree on all fixtures). - Your student/main.go builds with go build -o verifier ./student. - student/findings.md exists and documents either a successful
disagreement-construction or a 30-minute null result.
Implementation notes for the Go verifier
// student/main.go — sketch.
package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
)
// Step 3 is the load-bearing step. The Python reference uses the
// rfc8785 package; you must implement the same canonicalization in Go.
// The reference solution provides canonicalize_jcs() as a helper.
//
// The four bug classes from Chapter 7 apply here. Test against the
// cyberphone vectors before you trust your output.
◆ Going Deeper — Why Go for the second language?
Three reasons: (1) Go's standard library has
crypto/ed25519and >crypto/sha256so the lab does not require third-party crypto > dependencies; (2) Go's syntax is approachable for a Python-trained > student; (3) Go is on the roadmap for the production >nora-canon-verifierstandalone package, so the lab is forward- > useful. > > If you prefer Rust, the lab also accepts a Rust implementation > understudent-rust/. The interop tests run against any binary > the harness cansubprocess.run. Stretch goal: do both, and > verify all three (Python, Go, Rust) agree on every fixture.## Exercises ### Warm-up 1. Read💡Key Takeaways- The three-step DSSE verification sequence is: (1) base64url-decode the envelope payload and verifySHA-256(decoded_bytes) == chain_hash, (2) reconstructPAE(payload_type, decoded_bytes)and verify the Ed25519 signature using the public key fetched frompublic_key_url, (3) parse the decoded JSON and check internal structural invariants (supports closure, refutation targets). - Step 2 (chain_hash check) is technically redundant with step 3 (PAE signature covers the same bytes) but is required because it enables field-level integrity detection without a public key — a verifier that cannot reachpublic_key_urlcan still detect tampering via the chain hash alone. - Version detection routes to the correct verification path: presence ofdsse_envelopeindicates v0.2.0 (use PAE verification); absence with asealblock indicates v0.1.x (use legacy chain-hash-string signature verification); a conformant verifier must support both paths without error. - The PAE formula ("DSSEv1\n" + len_le8(payload_type) + "\n" + payload_type + "\n" + len_le8(payload) + "\n" + payload) must produce identical bytes in every language implementation — cross-language determinism is testable by running Python and Go (or Rust) verifiers against the same fixture inputs. -public_key_urlmakes verification custodian-independent: any recipient with network access can fetch the PEM, recompute the fingerprint, and verify the signature without contacting the issuer or trusting a third-party key directory.meridian/canon/walk.pyend-to-end. Identify each of the seven step functions and their failure modes. 2. Runpython -m meridian.canon.walk path/to/test/attestation.jsonon each fixture in the lab directory. Compare the output to the expected verdict listed infixtures/EXPECTED.md. ### Core 3. Readmeridian/canon/walk.py. Identify which of the seven steps performs the fingerprint check (comparing the fetched public key's SHA-256 to the sealedpublic_key_fingerprint). Write the exact Python expression that performs this comparison, and explain what an attacker would need to control to make a substituted key pass this check. 4. The Python reference treats step 4 (witness content re-hash) as "valid only if zero failures." Construct a fixture where this matters: an attestation with three valid witness entries and one invalid one. Run both verifiers; confirm both returninvalid. 5. Using the03_tampered_chain_hash.jsonfixture inlabs/ch25_verifier/fixtures/, runmeridian-canon verifyand note which step fails. Then manually compute the chain hash of the attestation (exclude the seal, canonicalize with RFC 8785, SHA-256) and compare it to thechain_hashin the seal. Identify the exact byte where the tampering occurs. ### Stretch 5. Implement Lab 25.2 — find or construct a disagreement. 6. Implement the verifier in Rust. Submit it asstudent-rust/. Verify three-language agreement on the fixture corpus. 7. The Python verifier usesurllib.request.urlopenfor HTTPS fetches. Identify three failure modes this implementation handles incorrectly compared to a production-grade HTTP client (timeout handling, certificate pinning, redirect handling). Patch them. ## Build-your-own prompt For your capstone matter: which language is your recipient most likely to verify in? If your evidence will be reviewed by an opposing-counsel expert who works in JavaScript, build a JavaScript verifier. If by a regulator whose tooling is in Java, build Java. The verifier is the artifact's first reader; choose its language for the recipient, not for yourself. ## Further reading - Meridian-Canon v0.2.0 §10.3 (Verification, with the seven-step protocol diagram and DSSE path). -meridian/canon/walk.pyin this repository. - Russ Cox, Transparent Logs for Skeptical Clients, https://research.swtch.com/tlog. - The cyberphone test vectors, https://github.com/cyberphone/json-canonicalization. - RFC 8032 (Ed25519); RFC 8410 (PEM encoding); RFC 8785 (JCS). - Soatok, Canonicalization Attacks Against MACs and Signatures, https://soatok.blog/2021/07/30/canonicalization-attacks-against-macs-and-signatures/. - The dossierresearch/01_cryptography_pedagogy.md.
Next: Chapter 26 — The Admissibility Auditor. Where the verifier's output meets the courtroom.