first commit
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user