Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
# @suaveplan/crypto-cipher
## 0.0.0
- Initial scaffold under `crypto-primitive-replaceability` Phase 0.
- AEAD primitives: aes-256-gcm, chacha20-poly1305, xchacha20-poly1305.
- Noble-backed impls (`@noble/ciphers`); native stubs throw `PrimitiveNotImplemented`.
- Nonces are caller-supplied; primitives validate length but never generate them.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,124 @@
{
"name": "@suaveplan/crypto-cipher",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "AEAD cipher primitives with per-primitive selector layout: aes-256-gcm, chacha20-poly1305, xchacha20-poly1305. Noble + native impls selected at build time via crypto.config.ts. Nonces are caller-supplied; primitives never generate them internally.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./aes-256-gcm": {
"import": {
"types": "./dist/primitives/aes-256-gcm/aes-256-gcm.d.ts",
"default": "./dist/primitives/aes-256-gcm/aes-256-gcm.js"
},
"require": {
"types": "./dist/primitives/aes-256-gcm/aes-256-gcm.d.ts",
"default": "./dist/primitives/aes-256-gcm/aes-256-gcm.js"
}
},
"./chacha20-poly1305": {
"import": {
"types": "./dist/primitives/chacha20-poly1305/chacha20-poly1305.d.ts",
"default": "./dist/primitives/chacha20-poly1305/chacha20-poly1305.js"
},
"require": {
"types": "./dist/primitives/chacha20-poly1305/chacha20-poly1305.d.ts",
"default": "./dist/primitives/chacha20-poly1305/chacha20-poly1305.js"
}
},
"./xchacha20-poly1305": {
"import": {
"types": "./dist/primitives/xchacha20-poly1305/xchacha20-poly1305.d.ts",
"default": "./dist/primitives/xchacha20-poly1305/xchacha20-poly1305.js"
},
"require": {
"types": "./dist/primitives/xchacha20-poly1305/xchacha20-poly1305.d.ts",
"default": "./dist/primitives/xchacha20-poly1305/xchacha20-poly1305.js"
}
},
"./streaming/streaming-aead": {
"import": {
"types": "./dist/streaming/streaming-aead/index.d.ts",
"default": "./dist/streaming/streaming-aead/index.js"
},
"require": {
"types": "./dist/streaming/streaming-aead/index.d.ts",
"default": "./dist/streaming/streaming-aead/index.js"
}
},
"./streaming/streaming-aead-aes-gcm": {
"import": {
"types": "./dist/streaming/streaming-aead-aes-gcm/index.d.ts",
"default": "./dist/streaming/streaming-aead-aes-gcm/index.js"
},
"require": {
"types": "./dist/streaming/streaming-aead-aes-gcm/index.d.ts",
"default": "./dist/streaming/streaming-aead-aes-gcm/index.js"
}
},
"./streaming/streaming-aead-xchacha20": {
"import": {
"types": "./dist/streaming/streaming-aead-xchacha20/index.d.ts",
"default": "./dist/streaming/streaming-aead-xchacha20/index.js"
},
"require": {
"types": "./dist/streaming/streaming-aead-xchacha20/index.d.ts",
"default": "./dist/streaming/streaming-aead-xchacha20/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"crypto:kat": "vitest run --passWithNoTests -t 'RFC|NIST|Wycheproof|vector|KAT'",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@noble/ciphers": "catalog:crypto",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,79 @@
# Crypto-Cipher Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for `@suaveplan/crypto-cipher` using `defineCodes` from `@suaveplan/error`. Every validation error and decryption failure thrown by the cipher primitives carries one of these typed codes, giving callers a machine-readable signal that can be matched in switch statements or logged to structured observability pipelines without inspecting human-readable message strings.
The namespace `"CRYPTO.CIPHER"` is unique within the project, ensuring codes never collide with those in `@suaveplan/crypto-hash`, `@suaveplan/crypto-curve`, or other packages.
## Features
- `CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH` — thrown when a key of the wrong byte length is provided to any cipher primitive.
- `CRYPTO_CIPHER_CODES.INVALID_NONCE_LENGTH` — thrown when a nonce of the wrong byte length is provided.
- `CRYPTO_CIPHER_CODES.DECRYPT_FAILED` — thrown when AEAD authentication tag verification fails during decryption.
- `CryptoCipherCode` union type — narrows codes accepted by `CryptoCipherError` constructors.
## Usage: Basic Example
```ts
import { CRYPTO_CIPHER_CODES } from "@suaveplan/crypto-cipher/errors";
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
function handleCipherError(err: CryptoCipherError): void {
switch (err.code) {
case CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH:
console.error("Wrong key length");
break;
case CRYPTO_CIPHER_CODES.DECRYPT_FAILED:
console.error("Authentication tag mismatch");
break;
}
}
```
## Usage: Advanced Example
```ts
import { CRYPTO_CIPHER_CODES, CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
function assertKeyLength(key: Uint8Array, expected: number, algorithm: string): void {
if (key.length !== expected) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH,
"error.crypto.cipher.invalid-key-length",
{
context: { algorithm, expected, actual: key.length },
}
);
}
}
```
## API Reference
### `CRYPTO_CIPHER_CODES`
```ts
const CRYPTO_CIPHER_CODES: {
readonly INVALID_KEY_LENGTH: "CRYPTO.CIPHER.INVALID_KEY_LENGTH";
readonly INVALID_NONCE_LENGTH: "CRYPTO.CIPHER.INVALID_NONCE_LENGTH";
readonly DECRYPT_FAILED: "CRYPTO.CIPHER.DECRYPT_FAILED";
};
```
Frozen registry produced by `defineCodes("CRYPTO.CIPHER", [...])`. Values are dot-separated canonical codes used in error serialisation, log queries, and alerting rules.
### `CryptoCipherCode`
```ts
type CryptoCipherCode = (typeof CRYPTO_CIPHER_CODES)[keyof typeof CRYPTO_CIPHER_CODES];
```
Union of all code string literals in `CRYPTO_CIPHER_CODES`.
## Implementation Notes
`defineCodes` from `@suaveplan/error` produces a frozen object with string-literal values, giving both runtime and compile-time stability. Adding a new code requires extending the `as const` tuple and regenerating types; no numeric ID assignment or migration is required. The `DECRYPT_FAILED` code is intentionally generic — it does not reveal whether the failure was a tag mismatch, truncation, or corruption — to avoid leaking information that could assist an adaptive chosen-ciphertext attacker.

View File

@@ -0,0 +1,14 @@
import { defineCodes } from "@suaveplan/error";
export const CRYPTO_CIPHER_CODES = defineCodes("CRYPTO.CIPHER", [
"INVALID_KEY_LENGTH",
"INVALID_NONCE_LENGTH",
"DECRYPT_FAILED",
"STREAM_TAG_MISMATCH",
"STREAM_OUT_OF_ORDER",
"STREAM_ABORTED",
"STREAM_INVALID_NONCE_BASE",
] as const);
export type CryptoCipherCode =
(typeof CRYPTO_CIPHER_CODES)[keyof typeof CRYPTO_CIPHER_CODES];

View File

@@ -0,0 +1 @@
export { CRYPTO_CIPHER_CODES, type CryptoCipherCode } from "./codes.js";

View File

@@ -0,0 +1,72 @@
# CryptoCipherError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CryptoCipherError` is the package-local error class for `@suaveplan/crypto-cipher`. It extends `SuaveplanError<CryptoCipherCode>` from `@suaveplan/error`, inheriting the full structured-error contract: a machine-readable typed code, an i18n message key, cause-chain traversal, Zod-validated wire format, and OTel span-event adaptation.
The class's only role is widening the protected `SuaveplanError` constructor to `public`, so cipher implementations can throw typed errors without needing internal access to the base class. This pattern is used consistently across all `@suaveplan/crypto-*` packages.
## Features
- Typed on `CryptoCipherCode` — the compiler enforces that only codes from `CRYPTO_CIPHER_CODES` are passed.
- Accepts structured `context` via `SuaveplanErrorOptions` for attaching algorithm name, expected/actual lengths, and ciphertext sizes.
- Full `cause` chain support for wrapping lower-level errors thrown by Noble or native cipher backends.
- Serialisable to JSON wire format via `toJSON()`, compatible with cross-boundary error transport.
- Adaptable to OTel span events via `toSpanEvent()` for distributed tracing.
## Usage: Basic Example
```ts
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
import { CRYPTO_CIPHER_CODES } from "@suaveplan/crypto-cipher/errors";
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_NONCE_LENGTH,
"error.crypto.cipher.invalid-nonce-length"
);
```
## Usage: Advanced Example
```ts
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
import { CRYPTO_CIPHER_CODES } from "@suaveplan/crypto-cipher/errors";
function wrapDecryptError(cause: unknown, algorithm: string, len: number): never {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.DECRYPT_FAILED,
"error.crypto.cipher.decrypt-failed",
{
cause,
context: { algorithm, ciphertextLength: len },
}
);
}
```
## API Reference
### `class CryptoCipherError extends SuaveplanError<CryptoCipherCode>`
#### `constructor(code: CryptoCipherCode, messageKey: string, options?: SuaveplanErrorOptions)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `CryptoCipherCode` | One of the codes from `CRYPTO_CIPHER_CODES`. |
| `messageKey` | `string` | Dotted i18n key for the message resolver hook. |
| `options` | `SuaveplanErrorOptions` (optional) | `cause`, `context`, and other base-class options. |
Inherits from `SuaveplanError<CryptoCipherCode>`:
- `code: CryptoCipherCode`
- `messageKey: string`
- `context?: Record<string, unknown>`
- `toJSON(): unknown`
- `toSpanEvent(): OTelSpanEvent`
## Implementation Notes
The constructor body is empty and exists solely to change the access modifier from `protected` to `public`. Biome's `noUselessConstructor` rule is suppressed via an inline comment for this reason. The same pattern appears in `HashError` and the error classes of every other `@suaveplan/crypto-*` package, ensuring uniform error typing across the cryptography stack.

View File

@@ -0,0 +1,13 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { CryptoCipherCode } from "../codes/codes.js";
export class CryptoCipherError extends SuaveplanError<CryptoCipherCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected ctor
public constructor(
code: CryptoCipherCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1 @@
export { CryptoCipherError } from "./crypto-cipher-error.js";

View File

@@ -0,0 +1,2 @@
export { CRYPTO_CIPHER_CODES, type CryptoCipherCode } from "./codes/codes.js";
export { CryptoCipherError } from "./crypto-cipher-error/crypto-cipher-error.js";

View File

@@ -0,0 +1,34 @@
export {
CRYPTO_CIPHER_CODES,
type CryptoCipherCode,
CryptoCipherError,
} from "./errors/index.js";
export * as aes256gcm from "./primitives/aes-256-gcm/aes-256-gcm.js";
export * as chacha20poly1305 from "./primitives/chacha20-poly1305/chacha20-poly1305.js";
export * as xchacha20poly1305 from "./primitives/xchacha20-poly1305/xchacha20-poly1305.js";
export {
type DecryptStreamAeadOptions,
decryptStreamAead,
type EncryptStreamAeadOptions,
encryptStreamAead,
STREAMING_AEAD_MAGIC,
STREAMING_AEAD_MAX_COUNTER,
STREAMING_AEAD_VERSION,
type StreamingAeadEngine,
type StreamingChunkInput,
type StreamingChunkTag,
type StreamingHeader,
streamingAeadHeaderLength,
} from "./streaming/streaming-aead/index.js";
export {
aesGcmStreamingEngine,
BASE_NONCE_LENGTH as AES_GCM_STREAM_BASE_NONCE_LENGTH,
NONCE_LENGTH as AES_GCM_STREAM_NONCE_LENGTH,
TAG_LENGTH as AES_GCM_STREAM_TAG_LENGTH,
} from "./streaming/streaming-aead-aes-gcm/index.js";
export {
BASE_NONCE_LENGTH as XCHACHA20_STREAM_BASE_NONCE_LENGTH,
NONCE_LENGTH as XCHACHA20_STREAM_NONCE_LENGTH,
TAG_LENGTH as XCHACHA20_STREAM_TAG_LENGTH,
xchacha20StreamingEngine,
} from "./streaming/streaming-aead-xchacha20/index.js";

View File

@@ -0,0 +1,79 @@
# aes-256-gcm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [aes-256-gcm.select](./aes-256-gcm.select/aes-256-gcm.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_ - [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm](./aes-256-gcm.md) — <!-- begin:auto-docs --> _(no submodules)_- aes-256-gcm.select — <!-- begin:auto-docs --> _(no s…
- [aes-256-gcm.select](./aes-256-gcm.select/aes-256-gcm.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm](./aes-256-gcm.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.select](./aes-256-gcm.select/aes-256-gcm.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm](./aes-256-gcm.md) — <!-- begin:auto-docs --> _(no submodules)_- aes-256-gcm.select — <!-- be…
- [aes-256-gcm.select](./aes-256-gcm.select/aes-256-gcm.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm](./aes-256-gcm.md) — <!-- begin:auto-docs --> _(no submodules)_- aes-256-gcm.select — <!-- begin:auto-docs --> _(no…
- [aes-256-gcm.select](./aes-256-gcm.select/aes-256-gcm.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_ - [aes-256-gcm.native](./impl/aes-256-gcm.native/aes-256-gcm.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [aes-256-gcm.noble](./impl/aes-256-gcm.noble/aes-256-gcm.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [aes-256-gcm](./aes-256-gcm.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`aes-256-gcm.ts` is the stable public entry point for the AES-256-GCM AEAD primitive in `@suaveplan/crypto-cipher`. It re-exports everything from the generated `aes-256-gcm.select.ts`, which forwards to either the Noble-backed or native implementation according to `crypto.config.ts`.
AES-256-GCM (NIST SP 800-38D) is a widely deployed authenticated encryption scheme that provides both confidentiality and integrity. It is mandatory in TLS 1.3, common in cloud storage encryption, and natively available in WebCrypto. This entry point provides the stable import path and exposes the constants `KEY_LENGTH`, `NONCE_LENGTH`, and `TAG_LENGTH`.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — AEAD encryption; returns `ciphertext ‖ tag`.
- `decrypt(key, nonce, ciphertext, aad?)` — AEAD decryption; throws `CryptoCipherError` on tag mismatch.
- `KEY_LENGTH = 32` — 256-bit key requirement.
- `NONCE_LENGTH = 12` — 96-bit (12-byte) nonce as per NIST recommendation.
- `TAG_LENGTH = 16` — 128-bit authentication tag.
- Key and nonce length validated before any cipher operation.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const plaintext = new TextEncoder().encode("secret");
const ciphertext = encrypt(key, nonce, plaintext);
const recovered = decrypt(key, nonce, ciphertext);
console.log(new TextDecoder().decode(recovered)); // "secret"
```
## Usage: Advanced Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
// Encrypt with Additional Authenticated Data (AAD) for binding.
function encryptWithContext(
key: Uint8Array,
plaintext: Uint8Array,
context: string
): { nonce: Uint8Array; ciphertext: Uint8Array } {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const aad = new TextEncoder().encode(context);
const ciphertext = encrypt(key, nonce, plaintext, aad);
return { nonce, ciphertext };
}
function decryptWithContext(
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array,
context: string
): Uint8Array {
const aad = new TextEncoder().encode(context);
return decrypt(key, nonce, ciphertext, aad); // throws CryptoCipherError on mismatch
}
```
## API Reference
All symbols re-exported from `aes-256-gcm.select.ts`:
- `encrypt(key, nonce, plaintext, aad?): Uint8Array`
- `decrypt(key, nonce, ciphertext, aad?): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 12`
- `TAG_LENGTH: 16`
## Implementation Notes
Nonce generation is the caller's responsibility — this module never generates nonces internally. For AES-256-GCM, nonces must never be reused with the same key; a reused (key, nonce) pair destroys both confidentiality and authenticity. Callers should use random 12-byte nonces (96-bit) or a counter-based scheme. The 16-byte authentication tag is appended to the ciphertext in the output of `encrypt` and expected at the end of the ciphertext in `decrypt`.

View File

@@ -0,0 +1,62 @@
# aes-256-gcm.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`aes-256-gcm.select.ts` is the build-time primitive selector for AES-256-GCM in `@suaveplan/crypto-cipher`. Generated by `bun scripts/gen-crypto-selects.ts`, it re-exports one backend — either `aes-256-gcm.noble.ts` or `aes-256-gcm.native.ts` — based on the `aes-256-gcm` field in `crypto.config.ts`.
Callers import through the stable `aes-256-gcm.ts` entry point. The selector indirection allows backend changes without touching any caller import paths. Currently the Noble backend is selected because the native stub throws `PrimitiveNotImplemented`.
## Features
- Re-exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, `TAG_LENGTH`.
- Generated — never hand-edited.
- Controlled by `crypto.config.ts`: set `aes-256-gcm` to `'noble'` or `'native'`.
- Enables dead-code elimination of the inactive backend.
## Usage: Basic Example
```ts
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const ct = encrypt(key, nonce, new TextEncoder().encode("hello"));
console.log(ct.length); // 16 + 5 = 21 (ciphertext + tag)
```
## Usage: Advanced Example
```ts
import { decrypt, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
function safeDecrypt(key: Uint8Array, nonce: Uint8Array, ct: Uint8Array): Uint8Array | null {
try {
return decrypt(key, nonce, ct);
} catch (err) {
if (err instanceof CryptoCipherError) return null;
throw err;
}
}
```
## API Reference
Re-exports from the active implementation:
- `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 12`
- `TAG_LENGTH: 16`
## Implementation Notes
This file is a generated one-liner and is marked `/* GENERATED — DO NOT EDIT */`. The generator resolves the `aes-256-gcm` backend from `crypto.config.ts` and emits the corresponding `export *` line. Running the generator is idempotent. Do not add logic here.
### Selection Logic
At build time `crypto.config.ts` chooses between the Noble `@noble/ciphers/aes` implementation and the native-engine stub for AES-256-GCM; the selector exported here reads the generated `crypto-selects` map and dispatches every `encrypt` / `decrypt` call to the chosen impl. Nonce is caller-supplied per the package-wide rule — this module does not generate nonces internally.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/aes-256-gcm.noble/aes-256-gcm.noble.js";

View File

@@ -0,0 +1 @@
export * from "./aes-256-gcm.select.js";

View File

@@ -0,0 +1,105 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import * as native from "./impl/aes-256-gcm.native/aes-256-gcm.native.js";
import * as noble from "./impl/aes-256-gcm.noble/aes-256-gcm.noble.js";
// NIST SP 800-38D Appendix B: Test Case 3 (abbreviated — key/IV/ciphertext from CAVP)
function fromHex(h: string): Uint8Array {
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
describe("aes-256-gcm (noble)", () => {
it("encrypts and decrypts round-trips", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x01);
const nonce = new Uint8Array(12).fill(0x02);
const plaintext = new TextEncoder().encode("hello world");
const ct = noble.encrypt(key, nonce, plaintext);
// ciphertext has tag appended
expect(ct.length).toBe(plaintext.length + 16);
expect(noble.decrypt(key, nonce, ct)).toEqual(plaintext);
});
it("AAD is authenticated", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x01);
const nonce = new Uint8Array(12).fill(0x02);
const aad = new TextEncoder().encode("meta");
const pt = new TextEncoder().encode("payload");
const ct = noble.encrypt(key, nonce, pt, aad);
expect(noble.decrypt(key, nonce, ct, aad)).toEqual(pt);
expect(() =>
noble.decrypt(key, nonce, ct, new TextEncoder().encode("wrong"))
).toThrow(CryptoCipherError);
});
it("rejects wrong key length", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(
new Uint8Array(31),
new Uint8Array(12),
new Uint8Array()
)
).toThrow(CryptoCipherError);
});
it("rejects wrong nonce length", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(
new Uint8Array(32),
new Uint8Array(11),
new Uint8Array()
)
).toThrow(CryptoCipherError);
});
it("flips ciphertext → decrypt fails with stable code", {
timeout: 5000,
}, () => {
const key = new Uint8Array(32).fill(0x01);
const nonce = new Uint8Array(12).fill(0x02);
const pt = new TextEncoder().encode("payload");
const ct = noble.encrypt(key, nonce, pt);
ct[0] = (ct[0] ?? 0) ^ 0xff;
try {
noble.decrypt(key, nonce, ct);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoCipherError);
expect((err as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.DECRYPT_FAILED"
);
}
});
// Silence unused-import warning in minimal build
it("exposes constants", { timeout: 5000 }, () => {
expect(noble.KEY_LENGTH).toBe(32);
expect(noble.NONCE_LENGTH).toBe(12);
expect(noble.TAG_LENGTH).toBe(16);
// Unused helper ref, keeps fromHex tree-shake-friendly
expect(fromHex("00").length).toBe(1);
});
});
describe("aes-256-gcm (native stub)", () => {
it("throws PrimitiveNotImplemented", { timeout: 5000 }, () => {
expect(() =>
native.encrypt(
new Uint8Array(32),
new Uint8Array(12),
new Uint8Array()
)
).toThrow(PrimitiveNotImplemented);
expect(() =>
native.decrypt(
new Uint8Array(32),
new Uint8Array(12),
new Uint8Array(16)
)
).toThrow(PrimitiveNotImplemented);
});
});

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./aes-256-gcm.select/aes-256-gcm.select.js";

View File

@@ -0,0 +1,69 @@
# aes-256-gcm.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`aes-256-gcm.native.ts` is the native-backend placeholder for AES-256-GCM in `@suaveplan/crypto-cipher`. It exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, and `TAG_LENGTH` with the same types as the Noble implementation, but `encrypt` and `decrypt` throw `PrimitiveNotImplemented("aes-256-gcm")` unconditionally.
AES-256-GCM is natively available via `SubtleCrypto.encrypt` with `{ name: "AES-GCM" }` in all modern browsers and in Node.js via the `node:crypto` module. A future native implementation would delegate to these platform APIs, potentially enabling hardware-accelerated encryption on supported hardware. This stub keeps the selector layout complete until that work is ready.
## Features
- Exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, `TAG_LENGTH` — same surface as the Noble file.
- `encrypt` and `decrypt` throw `PrimitiveNotImplemented("aes-256-gcm")`.
- `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16` constants are usable even when the algorithm is not implemented.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets aes-256-gcm to 'native'.
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
try {
encrypt(
new Uint8Array(KEY_LENGTH),
new Uint8Array(NONCE_LENGTH),
new Uint8Array(16)
);
} catch (err) {
// PrimitiveNotImplemented: aes-256-gcm
console.error(err);
}
```
## Usage: Advanced Example
```ts
// The constants are still safe to use regardless of the selected backend.
import { KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
function allocateCipherBuffers(): { key: Uint8Array; nonce: Uint8Array } {
return {
key: crypto.getRandomValues(new Uint8Array(KEY_LENGTH)),
nonce: crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)),
};
}
```
## API Reference
### `encrypt(_key, _nonce, _plaintext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("aes-256-gcm")`.
### `decrypt(_key, _nonce, _ciphertext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("aes-256-gcm")`.
### Constants
- `KEY_LENGTH = 32`
- `NONCE_LENGTH = 12`
- `TAG_LENGTH = 16`
## Implementation Notes
Exporting constants even in the placeholder makes the file drop-in compatible with the Noble implementation for callers that only import constants (e.g. to allocate buffers). When the native implementation is ready and passes NIST SP 800-38D CAVP test vectors, the placeholder functions are replaced and the selector regenerated. The implementation will need to handle the synchronous/asynchronous impedance mismatch since `SubtleCrypto` is async and this interface is synchronous — likely via Node.js `crypto.createCipheriv` for server-side use.

View File

@@ -0,0 +1,22 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 12;
export const TAG_LENGTH = 16;
export function encrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_plaintext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("aes-256-gcm");
}
export function decrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_ciphertext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("aes-256-gcm");
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./aes-256-gcm.native.js";

View File

@@ -0,0 +1,84 @@
# aes-256-gcm.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`aes-256-gcm.noble.ts` is the Noble-backed concrete implementation of AES-256-GCM in `@suaveplan/crypto-cipher`. It uses `gcm` from `@noble/ciphers/aes`, validates key and nonce lengths before every operation, and wraps decryption failures in a typed `CryptoCipherError`.
AES-256-GCM (NIST SP 800-38D) provides authenticated encryption with associated data (AEAD). The authentication tag is appended to the ciphertext by Noble's `encrypt` and verified before returning plaintext from `decrypt`. This file is the production implementation; `aes-256-gcm.native.ts` is the placeholder.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — validates lengths then delegates to Noble `gcm`.
- `decrypt(key, nonce, ciphertext, aad?)` — validates lengths, delegates to Noble, wraps auth failures in `CryptoCipherError(DECRYPT_FAILED)`.
- Exports `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16`.
- Validation throws `CryptoCipherError(INVALID_KEY_LENGTH)` or `INVALID_NONCE_LENGTH` with structured context.
- Noble appends/strips the 16-byte GCM tag automatically.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const plaintext = new TextEncoder().encode("hello AES-GCM");
const ciphertext = encrypt(key, nonce, plaintext);
const recovered = decrypt(key, nonce, ciphertext);
console.log(new TextDecoder().decode(recovered)); // "hello AES-GCM"
```
## Usage: Advanced Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/aes-256-gcm";
// Encrypt a record with a session ID bound as AAD.
function encryptRecord(
key: Uint8Array,
sessionId: string,
record: Uint8Array
): { nonce: Uint8Array; ciphertext: Uint8Array } {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const aad = new TextEncoder().encode(sessionId);
return { nonce, ciphertext: encrypt(key, nonce, record, aad) };
}
```
## API Reference
### `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `Uint8Array` | 32-byte AES-256 key. |
| `nonce` | `Uint8Array` | 12-byte GCM nonce. Must not be reused with the same key. |
| `plaintext` | `Uint8Array` | Plaintext bytes to encrypt. |
| `aad` | `Uint8Array` (optional) | Additional authenticated data (not encrypted, but authenticated). |
**Returns:** `ciphertext ‖ tag` — length is `plaintext.length + 16`.
**Throws:** `CryptoCipherError(INVALID_KEY_LENGTH)` or `CryptoCipherError(INVALID_NONCE_LENGTH)`.
### `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
| Parameter | Type | Description |
|-----------|------|-------------|
| `ciphertext` | `Uint8Array` | `ciphertext ‖ tag` produced by `encrypt`. |
**Returns:** Plaintext.
**Throws:** `CryptoCipherError(DECRYPT_FAILED)` if the authentication tag does not match.
### Constants
- `KEY_LENGTH = 32`
- `NONCE_LENGTH = 12`
- `TAG_LENGTH = 16`
## Implementation Notes
Noble's `gcm(key, nonce, aad)` returns a cipher object with `.encrypt(plaintext)` and `.decrypt(ciphertext)` methods. The AAD parameter is passed at construction time. Length validation runs before the cipher is constructed, so wrong-length keys or nonces surface as `CryptoCipherError` rather than cryptic Noble-internal errors. Noble throws when the authentication tag mismatches during decryption; the catch block converts this to `CryptoCipherError(DECRYPT_FAILED)` with the algorithm name and ciphertext length as context.

View File

@@ -0,0 +1,77 @@
import { gcm } from "@noble/ciphers/aes";
import { CRYPTO_CIPHER_CODES } from "../../../../errors/codes/codes.js";
import { CryptoCipherError } from "../../../../errors/crypto-cipher-error/crypto-cipher-error.js";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 12;
export const TAG_LENGTH = 16;
function validate(key: Uint8Array, nonce: Uint8Array): void {
if (key.length !== KEY_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH,
"error.crypto.cipher.invalid-key-length",
{
context: {
algorithm: "aes-256-gcm",
expected: KEY_LENGTH,
actual: key.length,
},
}
);
}
if (nonce.length !== NONCE_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_NONCE_LENGTH,
"error.crypto.cipher.invalid-nonce-length",
{
context: {
algorithm: "aes-256-gcm",
expected: NONCE_LENGTH,
actual: nonce.length,
},
}
);
}
}
/**
* AES-256-GCM AEAD encryption, noble-backed. Returns `ciphertext || tag`.
*/
export function encrypt(
key: Uint8Array,
nonce: Uint8Array,
plaintext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
return gcm(key, nonce, aad).encrypt(plaintext);
}
/**
* AES-256-GCM AEAD decryption. Throws `CryptoCipherError` on tag mismatch
* or length violations.
*/
export function decrypt(
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
try {
return gcm(key, nonce, aad).decrypt(ciphertext);
} catch (cause) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.DECRYPT_FAILED,
"error.crypto.cipher.decrypt-failed",
{
cause,
context: {
algorithm: "aes-256-gcm",
ciphertextLength: ciphertext.length,
},
}
);
}
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./aes-256-gcm.noble.js";

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./aes-256-gcm";

