Crux Daemon · 13. Receipts and proof
A CROWN receipt is a verifiable record of what was stored and retrieved. Verification proves that these exact bytes were signed by the holder of a named key and have not changed since. It is tamper-evidence. It is not an attestation of what an agent did, and no sentence in this documentation set will say that it is.
Six facts belong in the same breath as the capability, because a reader who has only the first half will draw a conclusion the code does not support:
- Every receipt field is a client-supplied self-report. Nothing is independently measured.
- An operator holding the signing key can forge freely.
- Truncation and whole-chain deletion are undetectable; there is no signed tip.
- There is no global receipt chain.
- The main receipt classes default OFF, and
memory_useis never emitted at all. - Witness anchoring is default off and unreachable in this build.
This chapter is reference. It documents the mechanism in full, then states the boundary at least as prominently. Read §13.13 and §13.14 together; either one alone is misleading. The process and data directory these files live in is chapter 1, the flags are listed in chapter 5, and verified defects are in chapter 16.
13.0 In plain English
A receipt is a small record the daemon writes when something notable happens, describing what the operation was. It hashes the exact bytes of that record, and for the signed classes it signs the hash with a key the daemon holds. Later, anyone with the record and the public key can re-compute the hash and check the signature, and thereby establish that these exact bytes were signed by the holder of that key and have not changed by so much as a character since.
The analogy that fits is a wax seal on an envelope, and it fits precisely because of what it excludes. An intact seal tells you the envelope has not been opened since it was sealed, and that whoever sealed it held the seal. It tells you nothing at all about whether the letter inside is true. That is exactly the boundary here. A receipt is evidence about bytes; it is not evidence about conduct, and every field inside it is a self-report by the caller, measured independently by nobody.
Why does the mechanism exist? Because "the agent said it did this" is not something you can hand to anyone who has reason to doubt you. A receipt turns that claim into a fixed artefact a third party can check offline, without trusting the daemon that produced it and without the daemon even being reachable. Verification is public and needs no credentials. That is a genuinely useful property and it is the one on offer: tamper-evidence for a record.
You will come here at two moments. When someone asks you to reconstruct what your fleet did, and you need to know what these records can and cannot carry before you promise anything. And when you are deciding what to switch on, which matters more than it sounds: the main receipt classes are off by default, so a daemon that has run for a month with stock configuration has not been writing most of the receipts you are about to go looking for. Read §13.13 and §13.14 together before you rely on any of this. Either section alone leaves a misleading impression.
What people get wrong is the word itself. "Receipt" implies a witnessed transaction, so readers assume the daemon observed the work and is vouching for it. It did not and it is not. Four limits belong in the same breath as the capability, and none is a footnote: an operator holding the signing key can forge freely; truncation and whole-chain deletion cannot be detected, because there is no signed tip; there is no global chain linking receipts to one another; and memory_use is never emitted at all, so its absence from a record set means nothing.
13.1 The bytes-first invariant
The crate's opening invariants (lib.rs:10): the body is stored and returned as opaque canonical bytes, typically CBOR; hashing and verification operate over the stored bytes exactly, with no re-serialisation; and verification output is derived state, rebuildable rather than authoritative. body_v1.rs restates it, "CoreCrux never reserializes; canonicalization is producer-owned" (body_v1.rs:20), as does verify_v1.rs:55.
This is the right design. A CBOR library upgrade that changes map ordering can never silently invalidate historical receipts. Three consequences follow, and all three must be stated:
- "Canonical" is a producer convention, not an enforced property. Each builder pushes CBOR map entries in a hard-coded order. There is no RFC 8949 §4.2 canonical-CBOR check, no key sorting, no shortest-form integers, no definite-length requirement, and nothing re-checks the encoding at verify time.
canonical_bytes_parse_okis a misleading field name. It means "the bytes parsed as some CBOR" (verify_v1.rs:123), nothing about canonicity. A validly-signed non-CBOR blob returnssignature_valid: truealongsideBODY_CBOR_PARSE_ERROR(test at tests.rs:1983).- Schema and
kindare not verified. A test asserts the current behaviour outright, "schema string ignored, verification succeeds" (tests.rs:1970). Kind is checked only by optional post-hocassert_*_kind_v1helpers (audit_gap_v1.rs:591) that a caller must remember to invoke.
The one place with real canonicalisation is the audit-bundle manifest: canonical_json_bytes (audit_bundle_v1.rs:265) recursively sorts object keys, with an honest note that it is deliberately not full RFC 8785 JCS (audit_bundle_v1.rs:271).
13.2 Where receipts actually live
In this edition, the receipts that are actually emitted are per-session JSONL files, not segment frames. The record is ObservationRecordV1 (observations.rs:228), written to <data_dir>/observations/<sanitized_session_id>.jsonl (observations.rs:452); the sanitiser maps anything outside [A-Za-z0-9-_.] to _ (observations.rs:442).
| Receipt family | File |
|---|---|
| Stream and model-invocation receipts | observations/mediation__<session_id>.jsonl (stream_receipts.rs:335) |
| Usage pings | observations/usage__<actor>.jsonl (stream_receipts.rs:500) |
| Approval decisions | observations/.work_gate_receipts_v1.jsonl (approval_receipts.rs:18) |
The segment-store shape, paired receipt.body.v1 and receipt.sig.v1 events under stream type "receipt" (lib.rs:155), is real. It is what corecruxctl receipts seed-minimal writes and what the tamper demo tampers with. But in a stock deployment nothing puts receipt frames in shards/, because the dataplane append path is not part of this build.
Verification reports are a third location: <shard_dir>/receipts/verification/tenant-<hash>/<receipt_id>.json (store_v1.rs:24), written temp-then-rename with a load-time key cross-check to catch tenant-hash collisions (store_v1.rs:69). This module is library-only here; nothing in corecruxd or corecruxctl calls it.
13.3 Chaining: three unrelated mechanisms, and no global chain
(a) The observation chain
This is the only one that actually links emitted receipts. ObservationRecordV1 carries seq: Option<u64> (observations.rs:244) and prev_hash: Option<String> (observations.rs:248). The link is:
prev_hash_n = "blake3:" + hex(blake3(canonical_body_bytes(record_{n-1})))
where canonical_body_bytes (observations.rs:480) serialises the record, removes the receipt key, and re-serialises. Because the pre-image already contains prev_hash_{n-1} and seq_{n-1}, this is a standard hash chain over signed bodies.
Genesis has three cases (observations.rs:946): no prior records means (seq = 0, prev_hash = None); a prior record that is pre-chain legacy with seq == None starts a brand-new chain at 0 that deliberately does not reference the legacy record; a chained prior record extends with (prev_seq + 1, prev_body_hash). A file may therefore legitimately hold an unchained legacy prefix followed by a chained suffix, which validate_chain (observations.rs:1033) models as ChainStatus::Ok { legacy_prefix_len, chained_len }.
There is no chain-head file. The head is the last parseable line. read_chain_tip (observations.rs:643) seeks the last 64 KiB, walks lines in reverse, and falls back to a full read if nothing parses, so the chain survives restart with no in-memory state. A torn final line is quarantined before each append (observations.rs:903).
There are many chains, not one. One independent chain per session file, with no cross-file linkage, no global head and no enumeration of expected chains.
(b) The coverage-window fold
coverage_window_chain_fold_v1(head, payload_hash) = BLAKE3(head ‖ payload_hash) (audit_gap_v1.rs:261), genesis being an all-zero 32-byte seed (audit_gap_v1.rs:182). It has no domain-separation prefix, no length prefix and no index. Order-sensitivity is asserted by test (audit_gap_v1.rs:1082), but nothing binds the fold to window bounds or ordinal position. It is computed on demand by corecruxctl receipts coverage-window-attest and never persisted as a running head.
(c) Re-anchor bodies
chain_reanchor (audit_gap_v1.rs:61) records old_chain_head and new_chain_head as opaque operator-supplied strings; verify_chain_reanchor_body_v1 (audit_gap_v1.rs:619) checks only that they are non-empty, distinct and of a known algorithm; it recomputes nothing.
chain_signature_reanchor (chain_reanchor_v1.rs:53) is genuinely stronger: verification requires the caller to supply the original head bytes, checks blake3(head_bytes) == chain_head_hash, and re-verifies the original signature (chain_reanchor_v1.rs:261). But build_chain_signature_reanchor_body_v1 has no caller outside the receipts crate; it is DECLARED-NOT-WIRED.
No Merkle tree exists. The only Merkle code in the repository is RFC 6962 inclusion-proof verification for externally supplied Rekor proofs (witness_v1.rs:474). Crux never builds a tree.
13.4 Hashing and domain separation
Every build_*_body_v1 uses the same shape: build a ciborium::Value::Map in fixed order, into_writer, blake3::hash. It is duplicated per module (audit_gap_v1.rs:297, stream_v1.rs:136, witness_v1.rs:364, memory_use_v1.rs:242).
One failure mode is worth flagging at 3am. All of those builders swallow serialisation failure by clearing the buffer, if ciborium::ser::into_writer(&v, &mut bytes).is_err() { bytes.clear(); } (audit_gap_v1.rs:300 and identically elsewhere). The resulting hash is blake3(b""). The daemon's call sites guard against an empty body (stream_receipts.rs:305), but the library will happily return a signed hash of nothing.
Domain separation is nearly absent. There are exactly three domain tags in the crate, and none covers a receipt body or a chain link:
| Tag | Location | Covers |
|---|---|---|
cuecrux.audit_bundle.v2\0 | audit_bundle_v1.rs:62 | audit-bundle manifest, v2 |
cuecrux.audit_bundle.v3\0 | audit_bundle_v1.rs:65 | audit-bundle manifest, v3 (current) |
cuecrux.crypto_shred.subject_cek_commitment.v1 | crypto_shred_v1.rs:117 | CEK commitment |
The receipt body hash, the coverage-window fold and the observation-chain body hash are all bare blake3::hash(bytes). Cross-protocol confusion is limited in practice, because Ed25519 signatures over different message shapes do not interchange, but do not claim domain separation the code does not have.
13.5 Signing keys: four distinct sources
(1) The node passport key, the de-facto CROWN signer. <data_dir>/passport.key (passport.rs:36), lowercase hex of the 32-byte Ed25519 seed plus a newline (passport.rs:250), created 0600 via OpenOptions::mode(0o600) with create_new(true) (passport.rs:240), on Unix only; no permission is set on other platforms. The key id is p_ + hex(blake3(pubkey)[..16]) (passport.rs:283). Both the hex string and the seed are wrapped in Zeroizing (passport.rs:145).
One sharp edge: from_path mints a fresh random seed on NotFound (passport.rs:194). from_existing_path (passport.rs:150) exists specifically to avoid that, and its doc cites the crypto review that found it. mint_receipt calls from_path (observations.rs:489), so if the key file is deleted between restarts the daemon silently mints a new identity and carries on signing, every receipt after that point verifies under a different key, and nothing warns you.
(2) The audit-export signing key, a separate key with three-tier precedence (audit_signing_key.rs:61): CORECRUXD_AUDIT_EXPORT_SIGNING_KEY_B64 gives key_class = env, and a malformed value is a hard error, never a silent fallback (audit_signing_key.rs:66); otherwise a generate-once 0600 key at <data_dir>/audit-export-signing.key gives persistent (audit_signing_key.rs:143); otherwise a one-shot OsRng key gives ephemeral (audit_signing_key.rs:190). The class is signed into the manifest (audit_bundle_v1.rs:216) and v3 bundles are rejected if it is missing.
(3) The keyring, public keys only, verification-only. Ed25519KeyRingV1 is { v: 1, keys: [{ keyId, pubKeyBase64 }] } (keyring_v1.rs:27), parsing rejects v != 1, empty lists and blank ids (keyring_v1.rs:43), and non-base64 or non-32-byte entries are rejected at index time (keyring_v1.rs:65). corecruxctl receipts seed-minimal writes it to <data_dir>/meta/keys/ed25519-keyring.json (corecruxctl/receipts.rs:1509).
Rotation means adding an entry. There is no revocation, no validity window, no not_before or not_after, no key state, and the keyring is itself unsigned. Removing a key retroactively invalidates every receipt it ever signed. The source says so: "intentionally not a full JWKS implementation yet" (keyring_v1.rs:23).
(4) The witness signer, a separate P-256 ECDSA key from CORECRUXD_WITNESS_SIGNING_KEY or Vault Transit (witness_submit.rs:83), unrelated to CROWN. C2PA uses yet another (output_attest.rs:83).
13.6 What gets signed
The library signs the body bytes directly. Every sign_*_v1 is signing_key.sign(body_bytes) (audit_gap_v1.rs:315, memory_use_v1.rs:271, stream_v1.rs:228).
The daemon's outer observation envelope signs the 32-byte hash instead, key.sign_hash(hash.as_bytes()) (observations.rs:506, implemented at passport.rs:180). The same key therefore signs in two modes on two layers. Note also that the two observation verification paths use plain verify, not verify_strict (http/receipts.rs:250, http/receipts.rs:334).
The signature envelope is not itself signed. ReceiptSigV1 (verify_v1.rs:59) carries {schema, receipt_id, alg, key_id, signed_at, signature[64], signed_payload_hash[32]}. signed_payload_hash is cross-checked against the body hash (verify_v1.rs:349), but receipt_id, key_id, signed_at and schema are unauthenticated, attacker-mutable fields. Editing signed_at in the stored CBOR breaks nothing.
13.7 verify_strict: what it is
verify_strict in this codebase is exclusively the ed25519_dalek::VerifyingKey::verify_strict API method. It is never a Crux configuration toggle, feature flag or "strict mode" posture. The workspace pins ed25519-dalek 2.2.0 (Cargo.toml:68). Any documentation describing it as a configurable posture is wrong.
Beyond plain verify, it rejects:
- Non-canonical scalar
S, a signature whoseShalf is not reduced mod ℓ (RFC 8032 §5.1.7). All dalek verify functions do this. - Small-order
R, the RFC's optional group-element malleability check. - Small-order public key
A, the part unique toverify_strict. A signature that would verify under a torsion-component key is refused, which is what makes signature-to-key binding unique per key. - Undecompressable
R.
Then the standard [S]B == R + [k]A'. Note that the signing side throughout the crate is plain Ed25519; only verification is strict.
13.8 verify_receipt_v1: every check, in order
Entry at verify_v1.rs:187, input struct at verify_v1.rs:172. The function is total: every failure returns Ok(report), and the sole Err variant VerifyError::Cbor (verify_v1.rs:49) is never constructed.
One cross-cutting rule: every failure branch first tests payload_hash_matches and reports BODY_HASH_MISMATCH instead of the branch-specific code if the body is also wrong. Body-hash failure always wins, and that is pinned by tests (verify_v1.rs:1293).
| # | Line | Check | Failure code |
|---|---|---|---|
| 0 | :188 | blake3(body_bytes) equals the stored payload hash | recorded, not fatal |
| 1 | :194 | body parses as some CBOR | recorded, not fatal |
| 2 | :203 | best-effort trace extraction, optional digest recompute | never fatal |
| 3 | :221 | signature bytes present | SIG_MISSING |
| 4 | :256 | sig CBOR decodes into ReceiptSigV1 | SIG_PARSE_ERROR |
| 5 | :290 | sig.alg == "ed25519" | SIG_ALG_UNSUPPORTED |
| 6 | :318 | sig.receipt_id equals the requested receipt id | SIG_RECEIPT_ID_MISMATCH |
| 7 | :349 | signed_payload_hash is 32 bytes and equals the stored hash | SIG_PAYLOAD_HASH_MISMATCH |
| 8 | :384 | a keyring was supplied | KEYRING_MISSING |
| 9 | :412 | the keyring indexes (base64, 32 bytes per entry) | PUBKEY_INVALID |
| 10 | :443 | sig.key_id resolves in the keyring | KEY_NOT_FOUND |
| 11 | :471 | VerifyingKey::from_bytes, a valid compressed Edwards point | PUBKEY_INVALID |
| 12 | :504 | the signature is 64 bytes | SIG_INVALID |
| 13 | :536 | vk.verify_strict(body_bytes, &signature) | SIG_INVALID |
| 14 | :539 | re-apply the hash result, then the parse result | BODY_HASH_MISMATCH, then BODY_CBOR_PARSE_ERROR, then OK |
When the body hash does not match, the signature is still checked, deliberately, "so operators can distinguish 'corrupt storage' from 'bad signature'" (verify_v1.rs:209).
Never checked, anywhere: sig.schema; sig.signed_at (no freshness, no skew, no monotonicity); the body's own schema; the body's own kind; tenant_id against the body; any chain link; any anchor; any key validity window; any revocation.
The 12 error codes (verify_v1.rs:14), exhaustively pinned by a test at verify_v1.rs:813: OK, BODY_HASH_MISMATCH, BODY_CBOR_PARSE_ERROR, SIG_MISSING, SIG_PARSE_ERROR, SIG_ALG_UNSUPPORTED, SIG_RECEIPT_ID_MISMATCH, SIG_PAYLOAD_HASH_MISMATCH, KEYRING_MISSING, KEY_NOT_FOUND, PUBKEY_INVALID, SIG_INVALID.
The report (verify_v1.rs:75) carries schema ("cuecrux.receipt.verify.v1"), receipt_id, tenant_id, payload_hash, signature: {alg, key_id?}, integrity: {payload_hash_matches, canonical_bytes_parse_ok}, trace_checks, an optional trace_summary, signature_valid, pubkey_fingerprint, error_code, error_message, verified_at (caller-supplied, for determinism) and verifier_build.
Two report fields need a caveat. payload_hash is the stored header value, not the recomputed one (verify_v1.rs:205), a discrepancy shows only in error_message. And VerificationTraceChecksV1 (verify_v1.rs:127) is 13 presence booleans plus candidate_digest_matches_recompute. fusion_present: true means the CBOR map has a fusion key; it asserts nothing about correctness. The only substantive field recomputes the candidate digest (candidate_digest_v1.rs:17) and is off by default (CORECRUXD_RECEIPTS_RECOMPUTE_CANDIDATE_DIGEST, config.rs:988).
13.9 Receipt kinds, and which are actually minted
All share schema = "cuecrux.receipt.body.v1" and are discriminated by a kind field.
kind | Purpose | Constant |
|---|---|---|
memory_use | An agent declares which stored facts it consulted for a turn | memory_use_v1.rs:48 |
context_injected | What context entered the model, bundle hash, fact ids, budget | stream_v1.rs:51 |
stream_completed | A model stream ended normally; carries output_digest, not content | stream_v1.rs:53 |
stream_aborted | A stream was abandoned or errored, first-class, not an error path | stream_v1.rs:56 |
model_invocation | A model call happened: provider, model id, prompt and output hashes | audit_gap_v1.rs:21 |
approval_decision | An operator-tier passport approved or rejected a gated action | approval_decision_v1.rs:47 |
usage_ping | Metadata-only adoption signal; provably carries no content | usage_receipt_v1.rs:51 |
consolidation | A consolidation or undo collapsed facts into a canonical one | audit_gap_v1.rs:24 |
redaction | Subject-scoped erasure with CEK commitment; hash-only | audit_gap_v1.rs:23 |
chain_reanchor | Body-hash-algorithm migration metadata across a window | audit_gap_v1.rs:22 |
chain_signature_reanchor | Counter-signs a chain head under a new signature algorithm | chain_reanchor_v1.rs:40 |
coverage_attestation | A metric run over a corpus, hash-bound to a report file | audit_gap_v1.rs:25 |
coverage_window | Signed window census: events, receipts, anchored, gaps, chain head | audit_gap_v1.rs:33 |
external_anchor | An RFC 6962 inclusion proof for a seal-chain head | witness_v1.rs:44 |
rfc3161_timestamp | An RFC 3161 TSA token bound to a message imprint | witness_v1.rs:45 |
Separately-schema'd bodies, serde structs rather than kind-discriminated, include cuecrux.receipt.forget.v1 (forget_v1.rs:78), permanent_purge.v1, the passport_split / merge / link_device family (identity_v1.rs:58), crypto_shred.envelope.v1 (crypto_shred_v1.rs:28), coverage.window.report.v1, c2pa.manifest.v1, receipt.sig.v1, receipt.verify.v1, receipt.subject_index.v1, and the SCITT CrownReceiptV1 COSE profile (cose_sign1_v1.rs:155).
Which are actually minted: the table that matters
| Kind | Emitter | Signed? | Gate |
|---|---|---|---|
context_injected, stream_completed, stream_aborted, model_invocation | stream_receipts.rs:194 | yes | FLAG CORECRUXD_STREAM_RECEIPTS=1, default OFF (config.rs:1345) |
usage_ping | stream_receipts.rs:448 | yes | FLAG CORECRUXD_FEATURE_USAGE_RECEIPTS=1, default OFF (config.rs:1346) |
approval_decision | approval_receipts.rs | yes | on where its gate fires |
consolidation | consolidation_receipt.rs:41 | best-effort, returns None on key-load failure and the mutation still succeeds | - |
external_anchor, rfc3161_timestamp, chain_reanchor, redaction, coverage_attestation, coverage_window | corecruxctl only | signature optional (--out-sig) | operator CLI |
forget, permanent_purge, passport_* | crux-mcp tools | unsigned and unpersisted (forget.rs:461, identity.rs:374) | - |
memory_use | nothing | n/a | build_memory_use_body_v1 has no production caller |
chain_signature_reanchor | nothing | n/a | library-only |
| C2PA manifest | output_attest.rs:346 | yes | FLAG CORECRUXD_FEATURE_C2PA_OUTPUT=1, default OFF |
In a stock daemon, the main receipt classes emit nothing. Turning them on is one environment variable each, and that is the honest starting point for anyone planning to rely on receipts.
13.10 Offline verification and the tamper test
There is no standalone receipt-verifier binary
corecrux-receipts/Cargo.toml has no [[bin]]. The workspace's only binaries are crux-config-wizard, crux-hook, crux-llm-shim, crux-desktop-shell and four fuzz targets. What exists, in descending order of strength:
- tools/verify_audit_bundle_v1.py, the real third-party artefact, 463 lines. Run as
python3 tools/verify_audit_bundle_v1.py <dir-or-bundle.tar.zst> [--json] [--rekor-pubkey <path>](:438). It depends only oncryptographyand reimplements the domain tags and canonical JSON in Python (:39) rather than linking the Rust, which is what makes it an independent check. It covers bundle formats v1, v2 and v3, the Ed25519 manifest signature, the SHA-256 of each member, and an optional Rekor checkpoint trust root in Ed25519 or ECDSA P-256. It runs in CI against four committed vectors (audit-vectors.yml:54). - crates/corecruxd/examples/verify_observations.rs,
cargo run --example verify_observations -- --jsonl <session.jsonl> --pubkey-hex <64-hex>. Per line it stripsreceipt, re-canonicalises, recomputes BLAKE3, compares and Ed25519-verifies; then it validates the whole-fileseqandprev_hashchain (:300). It verifies the observation envelope, not the inner CROWN bodies, and it is an example rather than a distributed artefact. corecruxctlsubcommands, all no-daemon:audit-verify,receipts verify-cose,receipts verify-external-anchor,receipts verify-rfc3161-timestamp,receipts verify-chain-reanchor,output-verify. One trap: omitting--pubkey-b64fromreceipts verify-cosesilently uses a hardcoded public dev key (corecruxctl/receipts.rs:34); the report flagsdevelopment_key: true, so check that field.POST /v1/audit/bundle/verify(audit_verify.rs:43), stateless, no key fetch, an 8 MiB compressed cap and a 256 MiB decompressed cap. A failed verification isok: falseat HTTP 200; only malformed or over-cap input is 4xx.
corecruxctl inspect-receipt verifies nothing. It is a raw byte-substring grep over .ccxseg files (inspect_receipt.rs:10). Nothing in the Python or TypeScript SDKs verifies receipts either.
The tamper test, and exactly what it proves
scripts/demo-receipt-tamper.sh is 180 lines and needs bash, jq, xxd, dd and a built corecruxctl, no daemon, "pure offline verification" (:19).
- Locate
corecruxctlvia$CORECRUXCTL, thentarget/release, thentarget/debug, thenPATH; exit 2 with build instructions otherwise (:27). mktemp -da throwaway data dir with anEXITtrap (:53).corecruxctl receipts seed-minimal, which writes a keyring using the fixed dev seed[42u8; 32]with key iddev-k1(corecruxctl/receipts.rs:1516), builds a 5-field CBOR body, signs it, and appends the body and sig frames.corecruxctl verify-store --mode full --strictand assert.ok == true(:83).- Flip exactly one byte: compute
FLIP_AT = SIG_OFFSET - 16so it lands in the tail of the body frame's CBOR payload rather than its header, bounds-check, XOR with0x55, write back withdd conv=notrunc(:101). - Re-run and assert
.ok == falseand that the reason matches*MISMATCH*or*CORRUPT*(:135), deliberately permissive, because the exact classification depends on which integrity layer fires first.
**The critical caveat is stated in the script itself at :168: this proves the storage layer detects a byte flip. It does not exercise verify_receipt_v1 at all.** corecruxctl verify-store is a segment, frame and TOC integrity scan (verify_store.rs:6) with no keyring and no call into the receipt verifier; its reason codes are SEGMENT_HASH_MISMATCH, TRAILER_HASH_MISMATCH, FRAME_HEADER_HASH_MISMATCH and FRAME_PAYLOAD_HASH_MISMATCH (verify_store.rs:196).
Receipt-level tamper detection is proven, by a unit test. verify_body_corruption_surfaces_hash_mismatch (tests.rs:457) derives a seeded key, builds a keyring, encodes a body, captures stored_hash = blake3(body), signs, then does body[0] ^= 0x55 (tests.rs:480), assembles the signature with the pre-tamper hash, verifies, and asserts !payload_hash_matches and error_code == "BODY_HASH_MISMATCH". Companion tests cover a bad signature with a matching hash (verify_v1.rs:1345), an off-curve public key (verify_v1.rs:1404), a wrong signature length (verify_v1.rs:1431), an HTTP-layer tamper test (audit_verify.rs:100), proptests and a receipt_verify_cbor fuzz target.
13.11 Witness anchoring: default off, and unreachable in this build
Providers. Rekor only, REKOR_PROVIDER_V1 = "rekor" (witness_submit.rs:31); anything else is reported unconfigured by the preflight (witness.rs:133) and skipped by the submit loop (main.rs:1359). RFC 3161 TSA validation is separately implemented in full: verify_rfc3161_timestamp_token_strict_v1 (witness_v1.rs:641) checks the TSTInfo content type, signed attributes, message imprint, policy OID, nonce, CMS signature, the TSA time-stamping EKU, signer validity at genTime, and chain build to an operator-supplied anchor.
Is anything submitted by default? No, and in this build nothing can be, even with the flags on. Three independent reasons:
- Default off.
CORECRUXD_WITNESS_ENABLEDdefaults false (config.rs:993);witness_providerdefaults to the literal"disabled"(config.rs:994);CORECRUXD_TSA_ENABLEDdefaults false (config.rs:1002). - The submit loop needs four more things. It only spawns when the flag is on (main.rs:1329), and each tick (default 300 s via
CORECRUXD_WITNESS_INTERVAL_SECS, main.rs:1335) requires a non-empty pending set (main.rs:1356), thenprovider == "rekor"(main.rs:1359), then a non-emptyCORECRUXD_REKOR_URL(main.rs:1363), then a signer (main.rs:1367), before it will construct aRekorWitness. - Nothing is ever enqueued. The only non-test caller of
WitnessProofStore::enqueueisenqueue_sealed_headinside theforce-sealadmin action (admin.rs:941), which first requiresstate.dataplane_pool, and this build hard-codeslet dataplane_pool: Option<crate::pool::DataPlanePool> = None;(main.rs:565), because "the data-plane pool requires the proprietary edition" (pool.rs:6). The pending queue is therefore always empty and the loop always continues.
Preflight is local-only, and its green light means less than it looks. corecruxctl receipts witness-smoke and GET /v1/witness/smoke (handler at http/witness.rs:19, classified public and unauthenticated at route_auth.rs:88) both report mode: "local_config_only" (witness.rs:99). The module header is explicit that it "deliberately performs local configuration and trust-root checks only" (witness.rs:6). It never opens a socket. With witnessing disabled the report is configured: false, ok: true (witness.rs:106), so a green /v1/witness/smoke in a stock deploy means "witnessing is off and that is fine", not "anchoring works".
Offline witness verification is genuinely rigorous, and it is verification for proofs someone else produced: verify_rfc6962_inclusion_proof_v1 (witness_v1.rs:474), verify_witness_binding_v1 (witness_v1.rs:99), and Rekor checkpoint signatures in both Ed25519 and P-256. Two caveats: verify_witness_binding_v1 returns true when both binding fields are empty (witness_v1.rs:100) for legacy and synthetic proofs, so callers must separately require a non-empty head_hash; and witness_root_endorsed is left None unless the caller supplies a pinned log key, and verify_bundle_v1 passes None (audit_bundle_v1.rs:475).
Finally: anchoring, when enabled, would anchor seal-chain heads (witness_submit.rs:67), a storage-layer artefact, not receipt bodies and not observation-chain heads. No code path anywhere anchors a CROWN receipt hash.
13.12 Inert configuration, and a correction to the shipped threat model
Three environment variables are parsed into Config and then discarded. They matter because the shipped threat-model document cites them.
| Variable | Parsed at | Fate |
|---|---|---|
CORECRUXD_RECEIPTS_KEYRING_PATH | config.rs:991 | discarded at main.rs:376 |
CORECRUXD_RECEIPTS_KEYRING_JSON | config.rs:992 | discarded at main.rs:377 |
CORECRUXD_RECEIPTS_VERIFY_ENABLED | config.rs:985, default on | discarded at main.rs:374 |
All three land in a deliberate discard tuple prefaced by the comment "Keep config fields live on CPU-only builds for future use" (main.rs:349). Setting any of them changes nothing. CORECRUXD_RECEIPTS_VERIFY_ENABLED's only consumer would be DataPlaneStore::verify_receipt_stream_v1, which is unreachable!() in this build (dataplane_store.rs:275).
Correction, recorded here because the source document is shipped and wrong.
docs/THREAT_MODEL.md:51states: "The signing key is loaded fromCORECRUXD_RECEIPTS_KEYRING_PATHorCORECRUXD_RECEIPTS_KEYRING_JSON." That is wrong on two counts. First, the keyring holds public keys only,Ed25519KeyRingV1is{keyId, pubKeyBase64}pairs (keyring_v1.rs:27) and has no private-key field. Second, neither variable is read by any code path in this repository, both are discarded at main.rs:376. The actual CROWN signing key is the node passport key at<data_dir>/passport.key(§13.5). Do not configure receipt signing through those variables; there is nothing behind them.
13.13 What a receipt DOES prove
Given a body, its signature envelope, and a keyring containing the named key_id:
- These exact bytes were signed by the holder of that key. BLAKE3 over the stored bytes compared against the header hash, plus
verify_strict(verify_v1.rs:536), which additionally forecloses signature malleability and torsion-key ambiguity. - The bytes have not changed since signing. Any single-bit mutation breaks the payload hash or the signature (tests.rs:457).
- The signature is bound to this receipt id (verify_v1.rs:318).
- The declared trace fields are present, a structural completeness check, nothing more.
- For the observation chain specifically: intra-file reordering, insertion or a single-record edit is detectable by a party who does not hold the signing key (observations.rs:1033), and there is an offline verifier for it.
- For audit bundles: the member files are the ones the manifest names, verified against a public key pinned inside the signed manifest, offline, with no network and no daemon (audit_bundle_v1.rs:17), and independently reimplemented in Python and run in CI. This is the strongest link in the subsystem.
- A signed
coverage_windowcannot lie about its own arithmetic.verify_coverage_window_body_v1(audit_gap_v1.rs:645) enforcesanchored <= receipts,rwa == receipts - anchored,ewr <= eventsandgaps == ewr + rwa, so a receipt claiming zero gaps while its counts say otherwise fails structural verification. - A compile-time guard prevents silent receipt bypass. mutation_path_receipt_audit.rs enumerates 13 fact-store mutation paths (:56), classifies each as
Receipted(9),KnownGapFollowUp(2) orJustifiedMaintenance(2), fails the build on any unaddressedBypassNeedsReceipt(:166), and parsesFactStorefor everypub fn (&mut self …)so a new mutator cannot escape the table (:280).
13.14 What a receipt DOES NOT prove
This is the definitive limits list, and it is not shortened for any audience.
1. It does not prove the content is true. Every field is asserted by the daemon or by a client the daemon trusts, never independently measured. model_invocation.prompt_hash, output_hash and retrieval_set_hash are strings the client puts in the POST body (stream_receipts.rs:262), and non-emptiness is the only check. context_injected.stable_hash is the same (stream_receipts.rs:212). provider and model are marked "observational" in the struct docs and default to the literal "unknown" (stream_v1.rs:106).
2. It does not prove the model saw the cited memory. There is no signal from the model, no provider acknowledgement, and no proof an assembled bundle was transmitted or attended to. stream_links_injection_v1 (stream_v1.rs:273) matches (session_id, injected_stable_hash) across two receipts; that establishes the daemon's claim that the two events belong together, not causation. The class designed for agent acknowledgement, memory_use, is never minted: build_memory_use_body_v1 has no production caller, and the memory_acknowledge_use MCP tool is default-off behind CORECRUXD_FEATURE_MEMORY_ACK, buffers in-process, and returns a synthetic string "mu_{agent}_{turn}" (memory_use.rs:230) that is not a receipt id and dereferences to nothing.
The strongest honest statement available: a context_injected receipt proves the daemon committed, at the time, to a specific hash of a specific fact set for a specific session, and cannot later change that commitment without re-signing.
3. The operator holding the signing key can forge anything, and nothing here stops it. passport.key is a 0600 hex file readable by the daemon's UID. Rewriting a chain is: edit the JSONL, recompute canonical_body_bytes, re-BLAKE3, re-sign with sign_hash, then fix every downstream prev_hash and seq. validate_chain verifies internal consistency only, a fully recomputed chain passes cleanly.
4. Truncation and whole-chain deletion are undetectable. There is no signed tip, no count and no length commitment; read_chain_tip simply reads the last line. Delete the last N records and the chain still reports Ok. Delete a session file and an entire chain is gone, with nothing enumerating which chains were expected to exist.
5. Backdating is trivial. signed_at is unauthenticated and unchecked, verify_receipt_v1 performs zero temporal validation, and nothing is TSA-stamped by default.
6. There is no external anchoring in the default configuration, or in this build at all. See §13.11. A rewrite is not third-party-detectable.
7. The keyring is unsigned and out-of-band, and the HTTP route self-attests. GET /v1/receipts/{id}/verification builds a one-entry keyring from state.passport_public_key_hex, taking key_id from the stored signature envelope (http/receipts.rs:461). That rules out foreign keys; it does not rule out self-attestation. The verifier is asking the daemon "did you sign this?" and the daemon is answering with its own key.
8. Verification is not canonicalisation, and not schema validation. See §13.1.
9. candidate_digest, the one recomputable trace field, is off by default.
10. Coverage is partial, and "audit gap" has two meanings. audit_gap_v1.rs is a vocabulary module, not a detector; its header says it "provide[s] the stable receipt vocabulary needed by the Audit II remediation plan before daemon routes mint them" (audit_gap_v1.rs:6). The actual detector is coverage_window, and its limits are severe: it scans sealed segments only, so on a stock data dir, where receipts live in observations/*.jsonl; it returns events=0, receipts=0, gaps=0, a green report that measured nothing; events is a residual (everything in-window that is not a receipt frame), so it cannot see an operation that produced no frame; events_without_receipt is population subtraction (corecruxctl/receipts.rs:1088) rather than a per-event join, so it cannot name a missing receipt; it is a manual CLI invocation with no scheduler, route or alert; and receipts_without_anchor equals receipts in every default deployment, because nothing is ever anchored.
11. Erasure and chain integrity are decoupled, and verification passes either way. verify_receipt_v1 hashes only body_bytes, the observation chain links only canonical_body_bytes, and neither pre-image contains erased content. redaction receipts carry prior_content_hash and redacted_content_hash as pointer strings; permanent_purge carries only purged_fact_ids. A receipt chain can be intact and fully verifying over a store from which every fact has been deleted, the chain says nothing about what still exists. Three specific caveats sit under this one:
memory_forgetreceipts are never signed and never persisted (forget.rs:461). The body and its hash are returned in the tool response and then dropped. Only the caller who received that response holds the pre-forget content hashes; discard it and the "proof this content existed" is gone.crypto_shredis explicitly non-destructive (crypto_shred_v1.rs:6): "Production CEK destruction is a separate human-gated operation." There is no CEK registry and no key-destruction code path. The destroy marker records intent and never deletes a key. The envelope also retainsplaintext_hashin clear (crypto_shred_v1.rs:151), which is a brute-forceable oracle against low-entropy plaintexts and must not be described as privacy-preserving without that caveat.- Receipt bodies are immutable by construction and can themselves hold PII. Fact ids, entity names, subject ids, passport ids and free-text
reasonfields (forget_v1.rs:84) cannot be edited without breaking the signature. If PII lands in areasonor anentity, the receipt chain is precisely the thing that makes it un-erasable. The reserved-prefix filter (memory_use_v1.rs:138) mitigates this only for__agent::,__ops::and__bootstrap__::entities.
12. C2PA output is not viewer-validatable. The source states it: "the C2PA Viewer cannot validate our manifests without a published platform trust anchor" (c2pa_manifest_v1.rs:36). Verification is via corecruxctl output-verify or /v1/output/verify only, and the content hash is BLAKE3 rather than the mainline SHA-256.
13.15 The SCITT / COSE export profile
cose_sign1_v1.rs is "deliberately a profile adapter, not a new receipt format" (cose_sign1_v1.rs:8). It maps camelCase daemon names onto the SCITT Application Profile v0.2 kebab-case CDDL labels, wraps the CBOR in a tagged COSE_Sign1 (tag 18), declares application/vnd.crown.receipt+cbor and EdDSA (-8), and signs the RFC 9052 Sig_structure.
It is refreshingly explicit about what it drops rather than fakes: receiptId, evidence, top-level citations and counterfactual, and retrieval.rerankK do not map, and "No replacement values are synthesized" (cose_sign1_v1.rs:12). That is the right behaviour for an interop profile, and it is worth copying.
13.16 Status summary
| Capability | Status |
|---|---|
| Receipt emission | PARTIAL: main classes default OFF. CORECRUXD_STREAM_RECEIPTS=false, CORECRUXD_FEATURE_USAGE_RECEIPTS=false. memory_use never emitted; forget and passport_* unsigned and unpersisted; consolidation best-effort |
| Receipt verification (library) | SHIPPED: verify_receipt_v1 is complete and well tested |
| Receipt verification (HTTP route) | SELF-ATTESTING, the daemon verifies with its own key |
CORECRUXD_RECEIPTS_VERIFY_ENABLED | DECLARED-NOT-WIRED: parsed, then discarded |
CORECRUXD_RECEIPTS_KEYRING_PATH / _JSON | DECLARED-NOT-WIRED: parsed, then discarded |
CORECRUXD_RECEIPTS_RECOMPUTE_CANDIDATE_DIGEST | FLAG, default false |
| Offline verifier | PARTIAL: a genuine independent Python verifier for audit bundles, CI-gated; a cargo run --example for the observation chain; no standalone receipt verifier |
| Witness / Rekor / TSA anchoring | NOT ACTIVE: default off, and unreachable in this build regardless (main.rs:565). Verification machinery is real; submission is not |
| C2PA output | FLAG CORECRUXD_FEATURE_C2PA_OUTPUT, default false, and not validatable by third-party viewers |
| Audit bundle export | SHIPPED, the strongest link in the subsystem |
| Global receipt hash chain | Does not exist, see §13.3 |
chain_signature_reanchor | DECLARED-NOT-WIRED |
| Key revocation or validity windows | Not present: the keyring is unsigned, append-only, with no key state |
Sources
- crates/corecrux-receipts/src/lib.rs:10, the bytes-first invariants
- crates/corecrux-receipts/src/verify_v1.rs:187,
verify_receipt_v1 - crates/corecrux-receipts/src/verify_v1.rs:536, the
verify_strictcall - crates/corecrux-receipts/src/keyring_v1.rs:27,
Ed25519KeyRingV1, public keys only - crates/corecruxd/src/http/observations.rs:228,
ObservationRecordV1 - crates/corecruxd/src/http/observations.rs:1033,
validate_chain - crates/corecruxd/src/http/receipts.rs:461, the self-attesting verification route
- crates/corecruxd/src/main.rs:376, the keyring-variable discard
- crates/corecruxd/src/main.rs:565,
dataplane_pool = None - docs/THREAT_MODEL.md:51, the incorrect signing-key statement corrected in §13.12
- scripts/demo-receipt-tamper.sh:168, the script's own statement of what it proves
- crates/corecrux-receipts/src/tests.rs:457, the receipt-level tamper test
- tools/verify_audit_bundle_v1.py:438, the independent Python verifier

