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 usesglobalThis.crypto.getRandomValuesand throwsCRYPTO_RNG_UNAVAILABLEif Web Crypto is missing — never silently falls back toMath.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/cryptoalgorithm?: AeadAlgorithm = "XChaCha20-Poly1305"rng?: Rng— defaults to aglobalThis.crypto.getRandomValues-backed Rngaad?: Uint8Array— optional additional authenticated data
defineEncryptedBitSchema<TBody>(bodyCodec, options): EncryptedBitSchema<TBody>
Returns:
pack(data: TBody): Promise<Uint8Array>- throws
CRYPTO_ENCRYPT_FAILEDon AEAD failure (very rare — usually a misbehaving Rng or wrong key length) - throws
CRYPTO_RNG_UNAVAILABLEon first call if Web Crypto is missing and no Rng was supplied
- throws
unpack(bytes: Uint8Array): Promise<TBody>- throws
CRYPTO_DECRYPT_FAILEDon tag mismatch / wrong key / too-short input - throws
CRYPTO_ENVELOPE_VERSION_MISMATCHon wire-format/algorithm mismatch
- throws
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.getRandomValuesdirectly. Tests use a seeded mulberry32 Rng to assert reproducibility — production deployments must use a CSPRNG. - AEAD failures are wrapped with
BitpackerErrorso consumers don't need to import the underlying@suaveplan/cryptoCryptoErrorto 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
nonceLenbyte is technically derivable from thealgTag, 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 —
packcallsbodyCodec.packbefore encrypting,unpackcallsbodyCodec.unpackafter 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.