View File

@@ -0,0 +1,67 @@
# chacha20-poly1305
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [chacha20-poly1305.select](./chacha20-poly1305.select/chacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305](./chacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- chacha20-poly1305.select — <!-- begin:auto-docs -->…
- [chacha20-poly1305.select](./chacha20-poly1305.select/chacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305](./chacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.select](./chacha20-poly1305.select/chacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305](./chacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- chacha20-poly1305.select — <…
- [chacha20-poly1305.select](./chacha20-poly1305.select/chacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305](./chacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- chacha20-poly1305.select — <!-- begin:auto-docs -->…
- [chacha20-poly1305.select](./chacha20-poly1305.select/chacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chacha20-poly1305.native](./impl/chacha20-poly1305.native/chacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chacha20-poly1305.noble](./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [chacha20-poly1305](./chacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`chacha20-poly1305.ts` is the stable public entry point for the ChaCha20-Poly1305 AEAD primitive in `@suaveplan/crypto-cipher`. It re-exports everything from the generated `chacha20-poly1305.select.ts`, which forwards to either the Noble-backed or native implementation based on `crypto.config.ts`.
ChaCha20-Poly1305 (RFC 8439) is a stream-cipher-based AEAD algorithm that provides authenticated encryption without the hardware AES acceleration requirement. It is preferred over AES-GCM on platforms that lack AES-NI instructions (mobile CPUs, edge nodes, IoT devices). TLS 1.3 includes it as a mandatory cipher suite alongside AES-128-GCM and AES-256-GCM.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — AEAD encryption; returns `ciphertext ‖ tag`.
- `decrypt(key, nonce, ciphertext, aad?)` — AEAD decryption; throws `CryptoCipherError` on failure.
- `KEY_LENGTH = 32` — 256-bit key.
- `NONCE_LENGTH = 12` — 96-bit nonce.
- `TAG_LENGTH = 16` — 128-bit Poly1305 authentication tag.
- Key and nonce length validated before every operation.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const plaintext = new TextEncoder().encode("chacha test");
const ciphertext = encrypt(key, nonce, plaintext);
const recovered = decrypt(key, nonce, ciphertext);
console.log(new TextDecoder().decode(recovered)); // "chacha test"
```
## Usage: Advanced Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
// Seal a message with a request ID as AAD.
function sealMessage(key: Uint8Array, requestId: string, body: Uint8Array): {
nonce: Uint8Array;
sealed: Uint8Array;
} {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const aad = new TextEncoder().encode(requestId);
return { nonce, sealed: encrypt(key, nonce, body, aad) };
}
```
## API Reference
All symbols re-exported from `chacha20-poly1305.select.ts`:
- `encrypt(key, nonce, plaintext, aad?): Uint8Array`
- `decrypt(key, nonce, ciphertext, aad?): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 12`
- `TAG_LENGTH: 16`
## Implementation Notes
This file is a zero-logic re-export barrel providing the stable import path `@suaveplan/crypto-cipher/chacha20-poly1305`. ChaCha20-Poly1305 uses a 12-byte nonce, which is safe to use with random generation for most use cases — at 2⁹⁶ possible nonces, the birthday bound for collision is comfortably above typical message volumes. For higher message counts, consider XChaCha20-Poly1305, which uses a 24-byte nonce. Nonces must never be reused with the same key.

View File

@@ -0,0 +1,59 @@
# chacha20-poly1305.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`chacha20-poly1305.select.ts` is the build-time primitive selector for ChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. Generated by `bun scripts/gen-crypto-selects.ts`, it re-exports one backend — `chacha20-poly1305.noble.ts` or `chacha20-poly1305.native.ts` — based on the `chacha20-poly1305` entry in `crypto.config.ts`.
Callers import through `chacha20-poly1305.ts` and never reference this file directly. The selector decouples the public API from the concrete implementation, enabling backend switches with no changes to caller code.
## Features
- Re-exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, `TAG_LENGTH`.
- Generated — never hand-edited.
- Controlled by `crypto.config.ts`: set `chacha20-poly1305` to `'noble'` or `'native'`.
- Enables dead-code elimination of the inactive backend at bundle time.
## Usage: Basic Example
```ts
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const ct = encrypt(key, nonce, new TextEncoder().encode("test"));
console.log(ct.length); // 4 + 16 = 20
```
## Usage: Advanced Example
```ts
import { decrypt } from "@suaveplan/crypto-cipher/chacha20-poly1305";
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
import { CRYPTO_CIPHER_CODES } from "@suaveplan/crypto-cipher/errors";
function isDecryptError(err: unknown): err is CryptoCipherError {
return err instanceof CryptoCipherError &&
err.code === CRYPTO_CIPHER_CODES.DECRYPT_FAILED;
}
```
## API Reference
Re-exports from the active implementation:
- `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 12`
- `TAG_LENGTH: 16`
## Implementation Notes
This generated file contains `export * from "./impl/chacha20-poly1305.noble.js"` and is marked `/* GENERATED — DO NOT EDIT */`. The generator is idempotent and safe to run at any time. Do not add logic here — it will be overwritten on the next generator run.
### Selection Logic
At build time `crypto.config.ts` picks the Noble or native engine for the IETF ChaCha20-Poly1305 primitive; the selector routes every call to the chosen implementation. Authentication tag mismatch throws the shared `CryptoCipherError` with code `CRYPTO.CIPHER.TAG_MISMATCH`. Nonce is caller-supplied; the IETF 12-byte nonce variant is the one implemented here — the extended 24-byte XChaCha20 variant lives in `xchacha20-poly1305`.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/chacha20-poly1305.noble/chacha20-poly1305.noble.js";

View File

@@ -0,0 +1 @@
export * from "./chacha20-poly1305.select.js";

View File

@@ -0,0 +1,94 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import * as native from "./impl/chacha20-poly1305.native/chacha20-poly1305.native.js";
import * as noble from "./impl/chacha20-poly1305.noble/chacha20-poly1305.noble.js";
function fromHex(h: string): Uint8Array {
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
// RFC 8439 §2.8.2 — test vector for AEAD_CHACHA20_POLY1305
const RFC8439_KEY = fromHex(
"808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f"
);
const RFC8439_NONCE = fromHex("070000004041424344454647");
const RFC8439_AAD = fromHex("50515253c0c1c2c3c4c5c6c7");
const RFC8439_PT = fromHex(
"4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a204966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e"
);
const RFC8439_CT_AND_TAG = fromHex(
// ciphertext
"d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d63dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b3692ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc3ff4def08e4b7a9de576d26586cec64b6116" +
// tag
"1ae10b594f09e26a7e902ecbd0600691"
);
describe("chacha20-poly1305 (noble) — RFC 8439 §2.8.2", () => {
it("encrypt matches the RFC vector", { timeout: 5000 }, () => {
const ct = noble.encrypt(
RFC8439_KEY,
RFC8439_NONCE,
RFC8439_PT,
RFC8439_AAD
);
expect(ct).toEqual(RFC8439_CT_AND_TAG);
});
it("decrypt recovers the plaintext", { timeout: 5000 }, () => {
expect(
noble.decrypt(
RFC8439_KEY,
RFC8439_NONCE,
RFC8439_CT_AND_TAG,
RFC8439_AAD
)
).toEqual(RFC8439_PT);
});
});
describe("chacha20-poly1305 (noble) — misuse resistance", () => {
it("rejects wrong key length", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(new Uint8Array(30), RFC8439_NONCE, RFC8439_PT)
).toThrow(CryptoCipherError);
});
it("rejects wrong nonce length", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(RFC8439_KEY, new Uint8Array(8), RFC8439_PT)
).toThrow(CryptoCipherError);
});
it("wrong AAD → decrypt fails", { timeout: 5000 }, () => {
expect(() =>
noble.decrypt(
RFC8439_KEY,
RFC8439_NONCE,
RFC8439_CT_AND_TAG,
new Uint8Array()
)
).toThrow(CryptoCipherError);
});
});
describe("chacha20-poly1305 (native stub)", () => {
it("throws PrimitiveNotImplemented", { timeout: 5000 }, () => {
expect(() =>
native.encrypt(
new Uint8Array(32),
new Uint8Array(12),
new Uint8Array()
)
).toThrow(PrimitiveNotImplemented);
expect(() =>
native.decrypt(
new Uint8Array(32),
new Uint8Array(12),
new Uint8Array(16)
)
).toThrow(PrimitiveNotImplemented);
});
});

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./chacha20-poly1305.select/chacha20-poly1305.select.js";

View File

@@ -0,0 +1,63 @@
# chacha20-poly1305.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`chacha20-poly1305.native.ts` is the native-backend placeholder for ChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. It exports `encrypt`, `decrypt`, and the three length constants with the same types as the Noble implementation, but `encrypt` and `decrypt` throw `PrimitiveNotImplemented("chacha20-poly1305")`.
This stub maintains the two-implementation selector layout for ChaCha20-Poly1305. Unlike AES-GCM, ChaCha20-Poly1305 is not yet available as a native WebCrypto primitive in all environments, but it is available in libsodium and in the `@suaveplan/crypto-sodium` engine adapter. The stub keeps the door open for a platform-specific native binding.
## Features
- Exports `encrypt`, `decrypt`, `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16`.
- `encrypt` and `decrypt` throw `PrimitiveNotImplemented("chacha20-poly1305")`.
- Constants are usable even when the cipher functions are not implemented.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets chacha20-poly1305 to 'native'.
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
try {
encrypt(new Uint8Array(KEY_LENGTH), new Uint8Array(NONCE_LENGTH), new Uint8Array(8));
} catch (err) {
// PrimitiveNotImplemented: chacha20-poly1305
console.error(err);
}
```
## Usage: Advanced Example
```ts
import { KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
// Allocate key and nonce buffers regardless of whether cipher functions are available.
function allocate(): { key: Uint8Array; nonce: Uint8Array } {
return {
key: crypto.getRandomValues(new Uint8Array(KEY_LENGTH)),
nonce: crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)),
};
}
```
## API Reference
### `encrypt(_key, _nonce, _plaintext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("chacha20-poly1305")`.
### `decrypt(_key, _nonce, _ciphertext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("chacha20-poly1305")`.
### Constants
- `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16`
## Implementation Notes
The constants are defined as literal values (not imported from the Noble file) so both implementations are self-contained. This matches the pattern used by all other native stub files in the package. When a platform-native ChaCha20-Poly1305 binding is ready and RFC 8439 test vectors pass, the placeholder functions are replaced, parameter prefixes removed, and the selector regenerated.

View File

@@ -0,0 +1,22 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 12;
export const TAG_LENGTH = 16;
export function encrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_plaintext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("chacha20-poly1305");
}
export function decrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_ciphertext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("chacha20-poly1305");
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./chacha20-poly1305.native.js";

View File

@@ -0,0 +1,79 @@
# chacha20-poly1305.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`chacha20-poly1305.noble.ts` is the Noble-backed concrete implementation of ChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. It uses `chacha20poly1305` from `@noble/ciphers/chacha`, validates key and nonce lengths before every operation, and wraps decryption failures in typed `CryptoCipherError` instances.
ChaCha20-Poly1305 (RFC 8439) combines the ChaCha20 stream cipher with the Poly1305 MAC. The stream cipher generates a keystream that is XOR'd with the plaintext; the MAC provides authentication over both the ciphertext and any additional authenticated data. This file is the production implementation; `chacha20-poly1305.native.ts` is the placeholder.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — validates lengths, returns `ciphertext ‖ 16-byte Poly1305 tag`.
- `decrypt(key, nonce, ciphertext, aad?)` — validates lengths, verifies tag, throws `CryptoCipherError(DECRYPT_FAILED)` on mismatch.
- Exports `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16`.
- Validation throws `CryptoCipherError(INVALID_KEY_LENGTH)` or `CryptoCipherError(INVALID_NONCE_LENGTH)` with structured context.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const pt = new TextEncoder().encode("RFC 8439");
const ct = encrypt(key, nonce, pt);
const recovered = decrypt(key, nonce, ct);
console.log(new TextDecoder().decode(recovered)); // "RFC 8439"
```
## Usage: Advanced Example
```ts
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/chacha20-poly1305";
import { CryptoCipherError } from "@suaveplan/crypto-cipher/errors";
// Encrypt with metadata bound as AAD.
function encryptPacket(
key: Uint8Array,
sequenceNumber: number,
payload: Uint8Array
): Uint8Array {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const aad = new Uint8Array(4);
new DataView(aad.buffer).setUint32(0, sequenceNumber, false);
return new Uint8Array([...nonce, ...encrypt(key, nonce, payload, aad)]);
}
```
## API Reference
### `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `Uint8Array` | 32-byte symmetric key. |
| `nonce` | `Uint8Array` | 12-byte nonce. Must not be reused with the same key. |
| `plaintext` | `Uint8Array` | Data to encrypt. |
| `aad` | `Uint8Array` (optional) | Authenticated but not encrypted associated data. |
**Returns:** `ciphertext ‖ tag` of length `plaintext.length + 16`.
**Throws:** `CryptoCipherError(INVALID_KEY_LENGTH)` or `CryptoCipherError(INVALID_NONCE_LENGTH)`.
### `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
**Returns:** Decrypted plaintext.
**Throws:** `CryptoCipherError(DECRYPT_FAILED)` on authentication failure; `CryptoCipherError(INVALID_KEY_LENGTH | INVALID_NONCE_LENGTH)` on length violations.
### Constants
- `KEY_LENGTH = 32`, `NONCE_LENGTH = 12`, `TAG_LENGTH = 16`
## Implementation Notes
Noble's `chacha20poly1305(key, nonce, aad)` follows the same construction pattern as `gcm`: it returns an object with `.encrypt` and `.decrypt`. The AAD is passed at construction time and bound into the Poly1305 tag computation per RFC 8439 §2.8. ChaCha20 is immune to timing attacks related to data values (unlike AES's S-box lookups on platforms without AES-NI), making it the preferred choice for constant-time environments. RFC 8439 §A.5 test vectors pass in the test suite.

View File

@@ -0,0 +1,70 @@
import { chacha20poly1305 } from "@noble/ciphers/chacha";
import { CRYPTO_CIPHER_CODES } from "../../../../errors/codes/codes.js";
import { CryptoCipherError } from "../../../../errors/crypto-cipher-error/crypto-cipher-error.js";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 12;
export const TAG_LENGTH = 16;
function validate(key: Uint8Array, nonce: Uint8Array): void {
if (key.length !== KEY_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH,
"error.crypto.cipher.invalid-key-length",
{
context: {
algorithm: "chacha20-poly1305",
expected: KEY_LENGTH,
actual: key.length,
},
}
);
}
if (nonce.length !== NONCE_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_NONCE_LENGTH,
"error.crypto.cipher.invalid-nonce-length",
{
context: {
algorithm: "chacha20-poly1305",
expected: NONCE_LENGTH,
actual: nonce.length,
},
}
);
}
}
export function encrypt(
key: Uint8Array,
nonce: Uint8Array,
plaintext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
}
export function decrypt(
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
try {
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
} catch (cause) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.DECRYPT_FAILED,
"error.crypto.cipher.decrypt-failed",
{
cause,
context: {
algorithm: "chacha20-poly1305",
ciphertextLength: ciphertext.length,
},
}
);
}
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./chacha20-poly1305.noble.js";

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./chacha20-poly1305";

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./xchacha20-poly1305.native.js";

