12 KiB
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-completedextract-shared-crypto-interfaces(2026-05-03). The slim archive addedSchemaFingerprint,HashAlgorithmRegistry, and the determinism gate; the rewrite shipped additionallyEnvelopeSchema,SignedBitSchema,MultiSignedBitSchema,HashChainSchema, andEncryptedBitSchema. The DI-using primitives acceptverifier: AuthorityVerifier(oraead: 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-equalsschemaFingerprint(B)
Scenario: unknown algorithm rejected
- WHEN
schemaFingerprint(schema, "magic-hash")is called andmagic-hashis not registered - THEN the call throws
BitpackerErrorwith codeCRYPTO_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 andhas("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 isparseStrict-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(notparseStrict) - 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 averifierfield - 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
verifyreturnsfalseANDunpackthrowsCRYPTO_VERIFY_FAILED
Scenario: bitpacker-crypto's compiled dist/ contains zero references to @suaveplan/crypto
- WHEN
bunx turbo build --filter=@suaveplan/bitpacker-cryptoruns - THEN
grep -r "@suaveplan/crypto" packages/bitpacker/bitpacker-crypto/dist/returns zero matches in*.jsand*.d.tsfiles
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 averifierfield - 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 averifierfield - 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 anaeadfield - 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 andunpack(tampered)is called - THEN the call throws
BitpackerErrorwith codeCRYPTO_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.tsfiles is run - THEN there are zero matches
Scenario: missing crypto.getRandomValues throws
- WHEN the package is loaded in an environment without
globalThis.crypto?.getRandomValuesand the defaultRngis exercised - THEN the package throws
BitpackerErrorwith codeCRYPTO_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"