Files
suaveplan/openspec/specs/bitpacker-crypto/spec.md
2026-05-27 23:29:53 +01:00

159 lines
12 KiB
Markdown

# Capability Spec: bitpacker-crypto
**Package**: `@suaveplan/bitpacker-crypto`
> Canonical capability spec assembled from the archived `bitpacker-crypto-rewrite`
> (slim, 2026-05-02) and the just-completed `extract-shared-crypto-interfaces`
> (2026-05-03). The slim archive added `SchemaFingerprint`,
> `HashAlgorithmRegistry`, and the determinism gate; the rewrite shipped
> additionally `EnvelopeSchema`, `SignedBitSchema`, `MultiSignedBitSchema`,
> `HashChainSchema`, and `EncryptedBitSchema`. The DI-using primitives
> accept `verifier: AuthorityVerifier` (or `aead: AeadOps`) so this package
> has no runtime dependency on `@suaveplan/crypto` — the canonical
> implementations are wired by the caller.
## Purpose
`@suaveplan/bitpacker-crypto` provides bit-packer-aware crypto primitives that consolidate patterns several genesis surfaces had been re-authoring inline (sign-then-pack-then-verify, encrypt-pack-frame, schema-versioned envelopes, append-only signed chains, schema fingerprints). The package owns the wire-format definitions and the structural primitive shapes. The canonical cryptographic implementations (signing, verification, AEAD) live in `@suaveplan/crypto` and are dependency-injected by the caller via `AuthorityVerifier` / `AeadOps`, so this package has no runtime dependency on `@suaveplan/crypto` and any layer (including `@suaveplan/crypto::ChainBundle`) can adopt these primitives without inducing a circular package dependency.
## Requirements
### Requirement: SchemaFingerprint canonical hash of a BitPackerSchema
The package SHALL expose `schemaFingerprint(schema, algorithm?, registry?) → Promise<Uint8Array>` that returns the chosen hash of a canonical serialization of the schema. Canonicalization SHALL sort object keys, omit whitespace, and produce identical bytes for any two schemas that describe the same wire layout regardless of property declaration order. Default `algorithm` SHALL be `"sha256"`. Default `registry` SHALL be the process-wide `defaultHashRegistry()` singleton. Unknown algorithm names SHALL throw `BitpackerError` with code `CRYPTO_HASH_ALGORITHM_UNKNOWN` and `context.algorithm` naming the missing algorithm. The package SHALL also expose `assertSchemaFingerprint(schema, expected, algorithm?, registry?) → Promise<void>` that throws `BitpackerError` with code `CRYPTO_SCHEMA_FINGERPRINT_MISMATCH` on disagreement.
#### Scenario: equal schemas in different declaration orders fingerprint identically
- **WHEN** schema A is `{ a: 255, b: 65535 }` and schema B is `{ b: 65535, a: 255 }`
- **THEN** `schemaFingerprint(A)` deep-equals `schemaFingerprint(B)`
#### Scenario: unknown algorithm rejected
- **WHEN** `schemaFingerprint(schema, "magic-hash")` is called and `magic-hash` is not registered
- **THEN** the call throws `BitpackerError` with code `CRYPTO_HASH_ALGORITHM_UNKNOWN`
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/fingerprint/schema-fingerprint.test.ts`
### Requirement: HashAlgorithmRegistry as pluggable extension point
The package SHALL expose a `HashAlgorithmRegistry` interface with `register(alg)`, `resolve(name)`, `has(name)`, and `list()` methods, a `HashAlgorithm` interface with `{ name, outputBytes, hash(data) }`, a `defaultHashRegistry()` returning a process-wide singleton, and a `createHashRegistry()` returning a fresh registry. The default registry SHALL ship `xxhash32`, `crc32`, and `sha256`. Re-register replaces (last-write-wins) and emits OTel span `bitpacker-crypto.hash-registry.replaced`.
#### Scenario: defaults are present out of the box
- **WHEN** `defaultHashRegistry()` is called and `has("sha256")`, `has("xxhash32")`, `has("crc32")` are checked
- **THEN** all three return `true`
#### Scenario: createHashRegistry isolates from singleton
- **WHEN** `createHashRegistry()` returns a fresh registry and a custom algorithm is registered into it
- **THEN** the singleton's `list()` does NOT include the custom algorithm
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/hash-registry/hash-registry.test.ts`
### Requirement: EnvelopeSchema is generic over body codec
The package SHALL expose `defineEnvelopeSchema<TBody>(options)` returning `{ pack(body), parse(bytes) → {magic, formatVersion, bodyBytes}, parseStrict(bytes) → TBody }`. Wire format SHALL be `[magic (n bytes)][formatVersion (u8)][bodyCodec.pack(body)]`. The body region SHALL be encoded via a caller-supplied `BodyCodec<TBody>` (`pack` + `unpack`); `parse` SHALL return raw body bytes without decoding; `parseStrict` SHALL validate magic + version against the schema and throw `BitpackerError` with code `CRYPTO_ENVELOPE_MAGIC_MISMATCH` or `CRYPTO_ENVELOPE_VERSION_MISMATCH` on mismatch.
#### Scenario: round-trip preserves bytes through structured body
- **WHEN** `defineEnvelopeSchema({ magic, formatVersion: 1, bodyCodec }).pack(body)` runs and the result is `parseStrict`-ed
- **THEN** the parsed body deep-equals the original
#### Scenario: parse can read v2 envelopes for forward-compat dispatch
- **WHEN** v2 bytes are passed through a v1 envelope's `parse` (not `parseStrict`)
- **THEN** the call returns `{ magic, formatVersion: 2, bodyBytes }` so a caller can dispatch to a v2 decoder
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/envelope/envelope-schema.test.ts`
### Requirement: SignedBitSchema integrates with Authority + DomainTag (verifier injected)
The package SHALL expose `defineSignedBitSchema<T>(bodyCodec, options)` returning `{ pack(data, authority, signal?), unpack(bytes, parentPublicSlot), verify(bytes, parentPublicSlot), unpackSelf(bytes, getEmbeddedSlot) }`. The options bundle SHALL require `domain: DomainTag` and `verifier: AuthorityVerifier` (typically `verifyAuthoritySignature` from `@suaveplan/crypto/authority`). `pack` SHALL bit-pack the value, then call `authority.sign(domain, slot, packed)` to produce the signature, and frame `[packed][signature]`. `unpack` SHALL verify the signature using `verifier(domain, parentPublicSlot, packed, signature)` BEFORE decoding the payload. On verification failure, `unpack` SHALL throw `BitpackerError` with code `CRYPTO_VERIFY_FAILED`. `verify` SHALL return a boolean and SHALL NOT throw on a verification failure.
#### Scenario: missing `verifier` rejects at construction time
- **WHEN** `defineSignedBitSchema(codec, { domain: D })` is called WITHOUT a `verifier` field
- **THEN** the Zod options-validation throws AND no schema instance is returned
#### Scenario: cross-domain replay rejected
- **WHEN** the same byte payload is signed under one domain and then `unpack(bytes, parentSlot)` is called expecting a different domain
- **THEN** `verify` returns `false` AND `unpack` throws `CRYPTO_VERIFY_FAILED`
#### Scenario: bitpacker-crypto's compiled `dist/` contains zero references to `@suaveplan/crypto`
- **WHEN** `bunx turbo build --filter=@suaveplan/bitpacker-crypto` runs
- **THEN** `grep -r "@suaveplan/crypto" packages/bitpacker/bitpacker-crypto/dist/` returns zero matches in `*.js` and `*.d.ts` files
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.test.ts`
### Requirement: MultiSignedBitSchema accepts injected verifier
The package SHALL expose `defineMultiSignedBitSchema<T>(bodyCodec, options)` returning `{ pack(data, authorities, signal?), unpack(bytes, signerPublicSlots), verify(bytes, signerPublicSlots) }`. Options SHALL require `domain`, `verifier: AuthorityVerifier`, and `signerCount` (1..64). Wire format SHALL be `[bit-packed payload][sig_1][sig_2]...[sig_N]`. `pack`/`unpack` SHALL throw `CRYPTO_MULTISIGN_COUNT_MISMATCH` on wrong-count authorities/slots. `verify` SHALL return `{valid, firstBadSignerIndex?}` pinpointing the first bad signer.
#### Scenario: missing `verifier` rejects at construction time
- **WHEN** `defineMultiSignedBitSchema(codec, { domain, signerCount: 2 })` is called WITHOUT a `verifier` field
- **THEN** the Zod options-validation throws
#### Scenario: verify pinpoints firstBadSignerIndex
- **WHEN** a 3-signer payload's second signature is flipped and `verify(bytes, [pubA, pubB, pubC])` is called
- **THEN** the result is `{ valid: false, firstBadSignerIndex: 1 }`
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/multisigned/multisigned-bitschema.test.ts`
### Requirement: HashChainSchema accepts injected verifier
The package SHALL expose `defineHashChainSchema<TPayload>(options)` returning `{ hashEntry, appendEntry, verifyEntry, verifyChain, packEntry, unpackEntry }`. Each entry is `{seq: bigint, prevHash: Uint8Array, payload: TPayload, hostSignature: Uint8Array}`. Options SHALL require `domain`, `verifier: AuthorityVerifier`, and `payloadCodec`. `appendEntry` SHALL sign over the bit-packed `(seq, prevHash, payload)` triplet under the configured domain. `verifyChain` SHALL walk entries in order, asserting prev-hash linkage and signature verification; on first failure SHALL set `firstBadIndex`. The hash algorithm SHALL be resolved via `HashAlgorithmRegistry`; default `"sha256"`.
#### Scenario: missing `verifier` rejects at construction time
- **WHEN** `defineHashChainSchema({ domain, payloadCodec })` is called WITHOUT a `verifier` field
- **THEN** the Zod options-validation throws
#### Scenario: chain verification detects tamper at exact index
- **WHEN** a 5-entry chain is built, then entry-2's payload is mutated, and `verifyChain(entries, hostPublicSlot)` is called
- **THEN** the result is `{ valid: false, firstBadIndex: 2 }`
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/hashchain/hashchain-schema.test.ts`
### Requirement: EncryptedBitSchema accepts injected AEAD operations
The package SHALL expose `defineEncryptedBitSchema<T>(bodyCodec, options)` returning `{ pack(data), unpack(bytes) }`. Options SHALL require `key: Uint8Array` (32 bytes) and `aead: AeadOps` (typically `{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` from `@suaveplan/crypto`). Wire format SHALL be `[version (u8)][algTag (u8)][nonceLen (u8)][nonce][ciphertext + tag]`. The constants `AEAD_KEY_LENGTH = 32`, `AEAD_NONCE_LENGTH = 12`, and `AEAD_XCHACHA_NONCE_LENGTH = 24` SHALL be declared locally in this primitive (not imported from `@suaveplan/crypto`). The package SHALL document that this primitive is intended for **small encrypted payloads** (token bodies, key-wrap envelopes); chunked-file encryption is owned by `@suaveplan/packer`.
#### Scenario: missing `aead` rejects at construction time
- **WHEN** `defineEncryptedBitSchema(codec, { key })` is called WITHOUT an `aead` field
- **THEN** the Zod options-validation throws
#### Scenario: tampered ciphertext rejects with CRYPTO_DECRYPT_FAILED
- **WHEN** any byte after the nonce in `pack(...)` output is flipped and `unpack(tampered)` is called
- **THEN** the call throws `BitpackerError` with code `CRYPTO_DECRYPT_FAILED`
#### Scenario: bitpacker-crypto's package.json lists `@suaveplan/crypto` only as devDependency
- **WHEN** `cat packages/bitpacker/bitpacker-crypto/package.json | jq -r '.dependencies | keys[]'` runs
- **THEN** the output does NOT include `@suaveplan/crypto`
- **AND** `cat packages/bitpacker/bitpacker-crypto/package.json | jq -r '.devDependencies | keys[]'` DOES include `@suaveplan/crypto`
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/encrypted/encrypted-bitschema.test.ts`
### Requirement: Determinism — no module-scope Math.random / Date.now
The package SHALL contain zero `Math.random()` and zero `Date.now()` calls in implementation files. Random-dependent code SHALL accept an injectable `Rng` from `@suaveplan/types`; default `Rng` SHALL be backed by `crypto.getRandomValues` and SHALL throw `CRYPTO_RNG_UNAVAILABLE` rather than fall back to `Math.random` when Web Crypto is missing.
#### Scenario: no Math.random / Date.now in implementation files
- **WHEN** `grep -rE "Math\\.random\\(\\)|Date\\.now\\(\\)" packages/bitpacker/bitpacker-crypto/src/` excluding `.test.ts` files is run
- **THEN** there are zero matches
#### Scenario: missing crypto.getRandomValues throws
- **WHEN** the package is loaded in an environment without `globalThis.crypto?.getRandomValues` and the default `Rng` is exercised
- **THEN** the package throws `BitpackerError` with code `CRYPTO_RNG_UNAVAILABLE`
**Verified by:** `packages/bitpacker/bitpacker-crypto/src/signed/signed-bitschema.spec-conformance.test.ts`::"no Math.random or Date.now in implementation files"