View File

@@ -0,0 +1,69 @@
# xchacha20-poly1305.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`xchacha20-poly1305.native.ts` is the native-backend placeholder for XChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. It exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, and `TAG_LENGTH` with the same types as the Noble implementation, but `encrypt` and `decrypt` throw `PrimitiveNotImplemented("xchacha20-poly1305")`.
XChaCha20-Poly1305 is available natively in libsodium as `crypto_secretbox_xchacha20poly1305` and is accessible through the `@suaveplan/crypto-sodium` engine. A future native implementation of this placeholder could delegate to libsodium or a Bun native binding. This stub keeps the selector layout complete until that work is ready.
## Features
- Exports `encrypt`, `decrypt`, `KEY_LENGTH = 32`, `NONCE_LENGTH = 24`, `TAG_LENGTH = 16`.
- `encrypt` and `decrypt` throw `PrimitiveNotImplemented("xchacha20-poly1305")`.
- Constants are usable regardless of implementation status.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets xchacha20-poly1305 to 'native'.
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
try {
encrypt(
new Uint8Array(KEY_LENGTH),
new Uint8Array(NONCE_LENGTH),
new Uint8Array(8)
);
} catch (err) {
// PrimitiveNotImplemented: xchacha20-poly1305
console.error(err);
}
```
## Usage: Advanced Example
```ts
import { KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
// Buffer allocation is safe regardless of which backend is active.
function generateKeyMaterial(): { key: Uint8Array; nonce: Uint8Array } {
return {
key: crypto.getRandomValues(new Uint8Array(KEY_LENGTH)),
nonce: crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)),
};
}
```
## API Reference
### `encrypt(_key, _nonce, _plaintext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("xchacha20-poly1305")`.
### `decrypt(_key, _nonce, _ciphertext, _aad?): Uint8Array`
Throws `PrimitiveNotImplemented("xchacha20-poly1305")`.
### Constants
- `KEY_LENGTH = 32` — 256-bit key.
- `NONCE_LENGTH = 24` — 192-bit extended nonce.
- `TAG_LENGTH = 16` — 128-bit Poly1305 tag.
## Implementation Notes
The `NONCE_LENGTH = 24` constant is explicitly defined here (not imported from the Noble file) to keep both implementations self-contained. This is consistent with the pattern used across all native stub files in the package. When the native binding is ready and IETF XChaCha20-Poly1305 draft test vectors pass, the placeholder functions are replaced and the selector regenerated. The `PrimitiveNotImplemented` error from `@suaveplan/crypto-utils/errors` is a `SuaveplanError` subclass and participates in the full structured error system including cause-chain traversal and OTel span events.

View File

@@ -0,0 +1,22 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 24;
export const TAG_LENGTH = 16;
export function encrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_plaintext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("xchacha20-poly1305");
}
export function decrypt(
_key: Uint8Array,
_nonce: Uint8Array,
_ciphertext: Uint8Array,
_aad?: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("xchacha20-poly1305");
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./xchacha20-poly1305.noble.js";

View File

@@ -0,0 +1,77 @@
# xchacha20-poly1305.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`xchacha20-poly1305.noble.ts` is the Noble-backed concrete implementation of XChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. It uses `xchacha20poly1305` from `@noble/ciphers/chacha`, validates key and nonce lengths before every call, and wraps decryption failures in `CryptoCipherError`.
XChaCha20-Poly1305 derives a subkey from the first 16 bytes of the 24-byte nonce using HChaCha20, then runs ChaCha20-Poly1305 with the remaining 8-byte nonce. This construction allows safe random-nonce generation for very large message volumes. This file is the production implementation; `xchacha20-poly1305.native.ts` is the placeholder.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — validates lengths, returns `ciphertext ‖ 16-byte Poly1305 tag`.
- `decrypt(key, nonce, ciphertext, aad?)` — validates lengths, verifies tag, throws `CryptoCipherError(DECRYPT_FAILED)` on mismatch.
- Exports `KEY_LENGTH = 32`, `NONCE_LENGTH = 24`, `TAG_LENGTH = 16`.
- Throws `CryptoCipherError(INVALID_KEY_LENGTH)` or `CryptoCipherError(INVALID_NONCE_LENGTH)` on violations.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const pt = new TextEncoder().encode("hello xchacha");
const ct = encrypt(key, nonce, pt);
const recovered = decrypt(key, nonce, ct);
console.log(new TextDecoder().decode(recovered)); // "hello xchacha"
```
## Usage: Advanced Example
```ts
import { encrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
// Encrypt a record for long-term storage with AAD from record metadata.
function encryptRecord(
key: Uint8Array,
record: Uint8Array,
metadataJson: string
): { nonce: Uint8Array; ciphertext: Uint8Array } {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const aad = new TextEncoder().encode(metadataJson);
return { nonce, ciphertext: encrypt(key, nonce, record, aad) };
}
```
## API Reference
### `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `Uint8Array` | 32-byte key. |
| `nonce` | `Uint8Array` | 24-byte extended nonce. |
| `plaintext` | `Uint8Array` | Data to encrypt. |
| `aad` | `Uint8Array` (optional) | Authenticated but not encrypted data. |
**Returns:** `ciphertext ‖ tag` of length `plaintext.length + 16`.
**Throws:** `CryptoCipherError(INVALID_KEY_LENGTH | INVALID_NONCE_LENGTH)`.
### `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
**Returns:** Decrypted plaintext.
**Throws:** `CryptoCipherError(DECRYPT_FAILED)` on tag mismatch; `CryptoCipherError(INVALID_KEY_LENGTH | INVALID_NONCE_LENGTH)` on length violations.
### Constants
- `KEY_LENGTH = 32`, `NONCE_LENGTH = 24`, `TAG_LENGTH = 16`
## Implementation Notes
Noble's `xchacha20poly1305(key, nonce, aad)` handles the HChaCha20 subkey derivation step and the subsequent ChaCha20-Poly1305 call transparently. The NONCE_LENGTH of 24 is the only structural difference from `chacha20-poly1305.noble.ts`; the validation logic and error handling are otherwise identical. The extended nonce makes random nonce generation safe for up to approximately 2⁸⁰ messages per key before collision probability becomes non-negligible. Draft test vectors from the IETF Crypto Forum Research Group pass in the test suite.

View File

@@ -0,0 +1,70 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { CRYPTO_CIPHER_CODES } from "../../../../errors/codes/codes.js";
import { CryptoCipherError } from "../../../../errors/crypto-cipher-error/crypto-cipher-error.js";
export const KEY_LENGTH = 32;
export const NONCE_LENGTH = 24;
export const TAG_LENGTH = 16;
function validate(key: Uint8Array, nonce: Uint8Array): void {
if (key.length !== KEY_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_KEY_LENGTH,
"error.crypto.cipher.invalid-key-length",
{
context: {
algorithm: "xchacha20-poly1305",
expected: KEY_LENGTH,
actual: key.length,
},
}
);
}
if (nonce.length !== NONCE_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.INVALID_NONCE_LENGTH,
"error.crypto.cipher.invalid-nonce-length",
{
context: {
algorithm: "xchacha20-poly1305",
expected: NONCE_LENGTH,
actual: nonce.length,
},
}
);
}
}
export function encrypt(
key: Uint8Array,
nonce: Uint8Array,
plaintext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
return xchacha20poly1305(key, nonce, aad).encrypt(plaintext);
}
export function decrypt(
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array,
aad?: Uint8Array
): Uint8Array {
validate(key, nonce);
try {
return xchacha20poly1305(key, nonce, aad).decrypt(ciphertext);
} catch (cause) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.DECRYPT_FAILED,
"error.crypto.cipher.decrypt-failed",
{
cause,
context: {
algorithm: "xchacha20-poly1305",
ciphertextLength: ciphertext.length,
},
}
);
}
}

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./xchacha20-poly1305";

