Skip to content
Docs/CITADEL Governance/WORM Chain & TripleHash
Edit on GitHub

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

worm_entries DDL
Bash
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.

FieldPurpose
idCaller-facing UUID — returned as worm_entry_id to IRFlow etc. Primary key but not used in chain math.
sequence_numMonotonic ordinal; the chain walks entries in sequence_num ASC order.
ts_utcWhen CITADEL accepted the entry — not when the caller built the payload.
sourceSubsystem or platform that emitted this entry, e.g. citadel.marshal, irflow.incident.
event_typeSpecific event taxonomy, e.g. marshal.decision, incident.created. Enables per-type queries without payload parsing.
project_idLogical partition for auditor queries.
payloadRaw bytes the caller sent. Never canonicalised, never re-serialised.
triple_hashContent-addressable composite digest — 256 hex characters.
chain_hashTamper-evident link to all prior entries.
prev_hashThe 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 hash
Bash
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:

TripleHash layout (128 bytes)
Bash
0               32                              96         128
      ├──── SHA-256 ───┼──────── SHA-512 ────────────────┼── BLAKE3 ─┤
      │   32 bytes     │          64 bytes               │  32 bytes │

"cm"># On-disk form: hex-encoded → 256 hex characters
internal/db/worm.go — TripleHash
Go
h256 := 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:

HashFamilyDesign pedigree
SHA-256Merkle–DamgårdNIST FIPS 180-4, widely scrutinised since 2001. Mandatory for regulatory acceptance.
SHA-512Merkle–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.
BLAKE3Sponge / 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:

CandidateReason excluded
SHA-256 + SHA-3-256 + BLAKE3SHA-3 is excellent, but BLAKE3 is faster and has wider library support in Go.
SHA-256 + SHA-512 + Keccak-256Keccak has hardware adoption bias; keeps the ecosystem tied to Ethereum-style tooling.
SHA-512/256 + BLAKE3 + Argon2Argon2 is a KDF, not a hash — including it confuses the semantics.
Note: TripleHash is not a MAC — it uses no secret key. Two parties with the same payload compute the same digest. Authentication comes from the Ed25519 chain anchor, not from TripleHash itself.

Per-entry chain linkage

Each entry's chain_hash is computed as:

Chain-hash formula
Bash
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:

WORM append (pseudocode)
Bash
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.

Anchor flow
Bash
"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, ...)
Warning: Without a valid 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

chain_anchors DDL
Bash
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:

Verification response (success)
Bash
{
  "valid":            true,
  "entries_verified": 12847,
  "anchor_verified":  true
}

Verification steps, per entry in [from, to] by sequence_num ASC:

  1. Recompute TripleHash. If TripleHash(payload) != stored triple_hash, the payload bytes changed. Return valid: false.
  2. Recompute chain_hash. If SHA-256(prev_hash ‖ payload) != stored chain_hash, the chain linkage was tampered. Return valid: false.
  3. Continuity. prev_hash(i) == chain_hash(i-1) for i > 1. Otherwise an entry was spliced into a chain it does not belong to.
Verification response (failure)
Bash
{
  "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:

Anchor verification
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):

Key rotation
Bash
"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.
Warning: An anchor private-key compromise is the only attack that wholly defeats the chain. Best practice: store the master key in an HSM with a PKCS#11 adapter. CITADEL v2.0 roadmap includes KMIP/PKCS#11 support so the private key never leaves the HSM.
Adversary capabilityCovered byRecovery
DB-level read attackerTripleHash — payload changes are detectedRe-issue anchor from the good state; restore from backup
DB-level write — single payloadchain_hash — break propagates to all later entriesVerify chain; break_at pinpoints the forgery
DB-level write — entire chain rewrittenEd25519 anchor — signature won't verify on the forged chain_hashPublic key doesn't verify; chain is void
Attacker with the anchor private keyNot coveredRotate 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.

OperationResultNotes
SHA-256 only (100-byte payload)~0.35 µs
SHA-512 only (100-byte payload)~0.55 µsDominates TripleHash time
BLAKE3 only (100-byte payload)~0.40 µs
Hex encode (128 bytes)~0.22 µs
TripleHash total (100-byte payload)1.52 µsSHA-256 + SHA-512 + BLAKE3 in parallel
WORM chain step (hash linkage only, no DB)427 ns, 0 allocationsSHA-256 linkage of prev_hash + payload
WORM append (PostgreSQL 16, synchronous)4.22 msIncludes immutability trigger and fsync
Chain verification (1,000 entries)10.19 msFull TripleHash recomputation per entry
Ed25519 sign (one anchor)~50–80 µsAmortised 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.

SIN — Security Intelligence Network — Open-source cybersecurity & compliance for the EU Digital Decade.

Apache-2.0 & AGPL-3.0 · GitHub · Contribute