first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
# 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`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<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
```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<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`.