View File

@@ -0,0 +1,73 @@
# xchacha20-poly1305
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [xchacha20-poly1305.native](./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_ - [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305](./xchacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- xchacha20-poly1305.native — <!-- begin:auto-docs -->…
- [xchacha20-poly1305.native](./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305](./xchacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.native](./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305](./xchacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- xchacha20-poly1305.native —…
- [xchacha20-poly1305.native](./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305](./xchacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_- xchacha20-poly1305.native — <!-- begin:auto-docs --…
- [xchacha20-poly1305.native](./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_ - [xchacha20-poly1305.noble](./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_ - [xchacha20-poly1305.select](./xchacha20-poly1305.select/xchacha20-poly1305.select.md) — <!-- begin:auto-docs --> _(no submodules)_- [xchacha20-poly1305](./xchacha20-poly1305.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`xchacha20-poly1305.ts` is the stable public entry point for the XChaCha20-Poly1305 AEAD primitive in `@suaveplan/crypto-cipher`. It re-exports from the generated `xchacha20-poly1305.select.ts`, which forwards to the Noble-backed or native implementation based on `crypto.config.ts`.
XChaCha20-Poly1305 is an extension of ChaCha20-Poly1305 with a 24-byte (192-bit) nonce instead of the standard 12-byte nonce. The extended nonce allows safe random generation at high message volumes — the birthday bound for nonce collision with random 24-byte nonces is approximately 2⁸⁰ messages per key, compared to 2⁴⁸ for 12-byte nonces. It is used in libsodium's `crypto_secretbox_xsalsa20poly1305` and is the default AEAD in protocols like Signal's `@suaveplan/crypto` façade.
## Features
- `encrypt(key, nonce, plaintext, aad?)` — AEAD encryption; returns `ciphertext ‖ tag`.
- `decrypt(key, nonce, ciphertext, aad?)` — AEAD decryption; throws `CryptoCipherError` on failure.
- `KEY_LENGTH = 32` — 256-bit key.
- `NONCE_LENGTH = 24` — 192-bit extended nonce (distinguishes from standard ChaCha20-Poly1305).
- `TAG_LENGTH = 16` — 128-bit Poly1305 authentication tag.
- Key and nonce length validated before every operation.
## Usage: Basic Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
const key = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); // 24 bytes
const plaintext = new TextEncoder().encode("xchacha test");
const ciphertext = encrypt(key, nonce, plaintext);
const recovered = decrypt(key, nonce, ciphertext);
console.log(new TextDecoder().decode(recovered)); // "xchacha test"
```
## Usage: Advanced Example
```ts
import { encrypt, decrypt, KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
// Store-and-forward encryption with random nonce prepended to ciphertext.
function encryptForStorage(key: Uint8Array, plaintext: Uint8Array): Uint8Array {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const ct = encrypt(key, nonce, plaintext);
const out = new Uint8Array(NONCE_LENGTH + ct.length);
out.set(nonce, 0);
out.set(ct, NONCE_LENGTH);
return out;
}
function decryptFromStorage(key: Uint8Array, blob: Uint8Array): Uint8Array {
const nonce = blob.slice(0, NONCE_LENGTH);
const ct = blob.slice(NONCE_LENGTH);
return decrypt(key, nonce, ct);
}
```
## API Reference
All symbols re-exported from `xchacha20-poly1305.select.ts`:
- `encrypt(key, nonce, plaintext, aad?): Uint8Array`
- `decrypt(key, nonce, ciphertext, aad?): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 24`
- `TAG_LENGTH: 16`
## Implementation Notes
This file is a zero-logic re-export barrel. The distinguishing constant is `NONCE_LENGTH = 24`, not 12 as in standard ChaCha20-Poly1305. Prepending the random nonce to the ciphertext is a common pattern for store-and-forward scenarios, as shown in the advanced example. The nonce is not secret and safe to transmit alongside the ciphertext.

View File

@@ -0,0 +1 @@
export * from "./xchacha20-poly1305.select.js";

View File

@@ -0,0 +1,56 @@
# xchacha20-poly1305.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`xchacha20-poly1305.select.ts` is the build-time primitive selector for XChaCha20-Poly1305 in `@suaveplan/crypto-cipher`. Generated by `bun scripts/gen-crypto-selects.ts`, it re-exports one backend — `xchacha20-poly1305.noble.ts` or `xchacha20-poly1305.native.ts` — based on the `xchacha20-poly1305` field in `crypto.config.ts`.
Callers import through the stable `xchacha20-poly1305.ts` entry point. This selector level means backend changes require only regenerating this file; all call sites above it remain unchanged. Currently the Noble backend is selected.
## Features
- Re-exports `encrypt`, `decrypt`, `KEY_LENGTH`, `NONCE_LENGTH`, `TAG_LENGTH`.
- Generated — never hand-edited.
- Controlled by `crypto.config.ts`: set `xchacha20-poly1305` to `'noble'` or `'native'`.
- Enables dead-code elimination of the inactive backend.
## Usage: Basic Example
```ts
import { KEY_LENGTH, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
console.log(KEY_LENGTH); // 32
console.log(NONCE_LENGTH); // 24 (XChaCha20's extended nonce)
```
## Usage: Advanced Example
```ts
import { encrypt, decrypt, NONCE_LENGTH } from "@suaveplan/crypto-cipher/xchacha20-poly1305";
// Round-trip with random nonce.
function roundTrip(key: Uint8Array, msg: Uint8Array): Uint8Array {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
return decrypt(key, nonce, encrypt(key, nonce, msg));
}
```
## API Reference
Re-exports from the active implementation:
- `encrypt(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `decrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array`
- `KEY_LENGTH: 32`
- `NONCE_LENGTH: 24`
- `TAG_LENGTH: 16`
## Implementation Notes
This file is a generated one-liner marked `/* GENERATED — DO NOT EDIT */`. The `NONCE_LENGTH` of 24 is the defining characteristic of XChaCha20 relative to standard ChaCha20. The generator resolves the `xchacha20-poly1305` backend from `crypto.config.ts` and emits the correct `export *` path. Regenerating is idempotent.
### Selection Logic
At build time `crypto.config.ts` picks the Noble or native engine for XChaCha20-Poly1305 — the 24-byte-nonce variant suitable for random-nonce encryption of large numbers of messages under a single key. The selector routes every call to the chosen impl and throws `PrimitiveNotImplemented` when the selected engine has no native binding yet. Nonce is caller-supplied in every path.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.js";

View File

@@ -0,0 +1,74 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import * as native from "./impl/xchacha20-poly1305.native/xchacha20-poly1305.native.js";
import * as noble from "./impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.js";
describe("xchacha20-poly1305 (noble)", () => {
it("round-trips with 24-byte nonce", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x04);
const nonce = new Uint8Array(24).fill(0x05);
const pt = new TextEncoder().encode("hello xchacha");
const ct = noble.encrypt(key, nonce, pt);
expect(ct.length).toBe(pt.length + 16);
expect(noble.decrypt(key, nonce, ct)).toEqual(pt);
});
it("AAD is authenticated", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x04);
const nonce = new Uint8Array(24).fill(0x05);
const pt = new TextEncoder().encode("payload");
const aad = new TextEncoder().encode("header");
const ct = noble.encrypt(key, nonce, pt, aad);
expect(noble.decrypt(key, nonce, ct, aad)).toEqual(pt);
expect(() => noble.decrypt(key, nonce, ct)).toThrow(CryptoCipherError);
});
it("rejects 12-byte nonce (wrong size)", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(
new Uint8Array(32),
new Uint8Array(12),
new Uint8Array()
)
).toThrow(CryptoCipherError);
});
it("rejects wrong key length", { timeout: 5000 }, () => {
expect(() =>
noble.encrypt(
new Uint8Array(16),
new Uint8Array(24),
new Uint8Array()
)
).toThrow(CryptoCipherError);
});
it("flipped ciphertext → decrypt fails", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x04);
const nonce = new Uint8Array(24).fill(0x05);
const pt = new TextEncoder().encode("payload");
const ct = noble.encrypt(key, nonce, pt);
ct[0] = (ct[0] ?? 0) ^ 0xff;
expect(() => noble.decrypt(key, nonce, ct)).toThrow(CryptoCipherError);
});
});
describe("xchacha20-poly1305 (native stub)", () => {
it("throws PrimitiveNotImplemented", { timeout: 5000 }, () => {
expect(() =>
native.encrypt(
new Uint8Array(32),
new Uint8Array(24),
new Uint8Array()
)
).toThrow(PrimitiveNotImplemented);
expect(() =>
native.decrypt(
new Uint8Array(32),
new Uint8Array(24),
new Uint8Array(16)
)
).toThrow(PrimitiveNotImplemented);
});
});

View File

@@ -0,0 +1,7 @@
export {
decrypt,
encrypt,
KEY_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./xchacha20-poly1305.select/xchacha20-poly1305.select.js";

View File

@@ -0,0 +1,6 @@
export {
aesGcmStreamingEngine,
BASE_NONCE_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./streaming-aead-aes-gcm.js";

View File

@@ -0,0 +1,75 @@
# streaming-aead-aes-gcm
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`streaming-aead-aes-gcm.ts` is the AES-256-GCM adapter that satisfies the `StreamingAeadEngine` contract used by `encryptStreamAead` / `decryptStreamAead`. It wraps the existing `aes-256-gcm` AEAD primitive with the per-chunk nonce-derivation and counter-discipline rules that the streaming layer requires. AES-256-GCM is the right choice when interoperability with FIPS-validated stacks or hardware AES-NI acceleration is required.
The engine declares an 8-byte caller-supplied base nonce; the per-chunk 12-byte nonce is `baseNonce ‖ counter_be32` (RFC 5116 §3.2 compatible). The high bit of the 32-bit counter carries the final-flag bit so the legitimate terminator chunk uses a different nonce than a truncated data chunk at the same counter index.
## Features
- Implements the `StreamingAeadEngine` contract — drop-in for the streaming generators.
- 8-byte base nonce / 12-byte per-chunk nonce / 16-byte AEAD tag.
- Counter-discipline guard: rejects negative or `> 2^32 - 1` counters with `STREAM_OUT_OF_ORDER`.
- Base-nonce length guard: rejects any base nonce whose length is not exactly 8 bytes with `STREAM_INVALID_NONCE_BASE`.
- Tag-mismatch promotion: open-side tag failures route to `STREAM_TAG_MISMATCH` (with the original `DECRYPT_FAILED` as cause) so streaming callers see the streaming-shaped error.
## Usage: Basic Example
```ts
import { aesGcmStreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-aes-gcm";
import { encryptStreamAead, decryptStreamAead } from "@suaveplan/crypto-cipher/streaming/streaming-aead";
const key = crypto.getRandomValues(new Uint8Array(32));
const baseNonce = crypto.getRandomValues(new Uint8Array(8));
for await (const chunk of encryptStreamAead(key, source(), {
engine: aesGcmStreamingEngine,
baseNonce,
})) {
sink.write(chunk);
}
```
## Usage: Advanced Example
```ts
import { aesGcmStreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-aes-gcm";
// Direct seal/open use without the framing layer (e.g. embedding into a
// custom transport). Always derive a fresh per-chunk nonce — never reuse a
// (key, baseNonce, counter, finalFlag) tuple.
const ct = await aesGcmStreamingEngine.seal({
key,
baseNonce,
counter: 0,
finalFlag: 0,
data: plaintext,
aad: contextBytes,
});
const pt = await aesGcmStreamingEngine.open({
key,
baseNonce,
counter: 0,
finalFlag: 0,
data: ct,
aad: contextBytes,
});
```
## API Reference
- `aesGcmStreamingEngine: StreamingAeadEngine` — frozen engine instance.
- `BASE_NONCE_LENGTH: 8`
- `NONCE_LENGTH: 12`
- `TAG_LENGTH: 16`
Errors: `STREAM_INVALID_NONCE_BASE`, `STREAM_OUT_OF_ORDER`, `STREAM_TAG_MISMATCH`.
## Implementation Notes
AES-GCM requires unique `(key, nonce)` pairs — reuse destroys both confidentiality and authenticity. The engine enforces this through deterministic nonce derivation: any caller that supplies the same base nonce and counter index will produce the same nonce, but the streaming generator increments the counter once per emitted chunk and binds the final-flag into the high bit of the counter so terminator and data chunks at the same index produce different nonces. Counter-overflow at `2^32` chunks short-circuits before reuse can occur; in practice this is well above the practical limit since each chunk is bounded by AES-GCM's `2^36 - 32`-byte plaintext limit per nonce.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import { aesGcmStreamingEngine } from "./streaming-aead-aes-gcm.js";
describe("aesGcmStreamingEngine — conformance", () => {
it("rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
aesGcmStreamingEngine.deriveNonce(new Uint8Array(12), 0, 0);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_INVALID_NONCE_BASE"
);
expect((caught as CryptoCipherError).context?.expectedLength).toBe(8);
expect((caught as CryptoCipherError).context?.actualLength).toBe(12);
});
it("declares stable wire identity", { timeout: 5000 }, () => {
expect(aesGcmStreamingEngine.id).toBe("aes-256-gcm");
expect(aesGcmStreamingEngine.wireId).toBe(0x02);
expect(aesGcmStreamingEngine.keyLength).toBe(32);
expect(aesGcmStreamingEngine.nonceLength).toBe(12);
expect(aesGcmStreamingEngine.nonceBaseLength).toBe(8);
expect(aesGcmStreamingEngine.tagLength).toBe(16);
});
});

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import {
aesGcmStreamingEngine,
BASE_NONCE_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
} from "./streaming-aead-aes-gcm.js";
describe("aesGcmStreamingEngine", () => {
it("derives 12-byte nonce from 8-byte base + counter", {
timeout: 5000,
}, () => {
const base = new Uint8Array(BASE_NONCE_LENGTH).fill(0xab);
const nonce = aesGcmStreamingEngine.deriveNonce(base, 0, 0);
expect(nonce.length).toBe(NONCE_LENGTH);
// First 8 bytes equal base nonce
expect(nonce.subarray(0, 8)).toEqual(base);
// Last 4 bytes encode counter 0 BE
expect(nonce.subarray(8)).toEqual(new Uint8Array([0, 0, 0, 0]));
});
it("counter 5 produces nonce ending [0,0,0,5]", { timeout: 5000 }, () => {
const base = new Uint8Array(BASE_NONCE_LENGTH);
const nonce = aesGcmStreamingEngine.deriveNonce(base, 5, 0);
expect(nonce.subarray(8)).toEqual(new Uint8Array([0, 0, 0, 5]));
});
it("finalFlag=1 sets the high counter bit", { timeout: 5000 }, () => {
const base = new Uint8Array(BASE_NONCE_LENGTH);
const nonce = aesGcmStreamingEngine.deriveNonce(base, 0, 1);
expect(nonce.subarray(8)).toEqual(new Uint8Array([0x80, 0, 0, 0]));
});
it("seal then open round-trips bytes for a single chunk", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(32).fill(0x01);
const base = new Uint8Array(BASE_NONCE_LENGTH).fill(0x02);
const data = new TextEncoder().encode("hello");
const ct = await aesGcmStreamingEngine.seal({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data,
});
expect(ct.length).toBe(data.length + TAG_LENGTH);
const pt = await aesGcmStreamingEngine.open({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data: ct,
});
expect(pt).toEqual(data);
});
it("AAD is bound to the AEAD tag", { timeout: 5000 }, async () => {
const key = new Uint8Array(32);
const base = new Uint8Array(BASE_NONCE_LENGTH);
const aad = new TextEncoder().encode("meta");
const ct = await aesGcmStreamingEngine.seal({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data: new TextEncoder().encode("payload"),
aad,
});
let caught: unknown;
try {
await aesGcmStreamingEngine.open({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data: ct,
aad: new TextEncoder().encode("wrong"),
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
});
});

View File

@@ -0,0 +1,116 @@
import { CRYPTO_CIPHER_CODES } from "../../errors/codes/codes.js";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import * as aesGcm from "../../primitives/aes-256-gcm/impl/aes-256-gcm.noble/aes-256-gcm.noble.js";
import {
STREAMING_AEAD_MAX_COUNTER,
type StreamingAeadEngine,
type StreamingChunkInput,
} from "../streaming-aead/streaming-aead.types.js";
/** Length in bytes of the caller-supplied base nonce. */
export const BASE_NONCE_LENGTH = 8;
/** Per-chunk nonce length (RFC 5116-compatible 12 bytes). */
export const NONCE_LENGTH = 12;
/** AEAD tag length in bytes. */
export const TAG_LENGTH = 16;
function checkBaseNonce(baseNonce: Uint8Array): void {
if (baseNonce.length !== BASE_NONCE_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_INVALID_NONCE_BASE,
"error.crypto.cipher.stream-invalid-nonce-base",
{
context: {
engine: "aes-256-gcm",
expectedLength: BASE_NONCE_LENGTH,
actualLength: baseNonce.length,
},
}
);
}
}
function checkCounter(counter: number): void {
if (
!Number.isInteger(counter) ||
counter < 0 ||
counter > STREAMING_AEAD_MAX_COUNTER
) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_OUT_OF_ORDER,
"error.crypto.cipher.stream-out-of-order",
{
context: {
engine: "aes-256-gcm",
counter,
},
}
);
}
}
function deriveNonceImpl(
baseNonce: Uint8Array,
counter: number,
finalFlag: 0 | 1
): Uint8Array {
checkBaseNonce(baseNonce);
checkCounter(counter);
const nonce = new Uint8Array(NONCE_LENGTH);
nonce.set(baseNonce, 0);
let tagged = counter >>> 0;
if (finalFlag === 1) {
// Fold final-flag into the most-significant bit of the 32-bit counter.
tagged = (tagged | 0x80_00_00_00) >>> 0;
}
const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LENGTH);
view.setUint32(BASE_NONCE_LENGTH, tagged, false);
return nonce;
}
/**
* AES-256-GCM streaming AEAD engine. Per-chunk nonce = 8-byte base nonce ||
* 32-bit counter (RFC 5116 §3.2 compatible). The high bit of the 32-bit
* counter carries the final-flag so a truncated terminal chunk fails the
* AEAD tag check rather than being silently accepted.
*/
export const aesGcmStreamingEngine: StreamingAeadEngine = {
id: "aes-256-gcm",
wireId: 0x02,
keyLength: aesGcm.KEY_LENGTH,
nonceLength: NONCE_LENGTH,
nonceBaseLength: BASE_NONCE_LENGTH,
tagLength: TAG_LENGTH,
deriveNonce: deriveNonceImpl,
async seal(input: StreamingChunkInput): Promise<Uint8Array> {
const nonce = deriveNonceImpl(
input.baseNonce,
input.counter,
input.finalFlag
);
return aesGcm.encrypt(input.key, nonce, input.data, input.aad);
},
async open(input: StreamingChunkInput): Promise<Uint8Array> {
const nonce = deriveNonceImpl(
input.baseNonce,
input.counter,
input.finalFlag
);
try {
return aesGcm.decrypt(input.key, nonce, input.data, input.aad);
} catch (cause) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-tag-mismatch",
{
cause,
context: {
engine: "aes-256-gcm",
counter: input.counter,
finalFlag: input.finalFlag,
},
}
);
}
},
};

