# EncryptedBitSchema Niche AEAD-encrypted envelope around a bit-packed body. Wire format: `[version u8][algTag u8][nonceLen u8][nonce][ciphertext+tag]`. **Intended scope: small encrypted payloads only.** Token bodies, key-wrap envelopes inside `MessageEnvelope`, sub-MTU control messages. **NOT a chunked-file encoder** — `@suaveplan/packer` owns chunked-file encryption with its locked v0x0001 codec. _(no submodules)_ ## Purpose Several suaveplan surfaces need a small, self-describing AEAD-encrypted envelope: a token body wrapped for transport, an inner key-wrap inside a higher-level message envelope, an opaque control record signed-then-encrypted by upstream layers. Re-implementing the "version + algTag + nonce + ciphertext" frame each time invites nonce-reuse mistakes and silent algorithm drift. `EncryptedBitSchema` standardises the frame, picks safe defaults (XChaCha20-Poly1305 with 24-byte random nonce, 32-byte key, no `Math.random` fallback), and refuses to be misused as a chunked-file encoder by being explicit in this README and the schema name. ## Features - Three AEAD algorithms supported: `AES-256-GCM`, `ChaCha20-Poly1305`, `XChaCha20-Poly1305` (default — its 24-byte random nonce avoids nonce-collision concerns even at high message volume) - Stable u8 algorithm tags (0/1/2) on the wire so an envelope encoded under one algorithm can be detected (and rejected) by a schema expecting another - Pluggable `Rng`; default uses `globalThis.crypto.getRandomValues` and throws `CRYPTO_RNG_UNAVAILABLE` if Web Crypto is missing — never silently falls back to `Math.random` - Optional `aad` (additional authenticated data) — held by reference; participates in the integrity tag so binding the envelope to a surrounding context (session id, conversation id) is one option away - Wire-format constants: `version=1`, no tag changes once allocated. Adding a new algorithm only appends to the tag table. - Tampered ciphertext rejects with `CRYPTO_DECRYPT_FAILED` - Wrong-algorithm envelope rejects with `CRYPTO_ENVELOPE_VERSION_MISMATCH` - Wrong-key decrypt rejects with `CRYPTO_DECRYPT_FAILED` - OTel spans `bitpacker-crypto.encrypted-pack` / `bitpacker-crypto.encrypted-unpack` ## Intended scope This primitive is for **small** payloads. The wire format prepends a 3-byte header + the full nonce + the AEAD output (ciphertext + 16-byte tag). For a 100-byte body, that's ~143 bytes total — fine. For a 50 MB chunked file, this is the wrong shape: you want chunked encryption with a streaming verifier, which is what `@suaveplan/packer::ChunkedPacker` provides. If you find yourself reaching for `EncryptedBitSchema` to wrap large payloads, stop and use `@suaveplan/packer` instead. This warning is also asserted by a spec-conformance test. ## Usage: Basic Example ```ts import { defineEncryptedBitSchema } from "@suaveplan/bitpacker-crypto"; import { defineBitSchema } from "@suaveplan/bitpacker"; import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto"; interface Token { userId: number; expiry: number } const tokenSchema = defineBitSchema([ { name: "userId", type: "uint", bits: 32 }, { name: "expiry", type: "uint", bits: 32 }, ]); const encryptedToken = defineEncryptedBitSchema( { pack: (t) => tokenSchema.pack(t), unpack: (bytes) => tokenSchema.unpack(bytes), }, { key: aeadKey, aead: { encrypt: aeadEncrypt, decrypt: aeadDecrypt }, } ); const wrapped = await encryptedToken.pack({ userId: 7, expiry: 1700000000 }); const original = await encryptedToken.unpack(wrapped); ``` The `aead` option is required and accepts an `AeadOps` bundle (`{ encrypt, decrypt }`). The canonical implementation is the `aeadEncrypt` / `aeadDecrypt` functions from `@suaveplan/crypto`. Passing them via DI keeps `@suaveplan/bitpacker-crypto` free of any runtime dependency on `@suaveplan/crypto`. The `AEAD_KEY_LENGTH`, `AEAD_NONCE_LENGTH`, and `AEAD_XCHACHA_NONCE_LENGTH` constants are declared locally in this primitive (not imported from `@suaveplan/crypto`); duplication is acceptable because these values are part of the bit-packer-crypto wire format. ## Usage: Advanced Example — bind to a session id via AAD ```ts const sessionId = new TextEncoder().encode("session:abc123"); const encrypted = defineEncryptedBitSchema(codec, { key, aad: sessionId, }); // Only an unpack with the SAME aad will succeed; replays into a different // session will reject with CRYPTO_DECRYPT_FAILED. ``` ## API Reference ### `interface EncryptedBitSchemaOptions` - `key: Uint8Array` — exactly 32 bytes (`AEAD_KEY_LENGTH`) - `aead: AeadOps` (required) — AEAD operations bundle; typically `{ encrypt: aeadEncrypt, decrypt: aeadDecrypt }` from `@suaveplan/crypto` - `algorithm?: AeadAlgorithm = "XChaCha20-Poly1305"` - `rng?: Rng` — defaults to a `globalThis.crypto.getRandomValues`-backed Rng - `aad?: Uint8Array` — optional additional authenticated data ### `defineEncryptedBitSchema(bodyCodec, options): EncryptedBitSchema` Returns: - `pack(data: TBody): Promise` - throws `CRYPTO_ENCRYPT_FAILED` on AEAD failure (very rare — usually a misbehaving Rng or wrong key length) - throws `CRYPTO_RNG_UNAVAILABLE` on first call if Web Crypto is missing and no Rng was supplied - `unpack(bytes: Uint8Array): Promise` - throws `CRYPTO_DECRYPT_FAILED` on tag mismatch / wrong key / too-short input - throws `CRYPTO_ENVELOPE_VERSION_MISMATCH` on wire-format/algorithm mismatch - `algorithm`, `version`, `nonceLength` (read-only) ### Algorithm tags (wire format) ```text 0 AES-256-GCM (12-byte nonce) 1 ChaCha20-Poly1305 (12-byte nonce) 2 XChaCha20-Poly1305 (24-byte nonce, default) ``` These numbers are wire-format and never renumbered. New algorithms append to the table. ## Implementation Notes - Nonces are caller-Rng-driven, never derived from message contents. The default Rng wraps `crypto.getRandomValues` directly. Tests use a seeded mulberry32 Rng to assert reproducibility — production deployments must use a CSPRNG. - AEAD failures are wrapped with `BitpackerError` so consumers don't need to import the underlying `@suaveplan/crypto` `CryptoError` to handle decrypt failures uniformly with the other bit-packer-crypto primitives. - The 3-byte header is intentionally minimal — version + alg + nonceLen is enough to dispatch a future algorithm without breaking older payloads. The `nonceLen` byte is technically derivable from the `algTag`, but storing it explicitly makes the parser simpler and catches an algorithm/nonce mismatch as a wire-format error instead of a more confusing AEAD failure. - The body codec runs on the _plaintext_ — `pack` calls `bodyCodec.pack` before encrypting, `unpack` calls `bodyCodec.unpack` after decrypting. This means the body codec sees full plaintext and must be trusted not to leak side-channels. - AEAD encrypt/decrypt are imported directly from `@suaveplan/crypto`'s main barrel; no engine override is exposed. If you need an engine override (e.g. for a hardware-accelerated AEAD), build a custom Rng wrapper or use `@suaveplan/crypto`'s engine-pluggable API directly outside this primitive.