7.3 KiB
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 completeHashChainEntryverifyEntry(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 failurepackEntry(entry)/unpackEntry(bytes)— wire-format codec for storage / network transporthashEntry(entry)— compute the entry's digest, used as the next entry'sprevHash- Pluggable hash algorithm via
HashAlgorithmRegistry(defaultsha25632 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-appendandbitpacker-crypto.hashchain-verify
Wire Format
┌────────────┬────────────────────┬──────────────────────┬──────────────────┐
│ 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
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<AuditPayload>([
{ name: "actorId", type: "uint", bits: 32 },
{ name: "eventCode", type: "uint", bits: 16 },
]);
const audit = defineHashChainSchema<AuditPayload>({
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
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<TPayload>
seq: bigint— non-negative sequence number, packed as u64 BEprevHash: Uint8Array— exactlyhashLengthbytespayload: TPayload— caller-typed bodyhostSignature: Uint8Array— exactlysignatureLengthbytes
interface HashChainSchemaOptions<TPayload>
domain: DomainTag— requiredverifier: AuthorityVerifier— required (typicallyverifyAuthoritySignaturefrom@suaveplan/crypto/authority)slot?: SlotId = "sign"— slot used by the host authoritypayloadCodec: BodyCodec<TPayload>— requiredhashAlgorithm?: string = "sha256"— must be registeredhashRegistry?: HashAlgorithmRegistry— defaults to singletonsignatureLength?: number = 64
defineHashChainSchema<TPayload>(options): HashChainSchema<TPayload>
Returns:
hashEntry(entry): Promise<Uint8Array>—outputBytes-byte digestappendEntry(seq, prevHash, payload, authority, signal?): Promise<HashChainEntry<TPayload>>- throws
VALUE_OUT_OF_RANGEif seq is negative or > 2^64-1 - throws
CRYPTO_KEY_INVALIDif prevHash length ≠ hashLength - throws
CRYPTO_SIGN_FAILEDif signature length ≠ signatureLength
- throws
verifyEntry(entry, expectedPrevHash, hostPublicSlot): Promise<boolean>verifyChain(entries, hostPublicSlot): Promise<ChainVerifyResult>packEntry(entry): Uint8ArrayunpackEntry(bytes): HashChainEntry<TPayload>- throws
CRYPTO_VERIFY_FAILEDif bytes too short
- throws
Implementation Notes
- The suaveplan entry's
prevHashis caller-supplied — typically all-zero bytes, but any deterministic value works as long as both producers and consumers agree on it.verifyChaintreats the first entry'sprevHashas the trust anchor (it is not derived from anything). - Tamper detection is positional:
firstBadIndexis 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 iterateverifyEntrydirectly. - 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.
appendEntrydoes NOT compute the newprevHash— it only appends. Callers are responsible for callinghashEntry(prev)between appends. This separation allows callers to batch-append from a known starting hash without forcing every append to be sequential.verifyChainrebuilds theexpectedPrevfor indexi ≥ 1by hashingentries[i-1]. The suaveplan entry'sprevHashis taken as the trust anchor; if it does not match what the producer supplied, every chain built atop it will fail atfirstBadIndex: 0.