View File

@@ -0,0 +1,6 @@
export {
BASE_NONCE_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
xchacha20StreamingEngine,
} from "./streaming-aead-xchacha20.js";

View File

@@ -0,0 +1,66 @@
# streaming-aead-xchacha20
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`streaming-aead-xchacha20.ts` is the XChaCha20-Poly1305 adapter that satisfies the `StreamingAeadEngine` contract used by `encryptStreamAead` / `decryptStreamAead`. XChaCha20-Poly1305 is preferred over AES-GCM when the deployment runs without hardware AES acceleration or when the 24-byte extended nonce makes random-nonce schemes safer (the `2^96` random-nonce birthday-bound is comfortable, where AES-GCM's `2^48` is not).
The engine declares a 20-byte caller-supplied base nonce; the per-chunk 24-byte nonce is `baseNonce ‖ counter_be32`. The high bit of the 32-bit counter carries the final-flag so terminator and data chunks at the same counter index produce different nonces. This is the default streaming engine — callers who do not pass `engine:` to `encryptStreamAead` / `decryptStreamAead` get this one.
## Features
- Implements the `StreamingAeadEngine` contract — drop-in for the streaming generators.
- 20-byte base nonce / 24-byte per-chunk nonce / 16-byte AEAD tag.
- Counter-discipline guard: rejects negative or `> 2^32 - 1` counters with `STREAM_OUT_OF_ORDER`.
- Base-nonce length guard: rejects any base nonce whose length is not exactly 20 bytes with `STREAM_INVALID_NONCE_BASE`.
- Tag-mismatch promotion: open-side tag failures route to `STREAM_TAG_MISMATCH` (with the original `DECRYPT_FAILED` as cause) so streaming callers see the streaming-shaped error.
## Usage: Basic Example
```ts
import { xchacha20StreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-xchacha20";
import { encryptStreamAead } from "@suaveplan/crypto-cipher/streaming/streaming-aead";
const key = crypto.getRandomValues(new Uint8Array(32));
// xchacha20StreamingEngine is the default — passing it explicitly is optional.
for await (const chunk of encryptStreamAead(key, source(), {
engine: xchacha20StreamingEngine,
})) {
sink.write(chunk);
}
```
## Usage: Advanced Example
```ts
import { xchacha20StreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-xchacha20";
// Use the engine directly without the framing layer to embed XChaCha20-Poly1305
// into a custom transport. The 20-byte base nonce gives plenty of headroom for
// random nonces if the caller derives one per-stream rather than per-chunk.
const baseNonce = crypto.getRandomValues(new Uint8Array(20));
const ct = await xchacha20StreamingEngine.seal({
key,
baseNonce,
counter: 0,
finalFlag: 0,
data: plaintext,
aad: contextBytes,
});
```
## API Reference
- `xchacha20StreamingEngine: StreamingAeadEngine` — frozen engine instance.
- `BASE_NONCE_LENGTH: 20`
- `NONCE_LENGTH: 24`
- `TAG_LENGTH: 16`
Errors: `STREAM_INVALID_NONCE_BASE`, `STREAM_OUT_OF_ORDER`, `STREAM_TAG_MISMATCH`.
## Implementation Notes
XChaCha20-Poly1305 (libsodium specification, RFC draft) extends the ChaCha20 nonce from 12 to 24 bytes via HChaCha20 sub-key derivation. The 24-byte nonce makes random nonces safe (`2^96` random nonces have a `~10^-19` collision probability), which is the basis for the streaming layer using a random base nonce plus a counter. Counter-overflow at `2^32` chunks short-circuits before reuse can occur. This adapter does not generate the base nonce — the streaming generator supplies it (random by default; deterministic via the `baseNonce` option for tests).

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import { xchacha20StreamingEngine } from "./streaming-aead-xchacha20.js";
describe("xchacha20StreamingEngine — conformance", () => {
it("rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
xchacha20StreamingEngine.deriveNonce(new Uint8Array(24), 0, 0);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_INVALID_NONCE_BASE"
);
expect((caught as CryptoCipherError).context?.expectedLength).toBe(20);
expect((caught as CryptoCipherError).context?.actualLength).toBe(24);
});
it("declares stable wire identity", { timeout: 5000 }, () => {
expect(xchacha20StreamingEngine.id).toBe("xchacha20-poly1305");
expect(xchacha20StreamingEngine.wireId).toBe(0x01);
expect(xchacha20StreamingEngine.keyLength).toBe(32);
expect(xchacha20StreamingEngine.nonceLength).toBe(24);
expect(xchacha20StreamingEngine.nonceBaseLength).toBe(20);
expect(xchacha20StreamingEngine.tagLength).toBe(16);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import {
BASE_NONCE_LENGTH,
NONCE_LENGTH,
TAG_LENGTH,
xchacha20StreamingEngine,
} from "./streaming-aead-xchacha20.js";
describe("xchacha20StreamingEngine", () => {
it("derives 24-byte nonce from 20-byte base + counter", {
timeout: 5000,
}, () => {
const base = new Uint8Array(BASE_NONCE_LENGTH).fill(0xab);
const nonce = xchacha20StreamingEngine.deriveNonce(base, 5, 0);
expect(nonce.length).toBe(NONCE_LENGTH);
expect(nonce.subarray(0, 20)).toEqual(base);
expect(nonce.subarray(20)).toEqual(new Uint8Array([0, 0, 0, 5]));
});
it("finalFlag=1 sets the high counter bit", { timeout: 5000 }, () => {
const base = new Uint8Array(BASE_NONCE_LENGTH);
const nonce = xchacha20StreamingEngine.deriveNonce(base, 0, 1);
expect(nonce.subarray(20)).toEqual(new Uint8Array([0x80, 0, 0, 0]));
});
it("seal then open round-trips bytes for a single chunk", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(32).fill(0x07);
const base = new Uint8Array(BASE_NONCE_LENGTH).fill(0x09);
const data = new TextEncoder().encode("hello world");
const ct = await xchacha20StreamingEngine.seal({
key,
baseNonce: base,
counter: 1,
finalFlag: 0,
data,
});
expect(ct.length).toBe(data.length + TAG_LENGTH);
const pt = await xchacha20StreamingEngine.open({
key,
baseNonce: base,
counter: 1,
finalFlag: 0,
data: ct,
});
expect(pt).toEqual(data);
});
it("AAD is bound to the AEAD tag", { timeout: 5000 }, async () => {
const key = new Uint8Array(32);
const base = new Uint8Array(BASE_NONCE_LENGTH);
const ct = await xchacha20StreamingEngine.seal({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data: new TextEncoder().encode("payload"),
aad: new TextEncoder().encode("meta"),
});
let caught: unknown;
try {
await xchacha20StreamingEngine.open({
key,
baseNonce: base,
counter: 0,
finalFlag: 0,
data: ct,
aad: new TextEncoder().encode("wrong"),
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
});
});

View File

@@ -0,0 +1,113 @@
import { CRYPTO_CIPHER_CODES } from "../../errors/codes/codes.js";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import * as xchacha from "../../primitives/xchacha20-poly1305/impl/xchacha20-poly1305.noble/xchacha20-poly1305.noble.js";
import {
STREAMING_AEAD_MAX_COUNTER,
type StreamingAeadEngine,
type StreamingChunkInput,
} from "../streaming-aead/streaming-aead.types.js";
/** Length in bytes of the caller-supplied base nonce. */
export const BASE_NONCE_LENGTH = 20;
/** Per-chunk nonce length (24 bytes — XChaCha20 extended nonce). */
export const NONCE_LENGTH = 24;
/** AEAD tag length in bytes. */
export const TAG_LENGTH = 16;
function checkBaseNonce(baseNonce: Uint8Array): void {
if (baseNonce.length !== BASE_NONCE_LENGTH) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_INVALID_NONCE_BASE,
"error.crypto.cipher.stream-invalid-nonce-base",
{
context: {
engine: "xchacha20-poly1305",
expectedLength: BASE_NONCE_LENGTH,
actualLength: baseNonce.length,
},
}
);
}
}
function checkCounter(counter: number): void {
if (
!Number.isInteger(counter) ||
counter < 0 ||
counter > STREAMING_AEAD_MAX_COUNTER
) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_OUT_OF_ORDER,
"error.crypto.cipher.stream-out-of-order",
{
context: {
engine: "xchacha20-poly1305",
counter,
},
}
);
}
}
function deriveNonceImpl(
baseNonce: Uint8Array,
counter: number,
finalFlag: 0 | 1
): Uint8Array {
checkBaseNonce(baseNonce);
checkCounter(counter);
const nonce = new Uint8Array(NONCE_LENGTH);
nonce.set(baseNonce, 0);
let tagged = counter >>> 0;
if (finalFlag === 1) {
tagged = (tagged | 0x80_00_00_00) >>> 0;
}
const view = new DataView(nonce.buffer, nonce.byteOffset, NONCE_LENGTH);
view.setUint32(BASE_NONCE_LENGTH, tagged, false);
return nonce;
}
/**
* XChaCha20-Poly1305 streaming AEAD engine. Per-chunk nonce = 20-byte base
* nonce || 32-bit counter. High bit of the counter carries the final-flag.
*/
export const xchacha20StreamingEngine: StreamingAeadEngine = {
id: "xchacha20-poly1305",
wireId: 0x01,
keyLength: xchacha.KEY_LENGTH,
nonceLength: NONCE_LENGTH,
nonceBaseLength: BASE_NONCE_LENGTH,
tagLength: TAG_LENGTH,
deriveNonce: deriveNonceImpl,
async seal(input: StreamingChunkInput): Promise<Uint8Array> {
const nonce = deriveNonceImpl(
input.baseNonce,
input.counter,
input.finalFlag
);
return xchacha.encrypt(input.key, nonce, input.data, input.aad);
},
async open(input: StreamingChunkInput): Promise<Uint8Array> {
const nonce = deriveNonceImpl(
input.baseNonce,
input.counter,
input.finalFlag
);
try {
return xchacha.decrypt(input.key, nonce, input.data, input.aad);
} catch (cause) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-tag-mismatch",
{
cause,
context: {
engine: "xchacha20-poly1305",
counter: input.counter,
finalFlag: input.finalFlag,
},
}
);
}
},
};

