WORM Chain & TripleHash
Every MARSHAL decision and every cross-platform governance event is recorded in CITADEL's WORM (Write-Once, Read-Many) audit chain — an append-only PostgreSQL table where no entry is ever mutable. Integrity is provable by recomputing hashes from the raw payload bytes: each entry carries a 128-byte TripleHash (SHA-256 + SHA-512 + BLAKE3) and a SHA-256 chain linkage that binds it to every preceding entry. Ed25519 chain anchors seal blocks of 100 entries, turning the log from tamper-evident into tamper-resistant.
Overview
Three layers of cryptographic protection stack on top of each other:
- TripleHash — per-entry content-addressable digest computed from three independent algorithms simultaneously. A forged payload must collide against all three.
- Chain hash — SHA-256 of (
prev_chain_hash ‖ payload_bytes) links every entry to all prior entries. Editing any byte of any past entry forces every subsequent chain_hash to change. - Ed25519 anchor — a cryptographic signature over the current chain_hash emitted every 100 entries. An attacker who rewrites the chain must also forge the anchor signature, which requires the private key.
Table structure
CREATE TABLE worm_entries (
id UUID PRIMARY KEY,
sequence_num BIGINT NOT NULL UNIQUE,
ts_utc TIMESTAMPTZ NOT NULL,
source TEXT NOT NULL, -- e.g. "citadel.marshal", "irflow.incident"
event_type TEXT NOT NULL, -- e.g. "marshal.decision", "incident.created"
project_id TEXT NOT NULL,
payload BYTEA NOT NULL, -- raw JSON, byte-for-byte
triple_hash TEXT NOT NULL, -- 256 hex chars
chain_hash TEXT NOT NULL, -- 64 hex chars
prev_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);sequence_num is monotonic from 1 upward. The table has a PostgreSQL immutability trigger that rejects any UPDATE or DELETE statement — append-only is enforced at the database level, not only by the application.
| Field | Purpose |
|---|---|
id | Caller-facing UUID — returned as worm_entry_id to IRFlow etc. Primary key but not used in chain math. |
sequence_num | Monotonic ordinal; the chain walks entries in sequence_num ASC order. |
ts_utc | When CITADEL accepted the entry — not when the caller built the payload. |
source | Subsystem or platform that emitted this entry, e.g. citadel.marshal, irflow.incident. |
event_type | Specific event taxonomy, e.g. marshal.decision, incident.created. Enables per-type queries without payload parsing. |
project_id | Logical partition for auditor queries. |
payload | Raw bytes the caller sent. Never canonicalised, never re-serialised. |
triple_hash | Content-addressable composite digest — 256 hex characters. |
chain_hash | Tamper-evident link to all prior entries. |
prev_hash | The previous entry's chain_hash, or the genesis hash for entry 1. |
Genesis hash
The chain starts at sequence_num = 1. Its prev_hash is the fixed genesis hash:
genesis = SHA-256("CITADEL-GENESIS-SIN-v1")
= f0c8e9... (64 hex chars)Auditors can independently compute this value. Changing the genesis constant would change every downstream hash in the chain, so the constant is implicitly covered by any chain verification pass.
TripleHash construction
TripleHash produces a 128-byte composite digest for every WORM entry. The three algorithms run over the identical payload bytes and their outputs are concatenated in a fixed order:
0 32 96 128
├──── SHA-256 ───┼──────── SHA-512 ────────────────┼── BLAKE3 ─┤
│ 32 bytes │ 64 bytes │ 32 bytes │
"cm"># On-disk form: hex-encoded → 256 hex charactersh256 := sha256.Sum256(payload) "cm">// 32 bytes h512 := sha512.Sum512(payload) "cm">// 64 bytes hB3 := blake3.Sum256(payload) "cm">// 32 bytes composite := append(h256[:], append(h512[:], hB3[:]...)...) return hex.EncodeToString(composite) "cm">// 256 hex chars
The order is fixed: SHA-256, then SHA-512, then BLAKE3. Any other ordering yields a different digest and fails verification.
Why three hashes?
Cryptographic hashes fail in three fundamentally different ways: collision attacks, preimage attacks, and algorithmic breaks (as happened to MD5 and SHA-1). TripleHash uses three algorithms with independent design histories:
| Hash | Family | Design pedigree |
|---|---|---|
| SHA-256 | Merkle–Damgård | NIST FIPS 180-4, widely scrutinised since 2001. Mandatory for regulatory acceptance. |
| SHA-512 | Merkle–Damgård (wider word size) | Same family as SHA-256 but different internal word size — a break in SHA-256 does not automatically break SHA-512. |
| BLAKE3 | Sponge / Merkle-tree (ChaCha permutation) | Completely different internal structure; resistant to length-extension attacks that SHA-2 tolerates. Faster than SHA-256 on modern hardware. |
An attacker who wants to substitute a forged payload must find a triple where all three independent digests collide simultaneously — the difficulty multiplies, not adds. A hypothetical collision in SHA-256 gives no leverage against SHA-512 or BLAKE3 on the same payload.
Other triples were considered and rejected:
| Candidate | Reason excluded |
|---|---|
| SHA-256 + SHA-3-256 + BLAKE3 | SHA-3 is excellent, but BLAKE3 is faster and has wider library support in Go. |
| SHA-256 + SHA-512 + Keccak-256 | Keccak has hardware adoption bias; keeps the ecosystem tied to Ethereum-style tooling. |
| SHA-512/256 + BLAKE3 + Argon2 | Argon2 is a KDF, not a hash — including it confuses the semantics. |
Per-entry chain linkage
Each entry's chain_hash is computed as:
chain_hash(i) = SHA-256( bytes(prev_hash(i)) || bytes(payload(i)) ) "cm"># where: "cm"># bytes(prev_hash(i)) = decoded 32-byte value of the previous chain_hash "cm"># (or genesis bytes for entry 1) "cm"># bytes(payload(i)) = raw JSON byte stream, byte-for-byte as stored "cm"># || = byte concatenation
The key invariant: changing any byte of any earlier payload changes every subsequent chain_hash. An attacker who wants to retroactively edit entry N must re-hash N, N+1, N+2, … and also produce a valid Ed25519 anchor over the range that covers them. The anchor private key is what makes this impossible without a key compromise.
Append operation
WORM appends run inside an exclusive table lock to guarantee strict single-writer ordering — concurrent appenders would produce divergent chains that cannot reconcile:
BEGIN;
LOCK TABLE worm_entries IN EXCLUSIVE MODE;
-- get previous tail
SELECT sequence_num, chain_hash
FROM worm_entries
ORDER BY sequence_num DESC
LIMIT 1;
-- if empty: seq = 0, prev_hash = genesisHash()
seq := prev.seq + 1
th := TripleHash(payload) -- 256 hex chars
ch := SHA-256(prev.chain_hash || payload) -- 64 hex chars
INSERT INTO worm_entries (
id, sequence_num, ts_utc, source, event_type, project_id,
payload, triple_hash, chain_hash, prev_hash, created_at
) VALUES (...);
COMMIT;Throughput scales vertically (faster disk, faster fsync) rather than horizontally. The v2.0 roadmap includes sharded chains per project_id as the path to horizontal scale.
Ed25519 chain anchors
TripleHash + chain_hash linkage makes the WORM log tamper-evident: any change forces a cascade of mismatching hashes that a verification pass catches. However, an attacker who can also rewrite the chain_hash values would need more work but could potentially re-validate the tail against itself.
Chain anchors close that gap. At a configurable interval (default: every 100 entries), CITADEL computes an Ed25519 signature over the latest chain_hash plus the sequence_num and ts_utc, then persists it to the chain_anchors table. An auditor holding the public key can verify the anchor independently — an attacker who does not have the private key cannot forge the signature no matter how many chain_hashes they rewrite, making the log tamper-resistant.
"cm"># Every CITADEL_ANCHOR_INTERVAL entries (default: 100): anchor_payload = sequence_num || ts_utc || chain_hash "cm"># pipe-delimited string signature = Ed25519.Sign( master_private_key, SHA-512(anchor_payload) ) INSERT INTO chain_anchors (sequence_num, ts_utc, chain_hash, signature, pubkey_id, ...)
CITADEL_CITADEL_MASTER_KEY configured, anchor emission is disabled. The WORM chain remains tamper-evident but not tamper-resistant. Do not run production CITADEL without an Ed25519 master key.Anchor storage
CREATE TABLE chain_anchors (
id UUID PRIMARY KEY,
sequence_num BIGINT NOT NULL REFERENCES worm_entries(sequence_num),
ts_utc TIMESTAMPTZ NOT NULL,
chain_hash TEXT NOT NULL, -- chain_hash at anchor time
signature BYTEA NOT NULL, -- 64-byte Ed25519 signature
pubkey_id TEXT NOT NULL, -- identifies the key that signed
created_at TIMESTAMPTZ NOT NULL
);Anchors reference the exact sequence_num and chain_hash they cover. An auditor queries the anchor they trust, reads its (sequence_num, chain_hash), then runs chain verification up to that point.
Chain verification
GET /api/v1/worm/verify?from=...&to=... runs over the requested time range and returns:
{
"valid": true,
"entries_verified": 12847,
"anchor_verified": true
}Verification steps, per entry in [from, to] by sequence_num ASC:
- Recompute TripleHash. If
TripleHash(payload) != stored triple_hash, the payload bytes changed. Returnvalid: false. - Recompute chain_hash. If
SHA-256(prev_hash ‖ payload) != stored chain_hash, the chain linkage was tampered. Returnvalid: false. - Continuity.
prev_hash(i) == chain_hash(i-1)fori > 1. Otherwise an entry was spliced into a chain it does not belong to.
{
"valid": false,
"entries_verified": 12846,
"break_at": "sequence_num=12847: chain_hash mismatch",
"anchor_verified": false
}break_at points at the first broken entry, which is almost always the first entry an attacker tried to forge.
To verify an anchor signature in Go:
pubKey := LoadTrustedPubKey("citadel-anchor-2026") anchor := GetAnchor(sequenceNum) payload := fmt.Sprintf("%d|%s|%s", anchor.SequenceNum, anchor.TsUTC.Format(time.RFC3339), anchor.ChainHash, ) digest := sha512.Sum512([]byte(payload)) if !ed25519.Verify(pubKey, digest[:], anchor.Signature) { "cm">// anchor is forged, or the private key was rotated after this anchor }
For a tamper-resistant end-to-end check: pick a trusted anchor A; run linear chain verification from the previous anchor (or genesis) up to A.sequence_num; confirm the chain_hash you reach equals A.chain_hash; then verify A.signature with the matching pubkey. Any forged entry in the window invalidates the chain_hash, which invalidates the anchor signature.
After any disaster recovery — even from the coldest backup — the restored log can be cryptographically verified against the original chain anchors. Tampered recovery is detectable.
Key management
The Ed25519 master key is the single most sensitive secret in the ecosystem. Losing it means all past anchors remain valid (their signatures verify against the public key), but no new anchors can be signed until a new key is provisioned.
Key rotation procedure (manual in v1.0.0; automation planned for v1.1):
"cm"># 1. Generate a new Ed25519 keypair openssl genpkey -algorithm ed25519 -out citadel-anchor-2027Q2.pem "cm"># 2. Publish the new pubkey under a new pubkey_id in your key bundle "cm"># e.g. citadel-anchor-2027Q2 "cm"># 3. Update CITADEL_CITADEL_MASTER_KEY in your secret manager "cm"># 4. Roll the CITADEL deployment (zero-downtime rolling update) "cm"># DO NOT delete the old pubkey — every anchor signed with it remains "cm"># valid and auditors need it for the entire retention period.
| Adversary capability | Covered by | Recovery |
|---|---|---|
| DB-level read attacker | TripleHash — payload changes are detected | Re-issue anchor from the good state; restore from backup |
| DB-level write — single payload | chain_hash — break propagates to all later entries | Verify chain; break_at pinpoints the forgery |
| DB-level write — entire chain rewritten | Ed25519 anchor — signature won't verify on the forged chain_hash | Public key doesn't verify; chain is void |
| Attacker with the anchor private key | Not covered | Rotate key, publish both old and new pubkeys; audit the interval of exposure |
What WORM does not do
- No deletion. Even under GDPR right-to-be-forgotten, PII must not enter the payload in the first place — there is no erasure path. Pseudonymise at the caller layer before constructing the Kerkese or WORM payload.
- No compaction. Entries accumulate forever. For a 10-year horizon at 1 MiB/day, expect roughly 3.7 TB. Archival tiers for entries older than one year are planned for v2.0.
- No multi-writer. Two CITADEL primaries appending to the same chain produce divergent tails that cannot merge. The exclusive table lock is deliberate and non-negotiable.
- No conditional append. Every emission unconditionally appends. Decisions about whether an action should happen are made upstream in MARSHAL.
Benchmarks
Measured on Go 1.24.4, Intel Core i7-7600U.
| Operation | Result | Notes |
|---|---|---|
| SHA-256 only (100-byte payload) | ~0.35 µs | |
| SHA-512 only (100-byte payload) | ~0.55 µs | Dominates TripleHash time |
| BLAKE3 only (100-byte payload) | ~0.40 µs | |
| Hex encode (128 bytes) | ~0.22 µs | |
| TripleHash total (100-byte payload) | 1.52 µs | SHA-256 + SHA-512 + BLAKE3 in parallel |
| WORM chain step (hash linkage only, no DB) | 427 ns, 0 allocations | SHA-256 linkage of prev_hash + payload |
| WORM append (PostgreSQL 16, synchronous) | 4.22 ms | Includes immutability trigger and fsync |
| Chain verification (1,000 entries) | 10.19 ms | Full TripleHash recomputation per entry |
| Ed25519 sign (one anchor) | ~50–80 µs | Amortised over 100 entries: <1 µs per entry |
The WORM append at 4.22 ms dominates every real-world call — it is ~2,800× slower than TripleHash itself. The SHA-512 component dominates TripleHash time, but moving to a SIMD-accelerated library would have no meaningful impact against the surrounding DB append cost.