first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
# 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.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<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
```ts
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)
```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.