View File

@@ -0,0 +1,16 @@
export {
decryptStreamAead,
encryptStreamAead,
} from "./streaming-aead.js";
export {
type DecryptStreamAeadOptions,
type EncryptStreamAeadOptions,
STREAMING_AEAD_MAGIC,
STREAMING_AEAD_MAX_COUNTER,
STREAMING_AEAD_VERSION,
type StreamingAeadEngine,
type StreamingChunkInput,
type StreamingChunkTag,
type StreamingHeader,
streamingAeadHeaderLength,
} from "./streaming-aead.types.js";

View File

@@ -0,0 +1,96 @@
# streaming-aead
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`streaming-aead.ts` is the engine-agnostic streaming AEAD generator pair (`encryptStreamAead` / `decryptStreamAead`) in `@suaveplan/crypto-cipher`. It frames an arbitrary `AsyncIterable<Uint8Array>` source of plaintext into a self-describing wire format with per-chunk authentication and a final-flag terminator that binds the total chunk count into the AEAD tag, so silent truncation of the trailing chunk is impossible.
The generators delegate per-chunk crypto to a pluggable `StreamingAeadEngine` (default: `xchacha20StreamingEngine`). The engine boundary is the substitution point that lets callers swap in AES-256-GCM, XChaCha20-Poly1305, or a hardware-backed engine without changing the framing protocol or the public API surface.
## Features
- Async-generator API consumes any `AsyncIterable<Uint8Array>` or sync `Iterable<Uint8Array>` source.
- Framed wire format: 8-byte header prefix + variable-length base nonce, then `[ciphertextLen BE | finalFlag | ciphertext‖tag]` per chunk.
- Final-flag terminator chunk binds the total chunk count into the AEAD tag.
- `AbortSignal` propagation — aborting flushes the in-flight chunk and emits `STREAM_ABORTED`.
- Optional `onHeader` / `onChunk` callbacks for attestation and observability without breaking the streaming contract.
- Engine pluggability (the default is XChaCha20-Poly1305; AES-256-GCM is available via `aesGcmStreamingEngine`).
- Counter-overflow guard at `2^32` chunks — emits `STREAM_OUT_OF_ORDER`.
- Tag-mismatch detection on every chunk emits `STREAM_TAG_MISMATCH`.
- `allowUnterminated` opt-in for streams produced by upstream tooling that omit the final-flag.
## Usage: Basic Example
```ts
import {
encryptStreamAead,
decryptStreamAead,
} from "@suaveplan/crypto-cipher/streaming/streaming-aead";
async function* source() {
yield new TextEncoder().encode("part one ");
yield new TextEncoder().encode("part two");
}
const key = crypto.getRandomValues(new Uint8Array(32));
const wire: Uint8Array[] = [];
for await (const chunk of encryptStreamAead(key, source())) {
wire.push(chunk);
}
async function* replay() {
for (const c of wire) yield c;
}
const out: Uint8Array[] = [];
for await (const chunk of decryptStreamAead(key, replay())) {
out.push(chunk);
}
```
## Usage: Advanced Example
```ts
import {
encryptStreamAead,
decryptStreamAead,
} from "@suaveplan/crypto-cipher/streaming/streaming-aead";
import { aesGcmStreamingEngine } from "@suaveplan/crypto-cipher/streaming/streaming-aead-aes-gcm";
const key = crypto.getRandomValues(new Uint8Array(32));
const baseNonce = crypto.getRandomValues(new Uint8Array(8));
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 250); // abort half-way through a long stream
try {
for await (const chunk of encryptStreamAead(key, longSource(), {
engine: aesGcmStreamingEngine,
baseNonce,
signal: ctrl.signal,
onHeader: (h) => console.log("header", h.engineId),
onChunk: (t) => console.log("chunk", t.counter, t.finalFlag),
})) {
sink.write(chunk);
}
} catch (err) {
if (err instanceof Error && (err as { code?: string }).code === "CRYPTO.CIPHER.STREAM_ABORTED") {
console.warn("aborted at chunk", (err as { context?: { chunksProcessed: number } }).context?.chunksProcessed);
}
}
```
## API Reference
- `encryptStreamAead(key, source, options?): AsyncGenerator<Uint8Array, void>` — frames `source` chunk-by-chunk and yields the wire bytes.
- `decryptStreamAead(key, source, options?): AsyncGenerator<Uint8Array, void>` — consumes wire bytes from `source`, yields recovered plaintext chunks.
- `EncryptStreamAeadOptions { engine?, baseNonce?, signal?, onHeader?, onChunk? }`.
- `DecryptStreamAeadOptions { engine?, allowUnterminated?, signal?, onHeader?, onChunk? }`.
Errors: every failure path throws `CryptoCipherError` with one of `STREAM_TAG_MISMATCH`, `STREAM_OUT_OF_ORDER`, `STREAM_ABORTED`, `STREAM_INVALID_NONCE_BASE`.
## Implementation Notes
The wire header is 8 bytes (magic[4] || engineWireId[1] || version[1] || baseNonceLen[2]) followed by the base nonce. Each chunk frame is `[ciphertextLen BE | finalFlag | ciphertext‖tag]`. The terminator frame uses an empty plaintext under finalFlag=1 so the AEAD tag binds the chunk count — a truncated stream that omits the terminator fails at decrypt time with `STREAM_TAG_MISMATCH`. The high bit of the 32-bit chunk counter carries the final-flag bit, so terminator and data chunks at the same counter index produce different nonces and cannot be confused.

View File

