# HashChainSchema Append-only signed hash chain. Each entry carries a sequence number, the hash of the previous entry, an opaque payload, and a host signature over the bit-packed `(seq, prevHash, payload)` triplet. Tampering anywhere in the chain is detected at the first bad index by `verifyChain`. _(no submodules)_ ## Purpose Audit logs, replicated mesh state, and any "I cannot edit history, only append to it" scenario need a chain shape: each new entry binds to the previous via a hash, and the host signs the binding so a third party can verify the chain wasn't forged. suaveplan has the building blocks — `Authority`, `DomainTag`, `HashAlgorithmRegistry`, `BodyCodec` — but no primitive that composes them into a chain. `HashChainSchema` is that primitive. The signing input is `(seq, prevHash, payload)`, NOT just `payload`. This is load-bearing: a signature on a payload alone could be lifted into a different position in the chain (different seq) or grafted onto a different prev-link without invalidating the signature. By binding seq + prev-hash into the signed bytes, the signature is non-portable across positions. ## Features - `appendEntry(seq, prevHash, payload, authority)` — sign the triplet, return a complete `HashChainEntry` - `verifyEntry(entry, expectedPrevHash, hostPublicSlot)` — boolean result, never throws on bad signature (callers may probe candidate parents) - `verifyChain(entries, hostPublicSlot)` — walks the chain in order; returns `{valid: false, firstBadIndex}` on the first failure - `packEntry(entry)` / `unpackEntry(bytes)` — wire-format codec for storage / network transport - `hashEntry(entry)` — compute the entry's digest, used as the next entry's `prevHash` - Pluggable hash algorithm via `HashAlgorithmRegistry` (default `sha256` 32 bytes) - Strict prev-hash length check at append time (must match algorithm's `outputBytes`) — catches schema/algorithm mismatches before they reach the wire - AbortSignal threaded through `Authority.sign` - OTel spans `bitpacker-crypto.hashchain-append` and `bitpacker-crypto.hashchain-verify` ## Wire Format ```text ┌────────────┬────────────────────┬──────────────────────┬──────────────────┐ │ seq u64BE │ prevHash N bytes │ payload bit-packed │ signature 64 B │ └────────────┴────────────────────┴──────────────────────┴──────────────────┘ ``` `N` is the chosen hash algorithm's `outputBytes` (32 for sha256). The signature is 64 bytes (ed25519); other lengths are configurable via `signatureLength`. ## Usage: Basic Example ```ts import { defineHashChainSchema } from "@suaveplan/bitpacker-crypto"; import { defineBitSchema } from "@suaveplan/bitpacker"; import { DOMAIN_BITPACKER_HASHCHAIN_V1 } from "@suaveplan/crypto/domain-tags"; import { verifyAuthoritySignature } from "@suaveplan/crypto/authority"; interface AuditPayload { actorId: number; eventCode: number } const auditSchema = defineBitSchema([ { name: "actorId", type: "uint", bits: 32 }, { name: "eventCode", type: "uint", bits: 16 }, ]); const audit = defineHashChainSchema({ domain: DOMAIN_BITPACKER_HASHCHAIN_V1, verifier: verifyAuthoritySignature, payloadCodec: { pack: (b) => auditSchema.pack(b), unpack: (bytes) => auditSchema.unpack(bytes), }, }); let prevHash = new Uint8Array(audit.hashLength); // suaveplan sentinel const e0 = await audit.appendEntry(0n, prevHash, { actorId: 1, eventCode: 100 }, auth); prevHash = await audit.hashEntry(e0); const e1 = await audit.appendEntry(1n, prevHash, { actorId: 1, eventCode: 200 }, auth); const result = await audit.verifyChain([e0, e1], hostPublicSlot); // → { valid: true } ``` ## Usage: Advanced Example — BLAKE3-backed chain ```ts import { type HashAlgorithm, createHashRegistry, defineHashChainSchema, } from "@suaveplan/bitpacker-crypto"; import { blake3 as nobleBlake3 } from "@noble/hashes/blake3"; const registry = createHashRegistry(); registry.register({ name: "blake3", outputBytes: 32, hash: (data) => Promise.resolve(nobleBlake3(data)), }); const chain = defineHashChainSchema({ domain: DOMAIN_BITPACKER_HASHCHAIN_V1, payloadCodec, hashAlgorithm: "blake3", hashRegistry: registry, }); ``` ## API Reference ### `interface HashChainEntry` - `seq: bigint` — non-negative sequence number, packed as u64 BE - `prevHash: Uint8Array` — exactly `hashLength` bytes - `payload: TPayload` — caller-typed body - `hostSignature: Uint8Array` — exactly `signatureLength` bytes ### `interface HashChainSchemaOptions` - `domain: DomainTag` — required - `verifier: AuthorityVerifier` — required (typically `verifyAuthoritySignature` from `@suaveplan/crypto/authority`) - `slot?: SlotId = "sign"` — slot used by the host authority - `payloadCodec: BodyCodec` — required - `hashAlgorithm?: string = "sha256"` — must be registered - `hashRegistry?: HashAlgorithmRegistry` — defaults to singleton - `signatureLength?: number = 64` ### `defineHashChainSchema(options): HashChainSchema` Returns: - `hashEntry(entry): Promise` — `outputBytes`-byte digest - `appendEntry(seq, prevHash, payload, authority, signal?): Promise>` - throws `VALUE_OUT_OF_RANGE` if seq is negative or > 2^64-1 - throws `CRYPTO_KEY_INVALID` if prevHash length ≠ hashLength - throws `CRYPTO_SIGN_FAILED` if signature length ≠ signatureLength - `verifyEntry(entry, expectedPrevHash, hostPublicSlot): Promise` - `verifyChain(entries, hostPublicSlot): Promise` - `packEntry(entry): Uint8Array` - `unpackEntry(bytes): HashChainEntry` - throws `CRYPTO_VERIFY_FAILED` if bytes too short ## Implementation Notes - The suaveplan entry's `prevHash` is caller-supplied — typically all-zero bytes, but any deterministic value works as long as both producers and consumers agree on it. `verifyChain` treats the first entry's `prevHash` as the trust anchor (it is not derived from anything). - Tamper detection is positional: `firstBadIndex` is the LOWEST index at which either the prev-hash linkage or the signature failed. A chain with multiple tamper points reports only the first; a strict caller who needs to know every bad index should iterate `verifyEntry` directly. - The hash algorithm is resolved once at construction. Swapping the registry's algorithm later does not affect already-built schemas. This is deliberate: the chain's wire format is fixed at construction. - `appendEntry` does NOT compute the new `prevHash` — it only appends. Callers are responsible for calling `hashEntry(prev)` between appends. This separation allows callers to batch-append from a known starting hash without forcing every append to be sequential. - `verifyChain` rebuilds the `expectedPrev` for index `i ≥ 1` by hashing `entries[i-1]`. The suaveplan entry's `prevHash` is taken as the trust anchor; if it does not match what the producer supplied, every chain built atop it will fail at `firstBadIndex: 0`.