Files
skills/docs/foundation/bitpacker/bitpacker-crypto/src/hashchain/hashchain-schema.md
2026-05-29 08:20:56 +01:00

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 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

┌────────────┬────────────────────┬──────────────────────┬──────────────────┐
│  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 BE
  • prevHash: Uint8Array — exactly hashLength bytes
  • payload: TPayload — caller-typed body
  • hostSignature: Uint8Array — exactly signatureLength bytes

interface HashChainSchemaOptions<TPayload>

  • domain: DomainTag — required
  • verifier: AuthorityVerifier — required (typically verifyAuthoritySignature from @suaveplan/crypto/authority)
  • slot?: SlotId = "sign" — slot used by the host authority
  • payloadCodec: BodyCodec<TPayload> — required
  • hashAlgorithm?: string = "sha256" — must be registered
  • hashRegistry?: HashAlgorithmRegistry — defaults to singleton
  • signatureLength?: number = 64

defineHashChainSchema<TPayload>(options): HashChainSchema<TPayload>

Returns:

  • hashEntry(entry): Promise<Uint8Array> — outputBytes-byte digest
  • appendEntry(seq, prevHash, payload, authority, signal?): Promise<HashChainEntry<TPayload>>
    • 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<boolean>
  • verifyChain(entries, hostPublicSlot): Promise<ChainVerifyResult>
  • packEntry(entry): Uint8Array
  • unpackEntry(bytes): HashChainEntry<TPayload>
    • 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.