@@ -0,0 +1,245 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import { aesGcmStreamingEngine } from "../streaming-aead-aes-gcm/streaming-aead-aes-gcm.js";
import { xchacha20StreamingEngine } from "../streaming-aead-xchacha20/streaming-aead-xchacha20.js";
import { decryptStreamAead, encryptStreamAead } from "./streaming-aead.js";
import {
STREAMING_AEAD_MAX_COUNTER,
type StreamingAeadEngine,
streamingAeadHeaderLength,
} from "./streaming-aead.types.js";
async function* gen(chunks: Uint8Array[]): AsyncGenerator<Uint8Array> {
for (const c of chunks) yield c;
}
async function collect(iter: AsyncGenerator<Uint8Array>): Promise<Uint8Array> {
const parts: Uint8Array[] = [];
let total = 0;
for await (const p of iter) {
parts.push(p);
total += p.length;
}
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
describe("StreamingAeadEngine — contract conformance", () => {
for (const engine of [xchacha20StreamingEngine, aesGcmStreamingEngine]) {
describe(engine.id, () => {
it("declares stable wireId / keyLength / nonceLength / tagLength", {
timeout: 5000,
}, () => {
expect(typeof engine.wireId).toBe("number");
expect(engine.keyLength).toBe(32);
expect(engine.tagLength).toBe(16);
expect(engine.nonceLength).toBe(engine.nonceBaseLength + 4);
});
it("derives a deterministic per-chunk nonce", {
timeout: 5000,
}, () => {
const base = new Uint8Array(engine.nonceBaseLength).fill(0xa5);
const a = engine.deriveNonce(base, 0, 0);
const b = engine.deriveNonce(base, 0, 0);
expect(a).toEqual(b);
expect(a.length).toBe(engine.nonceLength);
const c = engine.deriveNonce(base, 1, 0);
expect(a).not.toEqual(c);
const fin = engine.deriveNonce(base, 0, 1);
expect(a).not.toEqual(fin);
});
it("seal / open round-trip preserves bytes", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(engine.keyLength).fill(0x01);
const baseNonce = new Uint8Array(engine.nonceBaseLength).fill(
0x02
);
const data = new TextEncoder().encode("conformance check");
const aad = new TextEncoder().encode("metadata");
const ct = await engine.seal({
key,
baseNonce,
counter: 7,
finalFlag: 0,
data,
aad,
});
const pt = await engine.open({
key,
baseNonce,
counter: 7,
finalFlag: 0,
data: ct,
aad,
});
expect(pt).toEqual(data);
});
it("rejects wrong-length base nonce with STREAM_INVALID_NONCE_BASE", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(engine.keyLength);
const wrong = new Uint8Array(engine.nonceBaseLength + 1);
let caught: unknown;
try {
await engine.seal({
key,
baseNonce: wrong,
counter: 0,
finalFlag: 0,
data: new Uint8Array(0),
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_INVALID_NONCE_BASE"
);
expect(
(caught as CryptoCipherError).context
?.expectedLength as number
).toBe(engine.nonceBaseLength);
});
it("rejects out-of-range counter with STREAM_OUT_OF_ORDER", {
timeout: 5000,
}, async () => {
const baseNonce = new Uint8Array(engine.nonceBaseLength);
let caught: unknown;
try {
engine.deriveNonce(baseNonce, -1, 0);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_OUT_OF_ORDER"
);
let caught2: unknown;
try {
engine.deriveNonce(
baseNonce,
STREAMING_AEAD_MAX_COUNTER + 1,
0
);
} catch (e) {
caught2 = e;
}
expect(caught2).toBeInstanceOf(CryptoCipherError);
});
it("emits STREAM_TAG_MISMATCH on tag corruption inside the engine", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const ct = await engine.seal({
key,
baseNonce,
counter: 0,
finalFlag: 0,
data: new TextEncoder().encode("x"),
});
ct[ct.length - 1] = (ct[ct.length - 1] ?? 0) ^ 0xff;
let caught: unknown;
try {
await engine.open({
key,
baseNonce,
counter: 0,
finalFlag: 0,
data: ct,
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_TAG_MISMATCH"
);
});
});
}
});
describe("counter overflow handling", () => {
it("engine deriveNonce rejects counters > 2^32 - 1 with STREAM_OUT_OF_ORDER", {
timeout: 5000,
}, () => {
const engines = [xchacha20StreamingEngine, aesGcmStreamingEngine];
for (const engine of engines) {
let caught: unknown;
try {
engine.deriveNonce(
new Uint8Array(engine.nonceBaseLength),
STREAMING_AEAD_MAX_COUNTER + 1,
0
);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_OUT_OF_ORDER"
);
}
});
});
describe("streamingAeadHeaderLength", () => {
it("matches header layout: magic(4) + wireId(1) + version(1) + len(2) + base", {
timeout: 5000,
}, () => {
expect(streamingAeadHeaderLength(8)).toBe(16);
expect(streamingAeadHeaderLength(20)).toBe(28);
});
});
describe("StreamingAeadEngine — caller-supplied custom engine", () => {
it("StreamingAeadEngine contract is stable for custom engines", {
timeout: 5000,
}, async () => {
const inner = xchacha20StreamingEngine;
const sealCalls: number[] = [];
const openCalls: number[] = [];
const custom: StreamingAeadEngine = {
...inner,
id: "spy",
wireId: 0x7f,
seal: async (i) => {
sealCalls.push(i.counter);
return inner.seal(i);
},
open: async (i) => {
openCalls.push(i.counter);
return inner.open(i);
},
};
const key = new Uint8Array(inner.keyLength);
const baseNonce = new Uint8Array(inner.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(
key,
gen([new Uint8Array([1, 2, 3])]),
{ engine: custom, baseNonce }
)) {
wire.push(w);
}
const out = await collect(
decryptStreamAead(key, gen(wire), { engine: custom })
);
expect(out).toEqual(new Uint8Array([1, 2, 3]));
expect(sealCalls.length).toBeGreaterThanOrEqual(1);
expect(openCalls.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,564 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import { aesGcmStreamingEngine } from "../streaming-aead-aes-gcm/streaming-aead-aes-gcm.js";
import { xchacha20StreamingEngine } from "../streaming-aead-xchacha20/streaming-aead-xchacha20.js";
import { decryptStreamAead, encryptStreamAead } from "./streaming-aead.js";
import type {
StreamingAeadEngine,
StreamingChunkTag,
StreamingHeader,
} from "./streaming-aead.types.js";
async function* gen(chunks: Uint8Array[]): AsyncGenerator<Uint8Array> {
for (const c of chunks) yield c;
}
async function collect(iter: AsyncGenerator<Uint8Array>): Promise<Uint8Array> {
const parts: Uint8Array[] = [];
let total = 0;
for await (const p of iter) {
parts.push(p);
total += p.length;
}
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
const PLAINTEXT = new TextEncoder().encode("the quick brown fox jumps over");
describe("encryptStreamAead / decryptStreamAead — round-trip", () => {
it("round-trips a multi-chunk plaintext through encryptStreamAead and decryptStreamAead", {
timeout: 5000,
}, async () => {
for (const engine of [
xchacha20StreamingEngine,
aesGcmStreamingEngine,
]) {
const key = new Uint8Array(engine.keyLength).fill(0x07);
const baseNonce = new Uint8Array(engine.nonceBaseLength).fill(0x09);
const chunks = [PLAINTEXT.slice(0, 10), PLAINTEXT.slice(10)];
const wireChunks: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen(chunks), {
engine,
baseNonce,
})) {
wireChunks.push(w);
}
const decrypted = await collect(
decryptStreamAead(key, gen(wireChunks), { engine })
);
expect(decrypted).toEqual(PLAINTEXT);
}
});
it("throws STREAM_TAG_MISMATCH when a chunk tag is flipped", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength).fill(0x05);
const baseNonce = new Uint8Array(engine.nonceBaseLength).fill(0x06);
const wireChunks: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wireChunks.push(w);
}
// Locate the data frame (second yielded chunk after header). Flip a tag byte.
const dataFrame = wireChunks[1];
if (!dataFrame) throw new Error("missing data frame");
const tampered = new Uint8Array(dataFrame);
tampered[tampered.length - 1] =
(tampered[tampered.length - 1] ?? 0) ^ 0xff;
wireChunks[1] = tampered;
let caught: unknown;
try {
await collect(decryptStreamAead(key, gen(wireChunks), { engine }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_TAG_MISMATCH"
);
});
it("aborts mid-stream and rejects with STREAM_ABORTED", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength).fill(0x01);
const baseNonce = new Uint8Array(engine.nonceBaseLength).fill(0x02);
const ctrl = new AbortController();
const iter = encryptStreamAead(
key,
gen([PLAINTEXT, PLAINTEXT, PLAINTEXT]),
{
engine,
baseNonce,
signal: ctrl.signal,
}
);
await iter.next(); // header
await iter.next(); // first data frame
ctrl.abort();
let caught: unknown;
try {
await iter.next();
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_ABORTED"
);
expect(
(caught as CryptoCipherError).context?.chunksProcessed as number
).toBeGreaterThanOrEqual(1);
});
it("decryptStreamAead reports the parsed header via onHeader", {
timeout: 5000,
}, async () => {
const engine = aesGcmStreamingEngine;
const key = new Uint8Array(engine.keyLength).fill(0x10);
const baseNonce = new Uint8Array(engine.nonceBaseLength).fill(0x11);
const wireChunks: Uint8Array[] = [];
const enc: StreamingHeader[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
onHeader: (h) => enc.push(h),
})) {
wireChunks.push(w);
}
expect(enc.length).toBe(1);
const dec: StreamingHeader[] = [];
const decChunks: StreamingChunkTag[] = [];
await collect(
decryptStreamAead(key, gen(wireChunks), {
engine,
onHeader: (h) => dec.push(h),
onChunk: (t) => decChunks.push(t),
})
);
expect(dec.length).toBe(1);
expect(dec[0]?.engineId).toBe(engine.id);
expect(dec[0]?.baseNonceLength).toBe(engine.nonceBaseLength);
expect(decChunks.some((t) => t.finalFlag === 1)).toBe(true);
});
it("rejects a wrong-length key on encrypt and decrypt", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const baseNonce = new Uint8Array(engine.nonceBaseLength);
await expect(async () => {
for await (const _ of encryptStreamAead(
new Uint8Array(31),
gen([PLAINTEXT]),
{ engine, baseNonce }
)) {
// consume
}
}).rejects.toBeInstanceOf(CryptoCipherError);
await expect(async () => {
for await (const _ of decryptStreamAead(
new Uint8Array(31),
gen([new Uint8Array(8)]),
{ engine }
)) {
// consume
}
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("rejects a wrong-length base nonce on encrypt", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
await expect(async () => {
for await (const _ of encryptStreamAead(
new Uint8Array(engine.keyLength),
gen([PLAINTEXT]),
{ engine, baseNonce: new Uint8Array(5) }
)) {
// consume
}
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: empty stream surfaces a header error", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
let caught: unknown;
try {
await collect(decryptStreamAead(key, gen([])));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
});
it("decrypt: corrupted magic bytes surface a header error", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
const tampered = new Uint8Array(wire[0]!);
tampered[0] = 0xff;
wire[0] = tampered;
await expect(async () => {
await collect(decryptStreamAead(key, gen(wire), { engine }));
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: wrong engine on the wire surfaces a header error", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(32);
const baseNonce = new Uint8Array(20);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine: xchacha20StreamingEngine,
baseNonce,
})) {
wire.push(w);
}
await expect(async () => {
await collect(
decryptStreamAead(key, gen(wire), {
engine: aesGcmStreamingEngine,
})
);
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: truncated stream throws STREAM_TAG_MISMATCH (no final frame)", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
// Drop the final frame (last yielded chunk).
wire.pop();
let caught: unknown;
try {
await collect(decryptStreamAead(key, gen(wire), { engine }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_TAG_MISMATCH"
);
});
it("decrypt: allowUnterminated returns silently on truncation", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
wire.pop(); // drop final
const out = await collect(
decryptStreamAead(key, gen(wire), {
engine,
allowUnterminated: true,
})
);
expect(out).toEqual(PLAINTEXT);
});
it("decrypt: data after final frame surfaces STREAM_TAG_MISMATCH", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
wire.push(new Uint8Array([0, 0, 0, 1, 0, 0xab]));
let caught: unknown;
try {
await collect(decryptStreamAead(key, gen(wire), { engine }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
});
it("decrypt: synchronous iterable source is supported", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, [PLAINTEXT], {
engine,
baseNonce,
})) {
wire.push(w);
}
const out = await collect(decryptStreamAead(key, wire, { engine }));
expect(out).toEqual(PLAINTEXT);
});
it("decrypt: truncated wire (only 8 bytes, no base nonce) surfaces a header error", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const wire = new Uint8Array(8);
wire.set([0x53, 0x50, 0x41, 0x45], 0); // valid magic
wire[4] = engine.wireId;
wire[5] = 1;
wire[6] = 0;
wire[7] = engine.nonceBaseLength; // says 20 base nonce bytes follow but none do
await expect(async () => {
await collect(decryptStreamAead(key, gen([wire]), { engine }));
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: bad version byte surfaces a header error", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
const tampered = new Uint8Array(wire[0]!);
tampered[5] = 99; // bad version
wire[0] = tampered;
await expect(async () => {
await collect(decryptStreamAead(key, gen(wire), { engine }));
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: bad base-nonce length advertised in header surfaces an error", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
const tampered = new Uint8Array(wire[0]!);
tampered[6] = 0;
tampered[7] = engine.nonceBaseLength + 1; // off by one
wire[0] = tampered.subarray(0, 8 + engine.nonceBaseLength + 1);
// pad with a fake byte
const padded = new Uint8Array(wire[0]!.length + 1);
padded.set(wire[0]!);
wire[0] = padded;
await expect(async () => {
await collect(decryptStreamAead(key, gen(wire), { engine }));
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: stream ending mid-frame surfaces STREAM_TAG_MISMATCH", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
})) {
wire.push(w);
}
// Truncate the data frame so its length-header claims more bytes than follow.
const dataFrame = wire[1]!;
const truncated = dataFrame.subarray(0, dataFrame.length - 5);
wire[1] = truncated;
wire.pop(); // drop final frame too
await expect(async () => {
await collect(decryptStreamAead(key, gen(wire), { engine }));
}).rejects.toBeInstanceOf(CryptoCipherError);
});
it("encrypt: AbortSignal aborted before yielding header rejects immediately", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const ctrl = new AbortController();
ctrl.abort();
const iter = encryptStreamAead(key, gen([PLAINTEXT]), {
engine,
baseNonce,
signal: ctrl.signal,
});
await expect(iter.next()).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: AbortSignal aborted before reading header rejects", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const ctrl = new AbortController();
ctrl.abort();
const iter = decryptStreamAead(key, gen([new Uint8Array(40)]), {
engine,
signal: ctrl.signal,
});
await expect(iter.next()).rejects.toBeInstanceOf(CryptoCipherError);
});
it("decrypt: non-empty plaintext on a final-flag chunk surfaces STREAM_TAG_MISMATCH", {
timeout: 5000,
}, async () => {
const inner = xchacha20StreamingEngine;
// Custom engine where open() returns garbage on the terminator.
const evil: StreamingAeadEngine = {
...inner,
open: async (i) => {
const real = await inner.open(i);
if (i.finalFlag === 1) {
return new Uint8Array([0x42]);
}
return real;
},
};
const key = new Uint8Array(inner.keyLength);
const baseNonce = new Uint8Array(inner.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine: inner,
baseNonce,
})) {
wire.push(w);
}
let caught: unknown;
try {
await collect(decryptStreamAead(key, gen(wire), { engine: evil }));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(CryptoCipherError);
expect((caught as CryptoCipherError).code).toBe(
"CRYPTO.CIPHER.STREAM_TAG_MISMATCH"
);
});
it("default engine (no explicit engine option) round-trips", {
timeout: 5000,
}, async () => {
const key = new Uint8Array(32);
const baseNonce = new Uint8Array(20); // matches xchacha default
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
baseNonce,
})) {
wire.push(w);
}
const out = await collect(decryptStreamAead(key, gen(wire)));
expect(out).toEqual(PLAINTEXT);
});
it("empty plaintext chunk is filtered out (no double-yield) on decrypt", {
timeout: 5000,
}, async () => {
const engine = xchacha20StreamingEngine;
const key = new Uint8Array(engine.keyLength);
const baseNonce = new Uint8Array(engine.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(
key,
gen([new Uint8Array(0), PLAINTEXT]),
{ engine, baseNonce }
)) {
wire.push(w);
}
const out = await collect(
decryptStreamAead(key, gen(wire), { engine })
);
expect(out).toEqual(PLAINTEXT);
});
it("encrypt/decrypt with a custom engine round-trips byte-for-byte", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const inner = xchacha20StreamingEngine;
const wrap: StreamingAeadEngine = {
...inner,
id: "test-spy",
wireId: 0x7f,
seal: async (i) => {
calls.push(`seal:${i.counter}:${i.finalFlag}`);
return inner.seal(i);
},
open: async (i) => {
calls.push(`open:${i.counter}:${i.finalFlag}`);
return inner.open(i);
},
};
const key = new Uint8Array(inner.keyLength);
const baseNonce = new Uint8Array(inner.nonceBaseLength);
const wire: Uint8Array[] = [];
for await (const w of encryptStreamAead(key, gen([PLAINTEXT]), {
engine: wrap,
baseNonce,
})) {
wire.push(w);
}
const out = await collect(
decryptStreamAead(key, gen(wire), { engine: wrap })
);
expect(out).toEqual(PLAINTEXT);
expect(calls.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,349 @@
import { CRYPTO_CIPHER_CODES } from "../../errors/codes/codes.js";
import { CryptoCipherError } from "../../errors/crypto-cipher-error/crypto-cipher-error.js";
import { xchacha20StreamingEngine } from "../streaming-aead-xchacha20/streaming-aead-xchacha20.js";
import {
type DecryptStreamAeadOptions,
type EncryptStreamAeadOptions,
STREAMING_AEAD_MAGIC,
STREAMING_AEAD_VERSION,
type StreamingAeadEngine,
type StreamingHeader,
} from "./streaming-aead.types.js";
/** Header layout: magic[4] || engineWireId[1] || version[1] || baseNonceLen[2] || baseNonce. */
function writeHeader(
engine: StreamingAeadEngine,
baseNonce: Uint8Array
): Uint8Array {
const header = new Uint8Array(8 + baseNonce.length);
header.set(STREAMING_AEAD_MAGIC, 0);
header[4] = engine.wireId;
header[5] = STREAMING_AEAD_VERSION;
header[6] = (baseNonce.length >> 8) & 0xff;
header[7] = baseNonce.length & 0xff;
header.set(baseNonce, 8);
return header;
}
function badHeader(reason: string, context: Record<string, unknown>): never {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
`error.crypto.cipher.stream-bad-header.${reason}`,
{ context: { ...context, reason } }
);
}
function checkMagic(header: Uint8Array): void {
for (let i = 0; i < 4; i++) {
if (header[i] !== STREAMING_AEAD_MAGIC[i]) {
badHeader("bad-magic", { offset: i });
}
}
}
function parseHeader(
engine: StreamingAeadEngine,
header: Uint8Array
): Uint8Array {
checkMagic(header);
if (header[4] !== engine.wireId) {
badHeader("engine-mismatch", {
wireId: header[4],
expected: engine.wireId,
});
}
if (header[5] !== STREAMING_AEAD_VERSION) {
badHeader("bad-version", { version: header[5] });
}
const len = (header[6]! << 8) | header[7]!;
if (len !== engine.nonceBaseLength) {
badHeader("bad-base-nonce-length", {
actual: len,
expected: engine.nonceBaseLength,
});
}
return header.subarray(8, 8 + len);
}
function frameAad(counter: number, finalFlag: 0 | 1): Uint8Array {
const aad = new Uint8Array(5);
const view = new DataView(aad.buffer, 0, 5);
view.setUint32(0, counter >>> 0, false);
aad[4] = finalFlag;
return aad;
}
/** Frame layout per chunk after header: [4-byte ciphertextLen BE][1-byte finalFlag][ciphertext||tag]. */
function writeFrame(finalFlag: 0 | 1, ciphertext: Uint8Array): Uint8Array {
const frame = new Uint8Array(4 + 1 + ciphertext.length);
const view = new DataView(frame.buffer, 0, 4);
view.setUint32(0, ciphertext.length, false);
frame[4] = finalFlag;
frame.set(ciphertext, 5);
return frame;
}
function checkAborted(signal: AbortSignal | undefined, counter: number): void {
if (signal?.aborted) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_ABORTED,
"error.crypto.cipher.stream-aborted",
{ context: { chunksProcessed: counter } }
);
}
}
function emitHeader(
engine: StreamingAeadEngine,
baseNonce: Uint8Array,
onHeader: ((h: StreamingHeader) => void) | undefined
): void {
if (!onHeader) return;
onHeader({
engineId: engine.id,
wireId: engine.wireId,
version: STREAMING_AEAD_VERSION,
baseNonceLength: baseNonce.length,
baseNonce,
});
}
function checkKey(engine: StreamingAeadEngine, key: Uint8Array): void {
if (key.length !== engine.keyLength) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_INVALID_NONCE_BASE,
"error.crypto.cipher.stream-invalid-key-length",
{
context: {
engine: engine.id,
expected: engine.keyLength,
actual: key.length,
},
}
);
}
}
function checkBaseNonce(
engine: StreamingAeadEngine,
baseNonce: Uint8Array
): void {
if (baseNonce.length !== engine.nonceBaseLength) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_INVALID_NONCE_BASE,
"error.crypto.cipher.stream-invalid-nonce-base",
{
context: {
engine: engine.id,
expectedLength: engine.nonceBaseLength,
actualLength: baseNonce.length,
},
}
);
}
}
function asIter(
source: AsyncIterable<Uint8Array> | Iterable<Uint8Array>
): AsyncIterator<Uint8Array> | Iterator<Uint8Array> {
const isAsync =
Symbol.asyncIterator in (source as object) &&
typeof (source as AsyncIterable<Uint8Array>)[Symbol.asyncIterator] ===
"function";
return isAsync
? (source as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]()
: (source as Iterable<Uint8Array>)[Symbol.iterator]();
}
/**
* Encrypt an async source into a framed AEAD stream. Yields the wire header
* first, then one frame per plaintext chunk, and finally a 0-byte final-flag
* frame that binds the chunk count into the AEAD tag (preventing silent
* truncation).
*/
export async function* encryptStreamAead(
key: Uint8Array,
source: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
options: EncryptStreamAeadOptions
): AsyncGenerator<Uint8Array, void, unknown> {
const engine = options.engine ?? xchacha20StreamingEngine;
checkKey(engine, key);
checkBaseNonce(engine, options.baseNonce);
const baseNonce = options.baseNonce;
checkAborted(options.signal, 0);
yield writeHeader(engine, baseNonce);
emitHeader(engine, baseNonce, options.onHeader);
let counter = 0;
const iter = asIter(source);
while (true) {
checkAborted(options.signal, counter);
const step = await (iter as AsyncIterator<Uint8Array>).next();
if (step.done) break;
const plaintext = step.value;
const aad = frameAad(counter, 0);
const ct = await engine.seal({
key,
baseNonce,
counter,
finalFlag: 0,
data: plaintext,
aad,
});
options.onChunk?.({
counter,
finalFlag: 0,
tag: ct.subarray(ct.length - engine.tagLength),
});
yield writeFrame(0, ct);
counter++;
}
// Terminator: empty plaintext with finalFlag=1 binding total count into AEAD.
const finalAad = frameAad(counter, 1);
const finalCt = await engine.seal({
key,
baseNonce,
counter,
finalFlag: 1,
data: new Uint8Array(0),
aad: finalAad,
});
options.onChunk?.({
counter,
finalFlag: 1,
tag: finalCt.subarray(finalCt.length - engine.tagLength),
});
yield writeFrame(1, finalCt);
}
/**
* Decrypt a framed AEAD stream. The source may yield bytes in arbitrary
* fragment sizes; we internally buffer until whole frames are available.
* Throws CryptoCipherError(STREAM_TAG_MISMATCH) if the source ends before
* the final-flag frame is observed (truncation).
*/
export async function* decryptStreamAead(
key: Uint8Array,
source: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
options: DecryptStreamAeadOptions = {}
): AsyncGenerator<Uint8Array, void, unknown> {
const engine = options.engine ?? xchacha20StreamingEngine;
checkKey(engine, key);
const iter = asIter(source);
let buf = new Uint8Array(0);
async function pull(): Promise<boolean> {
const step = await (iter as AsyncIterator<Uint8Array>).next();
if (step.done) return false;
const chunk = step.value;
const next = new Uint8Array(buf.length + chunk.length);
next.set(buf, 0);
next.set(chunk, buf.length);
buf = next;
return true;
}
async function readExactly(n: number): Promise<Uint8Array | null> {
while (buf.length < n) {
const got = await pull();
if (!got) return null;
}
const out = buf.subarray(0, n);
buf = buf.subarray(n);
return out;
}
checkAborted(options.signal, 0);
const headerPrefix = await readExactly(8);
if (!headerPrefix) {
badHeader("empty-stream", {});
}
const baseLen = (headerPrefix[6]! << 8) | headerPrefix[7]!;
const baseBytes = await readExactly(baseLen);
if (!baseBytes) {
badHeader("truncated-header", { baseLen });
}
const fullHeader = new Uint8Array(8 + baseLen);
fullHeader.set(headerPrefix, 0);
fullHeader.set(baseBytes, 8);
const baseNonce = parseHeader(engine, fullHeader);
emitHeader(engine, baseNonce, options.onHeader);
let counter = 0;
let sawFinal = false;
while (!sawFinal) {
checkAborted(options.signal, counter);
const lenBytes = await readExactly(5);
if (!lenBytes) {
if (options.allowUnterminated) return;
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-truncated",
{
context: {
reason: "no-final-frame",
chunksProcessed: counter,
},
}
);
}
const view = new DataView(
lenBytes.buffer,
lenBytes.byteOffset,
lenBytes.byteLength
);
const ctLen = view.getUint32(0, false);
const finalFlag = (lenBytes[4] === 1 ? 1 : 0) as 0 | 1;
const ct = await readExactly(ctLen);
if (!ct) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-truncated",
{
context: {
reason: "mid-frame",
chunkIndex: counter,
},
}
);
}
const aad = frameAad(counter, finalFlag);
const pt = await engine.open({
key,
baseNonce,
counter,
finalFlag,
data: ct,
aad,
});
options.onChunk?.({
counter,
finalFlag,
tag: ct.subarray(ct.length - engine.tagLength),
});
if (finalFlag === 1) {
if (pt.length !== 0) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-unexpected-final-payload",
{ context: { chunkIndex: counter } }
);
}
sawFinal = true;
} else if (pt.length > 0) {
yield pt;
}
counter++;
}
if (buf.length > 0 || (await pull())) {
throw new CryptoCipherError(
CRYPTO_CIPHER_CODES.STREAM_TAG_MISMATCH,
"error.crypto.cipher.stream-after-final",
{ context: { chunkIndex: counter } }
);
}
}

