Files
skills/docs/foundation/bitpacker/bitpacker-crypto/src/encrypted/encrypted-bitschema.md
2026-05-29 08:20:56 +01:00

7.2 KiB

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

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<Token>([
    { name: "userId", type: "uint", bits: 32 },
    { name: "expiry", type: "uint", bits: 32 },
]);

const encryptedToken = defineEncryptedBitSchema<Token>(
    {
        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

const sessionId = new TextEncoder().encode("session:abc123");
const encrypted = defineEncryptedBitSchema<Token>(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<TBody>(bodyCodec, options): EncryptedBitSchema<TBody>

Returns:

  • pack(data: TBody): Promise<Uint8Array>
    • 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<TBody>
    • 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)

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.