Files
2026-05-27 23:29:53 +01:00

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