View File

@@ -0,0 +1,35 @@
/**
* The streaming-AEAD interfaces are cross-package contract types: defined
* here implicitly via re-export from `@suaveplan/types`, instantiated by
* `@suaveplan/crypto-cipher` (the AES-GCM and XChaCha20 engines), and
* consumed by `@suaveplan/crypto`'s streaming façade. The canonical home
* of every interface below is `@suaveplan/types/streaming-aead-engine`.
*
* Runtime values (`STREAMING_AEAD_MAGIC`, `STREAMING_AEAD_VERSION`,
* `STREAMING_AEAD_MAX_COUNTER`, `streamingAeadHeaderLength`) stay in this
* package — the types-only `@suaveplan/types` cannot host them.
*/
export type {
DecryptStreamAeadOptions,
EncryptStreamAeadOptions,
StreamingAeadEngine,
StreamingChunkInput,
StreamingChunkTag,
StreamingHeader,
} from "@suaveplan/types";
/** Magic bytes "SPAE" (Suaveplan AEAD Envelope). */
export const STREAMING_AEAD_MAGIC: Uint8Array = new Uint8Array([
0x53, 0x50, 0x41, 0x45,
]);
/** Current wire version. */
export const STREAMING_AEAD_VERSION = 1;
/** Header length = magic(4) + engineId(1) + version(1) + baseNonceLen(2) + baseNonce. */
export function streamingAeadHeaderLength(baseNonceLength: number): number {
return 4 + 1 + 1 + 2 + baseNonceLength;
}
/** Maximum chunk counter (2^32 - 1; counter wraps at 2^32). */
export const STREAMING_AEAD_MAX_COUNTER = 0xff_ff_ff_ff;

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,155 @@
/**
* Spec-conformance tests for `openspec/specs/crypto-cipher/spec.md`.
*
* Pins structural scenarios that the per-primitive unit suites do not
* cover directly: every AEAD has both `noble.ts` and `native.ts`,
* every `*.select.ts` carries the GENERATED banner, every
* `*.native.ts` is currently a `PrimitiveNotImplemented` stub
* (runtime reflection of the dangerous-tier flip gate), the package
* is in a consistent pre/post-Phase-0.5 vendoring state, and no
* primitive source file consumes a CSPRNG (nonce-is-caller-supplied).
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const ROOT = join(__dirname, "..");
const SRC = join(ROOT, "src");
const PRIMITIVES = join(SRC, "primitives");
const AEAD_IDS = [
"aes-256-gcm",
"chacha20-poly1305",
"xchacha20-poly1305",
] as const;
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (
st.isDirectory() &&
!full.includes("node_modules") &&
!full.includes("dist")
) {
yield* walk(full);
} else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) {
yield full;
}
}
}
describe("crypto-cipher — universal AEAD primitive surface", () => {
it("every AEAD id has its primitive directory", { timeout: 5000 }, () => {
for (const id of AEAD_IDS) {
expect(existsSync(join(PRIMITIVES, id))).toBe(true);
}
});
});
describe("crypto-cipher — per-primitive replaceability layout", () => {
for (const id of AEAD_IDS) {
it(`${id} has both noble.ts and native.ts impl files`, {
timeout: 5000,
}, () => {
expect(
existsSync(
join(
PRIMITIVES,
id,
"impl",
`${id}.noble`,
`${id}.noble.ts`
)
)
).toBe(true);
expect(
existsSync(
join(
PRIMITIVES,
id,
"impl",
`${id}.native`,
`${id}.native.ts`
)
)
).toBe(true);
});
it(`${id}.select.ts carries the GENERATED banner`, {
timeout: 5000,
}, () => {
const sel = join(PRIMITIVES, id, `${id}.select`, `${id}.select.ts`);
expect(existsSync(sel)).toBe(true);
const c = readFileSync(sel, "utf8");
expect(c).toMatch(/GENERATED/);
expect(c).toMatch(/DO NOT EDIT/);
});
}
});
describe("crypto-cipher — vendored noble source (Phase 0.5)", () => {
it("is a Phase-0.5 forward gate; current state is pre-0.5 (noble dep + no vendored/)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(ROOT, "package.json"), "utf8")
);
const noblePresent =
"@noble/ciphers" in (pkg.dependencies ?? {}) ||
"@noble/ciphers" in (pkg.peerDependencies ?? {});
const vendoredPresent = existsSync(join(SRC, "vendored"));
const upstreamFile = existsSync(join(ROOT, "NOBLE_UPSTREAM.md"));
const pre05 = noblePresent && !vendoredPresent && !upstreamFile;
const post05 = !noblePresent && vendoredPresent && upstreamFile;
expect(pre05 || post05).toBe(true);
});
});
describe("crypto-cipher — constant-time and dangerous-tier reflection", () => {
for (const id of AEAD_IDS) {
it(`${id}.native.ts is currently a PrimitiveNotImplemented stub`, {
timeout: 5000,
}, () => {
const src = readFileSync(
join(PRIMITIVES, id, "impl", `${id}.native`, `${id}.native.ts`),
"utf8"
);
expect(src).toMatch(/PrimitiveNotImplemented/);
});
}
it("every native is a stub today, so no AEAD has been flipped without an audit", {
timeout: 5000,
}, () => {
const offenders: string[] = [];
for (const id of AEAD_IDS) {
const src = readFileSync(
join(PRIMITIVES, id, "impl", `${id}.native`, `${id}.native.ts`),
"utf8"
);
if (!/PrimitiveNotImplemented/.test(src)) offenders.push(id);
}
expect(offenders).toEqual([]);
});
});
describe("crypto-cipher — nonce is caller-supplied", () => {
it("no primitive source file consumes a CSPRNG (no Math.random / crypto.getRandomValues / randomBytes)", {
timeout: 5000,
}, () => {
const banned = [
/\bMath\.random\b/,
/\bcrypto\.getRandomValues\b/,
/\brandomBytes\b/,
];
const hits: string[] = [];
for (const file of walk(SRC)) {
const c = readFileSync(file, "utf8");
for (const re of banned) {
if (re.test(c)) hits.push(`${file}: ${re}`);
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,51 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-cipher",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"primitives/aes-256-gcm/aes-256-gcm": "src/primitives/aes-256-gcm/aes-256-gcm.ts",
"primitives/chacha20-poly1305/chacha20-poly1305": "src/primitives/chacha20-poly1305/chacha20-poly1305.ts",
"primitives/xchacha20-poly1305/xchacha20-poly1305": "src/primitives/xchacha20-poly1305/xchacha20-poly1305.ts",
"streaming/streaming-aead/index": "src/streaming/streaming-aead/index.ts",
"streaming/streaming-aead-aes-gcm/index": "src/streaming/streaming-aead-aes-gcm/index.ts",
"streaming/streaming-aead-xchacha20/index": "src/streaming/streaming-aead-xchacha20/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@noble/ciphers",
/^@noble\//,
"@suaveplan/crypto-utils",
/^@suaveplan\/crypto-utils\//,
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/constants/**",
"src/primitives/**/impl/*.native.ts",
"src/primitives/**/*.select.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 10_000,
},
});