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,51 @@
# `security-stack`
Cryptography, vaults, security utilities, and threat-detection primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 28 packages.
### `crypto`
Cryptographic primitives, ciphers, hashes, curves, and key management.
| Package | Description |
|---|---|
| [`@suaveplan/crypto`](./crypto/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
| [`@suaveplan/crypto-bin`](./crypto-bin/README.md) | Suaveplan password-hashing CLI. Ships the `suaveplan-hash-password` binary — Argon2id (default) and BLAKE2b password hashing + verification with PHC/MCF-format… |
| [`@suaveplan/crypto-browser`](./crypto-browser/README.md) | WebCrypto-backed engine implementations for `@suaveplan/crypto` — AES-256-GCM, Ed25519, ECDSA-P256/P384, X25519, ECDH-P256/P384, RSA, AES-KW, and non-extractab… |
| [`@suaveplan/crypto-cipher`](./crypto-cipher/README.md) | Low-level AEAD (Authenticated Encryption with Associated Data) cipher primitives for the `@suaveplan/crypto` stack. Provides three algorithms — AES-256-GCM, Ch… |
| [`@suaveplan/crypto-cli`](./crypto-cli/README.md) | Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore) |
| [`@suaveplan/crypto-core`](./crypto-core/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
| [`@suaveplan/crypto-curve`](./crypto-curve/README.md) | Low-level elliptic-curve building blocks for the `@suaveplan/crypto` stack. Exposes four curves — secp256k1, Ed25519, X25519, and NIST P-256 — each as a standa… |
| [`@suaveplan/crypto-hash`](./crypto-hash/README.md) | Pure-TypeScript hashing substrate. SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id, and hex/Base64 encoding — no WASM, no browser globals, fully tree-shakeable. |
| [`@suaveplan/crypto-keys`](./crypto-keys/README.md) | Hierarchical keychain chain-signing for the crypto ecosystem. Universal, store-agnostic, free of `node:` / `bun:` imports. |
| [`@suaveplan/crypto-rsa`](./crypto-rsa/README.md) | RSA primitives for Suaveplan — RFC 8017 PKCS #1 v2.2, RFC 5208 / RFC 5958 PKCS #8, RFC 5280 SPKI, RFC 7517 / RFC 7518 JWK, RFC 7468 PEM. Universal package… |
| [`@suaveplan/crypto-sodium`](./crypto-sodium/README.md) | libsodium-backed engine implementations for `@suaveplan/crypto`. Provides AEAD encryption, Ed25519 signing, X25519 key agreement, sealed-box, auth-box, determi… |
| [`@suaveplan/crypto-utils`](./crypto-utils/README.md) | Shared cryptographic utilities consumed internally by all other `@suaveplan/crypto-*` packages. Provides the low-level primitives that every crypto implementat… |
| [`@suaveplan/crypto-vault`](./crypto-vault/README.md) | Cryptographic foundation for the Vaulted password-manager ecosystem. Provides BIP39 mnemonic generation, OWASP-grade Argon2id derivation, two-secret key deriva… |
### `security`
Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.
| Package | Description |
|---|---|
| [`@suaveplan/hibp`](./hibp/README.md) | Privacy-preserving Have I Been Pwned v3 client. Universal — works in Node, Bun, Deno, browsers, edge workers. The only required runtime primitive is `fetc… |
| [`@suaveplan/jws`](./jws/README.md) | RFC 7515 **JSON Web Signature** (verify + sign). Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives (`@… |
| [`@suaveplan/license`](./license/README.md) | Compact, offline-verifiable, secp256k1-signed software-license-key format. Bit-for-bit wire-format compatible with upstream `@suaveplan/license`. runs unchange… |
| [`@suaveplan/otp`](./otp/README.md) | RFC 4226 (HOTP) + RFC 6238 (TOTP) + RFC 6287 (OCRA) + Google Authenticator Key URI Format + Base32 (RFC 4648 §6) + recovery codes + clock-drift detector — zero browser/Node deps |
| [`@suaveplan/password-formats`](./password-formats/README.md) | Universal import/export layer for common password-manager export formats. Parses Apple Passwords CSV, Bitwarden JSON, Chrome CSV, and KeePass XML into a normal… |
| [`@suaveplan/password-generator`](./password-generator/README.md) | Cryptographically-strong password and passphrase generator — char-class, pronounceable, EFF Diceware, and PIN modes with bias-free RNG, real entropy estimates,… |
| [`@suaveplan/password-strength`](./password-strength/README.md) | zxcvbn-style password strength estimator — pattern-matching + dictionary cracker model per Wheeler 2016. Universal; zero browser/Node/React deps. Re-imple… |
| [`@suaveplan/security-codes`](./security-codes/README.md) | Detect and sanitise sensitive security-code leakage in strings. Handles API keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks, AWS acc… |
| [`@suaveplan/server-keys`](./server-keys/README.md) | Operator-facing CLI bundle for the Suaveplan server-key lifecycle: 18 bins covering root + server keychain generation (including hierarchical `ChainedKeySigner… |
| [`@suaveplan/slsa`](./slsa/README.md) | SLSA L1-L2 supply-chain security for Suaveplan. Generates and verifies in-toto / SLSA v1.0 provenance, CycloneDX 1.5 SBOMs, DSSE attestation envelopes, and RFC… |
| [`@suaveplan/srp`](./srp/README.md) | SRP-6a zero-knowledge password proof per RFC 5054 / RFC 2945. Universal — client and server roles in one package, bundled 2048-bit safe-prime group, optio… |
| [`@suaveplan/tpm-struct`](./tpm-struct/README.md) | Universal, zero-runtime-dep TPM 2.0 attestation-structure parser and serialiser. Decodes `TPMS_ATTEST` (TCG TPM 2.0 Library Part 2 §10.12), decodes `TPMT_… |
| [`@suaveplan/vault`](./vault/README.md) | Vault orchestration layer for the Vaulted password-manager ecosystem. Provides a drive-backed encrypted SQLite-style container, a strict `locked` ↔ `unlocked`… |
| [`@suaveplan/vault-sync`](./vault-sync/README.md) | Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem. Builds on `@suaveplan/crdt` and `@suaveplan/diff` (RFC 6902 JSO… |
| [`@suaveplan/x509`](./x509/README.md) | RFC 5280 X.509 v3 certificate parser and certification-path validator. Universal (zero browser, Node, or React deps). Pure TypeScript over `Uint8Array` /… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `security-stack`
Cryptography, vaults, security utilities, and threat-detection primitives.

View File

@@ -0,0 +1,25 @@
# `crypto`
Cryptographic primitives, ciphers, hashes, curves, and key management.
<!-- begin:auto-docs -->
## Packages in `crypto`
13 packages.
| Package | Description |
|---|---|
| [`@suaveplan/crypto`](./crypto/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
| [`@suaveplan/crypto-bin`](./crypto-bin/README.md) | Suaveplan password-hashing CLI. Ships the `suaveplan-hash-password` binary — Argon2id (default) and BLAKE2b password hashing + verification with PHC/MCF-format… |
| [`@suaveplan/crypto-browser`](./crypto-browser/README.md) | WebCrypto-backed engine implementations for `@suaveplan/crypto` — AES-256-GCM, Ed25519, ECDSA-P256/P384, X25519, ECDH-P256/P384, RSA, AES-KW, and non-extractab… |
| [`@suaveplan/crypto-cipher`](./crypto-cipher/README.md) | Low-level AEAD (Authenticated Encryption with Associated Data) cipher primitives for the `@suaveplan/crypto` stack. Provides three algorithms — AES-256-GCM, Ch… |
| [`@suaveplan/crypto-cli`](./crypto-cli/README.md) | Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore) |
| [`@suaveplan/crypto-core`](./crypto-core/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
| [`@suaveplan/crypto-curve`](./crypto-curve/README.md) | Low-level elliptic-curve building blocks for the `@suaveplan/crypto` stack. Exposes four curves — secp256k1, Ed25519, X25519, and NIST P-256 — each as a standa… |
| [`@suaveplan/crypto-hash`](./crypto-hash/README.md) | Pure-TypeScript hashing substrate. SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id, and hex/Base64 encoding — no WASM, no browser globals, fully tree-shakeable. |
| [`@suaveplan/crypto-keys`](./crypto-keys/README.md) | Hierarchical keychain chain-signing for the crypto ecosystem. Universal, store-agnostic, free of `node:` / `bun:` imports. |
| [`@suaveplan/crypto-rsa`](./crypto-rsa/README.md) | RSA primitives for Suaveplan — RFC 8017 PKCS #1 v2.2, RFC 5208 / RFC 5958 PKCS #8, RFC 5280 SPKI, RFC 7517 / RFC 7518 JWK, RFC 7468 PEM. Universal package… |
| [`@suaveplan/crypto-sodium`](./crypto-sodium/README.md) | libsodium-backed engine implementations for `@suaveplan/crypto`. Provides AEAD encryption, Ed25519 signing, X25519 key agreement, sealed-box, auth-box, determi… |
| [`@suaveplan/crypto-utils`](./crypto-utils/README.md) | Shared cryptographic utilities consumed internally by all other `@suaveplan/crypto-*` packages. Provides the low-level primitives that every crypto implementat… |
| [`@suaveplan/crypto-vault`](./crypto-vault/README.md) | Cryptographic foundation for the Vaulted password-manager ecosystem. Provides BIP39 mnemonic generation, OWASP-grade Argon2id derivation, two-secret key deriva… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Cryptographic primitives, ciphers, hashes, curves, and key management.

View File

@@ -0,0 +1,105 @@
# @suaveplan/crypto-bin - Suaveplan crypto bins: suaveplan-hash-password (Argon2id / blake2b password hash + verify CLI). Lives in its own package so the universal @suaveplan/crypto does not pull in @suaveplan/cli runtime deps.
## Summary
Suaveplan password-hashing CLI. Ships the `suaveplan-hash-password`
binary — Argon2id (default) and BLAKE2b password hashing + verification
with PHC/MCF-formatted output strings, distinct exit codes per failure
class, and an env-injected output adapter so the core is testable
without subprocess spawn.
This package lives separately from `@suaveplan/crypto` so the
universal crypto façade does not pull CLI runtime deps into
browser/server consumers that just want primitives.
<!-- begin:auto-docs -->
## Documentation tree
- [bin](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/bin/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_- [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hash-password](./src/hash-password/hash-password.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quickstart
```bash
# Hash a password (Argon2id by default)
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
# Verify (exit 0 = match, 1 = mismatch)
$ suaveplan-hash-password --password "correct horse battery staple" \
--verify '$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ echo $?
0
```
## Algorithms
- **`--algo argon2id`** (default) — OWASP 2023-recommended password hash.
Output prefix `$argon2id$v=19$...`.
- **`--algo blake2b`** — opt-in alternative for environments that need
it. Output prefix `$blake2b$v=1$...`. Cost factor is configurable via
`--cost-factor <n>` (1–31).
The verify mode auto-detects the algorithm from the stored hash's prefix,
so callers do not have to track which algorithm was used at hash time.
## Exit codes
| Code | Meaning |
|------|------------------------------------------------------------|
| 0 | Success / verify matched |
| 1 | Verify mismatch (wrong password) |
| 2 | Bad arguments (missing/malformed flag value) |
| 3 | Unknown algorithm (unrecognised `--algo` or stored prefix) |
| 4 | Verify failed (stored hash is corrupt or unparseable) |
The `0` / `1` split matches the de-facto contract for shell pipelines —
`if suaveplan-hash-password --password ... --verify ...; then ...; fi`
works as expected, with `3+` reserved for operator errors so callers can
distinguish "wrong password" from "stored data is broken".
## Programmatic use
```ts
import { main } from "@suaveplan/crypto-bin";
const out: string[] = [];
const err: string[] = [];
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "10"],
{
write: (s) => out.push(s),
writeError: (s) => err.push(s),
},
);
if (code === 0) {
storeHash(out.join("").trim());
}
```
The `main()` function never throws — every error path is captured and
surfaced via `env.writeError(...)` and a non-zero return value. See
`src/hash-password/hash-password.md` for the full contract.
## Public surface
- `main` — testable CLI core.
- `parseArgs` — pure argv parser (throws `HashPasswordCliError`).
- `detectAlgo` — algorithm sniffer for stored hashes.
- `HashPasswordCliEnv` — output adapter interface.
- `HashPasswordCliError` — error class wrapping the stable code registry.
- `HASH_PASSWORD_CLI_CODES` — `BAD_ARGS` / `UNKNOWN_ALGO` /
`VERIFY_FAILED`.
## Dependencies
- `@suaveplan/crypto-hash` — Argon2id + BLAKE2b primitives.
- `@suaveplan/error` — `SuaveplanError` base + stable code registry.
- `@suaveplan/types` — shared cross-package contracts.
No dependency on `@suaveplan/cli` — argv parsing is hand-rolled (~40
lines) so the bin stays small.

View File

@@ -0,0 +1,56 @@
{
"name": "@suaveplan/crypto-bin",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Suaveplan crypto bins: suaveplan-hash-password (Argon2id / blake2b password hash + verify CLI). Lives in its own package so the universal @suaveplan/crypto does not pull in @suaveplan/cli runtime deps.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-hash-password": "./dist/bin/hash-password.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/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",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"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"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,107 @@
# `suaveplan-hash-password` bin entry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This file is the platform-runtime entry for the `suaveplan-hash-password`
binary shipped by `@suaveplan/crypto-bin`. It is intentionally a minimal
shim: it constructs an `HashPasswordCliEnv` adapter from `process.stdout`
and `process.stderr`, then delegates to the testable `main()` function in
`../hash-password/hash-password.ts`. All argument parsing, algorithm
dispatch, and exit-code logic live in that pure core so they can be unit
tested by direct invocation rather than by spawning a subprocess.
The shim exists so the package can publish a Node-style `bin` entry under
`./dist/bin/hash-password.js` (with the `#!/usr/bin/env node` shebang
preserved by Vite) without coupling the testable core to `process.*`
globals. Anything that touches `process.argv` / `process.exit` /
`process.stdout` is confined to this module.
## Features
- Forwards `process.argv.slice(2)` into `main()` and exits with the
returned numeric code.
- Wires `process.stdout.write` / `process.stderr.write` into the
`HashPasswordCliEnv` adapter — the core never references `process`
directly.
- Auto-exec guard: only runs `main()` when invoked as the top-level
module (`process.argv[1]` ends with `hash-password.js`). Importing this
module from a test or another script does not trigger an exit.
- Coverage-excluded by design — this module is exercised end-to-end via
the bin install path; the logic it wraps is fully covered by
`../hash-password/hash-password.test.ts`.
- Shebang line emitted verbatim into the dist bundle so the file is
directly executable once symlinked by `bun install` / `npm install`.
## Usage: Basic Example
```bash
# Once installed, the bin is on PATH via the package.json `bin` map
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
$ echo $?
0
```
## Usage: Advanced Example
```bash
# Pipe-friendly verify (exit 0 = match, 1 = mismatch, 3+ = error)
$ STORED='$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ if suaveplan-hash-password --password "$PW" --verify "$STORED"; then
> echo "login ok"
> else
> case $? in
> 1) echo "wrong password" ;;
> 3) echo "stored hash has unrecognised algorithm" ;;
> 4) echo "stored hash is corrupt" ;;
> *) echo "argument error" ;;
> esac
> fi
```
For programmatic invocation that does not spawn a process, import the
testable core directly instead of this shim:
```ts
import { main } from "@suaveplan/crypto-bin";
const code = main(["--password", "pw"], {
write: (s) => process.stdout.write(s),
writeError: (s) => process.stderr.write(s),
});
```
## API Reference
This module has no public exports — it is a side-effect entry point.
Behaviour:
- On import, registers the auto-exec guard and (if invoked as the
top-level script) calls `main(argv, env)` and exits with its return
value.
- `argv`: `process.argv.slice(2)` — same shape `main()` accepts.
- `env.write(s)`: forwards `s` to `process.stdout.write` synchronously.
- `env.writeError(s)`: forwards `s` to `process.stderr.write`
synchronously.
For the contract of `main()` itself (exit codes, accepted flags, error
classes) see `../hash-password/hash-password.md`.
## Implementation Notes
The auto-exec guard checks `process.argv[1]?.endsWith("hash-password.js")`
rather than `import.meta.url === ...` so the same module survives Vite's
ES-module bundling without ESM-vs-CJS surprises in older Node hosts. The
guard string matches the bundled filename (`hash-password.js`) deliberately
— the source is `.ts` and is never executed directly in production.
`/* istanbul ignore */` markers wrap the guard and the `process.exit`
call because the test suite invokes `main()` directly with a captured env
adapter (see `../hash-password/hash-password.test.ts`). Spawning a
subprocess to cover this two-line wrapper would add filesystem and Node
runtime coupling to the test suite without exercising any logic that is
not already covered. The `vitest.config.ts` coverage `exclude` list
documents this decision in a sibling comment so future readers do not
mistake the exclusion for an oversight.

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* `suaveplan-hash-password` binary entry. Thin wrapper that builds an
* env adapter from `process.stdout` / `process.stderr` and dispatches
* to the testable `main()` function in `../hash-password/hash-password.ts`.
*/
import { main } from "../hash-password/hash-password.js";
/* istanbul ignore if */
if (
typeof process !== "undefined" &&
process.argv?.[1]?.endsWith("hash-password.js")
) {
/* istanbul ignore next */
process.exit(
main(process.argv.slice(2), {
write: (s) => {
process.stdout.write(s);
},
writeError: (s) => {
process.stderr.write(s);
},
})
);
}

View File

@@ -0,0 +1,63 @@
# suaveplan-hash-password CLI
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hash-password.ts` is the testable core of the `suaveplan-hash-password` binary in `@suaveplan/crypto-bin`. It accepts an argv-style list of flags, an env adapter (so tests can capture stdout/stderr without process side effects), and returns an exit code. Two modes: hash mode (no `--verify`) prints a stored hash; verify mode (`--verify <stored>`) checks a candidate password against a stored hash and returns 0/1 by match outcome.
The bin lives in its own package (`@suaveplan/crypto-bin`) rather than alongside the universal `@suaveplan/crypto` because CLI surfaces would force the entire `@suaveplan/cli` runtime tree into universal consumers; keeping the bin in a separate package preserves universal purity.
## Features
- Two algorithms: `argon2id` (default, OWASP 2023 recommended) and `blake2b` (opt-in via `--algo blake2b`).
- Hash mode: prints `$argon2id$v=19$...` or `$blake2b$v=1$...` to stdout, exits 0.
- Verify mode: detects algorithm from the stored hash's prefix and runs the matching `verifyPassword*` function. Exits 0 on match, 1 on mismatch, 3+ on argument / format errors.
- Strict argument validation: missing `--password`, unknown `--algo`, malformed `--cost-factor` all surface as `HashPasswordCliError` with one of three stable error codes (`HASH_PASSWORD_CLI_BAD_ARGS` / `HASH_PASSWORD_CLI_UNKNOWN_ALGO` / `HASH_PASSWORD_CLI_VERIFY_FAILED`).
- Env-injected stdout/stderr — never touches `process.stdout` / `process.stderr` directly, so tests can capture output without spawning a subprocess.
- Per-spec exit-code contract: 0 = success / verify-match, 1 = verify-mismatch, 2 = bad args, 3 = unknown algo, 4 = verify failed (corrupt stored hash).
## Usage: Basic Example
```bash
# Hash a password (Argon2id by default)
$ suaveplan-hash-password --password "correct horse battery staple"
$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>
# Verify
$ suaveplan-hash-password --password "correct horse battery staple" --verify '$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>'
$ echo $?
0
```
## Usage: Advanced Example
```ts
// Programmatic invocation (e.g. from a test harness or a higher-level CLI)
import { main } from "@suaveplan/crypto-bin/hash-password/hash-password";
const out: string[] = [];
const err: string[] = [];
const code = main(["--password", "pw", "--algo", "blake2b", "--cost-factor", "10"], {
write: (s) => out.push(s),
writeError: (s) => err.push(s),
});
if (code === 0) {
storeHash(out.join("").trim());
}
```
## API Reference
- `main(argv: readonly string[], env: HashPasswordCliEnv): number` — testable entry point.
- `parseArgs(argv: readonly string[]): HashPasswordCliArgs` — pure argv parser; throws `HashPasswordCliError` on bad input.
- `detectAlgo(storedHash: string): Algo` — algorithm sniffer; throws on unknown prefix.
- `HashPasswordCliEnv { write(s), writeError(s) }` — output adapter interface.
- `HashPasswordCliError extends SuaveplanError` — error class wrapping the three stable codes.
- `HASH_PASSWORD_CLI_CODES` — exported registry: `HASH_PASSWORD_CLI_BAD_ARGS`, `HASH_PASSWORD_CLI_UNKNOWN_ALGO`, `HASH_PASSWORD_CLI_VERIFY_FAILED`.
## Implementation Notes
The CLI deliberately does not depend on `@suaveplan/cli` to avoid pulling the readline / TTY-prompt machinery into a tiny one-shot binary. argv parsing is hand-rolled (≈40 lines) because the option set is small and stable. Verify-mode exit codes are spec-pinned: 0 = match, 1 = mismatch — those two values are the contract for shell pipelines (`if suaveplan-hash-password --password ... --verify ...; then login; fi`). Errors from the verify path (corrupt stored hash) intentionally exit with `4` rather than `1` so a caller can distinguish "wrong password" from "your stored hash is corrupt"; the spec's `verify mismatch exit 1` requirement is preserved for the wrong-password case.

View File

@@ -0,0 +1,260 @@
import { hashPasswordArgon2id } from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
import { hashPasswordBlake2b } from "@suaveplan/crypto-hash/mac/blake2b-password/blake2b-password";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
detectAlgo,
type HashPasswordCliEnv,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password.js";
function captureEnv(): {
env: HashPasswordCliEnv;
out: string[];
err: string[];
} {
const out: string[] = [];
const err: string[] = [];
return {
env: {
write: (s) => {
out.push(s);
},
writeError: (s) => {
err.push(s);
},
},
out,
err,
};
}
describe("hash-password CLI — hash mode", () => {
it("argon2id mode writes a $argon2id$-prefixed hash to stdout and exits 0", {
timeout: 30_000,
}, () => {
const { env, out } = captureEnv();
const code = main(["--password", "pw"], env);
expect(code).toBe(0);
expect(out.join("").startsWith("$argon2id$")).toBe(true);
});
it("blake2b mode writes a $blake2b$-prefixed hash to stdout and exits 0", {
timeout: 5000,
}, () => {
const { env, out } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "4"],
env
);
expect(code).toBe(0);
expect(out.join("").startsWith("$blake2b$")).toBe(true);
});
});
describe("hash-password CLI — verify mode", () => {
it("verify mode exits 0 when password matches stored hash (argon2id)", {
timeout: 30_000,
}, () => {
const stored = hashPasswordArgon2id("pw", {
memoryCost: 16,
timeCost: 1,
parallelism: 1,
});
const { env, out } = captureEnv();
const code = main(["--password", "pw", "--verify", stored], env);
expect(code).toBe(0);
expect(out.join("")).toBe("");
});
it("verify mode exits 0 when password matches stored hash (blake2b)", {
timeout: 5000,
}, () => {
const stored = hashPasswordBlake2b("pw", { costFactor: 4 });
const { env } = captureEnv();
const code = main(["--password", "pw", "--verify", stored], env);
expect(code).toBe(0);
});
it("verify mode exits 1 when password does not match stored hash", {
timeout: 5000,
}, () => {
const stored = hashPasswordBlake2b("pw", { costFactor: 4 });
const { env } = captureEnv();
const code = main(["--password", "wrong", "--verify", stored], env);
expect(code).toBe(1);
});
it("verify mode rejects stored hash with unknown prefix", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--verify", "$bcrypt$..."], env);
expect(code).toBe(3);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_UNKNOWN_ALGO");
});
it("verify mode surfaces a corrupted stored hash as exit 4", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(
["--password", "pw", "--verify", "$blake2b$v=1$cf=garbage$00$11"],
env
);
expect(code).toBe(4);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_VERIFY_FAILED");
});
});
describe("hash-password CLI — argument validation", () => {
it("missing --password emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main([], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--password with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--verify with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--verify"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("unknown --algo emits HASH_PASSWORD_CLI_UNKNOWN_ALGO", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--algo", "bcrypt"], env);
expect(code).toBe(3);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_UNKNOWN_ALGO");
});
it("--cost-factor with non-integer value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "abc"],
env
);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor out of range emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--cost-factor", "99"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("unknown flag emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--unknown"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor with no value emits HASH_PASSWORD_CLI_BAD_ARGS", {
timeout: 5000,
}, () => {
const { env, err } = captureEnv();
const code = main(["--password", "pw", "--cost-factor"], env);
expect(code).toBe(2);
expect(err.join("")).toContain("HASH_PASSWORD_CLI_BAD_ARGS");
});
it("--cost-factor option is honored in blake2b mode", {
timeout: 5000,
}, () => {
const { env, out } = captureEnv();
const code = main(
["--password", "pw", "--algo", "blake2b", "--cost-factor", "5"],
env
);
expect(code).toBe(0);
expect(out.join("")).toContain("$blake2b$v=1$cf=5$");
});
it("blake2b mode without --cost-factor uses default cost factor", {
timeout: 30_000,
}, () => {
const { env, out } = captureEnv();
const code = main(["--password", "pw", "--algo", "blake2b"], env);
expect(code).toBe(0);
expect(out.join("")).toContain("$blake2b$v=1$cf=14$");
});
it("HashPasswordCliError can be constructed without options", {
timeout: 5000,
}, () => {
const e = new HashPasswordCliError(
"CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS",
"msg"
);
expect(e.code).toBe("CRYPTO.HASH_PASSWORD_CLI_BAD_ARGS");
});
});
describe("parseArgs", () => {
it("parses password / algo / verify / cost-factor", {
timeout: 5000,
}, () => {
const args = parseArgs([
"--password",
"pw",
"--algo",
"blake2b",
"--verify",
"$blake2b$x",
"--cost-factor",
"10",
]);
expect(args.password).toBe("pw");
expect(args.algo).toBe("blake2b");
expect(args.verify).toBe("$blake2b$x");
expect(args.costFactor).toBe(10);
});
});
describe("detectAlgo", () => {
it("recognises argon2id and blake2b prefixes", { timeout: 5000 }, () => {
expect(detectAlgo("$argon2id$v=19$...")).toBe("argon2id");
expect(detectAlgo("$blake2b$v=1$...")).toBe("blake2b");
});
it("throws HashPasswordCliError on unknown prefix", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
detectAlgo("$bcrypt$...");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashPasswordCliError);
expect((caught as HashPasswordCliError).code).toBe(
"CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO"
);
});
});

View File

@@ -0,0 +1,191 @@
import {
hashPasswordArgon2id,
verifyPasswordArgon2id,
} from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
import {
hashPasswordBlake2b,
verifyPasswordBlake2b,
} from "@suaveplan/crypto-hash/mac/blake2b-password/blake2b-password";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const HASH_PASSWORD_CLI_CODES = defineCodes("CRYPTO", [
"HASH_PASSWORD_CLI_BAD_ARGS",
"HASH_PASSWORD_CLI_UNKNOWN_ALGO",
"HASH_PASSWORD_CLI_VERIFY_FAILED",
] as const);
export type HashPasswordCliCode =
(typeof HASH_PASSWORD_CLI_CODES)[keyof typeof HASH_PASSWORD_CLI_CODES];
interface CliErrorOptions {
context?: Record<string, unknown>;
}
const DEFAULT_CLI_OPTIONS: CliErrorOptions = {};
export class HashPasswordCliError extends SuaveplanError<HashPasswordCliCode> {
public constructor(
code: HashPasswordCliCode,
messageKey: string,
options: CliErrorOptions = DEFAULT_CLI_OPTIONS
) {
super(code, messageKey, options);
}
}
export type Algo = "argon2id" | "blake2b";
export interface HashPasswordCliArgs {
readonly password?: string;
readonly algo?: Algo;
readonly verify?: string;
readonly costFactor?: number;
}
export interface HashPasswordCliEnv {
write(s: string): void;
writeError(s: string): void;
}
/** Parse argv into a structured args record. Returns either parsed args or an error. */
export function parseArgs(argv: readonly string[]): HashPasswordCliArgs {
const args: {
password?: string;
algo?: Algo;
verify?: string;
costFactor?: number;
} = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--password") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-password-value",
{ context: { flag: "--password" } }
);
}
args.password = v;
i++;
} else if (a === "--algo") {
const v = argv[i + 1];
if (v !== "argon2id" && v !== "blake2b") {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_UNKNOWN_ALGO,
"error.crypto.hash-password.cli.unknown-algo",
{ context: { algo: v } }
);
}
args.algo = v;
i++;
} else if (a === "--verify") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-verify-value",
{ context: { flag: "--verify" } }
);
}
args.verify = v;
i++;
} else if (a === "--cost-factor") {
const v = argv[i + 1];
if (v === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-cost-factor-value",
{ context: { flag: "--cost-factor" } }
);
}
const n = Number.parseInt(v, 10);
if (!Number.isInteger(n) || n < 1 || n > 31) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.bad-cost-factor",
{ context: { value: v } }
);
}
args.costFactor = n;
i++;
} else {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.unknown-flag",
{ context: { flag: a } }
);
}
}
if (args.password === undefined) {
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_BAD_ARGS,
"error.crypto.hash-password.cli.missing-password",
{ context: { flag: "--password" } }
);
}
return args;
}
/** Detect the algorithm from a stored hash's MCF/PHC prefix. */
export function detectAlgo(storedHash: string): Algo {
if (storedHash.startsWith("$argon2id$")) return "argon2id";
if (storedHash.startsWith("$blake2b$")) return "blake2b";
throw new HashPasswordCliError(
HASH_PASSWORD_CLI_CODES.HASH_PASSWORD_CLI_UNKNOWN_ALGO,
"error.crypto.hash-password.cli.unknown-stored-prefix",
{
context: {
prefix: storedHash.slice(0, Math.min(20, storedHash.length)),
},
}
);
}
/**
* `suaveplan-hash-password` CLI body. Given an argv slice and an env
* adapter, returns an exit code (0 success, 1 verify mismatch, 2+ for
* argument / algorithm errors). Never throws — every error path is
* captured and surfaced via `env.writeError(...)` and a non-zero return.
*/
export function main(argv: readonly string[], env: HashPasswordCliEnv): number {
let args: HashPasswordCliArgs;
try {
args = parseArgs(argv);
} catch (err) {
const e = err as HashPasswordCliError;
env.writeError(`[hash-password] ERROR: ${e.code} ${e.message}\n`);
return e.code === "CRYPTO.HASH_PASSWORD_CLI_UNKNOWN_ALGO" ? 3 : 2;
}
const algo: Algo = args.algo ?? "argon2id";
const password = args.password!;
if (args.verify !== undefined) {
let storedAlgo: Algo;
try {
storedAlgo = detectAlgo(args.verify);
} catch (err) {
const e = err as HashPasswordCliError;
env.writeError(`[hash-password] ERROR: ${e.code} ${e.message}\n`);
return 3;
}
try {
const ok =
storedAlgo === "argon2id"
? verifyPasswordArgon2id(args.verify, password)
: verifyPasswordBlake2b(args.verify, password);
return ok ? 0 : 1;
} catch (err) {
const msg = (err as Error).message;
env.writeError(
`[hash-password] ERROR: CRYPTO.HASH_PASSWORD_CLI_VERIFY_FAILED ${msg}\n`
);
return 4;
}
}
if (algo === "argon2id") {
env.write(`${hashPasswordArgon2id(password)}\n`);
return 0;
}
const opts: { costFactor?: number } = {};
if (args.costFactor !== undefined) opts.costFactor = args.costFactor;
env.write(`${hashPasswordBlake2b(password, opts)}\n`);
return 0;
}

View File

@@ -0,0 +1,13 @@
export type {
Algo,
HashPasswordCliArgs,
HashPasswordCliCode,
HashPasswordCliEnv,
} from "./hash-password.js";
export {
detectAlgo,
HASH_PASSWORD_CLI_CODES,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password.js";

View File

@@ -0,0 +1,13 @@
export const PACKAGE_NAME = "@suaveplan/crypto-bin" as const;
export {
type Algo,
detectAlgo,
HASH_PASSWORD_CLI_CODES,
type HashPasswordCliArgs,
type HashPasswordCliCode,
type HashPasswordCliEnv,
HashPasswordCliError,
main,
parseArgs,
} from "./hash-password/hash-password.js";

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,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", "dist", "node_modules"]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-bin",
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",
"bin/hash-password": "src/bin/hash-password.ts",
"hash-password/hash-password":
"src/hash-password/hash-password.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto-hash",
/^@suaveplan\/crypto-hash\//,
"@suaveplan/error",
"@suaveplan/types",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
// bin/hash-password.ts is a side-effect entry: an auto-exec
// guard (`if process.argv...; process.exit(main(...))`)
// wrapping a single delegation to main(). It can't be
// covered without spawning a subprocess. The main()
// function itself IS unit-tested via direct invocation in
// src/hash-password/hash-password.test.ts.
"src/bin/hash-password.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});

View File

@@ -0,0 +1,16 @@
# @suaveplan/crypto-browser Changelog
## 0.0.0 — suaveplan initial
- Initial implementation: WebCrypto-backed AEAD, sign, and agree engines
- `webCryptoAeadEngine` — AES-256-GCM via `crypto.subtle`
- `webCryptoSignEngine` / `webCryptoEd25519Engine` / `webCryptoEcdsaP256Engine` — Ed25519 and ECDSA-P256
- `webCryptoAgreeEngine` / `webCryptoX25519Engine` / `webCryptoEcdhP256Engine` — X25519 and ECDH-P256
- `rsa` namespace — RSA-PSS, RSA-OAEP, RSASSA-PKCS1-v1_5
- `aesKw` namespace — AES Key Wrap (RFC 3394)
- `ecdsaP384` namespace — ECDSA with P-384 / SHA-384
- `ecdhP384` namespace — ECDH with P-384
- `nonExtractableKey()` — non-extractable CryptoKey generation
- `isWebCryptoAvailable()` / `assertWebCryptoAvailable()` — runtime checks
- `BrowserCryptoError` with codes: `NOT_AVAILABLE`, `KEY.GENERATION_FAILED`, `OPERATION.FAILED`
- 100% coverage threshold (statements, branches, functions, lines)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,182 @@
{
"name": "@suaveplan/crypto-browser",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "WebCrypto-backed engine implementations for @suaveplan/crypto \u2014 AES-256-GCM, Ed25519, ECDSA-P256/P384, X25519, ECDH, RSA, AES-KW, and non-extractable key support via crypto.subtle.",
"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"
}
},
"./availability/availability": {
"import": {
"types": "./dist/availability/availability.d.ts",
"default": "./dist/availability/availability.js"
},
"require": {
"types": "./dist/availability/availability.d.ts",
"default": "./dist/availability/availability.js"
}
},
"./engines/webcrypto-aead-engine/webcrypto-aead-engine": {
"import": {
"types": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.d.ts",
"default": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
},
"require": {
"types": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.d.ts",
"default": "./dist/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
}
},
"./engines/webcrypto-sign-engine/webcrypto-sign-engine": {
"import": {
"types": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.d.ts",
"default": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.js"
},
"require": {
"types": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.d.ts",
"default": "./dist/engines/webcrypto-sign-engine/webcrypto-sign-engine.js"
}
},
"./engines/webcrypto-agree-engine/webcrypto-agree-engine": {
"import": {
"types": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.d.ts",
"default": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.js"
},
"require": {
"types": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.d.ts",
"default": "./dist/engines/webcrypto-agree-engine/webcrypto-agree-engine.js"
}
},
"./errors/browser-crypto-error/browser-crypto-error": {
"import": {
"types": "./dist/errors/browser-crypto-error/browser-crypto-error.d.ts",
"default": "./dist/errors/browser-crypto-error/browser-crypto-error.js"
},
"require": {
"types": "./dist/errors/browser-crypto-error/browser-crypto-error.d.ts",
"default": "./dist/errors/browser-crypto-error/browser-crypto-error.js"
}
},
"./errors/codes/codes": {
"import": {
"types": "./dist/errors/codes/codes.d.ts",
"default": "./dist/errors/codes/codes.js"
},
"require": {
"types": "./dist/errors/codes/codes.d.ts",
"default": "./dist/errors/codes/codes.js"
}
},
"./rsa/rsa": {
"import": {
"types": "./dist/rsa/rsa.d.ts",
"default": "./dist/rsa/rsa.js"
},
"require": {
"types": "./dist/rsa/rsa.d.ts",
"default": "./dist/rsa/rsa.js"
}
},
"./aes-kw/aes-kw": {
"import": {
"types": "./dist/aes-kw/aes-kw.d.ts",
"default": "./dist/aes-kw/aes-kw.js"
},
"require": {
"types": "./dist/aes-kw/aes-kw.d.ts",
"default": "./dist/aes-kw/aes-kw.js"
}
},
"./ecdsa-p384/ecdsa-p384": {
"import": {
"types": "./dist/ecdsa-p384/ecdsa-p384.d.ts",
"default": "./dist/ecdsa-p384/ecdsa-p384.js"
},
"require": {
"types": "./dist/ecdsa-p384/ecdsa-p384.d.ts",
"default": "./dist/ecdsa-p384/ecdsa-p384.js"
}
},
"./ecdh-p384/ecdh-p384": {
"import": {
"types": "./dist/ecdh-p384/ecdh-p384.d.ts",
"default": "./dist/ecdh-p384/ecdh-p384.js"
},
"require": {
"types": "./dist/ecdh-p384/ecdh-p384.d.ts",
"default": "./dist/ecdh-p384/ecdh-p384.js"
}
},
"./non-extractable-key/non-extractable-key": {
"import": {
"types": "./dist/non-extractable-key/non-extractable-key.d.ts",
"default": "./dist/non-extractable-key/non-extractable-key.js"
},
"require": {
"types": "./dist/non-extractable-key/non-extractable-key.d.ts",
"default": "./dist/non-extractable-key/non-extractable-key.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/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",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-core": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "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,106 @@
# aes-kw
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides AES Key Wrap (AES-KW, RFC 3394) operations via `crypto.subtle`. AES-KW is an authenticated key-wrapping algorithm that encapsulates cryptographic key material with integrity protection, producing output that is 8 bytes longer than the input. It is widely used in key management systems, JOSE (JSON Web Encryption), CMS enveloped-data, and TLS session ticket encryption.
Unlike general-purpose AES-GCM, AES-KW is specifically designed for wrapping other cryptographic keys. The algorithm provides integrity verification — any tampering with the wrapped output will be detected during unwrapping.
## Features
- `wrapKey(wrappingKey, keyToWrap)` — wraps raw key bytes using AES-KW via `crypto.subtle.wrapKey`
- `unwrapKey(wrappingKey, wrappedKey)` — unwraps and returns raw key bytes via `crypto.subtle.unwrapKey`
- Supports 128, 192, and 256-bit (16, 24, 32-byte) wrapping keys
- Input key must be a multiple of 8 bytes (AES-KW requirement)
- Wrapped output is input length + 8 (integrity check value)
- `assertWebCryptoAvailable()` guard on every call
- Typed `BrowserCryptoError` on all failure paths
## Usage: Basic Example
```ts
import { aesKw } from "@suaveplan/crypto-browser/aes-kw/aes-kw";
const wrappingKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit KEK
const aesKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit content key
const wrapped = await aesKw.wrapKey(wrappingKey, aesKey);
// wrapped.length === aesKey.length + 8 === 40
const recovered = await aesKw.unwrapKey(wrappingKey, wrapped);
// recovered deepEquals aesKey
```
## Usage: Advanced Example
```ts
import { aesKw } from "@suaveplan/crypto-browser/aes-kw/aes-kw";
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
// Key Encryption Key (KEK) derived from a user password or stored securely
declare const kek: Uint8Array; // 32-byte wrapping key
// Wrap content encryption key for storage or transmission
async function storeWrappedKey(contentKey: Uint8Array): Promise<Uint8Array> {
try {
return await aesKw.wrapKey(kek, contentKey);
} catch (err) {
if (err instanceof BrowserCryptoError && err.code === "BROWSER_CRYPTO.OPERATION.FAILED") {
throw new Error("Key wrapping failed — check wrapping key size");
}
throw err;
}
}
// Later: unwrap and use the content key
async function loadAndEncrypt(
wrappedKey: Uint8Array,
plaintext: Uint8Array
): Promise<Uint8Array> {
const contentKey = await aesKw.unwrapKey(kek, wrappedKey);
const nonce = crypto.getRandomValues(new Uint8Array(12));
return webCryptoAeadEngine.encrypt(contentKey, plaintext, nonce) as unknown as Promise<Uint8Array>;
}
```
## API Reference
### `aesKw.wrapKey(wrappingKey, keyToWrap): Promise<Uint8Array>`
Wraps `keyToWrap` using AES-KW.
| Parameter | Type | Description |
|-----------|------|-------------|
| `wrappingKey` | `Uint8Array` | 16, 24, or 32-byte AES Key Encryption Key (KEK) |
| `keyToWrap` | `Uint8Array` | Key bytes to wrap; must be a multiple of 8 bytes |
**Returns:** `Promise<Uint8Array>` — wrapped bytes, `keyToWrap.length + 8` bytes long.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
---
### `aesKw.unwrapKey(wrappingKey, wrappedKey): Promise<Uint8Array>`
Unwraps `wrappedKey` using AES-KW and returns the original key bytes.
| Parameter | Type | Description |
|-----------|------|-------------|
| `wrappingKey` | `Uint8Array` | 16, 24, or 32-byte KEK (must match wrapping key) |
| `wrappedKey` | `Uint8Array` | Previously wrapped bytes from `wrapKey()` |
**Returns:** `Promise<Uint8Array>` — original raw key bytes.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` when integrity check fails (wrong key or tampered data).
## Implementation Notes
`crypto.subtle.wrapKey` requires a `CryptoKey` object as input (not a raw `Uint8Array`). The implementation imports `keyToWrap` as a temporary `AES-GCM` key with `extractable: true` before wrapping — this is the minimal ceremony needed to satisfy the `crypto.subtle` API. The temporary key is never exported directly; only the AES-KW wrapped form is returned.
Similarly, `crypto.subtle.unwrapKey` produces a `CryptoKey` internally; the implementation immediately re-exports it as raw bytes via `exportKey("raw", ...)` so the caller receives a portable `Uint8Array`. This round-trip through `CryptoKey` is required by the Web Crypto API and adds negligible overhead.
AES-KW does not use a nonce — it is deterministic for a given (wrapping-key, key-to-wrap) pair. This is intentional for RFC 3394 compliance; nonce-based confidentiality is provided by AES-GCM when encrypting arbitrary data.

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
import { aesKw } from "./aes-kw.js";
function randomBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
describe("aesKw", () => {
it("wraps and unwraps a 16-byte key", { timeout: 30000 }, async () => {
const wrappingKey = randomBytes(32);
const keyToWrap = randomBytes(16);
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
expect(wrapped).toBeInstanceOf(Uint8Array);
// AES-KW adds 8 bytes
expect(wrapped.length).toBe(keyToWrap.length + 8);
const unwrapped = await aesKw.unwrapKey(wrappingKey, wrapped);
expect(unwrapped).toEqual(keyToWrap);
});
it("wraps and unwraps a 32-byte key", { timeout: 30000 }, async () => {
const wrappingKey = randomBytes(32);
const keyToWrap = randomBytes(32);
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
const unwrapped = await aesKw.unwrapKey(wrappingKey, wrapped);
expect(unwrapped).toEqual(keyToWrap);
});
it("unwrap fails with wrong wrapping key", { timeout: 30000 }, async () => {
const wk1 = randomBytes(32);
const wk2 = randomBytes(32);
const keyToWrap = randomBytes(16);
const wrapped = await aesKw.wrapKey(wk1, keyToWrap);
await expect(aesKw.unwrapKey(wk2, wrapped)).rejects.toBeInstanceOf(
BrowserCryptoError
);
});
it("wrapKey throws BrowserCryptoError for invalid wrapping key size", {
timeout: 30000,
}, async () => {
// A 5-byte key is not a valid AES key size — importKey will reject
const badWrappingKey = new Uint8Array(5);
const keyToWrap = randomBytes(16);
await expect(
aesKw.wrapKey(badWrappingKey, keyToWrap)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("wrapKey re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
aesKw.wrapKey(new Uint8Array(32), new Uint8Array(16))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("unwrapKey re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
aesKw.unwrapKey(new Uint8Array(32), new Uint8Array(24))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("wrapKey wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
aesKw.wrapKey(new Uint8Array(32), new Uint8Array(16))
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("unwrapKey wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
aesKw.unwrapKey(new Uint8Array(32), new Uint8Array(24))
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("round-trip property: wrap then unwrap recovers original", {
timeout: 30000,
}, async () => {
// AES-KW wraps keys that must be importable as AES keys (16, 24, or 32 bytes)
await fc.assert(
fc.asyncProperty(fc.constantFrom(16, 24, 32), async (keyLen) => {
const wrappingKey = randomBytes(32);
const keyToWrap = randomBytes(keyLen);
const wrapped = await aesKw.wrapKey(wrappingKey, keyToWrap);
const recovered = await aesKw.unwrapKey(wrappingKey, wrapped);
expect(recovered).toEqual(keyToWrap);
}),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,117 @@
import { assertWebCryptoAvailable } from "../availability/availability.js";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
/**
* Imports a wrapping key for use with AES-KW.
* Key must be 16, 24, or 32 bytes (128, 192, or 256 bits).
*/
async function importWrappingKey(
rawKey: Uint8Array,
usages: KeyUsage[]
): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
toBuffer(rawKey),
{ name: "AES-KW" },
false,
usages
);
}
/**
* AES Key Wrap (RFC 3394) operations via `crypto.subtle`.
*
* AES-KW provides authenticated wrapping of key material. The wrapped output
* is 8 bytes longer than the input (a 64-bit integrity check value is
* prepended). The input key must be a multiple of 8 bytes.
*/
export const aesKw = {
/**
* Wraps `keyToWrap` using `wrappingKey` with AES-KW.
*
* @param wrappingKey - 16, 24, or 32-byte AES key
* @param keyToWrap - key bytes to wrap (must be multiple of 8 bytes)
* @returns Promise resolving to wrapped key bytes (input length + 8)
* @throws BrowserCryptoError NOT_AVAILABLE | OPERATION.FAILED
*/
async wrapKey(
wrappingKey: Uint8Array,
keyToWrap: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const wk = await importWrappingKey(wrappingKey, ["wrapKey"]);
// Import keyToWrap as raw AES key to satisfy crypto.subtle.wrapKey
const keyToWrapCk = await crypto.subtle.importKey(
"raw",
toBuffer(keyToWrap),
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"]
);
const wrapped = await crypto.subtle.wrapKey(
"raw",
keyToWrapCk,
wk,
{ name: "AES-KW" }
);
return new Uint8Array(wrapped);
} catch (err) {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{
cause: err instanceof Error ? err : new Error(String(err)),
}
);
}
},
/**
* Unwraps a wrapped key using `wrappingKey` with AES-KW.
*
* @param wrappingKey - 16, 24, or 32-byte AES wrapping key
* @param wrappedKey - wrapped key bytes (output of `wrapKey`)
* @returns Promise resolving to original raw key bytes
* @throws BrowserCryptoError NOT_AVAILABLE | OPERATION.FAILED
*/
async unwrapKey(
wrappingKey: Uint8Array,
wrappedKey: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const wk = await importWrappingKey(wrappingKey, ["unwrapKey"]);
const unwrapped = await crypto.subtle.unwrapKey(
"raw",
toBuffer(wrappedKey),
wk,
{ name: "AES-KW" },
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"]
);
const exported = await crypto.subtle.exportKey("raw", unwrapped);
return new Uint8Array(exported);
} catch (err) {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{
cause: err instanceof Error ? err : new Error(String(err)),
}
);
}
},
};

View File

@@ -0,0 +1 @@
export { aesKw } from "./aes-kw";

View File

@@ -0,0 +1,85 @@
# availability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a runtime check for the presence of the Web Crypto API (`crypto.subtle`). The check is a prerequisite for every operation in `@suaveplan/crypto-browser`: all engine methods and primitives call `assertWebCryptoAvailable()` before attempting any `crypto.subtle` call, so callers receive a structured, typed `BrowserCryptoError` rather than an opaque `TypeError: Cannot read properties of undefined (reading 'subtle')`.
The two exported helpers form a small contract: `isWebCryptoAvailable` is a pure predicate useful for conditional branching or feature-flag evaluation, while `assertWebCryptoAvailable` is the guard variant used internally throughout the package.
## Features
- `isWebCryptoAvailable(): boolean` — returns `true` when both `globalThis.crypto` and `globalThis.crypto.subtle` are defined; otherwise `false`
- `assertWebCryptoAvailable(): void` — throws `BrowserCryptoError` with code `BROWSER_CRYPTO.NOT_AVAILABLE` if the check fails
- Zero side effects — purely reads globals, never modifies them
- Works across Node 20+, Bun, Deno, and modern browsers without polyfills
## Usage: Basic Example
```ts
import { isWebCryptoAvailable } from "@suaveplan/crypto-browser/availability/availability";
if (!isWebCryptoAvailable()) {
console.warn("WebCrypto not available — using fallback cipher");
}
```
## Usage: Advanced Example
```ts
import { assertWebCryptoAvailable } from "@suaveplan/crypto-browser/availability/availability";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
async function encryptUserData(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
// Guard before any crypto.subtle calls
assertWebCryptoAvailable();
// ... proceed with crypto.subtle.encrypt
return new Uint8Array();
}
// Caller can catch the specific structured error
try {
await encryptUserData(key, data);
} catch (err) {
if (err instanceof BrowserCryptoError && err.code === "BROWSER_CRYPTO.NOT_AVAILABLE") {
// Fall back to a server-side encryption endpoint
}
}
```
## API Reference
### `isWebCryptoAvailable(): boolean`
Returns `true` when `typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'`.
| Parameter | Type | Description |
|-----------|------|-------------|
| _(none)_ | — | — |
**Returns:** `boolean` — `true` if `crypto.subtle` is accessible.
**Throws:** never.
---
### `assertWebCryptoAvailable(): void`
Calls `isWebCryptoAvailable()` and throws if the result is `false`.
| Parameter | Type | Description |
|-----------|------|-------------|
| _(none)_ | — | — |
**Returns:** `void`
**Throws:** `BrowserCryptoError` — code `BROWSER_CRYPTO.NOT_AVAILABLE` when `crypto.subtle` is absent.
## Implementation Notes
The check deliberately uses `typeof crypto !== 'undefined'` (rather than a `try/catch` or optional chaining on a possibly-unset global) to avoid triggering a `ReferenceError` in environments where `crypto` is not declared at all. Optional chaining `globalThis?.crypto?.subtle` is not used because `globalThis` itself might be unavailable in some older embedded runtimes.
Both exports are side-effect-free and tree-shakeable: `"sideEffects": false` is set on the package, so bundlers that import only `isWebCryptoAvailable` will not include `BrowserCryptoError` in the output unless it is actually used.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import {
assertWebCryptoAvailable,
isWebCryptoAvailable,
} from "./availability.js";
describe("isWebCryptoAvailable", () => {
it("returns true when crypto.subtle is present", { timeout: 5000 }, () => {
// Node 20+ / Bun expose globalThis.crypto
expect(isWebCryptoAvailable()).toBe(true);
});
it("returns false when crypto.subtle is missing", {
timeout: 5000,
}, () => {
const fakeCrypto = {} as Crypto;
expect(isWebCryptoAvailable(fakeCrypto)).toBe(false);
});
it("returns false for an explicitly passed undefined ref", {
timeout: 5000,
}, () => {
// When the caller passes a ref that lacks subtle, the guard
// reports unavailable regardless of the global environment.
const missingSubtle = {
getRandomValues: () => {},
} as unknown as Crypto;
expect(isWebCryptoAvailable(missingSubtle)).toBe(false);
});
});
describe("assertWebCryptoAvailable", () => {
it("does not throw when available", { timeout: 5000 }, () => {
expect(() => assertWebCryptoAvailable()).not.toThrow();
});
it("throws BrowserCryptoError NOT_AVAILABLE when unavailable", {
timeout: 5000,
}, () => {
const fakeCrypto = {} as Crypto;
expect(() => assertWebCryptoAvailable(fakeCrypto)).toThrow(
BrowserCryptoError
);
try {
assertWebCryptoAvailable(fakeCrypto);
} catch (err) {
expect(err).toBeInstanceOf(BrowserCryptoError);
expect((err as BrowserCryptoError).code).toBe(
"BROWSER_CRYPTO.NOT_AVAILABLE"
);
}
});
});

View File

@@ -0,0 +1,44 @@
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
/**
* Returns `true` when `crypto.subtle` is available in the current runtime.
*
* Checks both that the global `crypto` object is defined and that the
* `crypto.subtle` sub-API is present. Modern browsers (Chromium 37+, Firefox
* 34+, Safari 10.1+), Node 20+, Bun, and Deno all pass this check. Older
* browsers, pure Worker contexts without `crypto`, or environments where the
* Web Crypto API has been stripped will return `false`.
*
* @param cryptoRef - Optional `Crypto` instance to inspect. Defaults to the
* global `crypto` object when omitted.
*/
export function isWebCryptoAvailable(cryptoRef?: Crypto): boolean {
const c =
cryptoRef ??
/* istanbul ignore next */ (typeof crypto !== "undefined"
? crypto
: undefined);
return c !== undefined && typeof c.subtle !== "undefined";
}
/**
* Throws `BrowserCryptoError("BROWSER_CRYPTO.NOT_AVAILABLE")` if
* `crypto.subtle` is not present in the current runtime.
*
* All engine methods and primitives call this guard before attempting any
* `crypto.subtle` operation, so callers receive a structured error instead of
* a raw `TypeError: Cannot read properties of undefined`.
*
* @throws {BrowserCryptoError} code `BROWSER_CRYPTO.NOT_AVAILABLE`
* @param cryptoRef - Optional `Crypto` instance to inspect. Defaults to the
* global `crypto` object when omitted.
*/
export function assertWebCryptoAvailable(cryptoRef?: Crypto): void {
if (!isWebCryptoAvailable(cryptoRef)) {
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["NOT_AVAILABLE"],
"browser-crypto.not-available"
);
}
}

View File

@@ -0,0 +1 @@
export { assertWebCryptoAvailable, isWebCryptoAvailable } from "./availability";

View File

@@ -0,0 +1,29 @@
/** Recommended AES-GCM nonce length in bytes (96-bit IV). */
export const AES_GCM_NONCE_LENGTH = 12 as const;
/** AES-256 key length in bytes. */
export const AES_256_KEY_LENGTH = 32 as const;
/** AES-128 key length in bytes. */
export const AES_128_KEY_LENGTH = 16 as const;
/** Ed25519 public key length in bytes. */
export const ED25519_PUBLIC_KEY_LENGTH = 32 as const;
/** Ed25519 signature length in bytes. */
export const ED25519_SIGNATURE_LENGTH = 64 as const;
/** X25519 public key length in bytes. */
export const X25519_PUBLIC_KEY_LENGTH = 32 as const;
/** X25519 shared secret length in bytes. */
export const X25519_SHARED_SECRET_LENGTH = 32 as const;
/** P-384 uncompressed public key length in bytes. */
export const P384_PUBLIC_KEY_LENGTH = 97 as const;
/** P-384 shared secret length in bytes (derived bits). */
export const P384_SHARED_SECRET_LENGTH = 48 as const;
/** P-256 uncompressed public key length in bytes. */
export const P256_PUBLIC_KEY_LENGTH = 65 as const;

View File

@@ -0,0 +1,105 @@
# ecdh-p384
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Elliptic Curve Diffie-Hellman key agreement using the NIST P-384 curve via `crypto.subtle`. P-384 ECDH delivers 192 bits of security, matching the security level of AES-256 and making it appropriate for long-term key agreement in high-security contexts. This module is the P-384 analogue of the `webCryptoEcdhP256Engine` provided in the engines module, exposed as a standalone namespace for direct use outside of the `AgreeEngine` interface.
Key agreement produces a 48-byte (384-bit) shared secret by calling `crypto.subtle.deriveBits` with `length: 384`. Callers are responsible for passing this secret through a KDF before using it as an encryption key.
## Features
- `generateKeypair()` — generates a P-384 ECDH keypair via `crypto.subtle.generateKey`
- `agree(privateKey, publicKey)` — derives 48-byte shared secret via `crypto.subtle.deriveBits`
- Full DH symmetry: `agree(alice, bob.pub)` equals `agree(bob, alice.pub)`
- Public key: 97-byte uncompressed point (raw format, 0x04 prefix)
- Private key: PKCS8 DER bytes
- `assertWebCryptoAvailable()` guard on every call
- Typed `BrowserCryptoError` for all failure paths
## Usage: Basic Example
```ts
import { ecdhP384 } from "@suaveplan/crypto-browser/ecdh-p384/ecdh-p384";
const alice = await ecdhP384.generateKeypair();
const bob = await ecdhP384.generateKeypair();
// Both sides independently compute the same 48-byte secret
const aliceShared = await ecdhP384.agree(alice.privateKey, bob.publicKey);
const bobShared = await ecdhP384.agree(bob.privateKey, alice.publicKey);
// aliceShared deepEquals bobShared — 48 bytes (384 bits)
```
## Usage: Advanced Example
```ts
import { ecdhP384 } from "@suaveplan/crypto-browser/ecdh-p384/ecdh-p384";
// KDF using HKDF to derive a 32-byte AES key from the P-384 shared secret
async function deriveAesKey(
privateKey: Uint8Array,
peerPublicKey: Uint8Array,
info: Uint8Array
): Promise<Uint8Array> {
const sharedSecret = await ecdhP384.agree(privateKey, peerPublicKey);
// Import shared secret as HKDF key material
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedSecret,
"HKDF",
false,
["deriveBits"]
);
const aesKeyBits = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-384",
salt: new Uint8Array(48), // zero salt
info,
},
hkdfKey,
256
);
return new Uint8Array(aesKeyBits);
}
```
## API Reference
### `ecdhP384.generateKeypair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>`
Generates a P-384 ECDH keypair.
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — 97-byte uncompressed public key and PKCS8 DER private key.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
---
### `ecdhP384.agree(privateKey, publicKey): Promise<Uint8Array>`
Performs ECDH key agreement, deriving 384 bits from `deriveBits`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `privateKey` | `Uint8Array` | PKCS8 DER private key bytes |
| `publicKey` | `Uint8Array` | 97-byte uncompressed P-384 public key |
**Returns:** `Promise<Uint8Array>` — 48-byte (384-bit) shared secret.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
## Implementation Notes
P-384 `deriveBits` is called with `length: 384` to extract the full 384-bit shared secret. This matches the curve's security level and avoids the truncation that would occur if 256 bits were requested. Callers using the output as an AES-256 key should pass through HKDF first to properly condition the key material.
The public key is exported from `crypto.subtle` via `"raw"` format, which produces the standard uncompressed point encoding. This is directly importable on any Web Crypto implementation that supports P-384.
P-384 is approximately 2–3× slower than P-256 for key generation and agreement. For applications where P-256's 128-bit security is sufficient, `webCryptoEcdhP256Engine` provides the same API with better performance.

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
import { ecdhP384 } from "./ecdh-p384.js";
describe("ecdhP384", () => {
it("generates a valid keypair", { timeout: 30000 }, async () => {
const kp = await ecdhP384.generateKeypair();
// P-384 uncompressed public key = 97 bytes
expect(kp.publicKey.length).toBe(97);
expect(kp.privateKey).toBeInstanceOf(Uint8Array);
expect(kp.privateKey.length).toBeGreaterThan(0);
});
it("two parties produce the same shared secret", {
timeout: 30000,
}, async () => {
const alice = await ecdhP384.generateKeypair();
const bob = await ecdhP384.generateKeypair();
const aliceShared = await ecdhP384.agree(
alice.privateKey,
bob.publicKey
);
const bobShared = await ecdhP384.agree(bob.privateKey, alice.publicKey);
expect(aliceShared).toEqual(bobShared);
// P-384 deriveBits(384) = 48 bytes
expect(aliceShared.length).toBe(48);
});
it("different keypairs produce different shared secrets", {
timeout: 30000,
}, async () => {
const alice = await ecdhP384.generateKeypair();
const bob = await ecdhP384.generateKeypair();
const charlie = await ecdhP384.generateKeypair();
const aliceBob = await ecdhP384.agree(alice.privateKey, bob.publicKey);
const aliceCharlie = await ecdhP384.agree(
alice.privateKey,
charlie.publicKey
);
expect(aliceBob).not.toEqual(aliceCharlie);
});
it("agree throws BrowserCryptoError for invalid private key", {
timeout: 30000,
}, async () => {
const badSecret = new Uint8Array(32); // not valid PKCS8 DER for P-384
const validKp = await ecdhP384.generateKeypair();
await expect(
ecdhP384.agree(badSecret, validKp.publicKey)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("agree throws BrowserCryptoError for invalid public key", {
timeout: 30000,
}, async () => {
const validKp = await ecdhP384.generateKeypair();
const badPub = new Uint8Array(10); // too short for uncompressed P-384
await expect(
ecdhP384.agree(validKp.privateKey, badPub)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("agree re-throws BrowserCryptoError from wrapErr without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
ecdhP384.agree(new Uint8Array(97), new Uint8Array(97))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(ecdhP384.generateKeypair()).rejects.toBe(sentinel);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("generateKeypair wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(ecdhP384.generateKeypair()).rejects.toBeInstanceOf(
BrowserCryptoError
);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("agree wraps non-Error thrown value via wrapErr in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
ecdhP384.agree(new Uint8Array(97), new Uint8Array(97))
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("round-trip property: DH symmetry holds", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(fc.constant(null), async () => {
const alice = await ecdhP384.generateKeypair();
const bob = await ecdhP384.generateKeypair();
const s1 = await ecdhP384.agree(
alice.privateKey,
bob.publicKey
);
const s2 = await ecdhP384.agree(
bob.privateKey,
alice.publicKey
);
expect(s1).toEqual(s2);
}),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,108 @@
import { assertWebCryptoAvailable } from "../availability/availability.js";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
const ALGO = { name: "ECDH", namedCurve: "P-384" } as const;
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
function wrapErr(err: unknown): never {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{ cause: toError(err) }
);
}
/**
* ECDH key agreement with P-384 curve via `crypto.subtle`.
*
* Keys are exported as: public key = 97-byte uncompressed point (raw),
* private key = PKCS8 DER bytes.
*/
export const ecdhP384 = {
/**
* Generates an ECDH-P384 keypair.
*
* @returns Promise resolving to `{ publicKey: Uint8Array, privateKey: Uint8Array }`
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
*/
async generateKeypair(): Promise<{
publicKey: Uint8Array;
privateKey: Uint8Array;
}> {
assertWebCryptoAvailable();
try {
const kp = (await crypto.subtle.generateKey(ALGO, true, [
"deriveBits",
])) as CryptoKeyPair;
const [raw, pkcs8] = await Promise.all([
crypto.subtle.exportKey("raw", kp.publicKey),
crypto.subtle.exportKey("pkcs8", kp.privateKey),
]);
return {
publicKey: new Uint8Array(raw),
privateKey: new Uint8Array(pkcs8),
};
} catch (err) {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed",
{ cause: toError(err) }
);
}
},
/**
* Performs ECDH key agreement on P-384.
*
* @param privateKey - PKCS8 DER private key bytes
* @param publicKey - 97-byte uncompressed P-384 public key
* @returns Promise resolving to 48-byte (384-bit) shared secret
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async agree(
privateKey: Uint8Array,
publicKey: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const [myKey, theirKey] = await Promise.all([
crypto.subtle.importKey(
"pkcs8",
toBuffer(privateKey),
ALGO,
false,
["deriveBits"]
),
crypto.subtle.importKey(
"raw",
toBuffer(publicKey),
ALGO,
false,
[]
),
]);
const bits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: theirKey },
myKey,
384
);
return new Uint8Array(bits);
} catch (err) {
wrapErr(err);
}
},
};

View File

@@ -0,0 +1 @@
export { ecdhP384 } from "./ecdh-p384";

View File

@@ -0,0 +1,117 @@
# ecdsa-p384
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides ECDSA (Elliptic Curve Digital Signature Algorithm) operations using the NIST P-384 curve and SHA-384 hash, backed by `crypto.subtle`. P-384 offers 192 bits of security — stronger than P-256's 128 bits — and is required by many compliance frameworks (NIST SP 800-57, CNSA Suite 2.0, NSA Suite B). This module is intended for contexts where P-384 is mandated by policy or interoperability requirements.
The public key format is the 97-byte uncompressed point representation (0x04 prefix followed by 48-byte X and Y coordinates). The private key is PKCS8 DER. Both formats are standard and directly importable by other Web Crypto implementations.
## Features
- `generateKeypair()` — generates a P-384 ECDSA keypair using `crypto.subtle.generateKey`
- `sign(privateKey, message)` — signs with ECDSA-P384 / SHA-384
- `verify(publicKey, signature, message)` — verifies an ECDSA-P384 signature
- Public key: 97-byte uncompressed point (raw format)
- Private key: PKCS8 DER bytes
- Signature format: IEEE P1363 (R || S) as returned by `crypto.subtle`
- `assertWebCryptoAvailable()` guard on every call
- Typed `BrowserCryptoError` for all failure paths
## Usage: Basic Example
```ts
import { ecdsaP384 } from "@suaveplan/crypto-browser/ecdsa-p384/ecdsa-p384";
const kp = await ecdsaP384.generateKeypair();
// kp.publicKey is 97 bytes (uncompressed P-384 point)
// kp.privateKey is PKCS8 DER bytes
const message = new TextEncoder().encode("document to sign");
const signature = await ecdsaP384.sign(kp.privateKey, message);
const valid = await ecdsaP384.verify(kp.publicKey, signature, message);
console.log(valid); // true
```
## Usage: Advanced Example
```ts
import { ecdsaP384 } from "@suaveplan/crypto-browser/ecdsa-p384/ecdsa-p384";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
// Issue a signed certificate-like structure
async function issueAttestation(
issuerPrivateKey: Uint8Array,
payload: Uint8Array
): Promise<{ signature: Uint8Array; algorithm: string }> {
try {
const signature = await ecdsaP384.sign(issuerPrivateKey, payload);
return { signature, algorithm: "ECDSA-P384-SHA384" };
} catch (err) {
if (err instanceof BrowserCryptoError) {
throw new Error(`Signing failed: ${err.code}`);
}
throw err;
}
}
// Verify the attestation with a stored public key
async function verifyAttestation(
publicKey: Uint8Array,
payload: Uint8Array,
signature: Uint8Array
): Promise<boolean> {
return ecdsaP384.verify(publicKey, signature, payload);
}
```
## API Reference
### `ecdsaP384.generateKeypair(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>`
Generates a P-384 ECDSA keypair.
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — 97-byte uncompressed public key and PKCS8 DER private key.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
---
### `ecdsaP384.sign(privateKey, message): Promise<Uint8Array>`
Signs `message` using ECDSA-P384 with SHA-384.
| Parameter | Type | Description |
|-----------|------|-------------|
| `privateKey` | `Uint8Array` | PKCS8 DER private key bytes |
| `message` | `Uint8Array` | Bytes to sign |
**Returns:** `Promise<Uint8Array>` — IEEE P1363 signature (96 bytes for P-384).
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
---
### `ecdsaP384.verify(publicKey, signature, message): Promise<boolean>`
Verifies an ECDSA-P384 / SHA-384 signature.
| Parameter | Type | Description |
|-----------|------|-------------|
| `publicKey` | `Uint8Array` | 97-byte uncompressed P-384 public key |
| `signature` | `Uint8Array` | IEEE P1363 signature bytes |
| `message` | `Uint8Array` | Original message bytes |
**Returns:** `Promise<boolean>` — `true` if signature is valid.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
## Implementation Notes
SHA-384 is paired with P-384 because the security level of the hash should match that of the curve. Using SHA-256 with P-384 would create a mismatch: the 192-bit curve security would be limited to the 128-bit collision resistance of SHA-256.
The signature output format from `crypto.subtle.sign` for ECDSA is IEEE P1363 (raw R || S concatenation), not DER/ASN.1. This differs from OpenSSL's default DER encoding. When interoperating with systems that expect DER signatures, a conversion layer is needed.
P-384 key generation is typically 2–5× slower than P-256 due to the larger field size. For high-throughput signing workloads where P-256 security is sufficient, consider using `webCryptoEcdsaP256Engine` instead.

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
import { ecdsaP384 } from "./ecdsa-p384.js";
describe("ecdsaP384", () => {
it("generates a keypair", { timeout: 30000 }, async () => {
const kp = await ecdsaP384.generateKeypair();
// P-384 uncompressed public key = 97 bytes
expect(kp.publicKey.length).toBe(97);
expect(kp.privateKey).toBeInstanceOf(Uint8Array);
expect(kp.privateKey.length).toBeGreaterThan(0);
});
it("signs and verifies a message", { timeout: 30000 }, async () => {
const kp = await ecdsaP384.generateKeypair();
const message = new TextEncoder().encode("p384 test");
const signature = await ecdsaP384.sign(kp.privateKey, message);
expect(signature).toBeInstanceOf(Uint8Array);
const valid = await ecdsaP384.verify(kp.publicKey, signature, message);
expect(valid).toBe(true);
});
it("verification fails with different message", {
timeout: 30000,
}, async () => {
const kp = await ecdsaP384.generateKeypair();
const message = new TextEncoder().encode("original");
const tampered = new TextEncoder().encode("tampered");
const signature = await ecdsaP384.sign(kp.privateKey, message);
const valid = await ecdsaP384.verify(kp.publicKey, signature, tampered);
expect(valid).toBe(false);
});
it("verification fails with wrong public key", {
timeout: 30000,
}, async () => {
const kp1 = await ecdsaP384.generateKeypair();
const kp2 = await ecdsaP384.generateKeypair();
const message = new TextEncoder().encode("msg");
const signature = await ecdsaP384.sign(kp1.privateKey, message);
const valid = await ecdsaP384.verify(kp2.publicKey, signature, message);
expect(valid).toBe(false);
});
it("sign throws BrowserCryptoError for invalid private key", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid PKCS8 DER for P-384
const msg = new TextEncoder().encode("hello");
await expect(ecdsaP384.sign(badKey, msg)).rejects.toBeInstanceOf(
BrowserCryptoError
);
});
it("verify throws BrowserCryptoError for invalid public key", {
timeout: 30000,
}, async () => {
const badPub = new Uint8Array(10); // too short for P-384 uncompressed
const sig = new Uint8Array(96);
const msg = new TextEncoder().encode("hello");
await expect(ecdsaP384.verify(badPub, sig, msg)).rejects.toBeInstanceOf(
BrowserCryptoError
);
});
it("sign re-throws BrowserCryptoError from wrapErr without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
ecdsaP384.sign(new Uint8Array(97), new Uint8Array(8))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("verify re-throws BrowserCryptoError from wrapErr without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
ecdsaP384.verify(
new Uint8Array(97),
new Uint8Array(96),
new Uint8Array(8)
)
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(ecdsaP384.generateKeypair()).rejects.toBe(sentinel);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("generateKeypair wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(ecdsaP384.generateKeypair()).rejects.toBeInstanceOf(
BrowserCryptoError
);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("sign wraps non-Error thrown value via wrapErr in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
ecdsaP384.sign(new Uint8Array(97), new Uint8Array(8))
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("verify wraps non-Error thrown value via wrapErr in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
ecdsaP384.verify(
new Uint8Array(97),
new Uint8Array(96),
new Uint8Array(8)
)
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("round-trip property: sign then verify", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.uint8Array({ minLength: 1, maxLength: 256 }),
async (msgArr) => {
const kp = await ecdsaP384.generateKeypair();
const msg = new Uint8Array(msgArr);
const sig = await ecdsaP384.sign(kp.privateKey, msg);
const ok = await ecdsaP384.verify(kp.publicKey, sig, msg);
expect(ok).toBe(true);
}
),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,134 @@
import { assertWebCryptoAvailable } from "../availability/availability.js";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
const ALGO = { name: "ECDSA", namedCurve: "P-384" } as const;
const SIGN_ALGO = { name: "ECDSA", hash: "SHA-384" } as const;
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
function wrapErr(err: unknown): never {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{ cause: toError(err) }
);
}
/**
* ECDSA with P-384 curve and SHA-384 — sign/verify/keypair generation via `crypto.subtle`.
*
* Keys are exported in raw (public, 97 bytes uncompressed) and PKCS8 DER (private) format.
*/
export const ecdsaP384 = {
/**
* Generates an ECDSA-P384 keypair.
*
* @returns Promise resolving to `{ publicKey: Uint8Array, privateKey: Uint8Array }`
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
*/
async generateKeypair(): Promise<{
publicKey: Uint8Array;
privateKey: Uint8Array;
}> {
assertWebCryptoAvailable();
try {
const kp = (await crypto.subtle.generateKey(ALGO, true, [
"sign",
"verify",
])) as CryptoKeyPair;
const [spki, pkcs8] = await Promise.all([
crypto.subtle.exportKey("raw", kp.publicKey),
crypto.subtle.exportKey("pkcs8", kp.privateKey),
]);
return {
publicKey: new Uint8Array(spki),
privateKey: new Uint8Array(pkcs8),
};
} catch (err) {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed",
{ cause: toError(err) }
);
}
},
/**
* Signs a message with an ECDSA-P384 private key.
*
* @param privateKey - PKCS8 DER private key bytes
* @param message - bytes to sign
* @returns Promise resolving to DER signature bytes
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async sign(
privateKey: Uint8Array,
message: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const key = await crypto.subtle.importKey(
"pkcs8",
toBuffer(privateKey),
ALGO,
false,
["sign"]
);
const sig = await crypto.subtle.sign(
SIGN_ALGO,
key,
toBuffer(message)
);
return new Uint8Array(sig);
} catch (err) {
wrapErr(err);
}
},
/**
* Verifies an ECDSA-P384 signature.
*
* @param publicKey - 97-byte uncompressed P-384 public key
* @param signature - signature bytes
* @param message - original message bytes
* @returns Promise resolving to `true` if signature is valid
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): Promise<boolean> {
assertWebCryptoAvailable();
try {
const key = await crypto.subtle.importKey(
"raw",
toBuffer(publicKey),
ALGO,
false,
["verify"]
);
return await crypto.subtle.verify(
SIGN_ALGO,
key,
toBuffer(signature),
toBuffer(message)
);
} catch (err) {
wrapErr(err);
}
},
};

View File

@@ -0,0 +1 @@
export { ecdsaP384 } from "./ecdsa-p384";

View File

@@ -0,0 +1,11 @@
export { webCryptoAeadEngine } from "./webcrypto-aead-engine/webcrypto-aead-engine.js";
export {
webCryptoAgreeEngine,
webCryptoEcdhP256Engine,
webCryptoX25519Engine,
} from "./webcrypto-agree-engine/webcrypto-agree-engine.js";
export {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
webCryptoSignEngine,
} from "./webcrypto-sign-engine/webcrypto-sign-engine.js";

View File

@@ -0,0 +1 @@
export { webCryptoAeadEngine } from "./webcrypto-aead-engine.js";

View File

@@ -0,0 +1,108 @@
# webcrypto-aead-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides an `AeadEngine` implementation that delegates all AES-256-GCM operations to the browser's (or Node's) native `crypto.subtle` API. This lets `@suaveplan/crypto` facade functions — `encrypt()` and `decrypt()` from the `aead` sub-module — run on hardware-accelerated paths wherever the Web Crypto API is available, instead of the default pure-JavaScript `@noble/ciphers` path.
The engine is designed for environments where minimising bundle size matters most: importing it adds zero third-party cryptography code because everything is provided by the host runtime.
## Features
- `encrypt(key, plaintext, nonce, aad?)` — AES-256-GCM encryption via `crypto.subtle.encrypt`, including automatic 16-byte authentication tag appended to ciphertext
- `decrypt(key, ciphertext, nonce, aad?)` — AES-256-GCM decryption via `crypto.subtle.decrypt` with authenticated tag verification
- Optional additional authenticated data (AAD) for binding ciphertext to a context
- Throws typed `BrowserCryptoError` with stable codes for every failure path
- Returns `Promise<Uint8Array>` — engine methods are async even though the `AeadEngine` interface declares sync return types; facade functions accept Promise results
- Guards every call with `assertWebCryptoAvailable()` to produce a structured error instead of a runtime `TypeError`
## Usage: Basic Example
```ts
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
const key = crypto.getRandomValues(new Uint8Array(32));
const nonce = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode("hello world");
const ciphertext = await (webCryptoAeadEngine.encrypt(key, plaintext, nonce) as unknown as Promise<Uint8Array>);
const recovered = await (webCryptoAeadEngine.decrypt(key, ciphertext, nonce) as unknown as Promise<Uint8Array>);
console.log(new TextDecoder().decode(recovered)); // "hello world"
```
## Usage: Advanced Example
```ts
import { encrypt } from "@suaveplan/crypto/aead";
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
async function sealPayload(
key: Uint8Array,
payload: Uint8Array,
context: Uint8Array // AAD binds the ciphertext to e.g. a session ID
): Promise<Uint8Array> {
try {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const sealed = await (webCryptoAeadEngine.encrypt(key, payload, nonce, context) as unknown as Promise<Uint8Array>);
// Prepend nonce for transport: [12 bytes nonce | ciphertext+tag]
const out = new Uint8Array(12 + sealed.length);
out.set(nonce, 0);
out.set(sealed, 12);
return out;
} catch (err) {
if (err instanceof BrowserCryptoError) {
// err.code is one of: BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
throw err;
}
throw err;
}
}
```
## API Reference
### `webCryptoAeadEngine: AeadEngine`
An object conforming to the `AeadEngine` interface from `@suaveplan/crypto/engines`.
#### `.encrypt(key, plaintext, nonce, aad?): Uint8Array`
Encrypts plaintext using AES-256-GCM.
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `Uint8Array` | 32-byte (256-bit) AES key |
| `plaintext` | `Uint8Array` | Bytes to encrypt |
| `nonce` | `Uint8Array` | 12-byte IV (use a fresh random nonce per message) |
| `aad` | `Uint8Array \| undefined` | Optional additional authenticated data |
**Returns:** `Uint8Array` (runtime: `Promise<Uint8Array>`) — ciphertext with 16-byte GCM tag appended.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
#### `.decrypt(key, ciphertext, nonce, aad?): Uint8Array`
Decrypts ciphertext and verifies the GCM authentication tag.
| Parameter | Type | Description |
|-----------|------|-------------|
| `key` | `Uint8Array` | 32-byte AES key (must match encryption key) |
| `ciphertext` | `Uint8Array` | Ciphertext with 16-byte GCM tag appended |
| `nonce` | `Uint8Array` | 12-byte IV used during encryption |
| `aad` | `Uint8Array \| undefined` | Optional AAD (must match encryption AAD exactly) |
**Returns:** `Uint8Array` (runtime: `Promise<Uint8Array>`) — decrypted plaintext.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED` on authentication failure or bad key.
## Implementation Notes
`crypto.subtle.importKey` is called on every encrypt/decrypt invocation rather than being cached, because raw `Uint8Array` keys are the idiomatic interface in `AeadEngine` and caching a `CryptoKey` across calls would require a `Map<Uint8Array, CryptoKey>` with careful lifecycle management. The overhead of `importKey` is dominated by the actual AES-GCM operation for typical payload sizes.
The `AeadEngine` interface uses synchronous return types (`Uint8Array`), while `crypto.subtle` is entirely asynchronous. The implementation returns `Promise<Uint8Array>` cast to `Uint8Array` at the type level. Facade functions in `@suaveplan/crypto` use `await` on engine results, so this is transparent to consumers using those facades.
The 12-byte nonce is the NIST-recommended IV length for AES-GCM and provides the best performance with `crypto.subtle`. Longer IVs (96–128 bits) would work but require internal counter splitting which is not done here — callers must supply exactly a 12-byte nonce for maximum compatibility.

View File

@@ -0,0 +1,285 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
import { webCryptoAeadEngine } from "./webcrypto-aead-engine.js";
function randomBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
}
describe("webCryptoAeadEngine", () => {
it("encrypts and decrypts a known message", {
timeout: 30000,
}, async () => {
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("hello world");
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce
) as unknown as Promise<Uint8Array>);
expect(ciphertext).toBeInstanceOf(Uint8Array);
expect(ciphertext.length).toBeGreaterThan(plaintext.length);
const decrypted = await (webCryptoAeadEngine.decrypt(
key,
ciphertext,
nonce
) as unknown as Promise<Uint8Array>);
expect(decrypted).toEqual(plaintext);
});
it("supports additional authenticated data (AAD)", {
timeout: 30000,
}, async () => {
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("authenticated message");
const aad = new TextEncoder().encode("header-data");
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce,
aad
) as unknown as Promise<Uint8Array>);
const decrypted = await (webCryptoAeadEngine.decrypt(
key,
ciphertext,
nonce,
aad
) as unknown as Promise<Uint8Array>);
expect(decrypted).toEqual(plaintext);
});
it("decryption fails with wrong key", { timeout: 30000 }, async () => {
const key = randomBytes(32);
const wrongKey = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("secret");
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce
) as unknown as Promise<Uint8Array>);
await expect(
webCryptoAeadEngine.decrypt(
wrongKey,
ciphertext,
nonce
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("decryption fails when ciphertext is tampered", {
timeout: 30000,
}, async () => {
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("secret");
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce
) as unknown as Promise<Uint8Array>);
const tampered = new Uint8Array(ciphertext);
tampered[0] = tampered[0] ^ 0xff;
await expect(
webCryptoAeadEngine.decrypt(
key,
tampered,
nonce
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("decryption fails with wrong AAD", { timeout: 30000 }, async () => {
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("secret");
const aad = new TextEncoder().encode("correct-header");
const wrongAad = new TextEncoder().encode("wrong-header");
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce,
aad
) as unknown as Promise<Uint8Array>);
await expect(
webCryptoAeadEngine.decrypt(
key,
ciphertext,
nonce,
wrongAad
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("encrypt throws BrowserCryptoError for invalid key size", {
timeout: 30000,
}, async () => {
// A 5-byte key is not a valid AES key — importKey will reject
const badKey = new Uint8Array(5);
const nonce = new Uint8Array(12);
const plaintext = new TextEncoder().encode("hello");
await expect(
webCryptoAeadEngine.encrypt(
badKey,
plaintext,
nonce
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("decrypt throws BrowserCryptoError for invalid key size", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(5);
const nonce = new Uint8Array(12);
const fakeCiphertext = new Uint8Array(32);
await expect(
webCryptoAeadEngine.decrypt(
badKey,
fakeCiphertext,
nonce
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("encrypt re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
// Monkey-patch importKey to throw a BrowserCryptoError directly
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoAeadEngine.encrypt(
new Uint8Array(32),
new Uint8Array(8),
new Uint8Array(12)
) as unknown as Promise<Uint8Array>
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("decrypt re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoAeadEngine.decrypt(
new Uint8Array(32),
new Uint8Array(32),
new Uint8Array(12)
) as unknown as Promise<Uint8Array>
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("encrypt wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
webCryptoAeadEngine.encrypt(
new Uint8Array(32),
new Uint8Array(8),
new Uint8Array(12)
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("decrypt wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
webCryptoAeadEngine.decrypt(
new Uint8Array(32),
new Uint8Array(32),
new Uint8Array(12)
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("round-trip property: encrypt then decrypt recovers plaintext", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.uint8Array({ minLength: 0, maxLength: 256 }),
async (plaintextArr) => {
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new Uint8Array(plaintextArr);
const ciphertext = await (webCryptoAeadEngine.encrypt(
key,
plaintext,
nonce
) as unknown as Promise<Uint8Array>);
const recovered = await (webCryptoAeadEngine.decrypt(
key,
ciphertext,
nonce
) as unknown as Promise<Uint8Array>);
expect(recovered).toEqual(plaintext);
}
),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,135 @@
import type { AeadEngine } from "@suaveplan/crypto-core/engines";
import { assertWebCryptoAvailable } from "../../availability/availability.js";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
/**
* Wraps a raw 32-byte key into a `CryptoKey` for AES-GCM.
*/
async function importAesKey(rawKey: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
toBuffer(rawKey),
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"]
);
}
/**
* AES-256-GCM AEAD engine backed by `crypto.subtle`.
*
* Implements the `AeadEngine` interface from `@suaveplan/crypto` so it can be
* plugged into `encrypt()` and `decrypt()` calls as the `engine` option:
*
* ```ts
* import { encrypt } from "@suaveplan/crypto-core/aead";
* import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
* const ciphertext = await encrypt(key, plaintext, nonce, { engine: webCryptoAeadEngine });
* ```
*
* The engine methods return `Promise<Uint8Array>` even though the interface
* declares `Uint8Array` — this is intentional: facade functions in
* `@suaveplan/crypto` accept both sync and async engine results.
*/
export const webCryptoAeadEngine: AeadEngine = {
/**
* Encrypts `plaintext` with AES-256-GCM.
*
* @param key - 32-byte raw key
* @param plaintext - plaintext bytes
* @param nonce - 12-byte IV (recommended)
* @param aad - optional additional authenticated data
* @returns Promise resolving to ciphertext + 16-byte GCM authentication tag
* @throws BrowserCryptoError BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
*/
encrypt(
key: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
assertWebCryptoAvailable();
return importAesKey(key)
.then((cryptoKey) => {
const params: AesGcmParams = {
name: "AES-GCM",
iv: toBuffer(nonce),
};
if (aad !== undefined && aad.length > 0) {
params.additionalData = toBuffer(aad);
}
return crypto.subtle.encrypt(
params,
cryptoKey,
toBuffer(plaintext)
);
})
.then((buf) => new Uint8Array(buf))
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{
cause:
err instanceof Error ? err : new Error(String(err)),
}
);
}) as unknown as Uint8Array;
},
/**
* Decrypts `ciphertext` (with appended 16-byte GCM tag) using AES-256-GCM.
*
* @param key - 32-byte raw key
* @param ciphertext - ciphertext + 16-byte authentication tag
* @param nonce - 12-byte IV used during encryption
* @param aad - optional additional authenticated data (must match encryption)
* @returns Promise resolving to plaintext bytes
* @throws BrowserCryptoError BROWSER_CRYPTO.NOT_AVAILABLE | BROWSER_CRYPTO.OPERATION.FAILED
*/
decrypt(
key: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
assertWebCryptoAvailable();
return importAesKey(key)
.then((cryptoKey) => {
const params: AesGcmParams = {
name: "AES-GCM",
iv: toBuffer(nonce),
};
if (aad !== undefined && aad.length > 0) {
params.additionalData = toBuffer(aad);
}
return crypto.subtle.decrypt(
params,
cryptoKey,
toBuffer(ciphertext)
);
})
.then((buf) => new Uint8Array(buf))
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{
cause:
err instanceof Error ? err : new Error(String(err)),
}
);
}) as unknown as Uint8Array;
},
};

View File

@@ -0,0 +1,5 @@
export {
webCryptoAgreeEngine,
webCryptoEcdhP256Engine,
webCryptoX25519Engine,
} from "./webcrypto-agree-engine.js";

View File

@@ -0,0 +1,118 @@
# webcrypto-agree-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides two `AgreeEngine` implementations — `webCryptoX25519Engine` (X25519 Diffie-Hellman) and `webCryptoEcdhP256Engine` (ECDH with P-256) — both backed by the native `crypto.subtle` API. A convenience alias `webCryptoAgreeEngine` resolves to the X25519 engine. These engines implement the `AgreeEngine` interface from `@suaveplan/crypto/engines`, making them drop-in substitutes for the default `@noble/curves`-backed engine in environments where `crypto.subtle` is available.
Key agreement is the foundation of forward-secrecy protocols (TLS, Noise, Signal). Using `crypto.subtle` for ECDH ensures hardware acceleration is used when the runtime supports it, and the key material never leaves the key management subsystem if non-extractable keys are used.
## Features
- `generateKeypair()` — generates an ephemeral X25519 or ECDH-P256 keypair; returns portable raw bytes
- `agree(mySecret, theirPublic)` — derives a 32-byte shared secret via `crypto.subtle.deriveBits`
- Full DH symmetry: `agree(alice, bob.pub) === agree(bob, alice.pub)` for any keypair pair
- All methods are async at runtime despite `AgreeEngine` sync declarations
- `assertWebCryptoAvailable()` guard on every call
- Typed `BrowserCryptoError` on generation or agreement failure
## Usage: Basic Example
```ts
import { webCryptoX25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-agree-engine";
const alice = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const bob = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const aliceSecret = await (webCryptoX25519Engine.agree(alice.secretKey, bob.publicKey) as unknown as Promise<Uint8Array>);
const bobSecret = await (webCryptoX25519Engine.agree(bob.secretKey, alice.publicKey) as unknown as Promise<Uint8Array>);
// aliceSecret deepEquals bobSecret — both parties derived the same 32 bytes
```
## Usage: Advanced Example
```ts
import { webCryptoX25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-agree-engine";
import { webCryptoAeadEngine } from "@suaveplan/crypto-browser/engines/webcrypto-aead-engine";
// Simple Diffie-Hellman key exchange followed by AES-GCM encryption
async function ephemeralEncrypt(
recipientPublicKey: Uint8Array,
plaintext: Uint8Array
): Promise<{ ephemeralPublic: Uint8Array; ciphertext: Uint8Array }> {
const ephemeral = await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const sharedSecret = await (webCryptoX25519Engine.agree(
ephemeral.secretKey,
recipientPublicKey
) as unknown as Promise<Uint8Array>);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await (webCryptoAeadEngine.encrypt(
sharedSecret,
plaintext,
nonce
) as unknown as Promise<Uint8Array>);
const out = new Uint8Array(12 + ciphertext.length);
out.set(nonce, 0);
out.set(ciphertext, 12);
return { ephemeralPublic: ephemeral.publicKey, ciphertext: out };
}
```
## API Reference
### `webCryptoX25519Engine: AgreeEngine`
X25519 engine via `crypto.subtle` with `{ name: "X25519" }`.
#### `.generateKeypair(): AgreeKeypair`
Returns `Promise<AgreeKeypair>` at runtime. Public key is 32 raw bytes. Secret key is 32-byte scalar.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
#### `.agree(mySecret, theirPublic): Uint8Array`
Returns `Promise<Uint8Array>` (32 bytes) at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `mySecret` | `Uint8Array` | 32-byte X25519 private scalar |
| `theirPublic` | `Uint8Array` | 32-byte X25519 public key |
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
---
### `webCryptoEcdhP256Engine: AgreeEngine`
ECDH-P256 engine via `crypto.subtle`. Public key is 65-byte uncompressed point. Secret key is PKCS8 DER. `agree()` returns 32 bytes via `deriveBits(..., 256)`.
---
### `webCryptoAgreeEngine: AgreeEngine`
Alias for `webCryptoX25519Engine`.
## Implementation Notes
X25519 public keys are exported from `crypto.subtle` in SPKI format and the raw 32-byte scalar is extracted from the last 32 bytes of the DER envelope. On import the scalar is re-wrapped with a minimal 48-byte PKCS8 structure. This keeps the external representation simple (just 32 bytes) while satisfying `crypto.subtle`'s requirement for DER-encoded keys.
For ECDH-P256 the key agreement produces a 256-bit shared secret by calling `deriveBits` with `length: 256`. The raw shared secret should be passed through a KDF (e.g., HKDF) before use as an encryption key; this is not done inside the engine so callers retain full control over key derivation.
X25519 requires Node 20+, Bun 1.0+, or Chrome 113+/Firefox 130+/Safari 15.4+. Older environments will receive `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.

View File

@@ -0,0 +1,269 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
import {
webCryptoAgreeEngine,
webCryptoEcdhP256Engine,
webCryptoX25519Engine,
} from "./webcrypto-agree-engine.js";
describe("webCryptoX25519Engine", () => {
it("generates a valid keypair", { timeout: 30000 }, async () => {
const kp =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
expect(kp.publicKey.length).toBe(32);
});
it("two parties produce the same shared secret", {
timeout: 30000,
}, async () => {
const alice =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const bob =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const aliceShared = await (webCryptoX25519Engine.agree(
alice.secretKey,
bob.publicKey
) as unknown as Promise<Uint8Array>);
const bobShared = await (webCryptoX25519Engine.agree(
bob.secretKey,
alice.publicKey
) as unknown as Promise<Uint8Array>);
expect(aliceShared).toEqual(bobShared);
expect(aliceShared.length).toBe(32);
});
it("round-trip property: both parties agree on same secret", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(fc.constant(null), async () => {
const alice =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const bob =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const s1 = await (webCryptoX25519Engine.agree(
alice.secretKey,
bob.publicKey
) as unknown as Promise<Uint8Array>);
const s2 = await (webCryptoX25519Engine.agree(
bob.secretKey,
alice.publicKey
) as unknown as Promise<Uint8Array>);
expect(s1).toEqual(s2);
}),
{ numRuns: 10 }
);
});
});
describe("webCryptoX25519Engine error paths", () => {
it("agree throws BrowserCryptoError for invalid private key bytes", {
timeout: 30000,
}, async () => {
// 32 bytes of zeroes is not valid X25519 PKCS8 DER
const badSecret = new Uint8Array(32);
const validKp =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
await expect(
webCryptoX25519Engine.agree(
badSecret,
validKp.publicKey
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("agree throws BrowserCryptoError for invalid public key bytes", {
timeout: 30000,
}, async () => {
const validKp =
await (webCryptoX25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const badPublic = new Uint8Array(10); // too short
await expect(
webCryptoX25519Engine.agree(
validKp.secretKey,
badPublic
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("agree re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoX25519Engine.agree(
new Uint8Array(48),
new Uint8Array(32)
) as unknown as Promise<Uint8Array>
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoX25519Engine.generateKeypair() as unknown as Promise<unknown>
).rejects.toBe(sentinel);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("generateKeypair wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(
webCryptoX25519Engine.generateKeypair() as unknown as Promise<unknown>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("agree wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
webCryptoX25519Engine.agree(
new Uint8Array(48),
new Uint8Array(32)
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
});
describe("webCryptoEcdhP256Engine", () => {
it("generates a valid keypair", { timeout: 30000 }, async () => {
const kp =
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
// P-256 uncompressed public key = 65 bytes
expect(kp.publicKey.length).toBe(65);
});
it("two parties produce the same shared secret", {
timeout: 30000,
}, async () => {
const alice =
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const bob =
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const aliceShared = await (webCryptoEcdhP256Engine.agree(
alice.secretKey,
bob.publicKey
) as unknown as Promise<Uint8Array>);
const bobShared = await (webCryptoEcdhP256Engine.agree(
bob.secretKey,
alice.publicKey
) as unknown as Promise<Uint8Array>);
expect(aliceShared).toEqual(bobShared);
});
});
describe("webCryptoEcdhP256Engine error paths", () => {
it("agree throws BrowserCryptoError for invalid private key bytes", {
timeout: 30000,
}, async () => {
const badSecret = new Uint8Array(32); // not valid PKCS8 DER for ECDH-P256
const validKp =
await (webCryptoEcdhP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
await expect(
webCryptoEcdhP256Engine.agree(
badSecret,
validKp.publicKey
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
});
describe("webCryptoAgreeEngine (default = X25519)", () => {
it("is the X25519 engine", { timeout: 5000 }, () => {
expect(webCryptoAgreeEngine).toBe(webCryptoX25519Engine);
});
});

View File

@@ -0,0 +1,173 @@
import type { AgreeEngine, AgreeKeypair } from "@suaveplan/crypto-core/engines";
import { assertWebCryptoAvailable } from "../../availability/availability.js";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
type AgreeAlgorithm = "X25519" | "ECDH-P256";
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
/** Coerce any thrown value to an Error instance. */
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
function keyGenAlgo(
algorithm: AgreeAlgorithm
): EcKeyGenParams | { name: string } {
if (algorithm === "ECDH-P256") {
return { name: "ECDH", namedCurve: "P-256" };
}
return { name: "X25519" };
}
async function exportPublicKey(
key: CryptoKey,
algorithm: AgreeAlgorithm
): Promise<Uint8Array> {
if (algorithm === "X25519") {
// X25519 public key: SPKI last 32 bytes
const spki = await crypto.subtle.exportKey("spki", key);
return new Uint8Array(spki).slice(-32);
}
const raw = await crypto.subtle.exportKey("raw", key);
return new Uint8Array(raw);
}
async function exportPrivateKey(
key: CryptoKey,
_algorithm: AgreeAlgorithm
): Promise<Uint8Array> {
// Store full PKCS8 DER for both X25519 and ECDH-P256
const pkcs8 = await crypto.subtle.exportKey("pkcs8", key);
return new Uint8Array(pkcs8);
}
async function importPublicKey(
algorithm: AgreeAlgorithm,
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (algorithm === "X25519") {
// Re-wrap into SPKI for X25519 (OID 1.3.101.110 = 2b 65 6e)
const spkiPrefix = new Uint8Array([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21,
0x00,
]);
const spki = new Uint8Array(spkiPrefix.length + keyBytes.length);
spki.set(spkiPrefix, 0);
spki.set(keyBytes, spkiPrefix.length);
return crypto.subtle.importKey(
"spki",
toBuffer(spki),
{ name: "X25519" },
false,
[]
);
}
return crypto.subtle.importKey(
"raw",
toBuffer(keyBytes),
{ name: "ECDH", namedCurve: "P-256" },
false,
[]
);
}
async function importPrivateKey(
algorithm: AgreeAlgorithm,
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (algorithm === "X25519") {
// keyBytes is full PKCS8 DER (48 bytes as exported)
return crypto.subtle.importKey(
"pkcs8",
toBuffer(keyBytes),
{ name: "X25519" },
false,
["deriveBits"]
);
}
return crypto.subtle.importKey(
"pkcs8",
toBuffer(keyBytes),
{ name: "ECDH", namedCurve: "P-256" },
false,
["deriveBits"]
);
}
function makeAgreeEngine(algorithm: AgreeAlgorithm): AgreeEngine {
return {
generateKeypair(): AgreeKeypair {
assertWebCryptoAvailable();
return crypto.subtle
.generateKey(keyGenAlgo(algorithm), true, ["deriveBits"])
.then(async (kp) => {
const pair = kp as CryptoKeyPair;
const [publicKey, secretKey] = await Promise.all([
exportPublicKey(pair.publicKey, algorithm),
exportPrivateKey(pair.privateKey, algorithm),
]);
return { publicKey, secretKey };
})
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed",
{ cause: toError(err) }
);
}) as unknown as AgreeKeypair;
},
agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array {
assertWebCryptoAvailable();
return Promise.all([
importPrivateKey(algorithm, mySecret),
importPublicKey(algorithm, theirPublic),
])
.then(([myKey, theirKey]) => {
const deriveAlgo =
algorithm === "ECDH-P256"
? { name: "ECDH", public: theirKey }
: { name: "X25519", public: theirKey };
return crypto.subtle.deriveBits(deriveAlgo, myKey, 256);
})
.then((buf) => new Uint8Array(buf))
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{ cause: toError(err) }
);
}) as unknown as Uint8Array;
},
};
}
/**
* X25519 key-agreement engine backed by `crypto.subtle`.
*
* Key encoding: public key is 32 raw bytes; secret key is 32-byte scalar.
*/
export const webCryptoX25519Engine: AgreeEngine = makeAgreeEngine("X25519");
/**
* ECDH-P256 key-agreement engine backed by `crypto.subtle`.
*
* Key encoding: public key is 65-byte uncompressed point; secret key is PKCS8 DER.
*/
export const webCryptoEcdhP256Engine: AgreeEngine =
makeAgreeEngine("ECDH-P256");
/**
* Default `webCryptoAgreeEngine` — uses X25519.
*/
export const webCryptoAgreeEngine: AgreeEngine = webCryptoX25519Engine;

View File

@@ -0,0 +1,5 @@
export {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
webCryptoSignEngine,
} from "./webcrypto-sign-engine.js";

View File

@@ -0,0 +1,119 @@
# webcrypto-sign-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides two `SignEngine` implementations — `webCryptoEd25519Engine` (Ed25519) and `webCryptoEcdsaP256Engine` (ECDSA with P-256) — both backed by `crypto.subtle`. The module also exports a convenience alias `webCryptoSignEngine` that resolves to the Ed25519 engine. These engines implement the `SignEngine` interface from `@suaveplan/crypto/engines`, making them drop-in replacements for the default `@noble/curves`-backed engine wherever `crypto.subtle` is available.
Ed25519 provides faster key generation, smaller signatures (64 bytes), and stronger security properties for most use-cases. ECDSA-P256 is provided for interoperability with systems that require NIST curves.
## Features
- `generateKeypair()` — generates an ephemeral Ed25519 or ECDSA-P256 keypair; returns raw public key bytes and raw/PKCS8 private key bytes
- `sign(secretKey, message)` — signs an arbitrary byte array using `crypto.subtle.sign`
- `verify(publicKey, signature, message)` — verifies a signature using `crypto.subtle.verify`
- All methods are async at runtime despite the `SignEngine` interface's sync declarations
- All methods guard with `assertWebCryptoAvailable()` before any `crypto.subtle` calls
- Typed `BrowserCryptoError` thrown on generation failures and operation failures
## Usage: Basic Example
```ts
import { webCryptoEd25519Engine } from "@suaveplan/crypto-browser/engines/webcrypto-sign-engine";
const kp = await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const message = new TextEncoder().encode("hello");
const signature = await (webCryptoEd25519Engine.sign(kp.secretKey, message) as unknown as Promise<Uint8Array>);
const valid = await (webCryptoEd25519Engine.verify(kp.publicKey, signature, message) as unknown as Promise<boolean>);
console.log(valid); // true
```
## Usage: Advanced Example
```ts
import {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
} from "@suaveplan/crypto-browser/engines/webcrypto-sign-engine";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
// Use P-256 for JWS / JOSE interoperability
async function signJwsPayload(payload: Uint8Array): Promise<{ publicKey: Uint8Array; signature: Uint8Array }> {
try {
const kp = await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const signature = await (webCryptoEcdsaP256Engine.sign(kp.secretKey, payload) as unknown as Promise<Uint8Array>);
return { publicKey: kp.publicKey, signature };
} catch (err) {
if (err instanceof BrowserCryptoError) {
// Structured error with err.code = "BROWSER_CRYPTO.KEY.GENERATION_FAILED"
// or "BROWSER_CRYPTO.OPERATION.FAILED"
throw err;
}
throw err;
}
}
```
## API Reference
### `webCryptoEd25519Engine: SignEngine`
Ed25519 engine via `crypto.subtle` with `{ name: "Ed25519" }` algorithm.
#### `.generateKeypair(): SignKeypair`
Returns `Promise<SignKeypair>` at runtime. Public key is 32 raw bytes. Secret key is the 32-byte Ed25519 seed.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
#### `.sign(secretKey, message): Uint8Array`
Returns `Promise<Uint8Array>` at runtime. Signature is 64 bytes.
| Parameter | Type | Description |
|-----------|------|-------------|
| `secretKey` | `Uint8Array` | 32-byte Ed25519 seed |
| `message` | `Uint8Array` | Arbitrary bytes to sign |
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
#### `.verify(publicKey, signature, message): boolean`
Returns `Promise<boolean>` at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `publicKey` | `Uint8Array` | 32-byte Ed25519 public key |
| `signature` | `Uint8Array` | 64-byte signature |
| `message` | `Uint8Array` | Original message bytes |
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.NOT_AVAILABLE` or `BROWSER_CRYPTO.OPERATION.FAILED`.
---
### `webCryptoEcdsaP256Engine: SignEngine`
ECDSA-P256 engine via `crypto.subtle` with `{ name: "ECDSA", namedCurve: "P-256" }`. Same method signatures as above. Public key is 65-byte uncompressed (0x04 prefix). Secret key is PKCS8 DER.
---
### `webCryptoSignEngine: SignEngine`
Alias for `webCryptoEd25519Engine`. Use this when you want the recommended default.
## Implementation Notes
Key export: `crypto.subtle.exportKey("pkcs8", privateKey)` returns the PKCS8 DER-encoded private key. For Ed25519, the 32-byte seed is located at byte offset 16 within the DER envelope; this is extracted to produce the compact secret key stored and transported by callers. On import, the seed is re-wrapped into the minimal 48-byte PKCS8 ASN.1 structure before calling `crypto.subtle.importKey`.
For ECDSA-P256, public keys are exported via `"raw"` format (65-byte uncompressed point), which is directly importable via `"raw"` as well. This avoids SPKI round-trip complexity.
Ed25519 support requires Node 20+, Bun 1.0+, or a modern browser (Chrome 113+, Firefox 129+, Safari 17+). Calling these engines in older environments will produce a `BrowserCryptoError.KEY.GENERATION_FAILED` from the `crypto.subtle.generateKey` rejection.

View File

@@ -0,0 +1,331 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
import {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
webCryptoSignEngine,
} from "./webcrypto-sign-engine.js";
describe("webCryptoEd25519Engine", () => {
it("generates a valid keypair", { timeout: 30000 }, async () => {
const kp =
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
expect(kp.publicKey.length).toBe(32);
});
it("signs and verifies a message", { timeout: 30000 }, async () => {
const kp =
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const message = new TextEncoder().encode("test message");
const signature = await (webCryptoEd25519Engine.sign(
kp.secretKey,
message
) as unknown as Promise<Uint8Array>);
expect(signature).toBeInstanceOf(Uint8Array);
expect(signature.length).toBe(64);
const valid = await (webCryptoEd25519Engine.verify(
kp.publicKey,
signature,
message
) as unknown as Promise<boolean>);
expect(valid).toBe(true);
});
it("verification fails with wrong public key", {
timeout: 30000,
}, async () => {
const kp1 =
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const kp2 =
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const message = new TextEncoder().encode("test");
const signature = await (webCryptoEd25519Engine.sign(
kp1.secretKey,
message
) as unknown as Promise<Uint8Array>);
const valid = await (webCryptoEd25519Engine.verify(
kp2.publicKey,
signature,
message
) as unknown as Promise<boolean>);
expect(valid).toBe(false);
});
it("round-trip property: sign then verify returns true", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.uint8Array({ minLength: 1, maxLength: 256 }),
async (msgArr) => {
const kp =
await (webCryptoEd25519Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const msg = new Uint8Array(msgArr);
const sig = await (webCryptoEd25519Engine.sign(
kp.secretKey,
msg
) as unknown as Promise<Uint8Array>);
const result = await (webCryptoEd25519Engine.verify(
kp.publicKey,
sig,
msg
) as unknown as Promise<boolean>);
expect(result).toBe(true);
}
),
{ numRuns: 10 }
);
});
});
describe("webCryptoEd25519Engine error paths", () => {
it("sign re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoEd25519Engine.sign(
new Uint8Array(32),
new Uint8Array(8)
) as unknown as Promise<Uint8Array>
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("verify re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoEd25519Engine.verify(
new Uint8Array(32),
new Uint8Array(64),
new Uint8Array(8)
) as unknown as Promise<boolean>
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("generateKeypair re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(
webCryptoEd25519Engine.generateKeypair() as unknown as Promise<unknown>
).rejects.toBe(sentinel);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("generateKeypair wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(
webCryptoEd25519Engine.generateKeypair() as unknown as Promise<unknown>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("sign wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
webCryptoEd25519Engine.sign(
new Uint8Array(32),
new Uint8Array(8)
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("verify wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(crypto.subtle);
crypto.subtle.importKey = async (): Promise<any> => {
throw "string-error";
};
try {
await expect(
webCryptoEd25519Engine.verify(
new Uint8Array(32),
new Uint8Array(64),
new Uint8Array(8)
) as unknown as Promise<boolean>
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
});
describe("webCryptoEcdsaP256Engine", () => {
it("generates a valid keypair", { timeout: 30000 }, async () => {
const kp =
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
expect(kp.publicKey).toBeInstanceOf(Uint8Array);
expect(kp.secretKey).toBeInstanceOf(Uint8Array);
});
it("signs and verifies a message", { timeout: 30000 }, async () => {
const kp =
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const message = new TextEncoder().encode("ecdsa p256 message");
const signature = await (webCryptoEcdsaP256Engine.sign(
kp.secretKey,
message
) as unknown as Promise<Uint8Array>);
expect(signature).toBeInstanceOf(Uint8Array);
const valid = await (webCryptoEcdsaP256Engine.verify(
kp.publicKey,
signature,
message
) as unknown as Promise<boolean>);
expect(valid).toBe(true);
});
it("verification fails with tampered message", {
timeout: 30000,
}, async () => {
const kp =
await (webCryptoEcdsaP256Engine.generateKeypair() as unknown as Promise<{
publicKey: Uint8Array;
secretKey: Uint8Array;
}>);
const message = new TextEncoder().encode("original");
const tampered = new TextEncoder().encode("tampered");
const signature = await (webCryptoEcdsaP256Engine.sign(
kp.secretKey,
message
) as unknown as Promise<Uint8Array>);
const valid = await (webCryptoEcdsaP256Engine.verify(
kp.publicKey,
signature,
tampered
) as unknown as Promise<boolean>);
expect(valid).toBe(false);
});
});
describe("webCryptoEcdsaP256Engine error paths", () => {
it("sign throws BrowserCryptoError for invalid PKCS8 bytes", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid PKCS8 DER
const msg = new TextEncoder().encode("hello");
await expect(
webCryptoEcdsaP256Engine.sign(
badKey,
msg
) as unknown as Promise<Uint8Array>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("verify throws BrowserCryptoError for invalid public key bytes", {
timeout: 30000,
}, async () => {
const badPub = new Uint8Array(10); // too short for raw P-256
const sig = new Uint8Array(64);
const msg = new TextEncoder().encode("hello");
await expect(
webCryptoEcdsaP256Engine.verify(
badPub,
sig,
msg
) as unknown as Promise<boolean>
).rejects.toBeInstanceOf(BrowserCryptoError);
});
});
describe("webCryptoSignEngine (default = Ed25519)", () => {
it("is the Ed25519 engine", { timeout: 5000 }, () => {
expect(webCryptoSignEngine).toBe(webCryptoEd25519Engine);
});
});

View File

@@ -0,0 +1,215 @@
import type { SignEngine, SignKeypair } from "@suaveplan/crypto-core/engines";
import { assertWebCryptoAvailable } from "../../availability/availability.js";
import { BrowserCryptoError } from "../../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../../errors/codes/codes.js";
type SignAlgorithm = "Ed25519" | "ECDSA-P256";
/** Normalize a Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
/** Coerce any thrown value to an Error instance. */
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}
/** Build the algorithm params object for the given algorithm. */
function algoParams(
algorithm: SignAlgorithm
): EcKeyGenParams | { name: string } {
if (algorithm === "ECDSA-P256") {
return { name: "ECDSA", namedCurve: "P-256" };
}
return { name: "Ed25519" };
}
/** Extract raw public key bytes from a CryptoKey (SPKI format → raw bytes). */
async function exportPublicKey(
key: CryptoKey,
algorithm: SignAlgorithm
): Promise<Uint8Array> {
if (algorithm === "Ed25519") {
const spki = await crypto.subtle.exportKey("spki", key);
// Ed25519 SPKI = 44 bytes header + 32 bytes raw key — last 32 bytes
return new Uint8Array(spki).slice(-32);
}
const raw = await crypto.subtle.exportKey("raw", key);
return new Uint8Array(raw);
}
/** Extract raw private key bytes from a CryptoKey (PKCS8 DER → raw bytes). */
async function exportPrivateKey(
key: CryptoKey,
algorithm: SignAlgorithm
): Promise<Uint8Array> {
const pkcs8 = await crypto.subtle.exportKey("pkcs8", key);
if (algorithm === "Ed25519") {
// Ed25519 PKCS8: ASN.1 wrapper; raw 32-byte seed is at offset 16
return new Uint8Array(pkcs8).slice(16, 48);
}
// P-256 PKCS8: return full DER bytes for import
return new Uint8Array(pkcs8);
}
async function importPublicKey(
algorithm: SignAlgorithm,
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (algorithm === "Ed25519") {
// Re-wrap raw bytes into SPKI (SubjectPublicKeyInfo) for import
const spkiPrefix = new Uint8Array([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21,
0x00,
]);
const spki = new Uint8Array(spkiPrefix.length + keyBytes.length);
spki.set(spkiPrefix, 0);
spki.set(keyBytes, spkiPrefix.length);
return crypto.subtle.importKey(
"spki",
toBuffer(spki),
{ name: "Ed25519" },
false,
["verify"]
);
}
return crypto.subtle.importKey(
"raw",
toBuffer(keyBytes),
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"]
);
}
async function importPrivateKey(
algorithm: SignAlgorithm,
keyBytes: Uint8Array
): Promise<CryptoKey> {
if (algorithm === "Ed25519") {
// Build minimal PKCS8 wrapper for Ed25519 private key (seed)
const prefix = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x70, 0x04, 0x22, 0x04, 0x20,
]);
const pkcs8 = new Uint8Array(prefix.length + 32);
pkcs8.set(prefix, 0);
pkcs8.set(keyBytes.slice(0, 32), prefix.length);
return crypto.subtle.importKey(
"pkcs8",
toBuffer(pkcs8),
{ name: "Ed25519" },
false,
["sign"]
);
}
return crypto.subtle.importKey(
"pkcs8",
toBuffer(keyBytes),
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"]
);
}
function makeSignEngine(algorithm: SignAlgorithm): SignEngine {
return {
generateKeypair(): SignKeypair {
assertWebCryptoAvailable();
return crypto.subtle
.generateKey(algoParams(algorithm), true, ["sign", "verify"])
.then(async (kp) => {
const pair = kp as CryptoKeyPair;
const [publicKey, secretKey] = await Promise.all([
exportPublicKey(pair.publicKey, algorithm),
exportPrivateKey(pair.privateKey, algorithm),
]);
return { publicKey, secretKey };
})
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed",
{ cause: toError(err) }
);
}) as unknown as SignKeypair;
},
sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array {
assertWebCryptoAvailable();
return importPrivateKey(algorithm, secretKey)
.then((ck) => {
const signAlgo =
algorithm === "ECDSA-P256"
? { name: "ECDSA", hash: "SHA-256" }
: { name: "Ed25519" };
return crypto.subtle.sign(signAlgo, ck, toBuffer(message));
})
.then((buf) => new Uint8Array(buf))
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{ cause: toError(err) }
);
}) as unknown as Uint8Array;
},
verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): boolean {
assertWebCryptoAvailable();
return importPublicKey(algorithm, publicKey)
.then((ck) => {
const verifyAlgo =
algorithm === "ECDSA-P256"
? { name: "ECDSA", hash: "SHA-256" }
: { name: "Ed25519" };
return crypto.subtle.verify(
verifyAlgo,
ck,
toBuffer(signature),
toBuffer(message)
);
})
.catch((err: unknown) => {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed",
{ cause: toError(err) }
);
}) as unknown as boolean;
},
};
}
/**
* Ed25519 signing engine backed by `crypto.subtle`.
*
* Key encoding: public key is 32 raw bytes; secret key is 32-byte seed
* (the first half of the 64-byte Ed25519 private key).
*/
export const webCryptoEd25519Engine: SignEngine = makeSignEngine("Ed25519");
/**
* ECDSA-P256 signing engine backed by `crypto.subtle`.
*
* Key encoding: public key is 65-byte uncompressed point (0x04 || X || Y);
* secret key is PKCS8 DER bytes.
*/
export const webCryptoEcdsaP256Engine: SignEngine =
makeSignEngine("ECDSA-P256");
/**
* Default `webCryptoSignEngine` — uses Ed25519.
*/
export const webCryptoSignEngine: SignEngine = webCryptoEd25519Engine;

View File

@@ -0,0 +1,91 @@
# browser-crypto-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `BrowserCryptoError`, the structured error class for all `@suaveplan/crypto-browser` failure paths. It extends `SuaveplanError<BrowserCryptoCode>` from `@suaveplan/error`, inheriting cause-chain traversal, OTel span-event adapter support, and wire-format serialization. Every thrown error in this package is an instance of `BrowserCryptoError` with a stable `BrowserCryptoCode` string code, enabling callers to discriminate failure modes with type-safe `instanceof` + `err.code` checks.
The error class follows the project convention: a package-scoped error with a typed code union, `cause`, `context`, and `timestamp` fields. This makes crypto failures observable, loggable, and serializable without depending on runtime-specific error formats.
## Features
- Extends `SuaveplanError<BrowserCryptoCode>` — participates in all project error infrastructure
- `code` is one of the stable `BROWSER_CRYPTO.*` codes from `codes.ts`
- `cause` captures the underlying `DOMException` or `Error` from `crypto.subtle`
- `context` carries optional structured diagnostic data
- `timestamp` records when the error was thrown
- Stacktrace captured via `Error.captureStackTrace` where available (V8/Node)
## Usage: Basic Example
```ts
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
try {
await webCryptoAeadEngine.decrypt(wrongKey, ciphertext, nonce);
} catch (err) {
if (err instanceof BrowserCryptoError) {
console.error(err.code); // "BROWSER_CRYPTO.OPERATION.FAILED"
console.error(err.cause); // DOMException: The operation failed ...
}
}
```
## Usage: Advanced Example
```ts
import { BrowserCryptoError, BROWSER_CRYPTO_CODES } from "@suaveplan/crypto-browser/errors";
function isNotAvailable(err: unknown): boolean {
return (
err instanceof BrowserCryptoError &&
err.code === BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]
);
}
async function encryptOrFallback(
key: Uint8Array,
data: Uint8Array
): Promise<Uint8Array> {
try {
return await (webCryptoAeadEngine.encrypt(key, data, nonce) as unknown as Promise<Uint8Array>);
} catch (err) {
if (isNotAvailable(err)) {
// Fall back to @noble/ciphers-backed implementation
return nobleAeadFallback(key, data);
}
throw err;
}
}
```
## API Reference
### `class BrowserCryptoError extends SuaveplanError<BrowserCryptoCode>`
**Constructor:**
```ts
new BrowserCryptoError(code: BrowserCryptoCode, messageKey: string, options?: SuaveplanErrorOptions)
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `BrowserCryptoCode` | Stable error code, e.g. `"BROWSER_CRYPTO.NOT_AVAILABLE"` |
| `messageKey` | `string` | i18n key for the human-readable message |
| `options` | `SuaveplanErrorOptions` | Optional `{ cause, context, traceId, clock }` |
**Inherited properties:**
- `code: BrowserCryptoCode` — stable error code
- `cause?: Error` — underlying cause
- `context?: ErrorContext` — structured diagnostic data
- `timestamp: number` — `Date.now()` at throw time
## Implementation Notes
The constructor is declared `public` despite mirroring the protected base — Biome lint would flag a "useless constructor" but the intent is to widen visibility. The `biome-ignore` comment is present to document this intent.
All `crypto.subtle` rejections produce `DOMException` instances. These are captured as the `cause` field, making the original platform error available for debugging while presenting a structured `BrowserCryptoError` to callers.

View File

@@ -0,0 +1,20 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { BrowserCryptoCode } from "../codes/codes.js";
/**
* Structured error thrown by all `@suaveplan/crypto-browser` operations.
*
* Extends `SuaveplanError` so it participates in the monorepo-wide cause-chain
* traversal, OTel span-event adapter, and wire-format serialization provided by
* `@suaveplan/error`.
*/
export class BrowserCryptoError extends SuaveplanError<BrowserCryptoCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: BrowserCryptoCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

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

View File

@@ -0,0 +1,81 @@
# BROWSER_CRYPTO_CODES
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`codes.ts` defines the complete set of stable error codes used throughout `@suaveplan/crypto-browser`. Each code is a namespaced string constant produced by `defineCodes` from `@suaveplan/error`, ensuring codes are unique within the `"BROWSER_CRYPTO"` namespace and that the `BrowserCryptoCode` union type is always in sync with the registered values at compile time. All WebCrypto-related failures in this package surface under one of these codes, allowing consumers to branch deterministically without parsing message strings.
## Features
- Three codes covering the main WebCrypto failure domains: API unavailability, key generation, and general operation failures
- Generated via `defineCodes("BROWSER_CRYPTO", [...])` which prefixes each entry and freezes the resulting object
- `BrowserCryptoCode` is derived as a `typeof` union for compile-time exhaustive narrowing
- No runtime dependencies beyond `@suaveplan/error`
## Usage: Basic Example
```typescript
import { BROWSER_CRYPTO_CODES } from "@suaveplan/crypto-browser/errors/codes";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors/browser-crypto-error";
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["NOT_AVAILABLE"],
"error.crypto-browser.not-available"
);
```
## Usage: Advanced Example
```typescript
import {
BROWSER_CRYPTO_CODES,
type BrowserCryptoCode,
} from "@suaveplan/crypto-browser/errors/codes";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors/browser-crypto-error";
function handleBrowserCryptoError(err: BrowserCryptoError): string {
switch (err.code as BrowserCryptoCode) {
case BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]:
return "WebCrypto API is not available in this environment (requires HTTPS or localhost).";
case BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"]:
return "Key generation failed — the requested algorithm may not be supported by this browser.";
case BROWSER_CRYPTO_CODES["OPERATION.FAILED"]:
return "Cryptographic operation failed — check key type, algorithm, and input size.";
default:
return "Unknown browser crypto error.";
}
}
```
## API Reference
### `BROWSER_CRYPTO_CODES`
```typescript
const BROWSER_CRYPTO_CODES: Readonly<Record<string, string>>
```
A frozen map from short code keys to their namespaced string values. All values are prefixed with `"BROWSER_CRYPTO."` by `defineCodes`.
| Key | Namespaced value | When thrown |
|------------------------|----------------------------------------------|-------------------------------------------------------------------|
| `NOT_AVAILABLE` | `"BROWSER_CRYPTO.NOT_AVAILABLE"` | `globalThis.crypto.subtle` is `undefined` or `null` |
| `KEY.GENERATION_FAILED`| `"BROWSER_CRYPTO.KEY.GENERATION_FAILED"` | `SubtleCrypto.generateKey` or `importKey` rejects |
| `OPERATION.FAILED` | `"BROWSER_CRYPTO.OPERATION.FAILED"` | Any `SubtleCrypto` operation (`encrypt`, `decrypt`, `sign`, `verify`, `deriveBits`, `wrapKey`, `unwrapKey`) rejects |
### `BrowserCryptoCode`
```typescript
type BrowserCryptoCode =
(typeof BROWSER_CRYPTO_CODES)[keyof typeof BROWSER_CRYPTO_CODES];
```
Union of all namespaced code strings. Use this type to annotate `err.code` when narrowing `BrowserCryptoError` in typed catch blocks.
## Implementation Notes
`defineCodes` returns a plain frozen object and the `BrowserCryptoCode` type collapses to a string literal union. Adding a new entry to the array automatically widens the union without requiring any manual type maintenance.
`OPERATION.FAILED` intentionally covers a broad set of `SubtleCrypto` rejections rather than splitting each operation into its own code. The distinction between, say, a failed `encrypt` and a failed `sign` is captured in the structured `context` field on the thrown `BrowserCryptoError` rather than in the code itself, which keeps the code registry small and stable across algorithm additions.

View File

@@ -0,0 +1,10 @@
import { defineCodes } from "@suaveplan/error";
export const BROWSER_CRYPTO_CODES = defineCodes("BROWSER_CRYPTO", [
"NOT_AVAILABLE",
"KEY.GENERATION_FAILED",
"OPERATION.FAILED",
] as const);
export type BrowserCryptoCode =
(typeof BROWSER_CRYPTO_CODES)[keyof typeof BROWSER_CRYPTO_CODES];

View File

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

View File

@@ -0,0 +1,2 @@
export { BrowserCryptoError } from "./browser-crypto-error/browser-crypto-error.js";
export { BROWSER_CRYPTO_CODES, type BrowserCryptoCode } from "./codes/codes.js";

View File

@@ -0,0 +1,35 @@
// Availability
// AES Key Wrap
export { aesKw } from "./aes-kw/aes-kw.js";
export {
assertWebCryptoAvailable,
isWebCryptoAvailable,
} from "./availability/availability.js";
// Constants
export * from "./constants/index.js";
export { ecdhP384 } from "./ecdh-p384/ecdh-p384.js";
// P-384 curves
export { ecdsaP384 } from "./ecdsa-p384/ecdsa-p384.js";
// Engine implementations
export { webCryptoAeadEngine } from "./engines/webcrypto-aead-engine/webcrypto-aead-engine.js";
export {
webCryptoAgreeEngine,
webCryptoEcdhP256Engine,
webCryptoX25519Engine,
} from "./engines/webcrypto-agree-engine/webcrypto-agree-engine.js";
export {
webCryptoEcdsaP256Engine,
webCryptoEd25519Engine,
webCryptoSignEngine,
} from "./engines/webcrypto-sign-engine/webcrypto-sign-engine.js";
// Errors
export {
BROWSER_CRYPTO_CODES,
type BrowserCryptoCode,
BrowserCryptoError,
} from "./errors/index.js";
// Non-extractable key generation
export { nonExtractableKey } from "./non-extractable-key/non-extractable-key.js";
// RSA primitives
export { type RsaAlgorithm, rsa } from "./rsa/rsa.js";

View File

@@ -0,0 +1 @@
export { nonExtractableKey } from "./non-extractable-key";

View File

@@ -0,0 +1,97 @@
# non-extractable-key
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `nonExtractableKey()`, a helper that generates a `CryptoKey` with `extractable: false`. Key material generated this way never leaves the cryptographic engine — it cannot be read by JavaScript code via `crypto.subtle.exportKey`. The key handle can be used for cryptographic operations but its raw bytes are permanently inaccessible, providing a hardware-security-module-like boundary in software.
This is the correct approach whenever keys must remain hardware-bound or compliance requirements prohibit key export. Examples include session-scoped encryption keys, FIDO2-style signing keys, and keys derived from hardware-backed key material in PWA contexts. The non-extractability also means that if an adversary gains script execution access, they cannot exfiltrate the key bytes — they can only use the key handle for operations it was created for.
## Features
- Generates a `CryptoKey` with `extractable: false` for any `crypto.subtle`-supported algorithm
- Supports symmetric algorithms: AES-GCM, AES-CBC, AES-KW, HMAC
- Supports asymmetric algorithms: ECDSA (P-256, P-384), ECDH (P-256, P-384), Ed25519, X25519
- For asymmetric algorithms, returns only the private key (the non-extractable half)
- `assertWebCryptoAvailable()` guard before any `crypto.subtle` call
- Typed `BrowserCryptoError` on generation failure
## Usage: Basic Example
```ts
import { nonExtractableKey } from "@suaveplan/crypto-browser/non-extractable-key/non-extractable-key";
// Generate a non-extractable AES-256-GCM key for session encryption
const sessionKey = await nonExtractableKey(
{ name: "AES-GCM", length: 256 },
["encrypt", "decrypt"]
);
// sessionKey.extractable === false
// crypto.subtle.exportKey("raw", sessionKey) will throw
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
sessionKey,
new TextEncoder().encode("private data")
);
```
## Usage: Advanced Example
```ts
import { nonExtractableKey } from "@suaveplan/crypto-browser/non-extractable-key/non-extractable-key";
// Hardware-bound ECDSA signing key — private key material never leaves the engine
async function createBoundSigningKey(): Promise<CryptoKey> {
return nonExtractableKey(
{ name: "ECDSA", namedCurve: "P-256" },
["sign"]
);
}
// Use the key for signing without any risk of key extraction
async function signChallenge(
key: CryptoKey,
challenge: Uint8Array
): Promise<Uint8Array> {
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key,
challenge
);
return new Uint8Array(sig);
}
// Store the key in IndexedDB for persistence (CryptoKey is structured-cloneable)
// async function storeKey(db: IDBDatabase, key: CryptoKey): Promise<void> { ... }
```
## API Reference
### `nonExtractableKey(algorithm, usages): Promise<CryptoKey>`
Generates a non-extractable `CryptoKey`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algorithm` | `AlgorithmIdentifier \| RsaHashedKeyGenParams \| EcKeyGenParams \| HmacKeyGenParams \| AesKeyGenParams` | Key generation algorithm descriptor |
| `usages` | `KeyUsage[]` | Intended key operations, e.g. `["encrypt", "decrypt"]` |
**Returns:** `Promise<CryptoKey>` with `extractable === false`.
- For symmetric algorithms: returns the `CryptoKey` directly.
- For asymmetric algorithms: returns the `privateKey` from the generated `CryptoKeyPair`.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED` or `BROWSER_CRYPTO.NOT_AVAILABLE`.
## Implementation Notes
The implementation calls `crypto.subtle.generateKey` with `extractable: false`. The Web Crypto API guarantees that once a key is created non-extractable, no subsequent API call can extract the raw bytes — `exportKey` will throw `InvalidAccessError` unconditionally.
For asymmetric algorithms, `generateKey` returns a `CryptoKeyPair`. The function detects this via `"privateKey" in result` and returns only the private key. The public key is discarded; if callers need the public key they should use the specific engine modules (`ecdsaP384.generateKeypair()`, etc.) which export both.
Non-extractable keys are structured-cloneable and can be persisted to `IndexedDB`, shared with `BroadcastChannel`, or transferred to `Worker` contexts via `postMessage` — without ever exposing the raw key bytes. This makes them suitable for long-lived application keys that survive page reloads.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
import { nonExtractableKey } from "./non-extractable-key.js";
describe("nonExtractableKey", () => {
it("generates an AES-GCM non-extractable key", {
timeout: 30000,
}, async () => {
const key = await nonExtractableKey({ name: "AES-GCM", length: 256 }, [
"encrypt",
"decrypt",
]);
expect(key).toBeInstanceOf(CryptoKey);
expect(key.extractable).toBe(false);
expect(key.type).toBe("secret");
expect(key.usages).toContain("encrypt");
expect(key.usages).toContain("decrypt");
});
it("key cannot be exported (extractable=false)", {
timeout: 30000,
}, async () => {
const key = await nonExtractableKey({ name: "AES-GCM", length: 256 }, [
"encrypt",
"decrypt",
]);
await expect(crypto.subtle.exportKey("raw", key)).rejects.toBeDefined();
});
it("generates an HMAC non-extractable key", {
timeout: 30000,
}, async () => {
const key = await nonExtractableKey({ name: "HMAC", hash: "SHA-256" }, [
"sign",
"verify",
]);
expect(key.extractable).toBe(false);
expect(key.type).toBe("secret");
});
it("generates an ECDSA P-256 non-extractable private key", {
timeout: 30000,
}, async () => {
const key = await nonExtractableKey(
{ name: "ECDSA", namedCurve: "P-256" },
["sign"]
);
expect(key.extractable).toBe(false);
expect(key.type).toBe("private");
});
it("generates an AES-256 non-extractable key for AES-KW", {
timeout: 30000,
}, async () => {
const key = await nonExtractableKey({ name: "AES-KW", length: 256 }, [
"wrapKey",
"unwrapKey",
]);
expect(key.extractable).toBe(false);
expect(key.usages).toContain("wrapKey");
});
it("throws BrowserCryptoError for invalid algorithm", {
timeout: 30000,
}, async () => {
// "AES-GCM" with invalid length triggers a DOMException
await expect(
nonExtractableKey(
{ name: "AES-GCM", length: 7 } as AesKeyGenParams,
["encrypt", "decrypt"]
)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("re-throws BrowserCryptoError without double-wrapping", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(
nonExtractableKey({ name: "AES-GCM", length: 256 }, [
"encrypt",
"decrypt",
])
).rejects.toBe(sentinel);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("wraps non-Error thrown value in new Error", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(
nonExtractableKey({ name: "AES-GCM", length: 256 }, [
"encrypt",
"decrypt",
])
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
});

View File

@@ -0,0 +1,64 @@
import { assertWebCryptoAvailable } from "../availability/availability.js";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
/**
* Generates a non-extractable `CryptoKey` handle.
*
* The key material is generated inside the browser's cryptographic engine and
* is never accessible to JavaScript code. The returned `CryptoKey` can only be
* used for the operations specified in `usages`; it cannot be exported via
* `crypto.subtle.exportKey`.
*
* This is useful when key material must remain hardware-bound or when
* compliance requirements forbid key export (e.g., HSM-like constraints in
* browser contexts, FIDO2 resident keys, session-scoped encryption keys).
*
* @param algorithm - Key generation algorithm descriptor. Supported:
* - AES-GCM / AES-CBC / AES-KW: `{ name: "AES-GCM", length: 256 }`
* - HMAC: `{ name: "HMAC", hash: "SHA-256" }`
* - RSA-PSS / RSA-OAEP / RSASSA-PKCS1-v1_5: `{ name: "RSA-PSS", modulusLength: 2048, publicExponent: ..., hash: "SHA-256" }`
* - ECDSA / ECDH: `{ name: "ECDSA", namedCurve: "P-256" }`
* - Ed25519 / X25519: `{ name: "Ed25519" }`
* @param usages - Key usages array, e.g. `["encrypt", "decrypt"]`, `["sign", "verify"]`
* @returns Promise resolving to a `CryptoKey` with `extractable === false`
* @throws BrowserCryptoError KEY.GENERATION_FAILED | NOT_AVAILABLE
*/
export async function nonExtractableKey(
algorithm:
| AlgorithmIdentifier
| RsaHashedKeyGenParams
| EcKeyGenParams
| HmacKeyGenParams
| AesKeyGenParams,
usages: KeyUsage[]
): Promise<CryptoKey> {
assertWebCryptoAvailable();
try {
const result = await crypto.subtle.generateKey(
algorithm,
false,
usages
);
// For asymmetric algorithms, generateKey returns a CryptoKeyPair
if (isCryptoKeyPair(result)) {
return result.privateKey;
}
return result;
} catch (err) {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed",
{
cause: err instanceof Error ? err : new Error(String(err)),
}
);
}
}
function isCryptoKeyPair(
value: CryptoKey | CryptoKeyPair
): value is CryptoKeyPair {
return "privateKey" in value && "publicKey" in value;
}

View File

@@ -0,0 +1 @@
export { type RsaAlgorithm, rsa } from "./rsa";

View File

@@ -0,0 +1,138 @@
# rsa
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a high-level `rsa` namespace object wrapping RSA cryptographic operations from `crypto.subtle`. Supports three algorithm variants — `RSA-PSS`, `RSA-OAEP`, and `RSASSA-PKCS1-v1_5` — for signing/verification and asymmetric encryption/decryption. Keys are represented as standard DER byte arrays (SPKI for public keys, PKCS8 for private keys), which are universally portable across platforms and easy to store and transmit.
RSA is provided alongside the elliptic-curve engines for interoperability with legacy systems, PKI infrastructure, and protocols that require RSA (e.g., certain TLS cipher suites, JWT RSA algorithms, legacy JOSE). For new designs, Ed25519 and ECDH-X25519 are preferred.
## Features
- `generateKeypair(algorithm, modulusLength?)` — generates an RSA keypair; default 2048-bit modulus, configurable up to 4096 bits
- `sign(algorithm, privateKey, message)` — RSA-PSS or RSASSA-PKCS1-v1_5 signature generation
- `verify(algorithm, publicKey, signature, message)` — RSA signature verification
- `encrypt(publicKey, plaintext)` — RSA-OAEP encryption (public-key encryption)
- `decrypt(privateKey, ciphertext)` — RSA-OAEP decryption
- SHA-256 hash used for all operations
- RSA-PSS uses 32-byte salt length
- All methods guard with `assertWebCryptoAvailable()`
- Typed `BrowserCryptoError` for all failure paths
## Usage: Basic Example
```ts
import { rsa } from "@suaveplan/crypto-browser/rsa/rsa";
// Generate and use RSA-PSS for signing
const { publicKey, privateKey } = await rsa.generateKeypair("RSA-PSS");
const message = new TextEncoder().encode("document to sign");
const signature = await rsa.sign("RSA-PSS", privateKey, message);
const valid = await rsa.verify("RSA-PSS", publicKey, signature, message);
console.log(valid); // true
```
## Usage: Advanced Example
```ts
import { rsa, type RsaAlgorithm } from "@suaveplan/crypto-browser/rsa/rsa";
import { BrowserCryptoError } from "@suaveplan/crypto-browser/errors";
// RSA-OAEP for encrypting a small payload (e.g., a symmetric key)
async function wrapAesKey(
recipientPublicKey: Uint8Array,
aesKey: Uint8Array // must be <= ~190 bytes for RSA-2048
): Promise<Uint8Array> {
try {
return await rsa.encrypt(recipientPublicKey, aesKey);
} catch (err) {
if (err instanceof BrowserCryptoError) {
console.error(`Crypto failure: ${err.code}`);
}
throw err;
}
}
async function unwrapAesKey(
recipientPrivateKey: Uint8Array,
wrappedKey: Uint8Array
): Promise<Uint8Array> {
return await rsa.decrypt(recipientPrivateKey, wrappedKey);
}
// 4096-bit keypair for higher-security contexts
const { publicKey, privateKey } = await rsa.generateKeypair("RSA-OAEP", 4096);
```
## API Reference
### `rsa.generateKeypair(algorithm, modulusLength?): Promise<{ publicKey, privateKey }>`
Generates an RSA keypair.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `algorithm` | `RsaAlgorithm` | — | `"RSA-PSS"` \| `"RSA-OAEP"` \| `"RSASSA-PKCS1-v1_5"` |
| `modulusLength` | `number` | `2048` | Key bit length (2048 or 4096 recommended) |
**Returns:** `Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }>` — SPKI/PKCS8 DER bytes.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.KEY.GENERATION_FAILED`.
---
### `rsa.sign(algorithm, privateKey, message): Promise<Uint8Array>`
Signs a message. Use with `"RSA-PSS"` or `"RSASSA-PKCS1-v1_5"`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algorithm` | `RsaAlgorithm` | Must match keypair generation algorithm |
| `privateKey` | `Uint8Array` | PKCS8 DER private key |
| `message` | `Uint8Array` | Message to sign |
**Returns:** `Promise<Uint8Array>` — signature bytes.
**Throws:** `BrowserCryptoError` — `BROWSER_CRYPTO.OPERATION.FAILED`.
---
### `rsa.verify(algorithm, publicKey, signature, message): Promise<boolean>`
| Parameter | Type | Description |
|-----------|------|-------------|
| `algorithm` | `RsaAlgorithm` | Must match signing algorithm |
| `publicKey` | `Uint8Array` | SPKI DER public key |
| `signature` | `Uint8Array` | Signature bytes |
| `message` | `Uint8Array` | Original message |
**Returns:** `Promise<boolean>`.
---
### `rsa.encrypt(publicKey, plaintext): Promise<Uint8Array>`
RSA-OAEP encryption. For RSA-2048, plaintext must be ≤ 190 bytes (2048-bit modulus minus OAEP overhead).
---
### `rsa.decrypt(privateKey, ciphertext): Promise<Uint8Array>`
RSA-OAEP decryption.
---
### `type RsaAlgorithm`
`"RSA-PSS" | "RSA-OAEP" | "RSASSA-PKCS1-v1_5"`
## Implementation Notes
All RSA operations use SHA-256 as the hash algorithm, which is the most widely supported and appropriate choice for 2048- and 4096-bit RSA. RSA-PSS uses a 32-byte (256-bit) salt, matching the hash output length, which is the PKCS#1v2.2 recommendation.
The `encrypt()` and `decrypt()` methods are hardcoded to RSA-OAEP — only RSA-OAEP provides semantic security for asymmetric encryption. `RSA-PSS` and `RSASSA-PKCS1-v1_5` are signing-only algorithms and cannot be used with `encrypt`/`decrypt`.
Key generation for RSA-2048 typically takes 50–200 ms in `crypto.subtle`; RSA-4096 can take 500–2000 ms. This is significantly slower than elliptic-curve key generation (< 5 ms). All operations use `await` and do not block the main thread.

View File

@@ -0,0 +1,300 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
import { rsa } from "./rsa.js";
describe("rsa", () => {
describe("RSA-PSS", () => {
it("generates a keypair, signs and verifies", {
timeout: 30000,
}, async () => {
const { publicKey, privateKey } =
await rsa.generateKeypair("RSA-PSS");
expect(publicKey).toBeInstanceOf(Uint8Array);
expect(privateKey).toBeInstanceOf(Uint8Array);
expect(publicKey.length).toBeGreaterThan(0);
const message = new TextEncoder().encode("rsa-pss test message");
const signature = await rsa.sign("RSA-PSS", privateKey, message);
expect(signature).toBeInstanceOf(Uint8Array);
expect(signature.length).toBeGreaterThan(0);
const valid = await rsa.verify(
"RSA-PSS",
publicKey,
signature,
message
);
expect(valid).toBe(true);
});
it("verification fails with wrong message", {
timeout: 30000,
}, async () => {
const { publicKey, privateKey } =
await rsa.generateKeypair("RSA-PSS");
const message = new TextEncoder().encode("correct");
const wrong = new TextEncoder().encode("wrong");
const signature = await rsa.sign("RSA-PSS", privateKey, message);
const valid = await rsa.verify(
"RSA-PSS",
publicKey,
signature,
wrong
);
expect(valid).toBe(false);
});
});
describe("RSA-OAEP", () => {
it("generates a keypair, encrypts and decrypts", {
timeout: 30000,
}, async () => {
const { publicKey, privateKey } =
await rsa.generateKeypair("RSA-OAEP");
const plaintext = new TextEncoder().encode("rsa-oaep secret");
const ciphertext = await rsa.encrypt(publicKey, plaintext);
expect(ciphertext).toBeInstanceOf(Uint8Array);
expect(ciphertext.length).toBeGreaterThan(plaintext.length);
const decrypted = await rsa.decrypt(privateKey, ciphertext);
expect(decrypted).toEqual(plaintext);
});
it("decryption fails with wrong private key", {
timeout: 30000,
}, async () => {
const kp1 = await rsa.generateKeypair("RSA-OAEP");
const kp2 = await rsa.generateKeypair("RSA-OAEP");
const plaintext = new TextEncoder().encode("secret");
const ciphertext = await rsa.encrypt(kp1.publicKey, plaintext);
await expect(
rsa.decrypt(kp2.privateKey, ciphertext)
).rejects.toBeDefined();
});
});
describe("RSASSA-PKCS1-v1_5", () => {
it("generates a keypair, signs and verifies", {
timeout: 30000,
}, async () => {
const { publicKey, privateKey } =
await rsa.generateKeypair("RSASSA-PKCS1-v1_5");
const message = new TextEncoder().encode("pkcs1 test");
const signature = await rsa.sign(
"RSASSA-PKCS1-v1_5",
privateKey,
message
);
expect(signature).toBeInstanceOf(Uint8Array);
const valid = await rsa.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
message
);
expect(valid).toBe(true);
});
});
describe("generateKeypair with custom modulus length", () => {
it("accepts 4096-bit modulus", { timeout: 30000 }, async () => {
const { publicKey, privateKey } = await rsa.generateKeypair(
"RSA-PSS",
4096
);
expect(publicKey.length).toBeGreaterThan(256); // larger key = larger SPKI
expect(privateKey.length).toBeGreaterThan(0);
});
});
describe("error paths", () => {
it("sign throws BrowserCryptoError for invalid private key bytes", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid PKCS8 DER
const msg = new TextEncoder().encode("hello");
await expect(
rsa.sign("RSA-PSS", badKey, msg)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("verify throws BrowserCryptoError for invalid public key bytes", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid SPKI DER
const sig = new Uint8Array(256);
const msg = new TextEncoder().encode("hello");
await expect(
rsa.verify("RSA-PSS", badKey, sig, msg)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("encrypt throws BrowserCryptoError for invalid public key bytes", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid SPKI DER for RSA-OAEP
const plaintext = new TextEncoder().encode("hello");
await expect(rsa.encrypt(badKey, plaintext)).rejects.toBeInstanceOf(
BrowserCryptoError
);
});
it("decrypt throws BrowserCryptoError for invalid private key bytes", {
timeout: 30000,
}, async () => {
const badKey = new Uint8Array(32); // not valid PKCS8 DER
const ciphertext = new Uint8Array(256);
await expect(
rsa.decrypt(badKey, ciphertext)
).rejects.toBeInstanceOf(BrowserCryptoError);
});
it("generateKeypair re-throws BrowserCryptoError via wrapError", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["KEY.GENERATION_FAILED"],
"browser-crypto.key-generation-failed"
);
(crypto.subtle as any).generateKey = async () => {
throw sentinel;
};
try {
await expect(rsa.generateKeypair("RSA-PSS")).rejects.toBe(
sentinel
);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("generateKeypair wraps non-Error thrown value via wrapError", {
timeout: 30000,
}, async () => {
const originalGenerateKey = crypto.subtle.generateKey.bind(
crypto.subtle
);
(crypto.subtle as any).generateKey = async () => {
throw "string-error";
};
try {
await expect(
rsa.generateKeypair("RSA-PSS")
).rejects.toBeInstanceOf(BrowserCryptoError);
} finally {
(crypto.subtle as any).generateKey = originalGenerateKey;
}
});
it("sign re-throws BrowserCryptoError via wrapError", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
rsa.sign("RSA-PSS", new Uint8Array(256), new Uint8Array(8))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("verify re-throws BrowserCryptoError via wrapError", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
rsa.verify(
"RSA-PSS",
new Uint8Array(256),
new Uint8Array(256),
new Uint8Array(8)
)
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("encrypt re-throws BrowserCryptoError via wrapError", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
rsa.encrypt(new Uint8Array(256), new Uint8Array(8))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
it("decrypt re-throws BrowserCryptoError via wrapError", {
timeout: 30000,
}, async () => {
const originalImportKey = crypto.subtle.importKey.bind(
crypto.subtle
);
const sentinel = new BrowserCryptoError(
BROWSER_CRYPTO_CODES["OPERATION.FAILED"],
"browser-crypto.operation-failed"
);
crypto.subtle.importKey = async () => {
throw sentinel;
};
try {
await expect(
rsa.decrypt(new Uint8Array(256), new Uint8Array(256))
).rejects.toBe(sentinel);
} finally {
crypto.subtle.importKey = originalImportKey;
}
});
});
});

View File

@@ -0,0 +1,235 @@
import { assertWebCryptoAvailable } from "../availability/availability.js";
import { BrowserCryptoError } from "../errors/browser-crypto-error/browser-crypto-error.js";
import { BROWSER_CRYPTO_CODES } from "../errors/codes/codes.js";
/** Supported RSA algorithm identifiers. */
export type RsaAlgorithm = "RSA-PSS" | "RSA-OAEP" | "RSASSA-PKCS1-v1_5";
/** Normalize Uint8Array to a clean ArrayBuffer for crypto.subtle. */
function toBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function genAlgo(
algorithm: RsaAlgorithm,
modulusLength: number
): RsaHashedKeyGenParams {
return {
name: algorithm,
modulusLength,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
hash: "SHA-256",
};
}
function signAlgo(algorithm: RsaAlgorithm): AlgorithmIdentifier | RsaPssParams {
if (algorithm === "RSA-PSS") {
return { name: "RSA-PSS", saltLength: 32 };
}
return { name: algorithm };
}
async function importPublicKey(
algorithm: RsaAlgorithm,
keyBytes: Uint8Array,
usages: KeyUsage[]
): Promise<CryptoKey> {
return crypto.subtle.importKey(
"spki",
toBuffer(keyBytes),
{ name: algorithm, hash: "SHA-256" },
false,
usages
);
}
async function importPrivateKey(
algorithm: RsaAlgorithm,
keyBytes: Uint8Array,
usages: KeyUsage[]
): Promise<CryptoKey> {
return crypto.subtle.importKey(
"pkcs8",
toBuffer(keyBytes),
{ name: algorithm, hash: "SHA-256" },
false,
usages
);
}
function wrapError(
err: unknown,
code: keyof typeof BROWSER_CRYPTO_CODES
): never {
if (err instanceof BrowserCryptoError) throw err;
throw new BrowserCryptoError(
BROWSER_CRYPTO_CODES[code],
`browser-crypto.${code === "KEY.GENERATION_FAILED" ? "key-generation-failed" : "operation-failed"}`,
{ cause: err instanceof Error ? err : new Error(String(err)) }
);
}
/**
* RSA cryptographic operations via `crypto.subtle`.
*
* Keys are represented as DER bytes: SPKI for public keys, PKCS8 for private
* keys. This matches the format returned by `generateKeypair()` and accepted
* by `sign()`, `verify()`, `encrypt()`, and `decrypt()`.
*/
export const rsa = {
/**
* Generates an RSA keypair.
*
* @param algorithm - RSA algorithm variant
* @param modulusLength - modulus bit length (default 2048)
* @returns Promise resolving to `{ publicKey, privateKey }` as DER bytes
* @throws BrowserCryptoError KEY.GENERATION_FAILED
*/
async generateKeypair(
algorithm: RsaAlgorithm,
modulusLength = 2048
): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> {
assertWebCryptoAvailable();
try {
const usages: KeyUsage[] =
algorithm === "RSA-OAEP"
? ["encrypt", "decrypt"]
: ["sign", "verify"];
const kp = (await crypto.subtle.generateKey(
genAlgo(algorithm, modulusLength),
true,
usages
)) as CryptoKeyPair;
const [spki, pkcs8] = await Promise.all([
crypto.subtle.exportKey("spki", kp.publicKey),
crypto.subtle.exportKey("pkcs8", kp.privateKey),
]);
return {
publicKey: new Uint8Array(spki),
privateKey: new Uint8Array(pkcs8),
};
} catch (err) {
wrapError(err, "KEY.GENERATION_FAILED");
}
},
/**
* Signs a message with RSA-PSS or RSASSA-PKCS1-v1_5.
*
* @param algorithm - must be `"RSA-PSS"` or `"RSASSA-PKCS1-v1_5"`
* @param privateKey - PKCS8 DER private key bytes
* @param message - message to sign
* @returns Promise resolving to DER-encoded signature bytes
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async sign(
algorithm: RsaAlgorithm,
privateKey: Uint8Array,
message: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const key = await importPrivateKey(algorithm, privateKey, ["sign"]);
const sig = await crypto.subtle.sign(
signAlgo(algorithm),
key,
toBuffer(message)
);
return new Uint8Array(sig);
} catch (err) {
wrapError(err, "OPERATION.FAILED");
}
},
/**
* Verifies an RSA signature.
*
* @param algorithm - must match algorithm used during signing
* @param publicKey - SPKI DER public key bytes
* @param signature - signature bytes
* @param message - original message bytes
* @returns Promise resolving to `true` if signature is valid
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async verify(
algorithm: RsaAlgorithm,
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): Promise<boolean> {
assertWebCryptoAvailable();
try {
const key = await importPublicKey(algorithm, publicKey, ["verify"]);
return await crypto.subtle.verify(
signAlgo(algorithm),
key,
toBuffer(signature),
toBuffer(message)
);
} catch (err) {
wrapError(err, "OPERATION.FAILED");
}
},
/**
* Encrypts plaintext using RSA-OAEP.
*
* @param publicKey - SPKI DER public key bytes
* @param plaintext - bytes to encrypt (max ~190 bytes for RSA-2048)
* @returns Promise resolving to ciphertext bytes
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async encrypt(
publicKey: Uint8Array,
plaintext: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const key = await importPublicKey("RSA-OAEP", publicKey, [
"encrypt",
]);
const ct = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
key,
toBuffer(plaintext)
);
return new Uint8Array(ct);
} catch (err) {
wrapError(err, "OPERATION.FAILED");
}
},
/**
* Decrypts ciphertext using RSA-OAEP.
*
* @param privateKey - PKCS8 DER private key bytes
* @param ciphertext - bytes to decrypt
* @returns Promise resolving to plaintext bytes
* @throws BrowserCryptoError OPERATION.FAILED | NOT_AVAILABLE
*/
async decrypt(
privateKey: Uint8Array,
ciphertext: Uint8Array
): Promise<Uint8Array> {
assertWebCryptoAvailable();
try {
const key = await importPrivateKey("RSA-OAEP", privateKey, [
"decrypt",
]);
const pt = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
key,
toBuffer(ciphertext)
);
return new Uint8Array(pt);
} catch (err) {
wrapError(err, "OPERATION.FAILED");
}
},
};

View File

@@ -0,0 +1,61 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_DIR = join(import.meta.dirname, "..", "src");
async function collectTsFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectTsFiles(full)));
} else if (
entry.isFile() &&
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts")
) {
files.push(full);
}
}
return files;
}
describe("source quality: no disallowed patterns", () => {
it("no vi.mock / jest.mock in src/", { timeout: 5000 }, async () => {
const files = await collectTsFiles(SRC_DIR);
const violations: string[] = [];
for (const file of files) {
const content = await readFile(file, "utf-8");
if (content.includes("vi.mock") || content.includes("jest.mock")) {
violations.push(file);
}
}
expect(violations).toEqual([]);
});
it("no TODO or FIXME in src/", { timeout: 5000 }, async () => {
const files = await collectTsFiles(SRC_DIR);
const violations: string[] = [];
for (const file of files) {
const content = await readFile(file, "utf-8");
if (/\bTODO\b|\bFIXME\b/.test(content)) {
violations.push(file);
}
}
expect(violations).toEqual([]);
});
it("no ts-ignore in src/", { timeout: 5000 }, async () => {
const files = await collectTsFiles(SRC_DIR);
const violations: string[] = [];
for (const file of files) {
const content = await readFile(file, "utf-8");
if (content.includes("@ts-ignore")) {
violations.push(file);
}
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,13 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto; CryptoKey?: typeof CryptoKey };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
if (g.CryptoKey === undefined) {
// Node exposes CryptoKey on the webcrypto object
g.CryptoKey = (
webcrypto as unknown as { CryptoKey: typeof CryptoKey }
).CryptoKey;
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake: selective imports work", () => {
it("importing only availability does not throw", {
timeout: 30000,
}, async () => {
const { isWebCryptoAvailable } = await import(
"../src/availability/availability.js"
);
expect(typeof isWebCryptoAvailable).toBe("function");
expect(isWebCryptoAvailable()).toBe(true);
});
it("importing only AEAD engine does not throw", {
timeout: 30000,
}, async () => {
const { webCryptoAeadEngine } = await import(
"../src/engines/webcrypto-aead-engine/webcrypto-aead-engine.js"
);
expect(typeof webCryptoAeadEngine.encrypt).toBe("function");
expect(typeof webCryptoAeadEngine.decrypt).toBe("function");
});
it("importing only errors does not throw", { timeout: 30000 }, async () => {
const { BrowserCryptoError, BROWSER_CRYPTO_CODES } = await import(
"../src/errors/index.js"
);
expect(BrowserCryptoError).toBeDefined();
expect(BROWSER_CRYPTO_CODES["NOT_AVAILABLE"]).toBe(
"BROWSER_CRYPTO.NOT_AVAILABLE"
);
});
it("barrel index exports all expected symbols", {
timeout: 30000,
}, async () => {
const mod = await import("../src/index.js");
expect(typeof mod.isWebCryptoAvailable).toBe("function");
expect(typeof mod.webCryptoAeadEngine).toBe("object");
expect(typeof mod.webCryptoSignEngine).toBe("object");
expect(typeof mod.webCryptoAgreeEngine).toBe("object");
expect(typeof mod.rsa).toBe("object");
expect(typeof mod.aesKw).toBe("object");
expect(typeof mod.ecdsaP384).toBe("object");
expect(typeof mod.ecdhP384).toBe("object");
expect(typeof mod.nonExtractableKey).toBe("function");
expect(mod.BrowserCryptoError).toBeDefined();
});
});

View File

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

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-browser",
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",
"availability/availability": "src/availability/availability.ts",
"engines/webcrypto-aead-engine/webcrypto-aead-engine":
"src/engines/webcrypto-aead-engine/webcrypto-aead-engine.ts",
"engines/webcrypto-sign-engine/webcrypto-sign-engine":
"src/engines/webcrypto-sign-engine/webcrypto-sign-engine.ts",
"engines/webcrypto-agree-engine/webcrypto-agree-engine":
"src/engines/webcrypto-agree-engine/webcrypto-agree-engine.ts",
"rsa/rsa": "src/rsa/rsa.ts",
"aes-kw/aes-kw": "src/aes-kw/aes-kw.ts",
"ecdsa-p384/ecdsa-p384": "src/ecdsa-p384/ecdsa-p384.ts",
"ecdh-p384/ecdh-p384": "src/ecdh-p384/ecdh-p384.ts",
"non-extractable-key/non-extractable-key":
"src/non-extractable-key/non-extractable-key.ts",
"errors/index": "src/errors/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto",
"@suaveplan/error",
"tslib",
/^@suaveplan\//,
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
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: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/constants/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30_000,
},
});

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";

Some files were not shown because too many files have changed in this diff Show More