first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user