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,41 @@
# @suaveplan/crypto Changelog
## 0.0.0 — suaveplan rebuild
Complete rewrite with engine-interface architecture.
### Architecture
- **Engine interfaces** — `AeadEngine`, `SignEngine`, `AgreeEngine`, `SealedBoxEngine`, `AuthBoxEngine` as pure TypeScript interface types. Noble defaults ship in-package; WebCrypto and libsodium engines live in `@suaveplan/crypto-browser` and `@suaveplan/crypto-sodium`.
- **`ByteSource` rename** — `Rng = (n: number) => Uint8Array` renamed to `ByteSource` to avoid collision with `@suaveplan/types`'s `Rng = { nextUint32(): number; nextFloat(): number }`. `Rng` kept as a deprecated alias.
- **`@suaveplan/crypto-hash` dependency** — hashing (SHA-2, HMAC, HKDF, PBKDF2, Argon2id) split into `@suaveplan/crypto-hash`. `@noble/hashes` is no longer a direct dependency of `@suaveplan/crypto`.
### New features
- `XChaCha20-Poly1305` added as a third AEAD algorithm (`AEAD_XCHACHA_NONCE_LENGTH = 24`)
- `sealedBox.seal` / `sealedBox.open` — anonymous-sender encryption (X25519 + XChaCha20-Poly1305 + HKDF-SHA256)
- `authBox.seal` / `authBox.open` — authenticated box (X25519 + XChaCha20-Poly1305 + HKDF-SHA256)
- `defaultByteSource` exported as canonical name alongside deprecated `defaultRng`
- All public functions accept trailing `{ engine?: <EngineType> }` for engine substitution
### Directory restructure
| Old path | New path |
|---|---|
| `src/random/random-bytes.ts` | `src/primitives/random-bytes.ts` |
| `src/compare/constant-time-equal.ts` | `src/primitives/constant-time-equal.ts` |
| `src/zeroize/zeroize.ts` | `src/primitives/zeroize.ts` |
| `src/hash/` | Removed → `@suaveplan/crypto-hash` |
| `src/hmac/` | Removed → `@suaveplan/crypto-hash` |
| `src/kdf/` | Removed → `@suaveplan/crypto-hash` |
### Subpath exports updated
New: `./primitives/random-bytes`, `./primitives/constant-time-equal`, `./primitives/zeroize`, `./sealed-box`, `./auth-box`, `./engines`
Removed: `./random`, `./compare`, `./hash`, `./hmac`, `./kdf`
### Quality
- 100% statement / branch / function / line coverage
- `tests/no-disallowed.test.ts` guards now point to `src/primitives/random-bytes.ts`
- `tests/dep-graph.test.ts` asserts `@noble/hashes` is NOT a direct dep

View File

@@ -0,0 +1,199 @@
# @suaveplan/crypto-core - Universal cryptographic primitives (formerly @suaveplan/crypto) — AEAD (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305), Ed25519 / X25519 / ECDSA-P256, sealed-box, auth-box, constant-time compare, zeroize. Engine-pluggable via noble defaults. Consumed by @suaveplan/crypto (the opinionated keychain/vault/packer layer); other packages must go through @suaveplan/crypto, not this package directly.
## Summary
Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited [@noble/*](https://paulmillr.com/noble/) libraries with zero WASM.
<!-- begin:auto-docs -->
## Documentation tree
- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-aead-engine — <!-- begin:auto-docs --> _(no su…
- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-aead-engine — <!-- begin:auto-docs --> _(no su…
- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-auth-box-engine — <!-- begin:auto-docs --> _(n…
- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-auth-box-engine — <!-- begin:auto-docs --> _(n…
- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-sealed-box-engine — <!-- begin:auto-docs --> _…
- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- noble-sealed-box-engine — <!-- begin:auto-docs --> _…
- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- beg…
- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- beg…
- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!--…
- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!--…
- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!…
- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!…
- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- begin:auto-docs --> _(no s…
- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- begin:auto-docs --> _(no s…
- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!-- begin:auto-docs --> _(…
- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!-- begin:auto-docs --> _(…
- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!-- begin:auto-docs -->…
- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!-- begin:auto-docs -->…
- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-aead-engine](./src/aead/noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./src/aead/aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-agree-engine](./src/agree/noble-agree-engine/noble-agree-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_ - [x25519](./src/agree/x25519/x25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-auth-box-engine](./src/auth-box/noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-box](./src/auth-box/auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [codes](./src/errors/codes/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [crypto-error](./src/errors/crypto-error/crypto-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_ - [constant-time-equal](./src/primitives/constant-time-equal/constant-time-equal.md) — <!-- begin:auto-docs --> _(no submodules)_- [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [random-bytes](./src/primitives/random-bytes/random-bytes.md) — <!-- begin:auto-docs --> _(no submodules)_- [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_ - [zeroize](./src/primitives/zeroize/zeroize.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-sealed-box-engine](./src/sealed-box/noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sealed-box](./src/sealed-box/sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ecdsa-p256](./src/sign/ecdsa-p256/ecdsa-p256.md) — <!-- begin:auto-docs --> _(no submodules)_- [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ed25519](./src/sign/ed25519/ed25519.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [noble-sign-engine](./src/sign/noble-sign-engine/noble-sign-engine.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/crypto
```
## Primitives
| Surface | What |
|---|---|
| `randomBytes(n, { byteSource? })` | CSPRNG bytes (WebCrypto default; injectable for tests) |
| `constantTimeEqual(a, b)` | Constant-time byte comparison |
| `zeroize(buf)` | Fill a `Uint8Array` with zeros |
| `aeadEncrypt` / `aeadDecrypt` | AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305 |
| `ed25519.{generateKeypair, sign, verify}` | Ed25519 signing |
| `ecdsaP256.{generateKeypair, sign, verify}` | ECDSA-P256 signing |
| `x25519.{generateKeypair, agree}` | X25519 ECDH key agreement |
| `sealedBox.{seal, open}` | Anonymous-sender sealed box (X25519 + XChaCha20-Poly1305) |
| `authBox.{seal, open}` | Authenticated box (X25519 + XChaCha20-Poly1305) |
> Hashing (SHA-2, HMAC, HKDF, PBKDF2, Argon2id) lives in [`@suaveplan/crypto-hash`](../hash).
## Engine interfaces
Every operation accepts an optional `{ engine?: <EngineType> }` argument. The default engines use `@noble/ciphers` and `@noble/curves`. Swap in a WebCrypto-backed or libsodium-backed engine without changing call sites.
```ts
import type { AeadEngine } from "@suaveplan/crypto-core/engines";
const webCryptoEngine: AeadEngine = { /* ... */ };
aeadEncrypt("AES-256-GCM", key, plaintext, { nonce }, { engine: webCryptoEngine });
```
## ByteSource
`ByteSource = (n: number) => Uint8Array` — the injected randomness type used for key generation. Replaces the old `Rng` alias (which conflicted with `@suaveplan/types`'s `Rng`).
```ts
import { randomBytes, type ByteSource } from "@suaveplan/crypto-core";
// Production
const key = randomBytes(32);
// Tests — deterministic
const seeded: ByteSource = (n) => new Uint8Array(n).fill(0x42);
const kp = ed25519.generateKeypair({ byteSource: seeded });
```
## AEAD
```ts
import { aeadEncrypt, aeadDecrypt, randomBytes } from "@suaveplan/crypto-core";
const key = randomBytes(32);
const nonce = randomBytes(12); // 12 for AES-GCM/ChaCha20, 24 for XChaCha20
const plaintext = new TextEncoder().encode("hello");
const ct = aeadEncrypt("AES-256-GCM", key, plaintext, { nonce });
const pt = aeadDecrypt("AES-256-GCM", key, ct, { nonce });
```
## Signing
```ts
import { ed25519, ecdsaP256 } from "@suaveplan/crypto-core";
const kp = ed25519.generateKeypair();
const sig = ed25519.sign(kp.secretKey, message);
ed25519.verify(kp.publicKey, sig, message); // true
```
## Key Agreement + Sealed Box
```ts
import { x25519, sealedBox, authBox, randomBytes } from "@suaveplan/crypto-core";
const alice = x25519.generateKeypair();
const bob = x25519.generateKeypair();
// Anonymous sender (sealed box)
const sealed = sealedBox.seal(bob.publicKey, plaintext);
const opened = sealedBox.open(bob.secretKey, bob.publicKey, sealed);
// Authenticated (auth box)
const nonce = randomBytes(24);
const ct = authBox.seal(alice.secretKey, bob.publicKey, plaintext, nonce);
const pt = authBox.open(alice.publicKey, bob.secretKey, ct, nonce);
```
## Error surface
Every failure surfaces as `CryptoError` (extends `SuaveplanError`) with a stable `CRYPTO.*` code. Error context never includes raw key bytes, plaintext, or authentication tags.
| Code | When |
|---|---|
| `CRYPTO.KEY.INVALID_LENGTH` | Key is not 32 bytes |
| `CRYPTO.AEAD.INVALID_NONCE` | Nonce length wrong for algorithm |
| `CRYPTO.AEAD.DECRYPT_FAILED` | Authentication tag mismatch |
| `CRYPTO.SIGN.INVALID_KEY` | Key is malformed |
| `CRYPTO.SIGN.VERIFY_FAILED` | Signature verification failed |
| `CRYPTO.RNG.INVALID_LENGTH` | `randomBytes(n)` called with negative/non-integer n |
| `CRYPTO.RNG.UNAVAILABLE` | No CSPRNG available |
## Subpath exports
```ts
import { randomBytes } from "@suaveplan/crypto-core/primitives/random-bytes";
import { constantTimeEqual } from "@suaveplan/crypto-core/primitives/constant-time-equal";
import { zeroize } from "@suaveplan/crypto-core/primitives/zeroize";
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto-core/aead";
import { ed25519, ecdsaP256 } from "@suaveplan/crypto-core/sign";
import { x25519 } from "@suaveplan/crypto-core/agree";
import { sealedBox } from "@suaveplan/crypto-core/sealed-box";
import { authBox } from "@suaveplan/crypto-core/auth-box";
import type { AeadEngine, SignEngine } from "@suaveplan/crypto-core/engines";
import { CryptoError, CRYPTO_CODES } from "@suaveplan/crypto-core/errors";
```
## Co-located docs
- [`src/primitives/random-bytes.md`](src/primitives/random-bytes.md)
- [`src/primitives/constant-time-equal.md`](src/primitives/constant-time-equal.md)
- [`src/primitives/zeroize.md`](src/primitives/zeroize.md)
- [`src/aead/aead.md`](src/aead/aead.md)
- [`src/sign/ed25519.md`](src/sign/ed25519.md)
- [`src/sign/ecdsa-p256.md`](src/sign/ecdsa-p256.md)
- [`src/agree/x25519.md`](src/agree/x25519.md)
- [`src/sealed-box/sealed-box.md`](src/sealed-box/sealed-box.md)
- [`src/auth-box/auth-box.md`](src/auth-box/auth-box.md)
## Foundation rules
- Runtime deps: `@noble/ciphers`, `@noble/curves`, `@suaveplan/crypto-hash`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`
- `sideEffects: false`, per-primitive subpath exports
- Zero `Math.random`, `Date.now`, or `crypto.getRandomValues` outside `src/primitives/random-bytes.ts` (guarded by `tests/no-disallowed.test.ts`)
- 100% statement / branch / function / line coverage (Layer 1/2 foundation rule)
- fast-check property tests for every primitive
## Testing
```bash
bunx turbo lint typecheck test:coverage --filter=@suaveplan/crypto
```

View File

@@ -0,0 +1,168 @@
{
"name": "@suaveplan/crypto-core",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal cryptographic primitives (formerly @suaveplan/crypto) — AEAD (AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305), Ed25519 / X25519 / ECDSA-P256, sealed-box, auth-box, constant-time compare, zeroize. Engine-pluggable via noble defaults. Consumed by @suaveplan/crypto (the opinionated keychain/vault/packer layer); other packages must go through @suaveplan/crypto, not this package directly.",
"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"
}
},
"./primitives/random-bytes": {
"import": {
"types": "./dist/primitives/random-bytes/random-bytes.d.ts",
"default": "./dist/primitives/random-bytes/random-bytes.js"
},
"require": {
"types": "./dist/primitives/random-bytes/random-bytes.d.ts",
"default": "./dist/primitives/random-bytes/random-bytes.js"
}
},
"./primitives/constant-time-equal": {
"import": {
"types": "./dist/primitives/constant-time-equal/constant-time-equal.d.ts",
"default": "./dist/primitives/constant-time-equal/constant-time-equal.js"
},
"require": {
"types": "./dist/primitives/constant-time-equal/constant-time-equal.d.ts",
"default": "./dist/primitives/constant-time-equal/constant-time-equal.js"
}
},
"./primitives/zeroize": {
"import": {
"types": "./dist/primitives/zeroize/zeroize.d.ts",
"default": "./dist/primitives/zeroize/zeroize.js"
},
"require": {
"types": "./dist/primitives/zeroize/zeroize.d.ts",
"default": "./dist/primitives/zeroize/zeroize.js"
}
},
"./aead": {
"import": {
"types": "./dist/aead/aead.d.ts",
"default": "./dist/aead/aead.js"
},
"require": {
"types": "./dist/aead/aead.d.ts",
"default": "./dist/aead/aead.js"
}
},
"./sign": {
"import": {
"types": "./dist/sign/index.d.ts",
"default": "./dist/sign/index.js"
},
"require": {
"types": "./dist/sign/index.d.ts",
"default": "./dist/sign/index.js"
}
},
"./agree": {
"import": {
"types": "./dist/agree/index.d.ts",
"default": "./dist/agree/index.js"
},
"require": {
"types": "./dist/agree/index.d.ts",
"default": "./dist/agree/index.js"
}
},
"./sealed-box": {
"import": {
"types": "./dist/sealed-box/index.d.ts",
"default": "./dist/sealed-box/index.js"
},
"require": {
"types": "./dist/sealed-box/index.d.ts",
"default": "./dist/sealed-box/index.js"
}
},
"./auth-box": {
"import": {
"types": "./dist/auth-box/index.d.ts",
"default": "./dist/auth-box/index.js"
},
"require": {
"types": "./dist/auth-box/index.d.ts",
"default": "./dist/auth-box/index.js"
}
},
"./engines": {
"import": {
"types": "./dist/engines/index.d.ts",
"default": "./dist/engines/index.js"
},
"require": {
"types": "./dist/engines/index.d.ts",
"default": "./dist/engines/index.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",
"bench": "bun run src/__benchmarks__/crypto.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@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",
"fast-check": "catalog:testing",
"mitata": "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,125 @@
# aead
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [noble-aead-engine](./noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./aead.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- begin:auto-docs --> _(no su…
- [noble-aead-engine](./noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./aead.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-aead-engine](./noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./aead.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- beg…
- [noble-aead-engine](./noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./aead.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-aead-engine — <!-- begin:auto-docs --> _(no s…
- [noble-aead-engine](./noble-aead-engine/noble-aead-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [aead](./aead.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
Provides authenticated encryption with associated data (AEAD) facade functions backed by `@noble/ciphers`. AEAD combines confidentiality and integrity in a single operation — encrypted data includes an authentication tag that is verified before decryption, making it safe against ciphertext tampering. The facade is engine-pluggable: pass `{ engine }` to swap in a WebCrypto-backed or libsodium-backed implementation without changing calling code.
## Features
- Three algorithms: `AES-256-GCM`, `ChaCha20-Poly1305`, and `XChaCha20-Poly1305`
- Engine-pluggable via `AeadEngine` interface (noble default, WebCrypto, libsodium)
- Key validation: rejects non-32-byte keys with `CRYPTO.KEY.INVALID_LENGTH`
- Nonce validation: 12 bytes for AES-GCM and ChaCha20, 24 bytes for XChaCha20
- Decrypt never leaks plaintext in error context
- Optional AAD (Additional Authenticated Data) for binding ciphertext to context
## Usage: Basic Example
```ts
import { aeadEncrypt, aeadDecrypt, randomBytes } from "@suaveplan/crypto-core";
const key = randomBytes(32);
const nonce = randomBytes(12);
const plaintext = new TextEncoder().encode("hello world");
const ciphertext = aeadEncrypt("AES-256-GCM", key, plaintext, { nonce });
const recovered = aeadDecrypt("AES-256-GCM", key, ciphertext, { nonce });
```
## Usage: Advanced Example
```ts
import { aeadEncrypt, aeadDecrypt, randomBytes } from "@suaveplan/crypto-core";
import type { AeadEngine } from "@suaveplan/crypto-core/engines";
// Custom engine stub for testing
const stubEngine: AeadEngine = {
encrypt: (_key, pt) => pt, // identity pass-through (test only!)
decrypt: (_key, ct) => ct,
};
// XChaCha20-Poly1305 with 24-byte nonce and AAD
const key = randomBytes(32);
const nonce = randomBytes(24);
const aad = new TextEncoder().encode("request-id:abc123");
const plaintext = new Uint8Array([1, 2, 3]);
const ct = aeadEncrypt("XChaCha20-Poly1305", key, plaintext, { nonce, aad });
const pt = aeadDecrypt("XChaCha20-Poly1305", key, ct, { nonce, aad });
```
## API Reference
### `AeadAlgorithm`
```ts
type AeadAlgorithm = "AES-256-GCM" | "ChaCha20-Poly1305" | "XChaCha20-Poly1305"
```
### `AEAD_KEY_LENGTH`
```ts
const AEAD_KEY_LENGTH = 32
```
Required key length in bytes for all algorithms.
### `AEAD_NONCE_LENGTH`
```ts
const AEAD_NONCE_LENGTH = 12
```
Required nonce length for AES-256-GCM and ChaCha20-Poly1305.
### `AEAD_XCHACHA_NONCE_LENGTH`
```ts
const AEAD_XCHACHA_NONCE_LENGTH = 24
```
Required nonce length for XChaCha20-Poly1305.
### `aeadEncrypt(algo, key, plaintext, options, opts?)`
```ts
function aeadEncrypt(
algo: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: AeadEncryptOptions,
opts?: AeadEngineOptions
): Uint8Array
```
Encrypts and authenticates `plaintext`. Returns `ciphertext || tag` (16-byte tag appended).
**Throws:** `CryptoError("CRYPTO.KEY.INVALID_LENGTH")` — key is not 32 bytes.
**Throws:** `CryptoError("CRYPTO.AEAD.INVALID_NONCE")` — nonce has wrong length for the algorithm.
### `aeadDecrypt(algo, key, ciphertext, options, opts?)`
```ts
function aeadDecrypt(
algo: AeadAlgorithm,
key: Uint8Array,
ciphertext: Uint8Array,
options: AeadDecryptOptions,
opts?: AeadEngineOptions
): Uint8Array
```
Verifies authentication tag and decrypts.
**Throws:** `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` — tag mismatch or corrupted ciphertext. Error context never includes key or plaintext bytes.
## Implementation Notes
The noble library AES-256-GCM uses the native Web Crypto API when available for hardware acceleration. ChaCha20-Poly1305 and XChaCha20-Poly1305 are implemented in pure JavaScript, making them the better choice on platforms without AES hardware support (e.g., older ARM). XChaCha20-Poly1305 accepts a 24-byte nonce, which is large enough to generate randomly without birthday-bound concerns for up to 2^96 messages with the same key — whereas the 12-byte nonce of standard ChaCha20-Poly1305 requires a counter or careful uniqueness management.

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import type { AeadEngine } from "../engines/aead-engine.types.js";
import { CRYPTO_CODES } from "../errors/codes/codes.js";
import { CryptoError } from "../errors/crypto-error/crypto-error.js";
import {
AEAD_KEY_LENGTH,
AEAD_NONCE_LENGTH,
AEAD_XCHACHA_NONCE_LENGTH,
type AeadAlgorithm,
aeadDecrypt,
aeadEncrypt,
} from "./aead.js";
function fixedKey(): Uint8Array {
return new Uint8Array(AEAD_KEY_LENGTH).fill(0xa1);
}
function fixedNonce(algo: AeadAlgorithm = "AES-256-GCM"): Uint8Array {
const len =
algo === "XChaCha20-Poly1305"
? AEAD_XCHACHA_NONCE_LENGTH
: AEAD_NONCE_LENGTH;
return new Uint8Array(len).fill(0x07);
}
describe.each<[AeadAlgorithm]>([
["AES-256-GCM"],
["ChaCha20-Poly1305"],
["XChaCha20-Poly1305"],
])("aead — %s", (algo) => {
it("round-trips plaintext", { timeout: 5000 }, () => {
const plaintext = new Uint8Array([1, 2, 3, 4]);
const nonce = fixedNonce(algo);
const ct = aeadEncrypt(algo, fixedKey(), plaintext, { nonce });
const pt = aeadDecrypt(algo, fixedKey(), ct, { nonce });
expect(pt).toEqual(plaintext);
});
it("AAD is authenticated", { timeout: 5000 }, () => {
const plaintext = new Uint8Array([1, 2]);
const nonce = fixedNonce(algo);
const ct = aeadEncrypt(algo, fixedKey(), plaintext, {
nonce,
aad: new Uint8Array([0xde]),
});
expect(() =>
aeadDecrypt(algo, fixedKey(), ct, {
nonce,
aad: new Uint8Array([0xad]),
})
).toThrow(CryptoError);
});
it("tampered ciphertext rejected with CRYPTO.AEAD.DECRYPT_FAILED", {
timeout: 5000,
}, () => {
const nonce = fixedNonce(algo);
const ct = aeadEncrypt(algo, fixedKey(), new Uint8Array([1]), {
nonce,
});
ct[0] = ct[0] ^ 0xff;
try {
aeadDecrypt(algo, fixedKey(), ct, { nonce });
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"]
);
}
});
it("property: arbitrary payloads round-trip", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ maxLength: 256 }),
fc.uint8Array({ maxLength: 64 }),
(pt, aad) => {
const nonce = fixedNonce(algo);
const ct = aeadEncrypt(algo, fixedKey(), pt, {
nonce,
aad,
});
const back = aeadDecrypt(algo, fixedKey(), ct, {
nonce,
aad,
});
return (
back.length === pt.length &&
back.every((v, i) => v === pt[i])
);
}
),
{ numRuns: 30 }
);
});
});
describe("aead — input validation", () => {
it("rejects wrong key length", { timeout: 5000 }, () => {
try {
aeadEncrypt("AES-256-GCM", new Uint8Array(16), new Uint8Array(1), {
nonce: fixedNonce(),
});
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["KEY.INVALID_LENGTH"]
);
}
});
it("rejects wrong nonce length for AES-256-GCM", { timeout: 5000 }, () => {
try {
aeadEncrypt("AES-256-GCM", fixedKey(), new Uint8Array(1), {
nonce: new Uint8Array(8),
});
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["AEAD.INVALID_NONCE"]
);
}
});
it("rejects wrong nonce length for ChaCha20-Poly1305", {
timeout: 5000,
}, () => {
expect(() =>
aeadEncrypt("ChaCha20-Poly1305", fixedKey(), new Uint8Array(1), {
nonce: new Uint8Array(8),
})
).toThrow(CryptoError);
});
it("rejects wrong nonce length for XChaCha20-Poly1305", {
timeout: 5000,
}, () => {
expect(() =>
aeadEncrypt("XChaCha20-Poly1305", fixedKey(), new Uint8Array(1), {
nonce: new Uint8Array(12), // must be 24
})
).toThrow(CryptoError);
});
});
describe("aead — engine substitution", () => {
it("passes call through to a custom engine", { timeout: 5000 }, () => {
const calls: string[] = [];
const stubEngine: AeadEngine = {
encrypt: (_key, pt) => {
calls.push("encrypt");
return pt.slice();
},
decrypt: (_key, ct) => {
calls.push("decrypt");
return ct.slice();
},
};
const key = fixedKey();
const nonce = fixedNonce();
const plaintext = new Uint8Array([0x42]);
const ct = aeadEncrypt(
"AES-256-GCM",
key,
plaintext,
{ nonce },
{ engine: stubEngine }
);
expect(calls).toEqual(["encrypt"]);
const pt = aeadDecrypt(
"AES-256-GCM",
key,
ct,
{ nonce },
{ engine: stubEngine }
);
expect(calls).toEqual(["encrypt", "decrypt"]);
expect(pt).toEqual(plaintext);
});
});

View File

@@ -0,0 +1,103 @@
import type { AeadEngine } from "../engines/aead-engine.types.js";
import { CRYPTO_CODES } from "../errors/codes/codes.js";
import { CryptoError } from "../errors/crypto-error/crypto-error.js";
import { NobleAeadEngine } from "./noble-aead-engine/noble-aead-engine.js";
export type AeadAlgorithm =
| "AES-256-GCM"
| "ChaCha20-Poly1305"
| "XChaCha20-Poly1305";
/** Key length in bytes. All supported algorithms use 32-byte keys. */
export const AEAD_KEY_LENGTH = 32;
/** Nonce length in bytes for AES-256-GCM and ChaCha20-Poly1305. */
export const AEAD_NONCE_LENGTH = 12;
/** Nonce length in bytes for XChaCha20-Poly1305. */
export const AEAD_XCHACHA_NONCE_LENGTH = 24;
export interface AeadEncryptOptions {
readonly nonce: Uint8Array;
readonly aad?: Uint8Array;
}
export type AeadDecryptOptions = AeadEncryptOptions;
export interface AeadEngineOptions {
readonly engine?: AeadEngine;
}
function validateKey(algo: AeadAlgorithm, key: Uint8Array): void {
if (key.length !== AEAD_KEY_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["KEY.INVALID_LENGTH"],
"error.crypto.key.invalid-length",
{
context: {
algorithm: algo,
expected: AEAD_KEY_LENGTH,
actual: key.length,
},
}
);
}
}
function validateNonce(algo: AeadAlgorithm, nonce: Uint8Array): void {
const expected =
algo === "XChaCha20-Poly1305"
? AEAD_XCHACHA_NONCE_LENGTH
: AEAD_NONCE_LENGTH;
if (nonce.length !== expected) {
throw new CryptoError(
CRYPTO_CODES["AEAD.INVALID_NONCE"],
"error.crypto.aead.invalid-nonce",
{
context: {
algorithm: algo,
expected,
actual: nonce.length,
},
}
);
}
}
/**
* AEAD encrypt. Returns `ciphertext || tag`.
*
* @throws {CryptoError} CRYPTO.KEY.INVALID_LENGTH if key is not 32 bytes.
* @throws {CryptoError} CRYPTO.AEAD.INVALID_NONCE if nonce length is wrong for the algorithm.
*/
export function aeadEncrypt(
algo: AeadAlgorithm,
key: Uint8Array,
plaintext: Uint8Array,
options: AeadEncryptOptions,
opts: AeadEngineOptions = {}
): Uint8Array {
validateKey(algo, key);
validateNonce(algo, options.nonce);
const engine = opts.engine ?? new NobleAeadEngine(algo);
return engine.encrypt(key, plaintext, options.nonce, options.aad);
}
/**
* AEAD decrypt. Throws `CRYPTO.AEAD.DECRYPT_FAILED` on tag mismatch.
* The thrown error does NOT include plaintext, key, or tag bytes in context.
*
* @throws {CryptoError} CRYPTO.KEY.INVALID_LENGTH if key is not 32 bytes.
* @throws {CryptoError} CRYPTO.AEAD.INVALID_NONCE if nonce length is wrong for the algorithm.
* @throws {CryptoError} CRYPTO.AEAD.DECRYPT_FAILED on authentication tag mismatch.
*/
export function aeadDecrypt(
algo: AeadAlgorithm,
key: Uint8Array,
ciphertext: Uint8Array,
options: AeadDecryptOptions,
opts: AeadEngineOptions = {}
): Uint8Array {
validateKey(algo, key);
validateNonce(algo, options.nonce);
const engine = opts.engine ?? new NobleAeadEngine(algo);
return engine.decrypt(key, ciphertext, options.nonce, options.aad);
}

View File

@@ -0,0 +1,12 @@
export {
AEAD_KEY_LENGTH,
AEAD_NONCE_LENGTH,
AEAD_XCHACHA_NONCE_LENGTH,
type AeadAlgorithm,
type AeadDecryptOptions,
type AeadEncryptOptions,
type AeadEngineOptions,
aeadDecrypt,
aeadEncrypt,
} from "./aead";
export { NobleAeadEngine } from "./noble-aead-engine";

View File

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

View File

@@ -0,0 +1,103 @@
# NobleAeadEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NobleAeadEngine` is the default AEAD (Authenticated Encryption with Associated Data) engine for `@suaveplan/crypto`. It delegates to `@suaveplan/crypto-cipher` — itself backed by `@noble/ciphers` — so the build-time primitive selector defined in `crypto.config.ts` applies uniformly to all cipher calls, whether from the high-level facade or from this engine directly. The engine supports three algorithms: AES-256-GCM, ChaCha20-Poly1305, and XChaCha20-Poly1305, selected per instance at construction time.
## Features
- Implements the `AeadEngine` interface, making it pluggable into `aeadEncrypt` / `aeadDecrypt`
- Supports `AES-256-GCM`, `ChaCha20-Poly1305`, and `XChaCha20-Poly1305` via `AeadAlgorithm` discriminant
- Decryption failures are caught and re-thrown as `CryptoError` with code `AEAD.DECRYPT_FAILED` and structured context (algorithm name, ciphertext byte length)
- Algorithm selection delegates through `@suaveplan/crypto-cipher` subpaths, keeping the build-time selector effective
- Nonce is caller-supplied; the engine never generates nonces internally
## Usage: Basic Example
```typescript
import { NobleAeadEngine } from "@suaveplan/crypto-core/aead/noble-aead-engine";
import { randomBytes } from "@suaveplan/crypto-core";
const engine = new NobleAeadEngine("XChaCha20-Poly1305");
const key = randomBytes(32);
const nonce = randomBytes(24); // 24 bytes for XChaCha20
const plaintext = new TextEncoder().encode("hello world");
const ciphertext = engine.encrypt(key, plaintext, nonce);
const recovered = engine.decrypt(key, ciphertext, nonce);
console.log(new TextDecoder().decode(recovered)); // "hello world"
```
## Usage: Advanced Example
```typescript
import { NobleAeadEngine } from "@suaveplan/crypto-core/aead/noble-aead-engine";
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
import { randomBytes } from "@suaveplan/crypto-core";
const engine = new NobleAeadEngine("AES-256-GCM");
const key = randomBytes(32);
const nonce = randomBytes(12); // 12 bytes for AES-256-GCM
// Encrypt with additional authenticated data (AAD)
const plaintext = new TextEncoder().encode("sensitive payload");
const aad = new TextEncoder().encode("request-id:abc123");
const ciphertext = engine.encrypt(key, plaintext, nonce, aad);
// Verify AAD is checked during decrypt
try {
const wrongAad = new TextEncoder().encode("request-id:wrong");
engine.decrypt(key, ciphertext, nonce, wrongAad);
} catch (err) {
if (err instanceof CryptoError && err.code === CRYPTO_CODES["AEAD.DECRYPT_FAILED"]) {
console.log("AAD mismatch detected — message rejected");
}
}
// Correct AAD succeeds
const recovered = engine.decrypt(key, ciphertext, nonce, aad);
console.log(new TextDecoder().decode(recovered)); // "sensitive payload"
```
## API Reference
### `NobleAeadEngine`
```typescript
class NobleAeadEngine implements AeadEngine
```
**Constructor**
| Parameter | Type | Description |
|-----------|-----------------|-------------------------------------------------|
| `algo` | `AeadAlgorithm` | Selects the cipher: `"AES-256-GCM"`, `"ChaCha20-Poly1305"`, or `"XChaCha20-Poly1305"`. |
**`encrypt(key, plaintext, nonce, aad?): Uint8Array`**
| Parameter | Type | Description |
|-------------|----------------|------------------------------------------------------|
| `key` | `Uint8Array` | Symmetric key (32 bytes for all supported algorithms). |
| `plaintext` | `Uint8Array` | Message bytes to encrypt. |
| `nonce` | `Uint8Array` | Unique nonce (12 bytes for AES-GCM; 12 or 24 bytes for ChaCha variants). |
| `aad` | `Uint8Array \| undefined` | Optional additional authenticated data. |
Returns the ciphertext with authentication tag appended (16-byte tag for all three algorithms).
**`decrypt(key, ciphertext, nonce, aad?): Uint8Array`**
Same parameters as `encrypt`, with `ciphertext` in place of `plaintext`.
Throws `CryptoError` with code `CRYPTO_CODES["AEAD.DECRYPT_FAILED"]` if authentication verification fails or if the underlying primitive rejects the ciphertext for any reason. The error's `context` field includes `algorithm` and `ciphertextLength`.
## Implementation Notes
The internal `primitiveFor` function maps the `AeadAlgorithm` discriminant to the matching `@suaveplan/crypto-cipher` subpath import (`aes-256-gcm`, `xchacha20-poly1305`, or `chacha20-poly1305`). This avoids a dynamic dispatch switch on every call while still keeping each cipher behind its own subpath so tree-shaking is effective.
Encryption is allowed to propagate raw errors from the primitive (malformed key length, nonce length mismatch) because these represent programming errors — the caller supplied invalid arguments — rather than runtime failures from untrusted input. Decryption is wrapped in a try/catch because failures there arise from untrusted ciphertext and must be surfaced as a stable, typed `CryptoError` rather than a raw `Error` with an implementation-detail message from `@noble/ciphers`.

View File

@@ -0,0 +1,77 @@
import * as aes from "@suaveplan/crypto-cipher/aes-256-gcm";
import * as chacha from "@suaveplan/crypto-cipher/chacha20-poly1305";
import * as xchacha from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import type { AeadEngine } from "../../engines/aead-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import type { AeadAlgorithm } from "../aead.js";
interface PrimitiveModule {
encrypt: (
key: Uint8Array,
nonce: Uint8Array,
plaintext: Uint8Array,
aad?: Uint8Array
) => Uint8Array;
decrypt: (
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array,
aad?: Uint8Array
) => Uint8Array;
}
function primitiveFor(algo: AeadAlgorithm): PrimitiveModule {
if (algo === "AES-256-GCM") return aes;
if (algo === "XChaCha20-Poly1305") return xchacha;
return chacha;
}
/**
* Noble-backed AEAD engine. Supports AES-256-GCM, ChaCha20-Poly1305, and
* XChaCha20-Poly1305. This is the default engine used by `aeadEncrypt` /
* `aeadDecrypt` when no engine option is passed.
*
* Delegates through `@suaveplan/crypto-cipher` so the build-time primitive
* selector (`crypto.config.ts`) applies uniformly.
*/
export class NobleAeadEngine implements AeadEngine {
/** The algorithm this engine instance handles. */
protected readonly algo: AeadAlgorithm;
public constructor(algo: AeadAlgorithm) {
this.algo = algo;
}
public encrypt(
key: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
return primitiveFor(this.algo).encrypt(key, nonce, plaintext, aad);
}
public decrypt(
key: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array {
try {
return primitiveFor(this.algo).decrypt(key, nonce, ciphertext, aad);
} catch (cause) {
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed",
{
cause,
context: {
algorithm: this.algo,
ciphertextLength: ciphertext.length,
},
}
);
}
}
}

View File

@@ -0,0 +1,6 @@
export {
agree,
generateKeypair,
type X25519Keypair,
type X25519Options,
} from "./x25519/x25519.js";

View File

@@ -0,0 +1 @@
export { NobleX25519Engine } from "./noble-agree-engine.js";

View File

@@ -0,0 +1,89 @@
# NobleX25519Engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NobleX25519Engine` is the default key-agreement engine for `@suaveplan/crypto`. It implements the `AgreeEngine` interface using X25519 Diffie-Hellman as exposed through `@suaveplan/crypto-curve`, which in turn delegates to `@noble/curves`. The engine handles both ephemeral keypair generation and shared-secret derivation. It is consumed by the `x25519Agree` facade function and can be swapped for alternative implementations (e.g. the WebCrypto or libsodium engines from sibling packages) via the engine option on `@suaveplan/crypto`'s public API.
## Features
- Implements `AgreeEngine` for X25519 Diffie-Hellman key agreement
- Keypair generation accepts an optional `ByteSource` for deterministic testing without mocks
- X25519 internal scalar clamping means every 32-byte value is a valid secret key — no rejection-sampling loop required
- Delegates through `@suaveplan/crypto-curve/x25519` so the build-time primitive selector applies
- Returns the raw 32-byte shared secret; key derivation (HKDF, etc.) is the caller's responsibility
## Usage: Basic Example
```typescript
import { NobleX25519Engine } from "@suaveplan/crypto-core/agree/noble-agree-engine";
const engine = new NobleX25519Engine();
const alice = engine.generateKeypair();
const bob = engine.generateKeypair();
// Both sides compute the same shared secret
const aliceShared = engine.agree(alice.secretKey, bob.publicKey);
const bobShared = engine.agree(bob.secretKey, alice.publicKey);
console.log(aliceShared.length); // 32
```
## Usage: Advanced Example
```typescript
import { NobleX25519Engine } from "@suaveplan/crypto-core/agree/noble-agree-engine";
import { hkdf } from "@suaveplan/crypto-hash";
const engine = new NobleX25519Engine();
// Use a deterministic byte source for reproducible tests
const fixedBytes = (n: number) => new Uint8Array(n).fill(0xab);
const aliceKeypair = engine.generateKeypair(fixedBytes);
const bob = engine.generateKeypair();
// Derive a session key from the raw shared secret
const rawSecret = engine.agree(aliceKeypair.secretKey, bob.publicKey);
const sessionKey = hkdf("SHA-256", rawSecret, {
salt: new Uint8Array(32),
info: new TextEncoder().encode("session-key-v1"),
length: 32,
});
console.log(sessionKey.length); // 32
```
## API Reference
### `NobleX25519Engine`
```typescript
class NobleX25519Engine implements AgreeEngine
```
**`generateKeypair(byteSource?): AgreeKeypair`**
| Parameter | Type | Description |
|--------------|-------------------------|--------------------------------------------------------------|
| `byteSource` | `ByteSource \| undefined` | Optional source of random bytes. Defaults to `defaultByteSource` (secure random). |
Returns `AgreeKeypair { publicKey: Uint8Array; secretKey: Uint8Array }` where both keys are 32 bytes.
**`agree(mySecret, theirPublic): Uint8Array`**
| Parameter | Type | Description |
|---------------|--------------|-------------------------------------------|
| `mySecret` | `Uint8Array` | Local 32-byte X25519 secret key. |
| `theirPublic` | `Uint8Array` | Remote 32-byte X25519 public key. |
Returns a 32-byte raw shared secret. Throws if either key is malformed (propagated from the underlying curve implementation).
## Implementation Notes
X25519 applies a clamping operation to the scalar before performing the Diffie-Hellman multiplication. This means any 32-byte sequence is a valid secret key — the clamp adjusts bits 0, 1, 2, 7, and 255 to ensure the scalar lands in the correct subgroup. As a result, `generateKeypair` simply draws 32 bytes from the byte source and passes them directly to `getPublicKey`; no validity check or retry loop is needed.
The raw shared secret returned by `agree` has the structure of a group element, not a uniformly random key. Callers performing symmetric encryption should always pass the output through a key derivation function (HKDF is recommended) before using it as a cipher key. The higher-level `authBox` and `sealedBox` constructs in this package perform HKDF-SHA256 derivation automatically.

View File

@@ -0,0 +1,26 @@
import * as nobleX25519 from "@suaveplan/crypto-curve/x25519";
import type {
AgreeEngine,
AgreeKeypair,
} from "../../engines/agree-engine.types.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
import { defaultByteSource } from "../../primitives/random-bytes/random-bytes.js";
/**
* Noble-backed X25519 key-agreement engine.
*
* Key generation picks 32 random bytes directly — X25519 clamps the scalar
* internally so every 32-byte value produces a valid keypair.
*/
export class NobleX25519Engine implements AgreeEngine {
public generateKeypair(byteSource?: ByteSource): AgreeKeypair {
const src = byteSource ?? defaultByteSource;
const secretKey = src(32);
const publicKey = nobleX25519.getPublicKey(secretKey);
return { publicKey, secretKey };
}
public agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array {
return nobleX25519.getSharedSecret(mySecret, theirPublic);
}
}

View File

@@ -0,0 +1,6 @@
export {
agree,
generateKeypair,
type X25519Keypair,
type X25519Options,
} from "./x25519.js";

View File

@@ -0,0 +1,83 @@
# x25519
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides X25519 Elliptic-Curve Diffie-Hellman (ECDH) key agreement — key generation and shared-secret derivation — backed by `@noble/curves`. X25519 is a state-of-the-art key-agreement protocol over Curve25519 offering 128-bit security, fast constant-time scalar multiplication, and immunity to many implementation pitfalls of older curve designs. The resulting shared secret should be passed through a KDF (e.g., HKDF via `@suaveplan/crypto-hash`) before use as a symmetric key. The facade is engine-pluggable: pass `{ engine }` to substitute a WebCrypto ECDH or libsodium-backed implementation.
## Features
- Key generation using injected `ByteSource` (testable) or `crypto.getRandomValues` (production)
- X25519 scalar clamping applied internally — every 32-byte value is a valid secret key
- Returns raw 32-byte shared secret (pass through HKDF before symmetric use)
- Engine-pluggable via `AgreeEngine` interface
- `ByteSource` / deprecated `rng` option for key-generation injection
## Usage: Basic Example
```ts
import { x25519 } from "@suaveplan/crypto-core";
const alice = x25519.generateKeypair();
const bob = x25519.generateKeypair();
const shared1 = x25519.agree(alice.secretKey, bob.publicKey);
const shared2 = x25519.agree(bob.secretKey, alice.publicKey);
// shared1 deep-equals shared2
```
## Usage: Advanced Example
```ts
import { x25519 } from "@suaveplan/crypto-core";
import { hkdf } from "@suaveplan/crypto-hash";
// Key agreement + HKDF key derivation
const alice = x25519.generateKeypair();
const bob = x25519.generateKeypair();
const rawSecret = x25519.agree(alice.secretKey, bob.publicKey);
const encKey = hkdf("SHA-256", rawSecret, {
salt: new Uint8Array(32),
info: new TextEncoder().encode("enc-key"),
length: 32,
});
```
## API Reference
### `X25519Keypair`
```ts
type X25519Keypair = { publicKey: Uint8Array; secretKey: Uint8Array }
```
Both keys are 32 bytes.
### `generateKeypair(opts?)`
```ts
function generateKeypair(opts?: X25519Options): X25519Keypair
```
Generates a fresh X25519 keypair. X25519 clamps the scalar internally, so any 32 random bytes form a valid key.
| Param | Type | Description |
|-------|------|-------------|
| `opts.byteSource` | `ByteSource?` | Byte source for key material. |
| `opts.rng` | `ByteSource?` | Deprecated alias for `byteSource`. |
| `opts.engine` | `AgreeEngine?` | Override the default noble engine. |
### `agree(mySecret, theirPublic, opts?)`
```ts
function agree(mySecret: Uint8Array, theirPublic: Uint8Array, opts?: { engine?: AgreeEngine }): Uint8Array
```
Performs X25519 ECDH and returns the 32-byte shared secret. Both parties calling `agree` with their own secret key and the other party's public key will derive the same secret.
## Implementation Notes
The raw X25519 shared secret must be passed through a KDF such as HKDF before use as a symmetric encryption key. Raw DH outputs have high entropy but are not uniformly distributed and may be biased in edge cases. See `@suaveplan/crypto-hash` for `hkdf`. The `agree` function is called `getSharedSecret` in the noble library — the facade renames it for clarity in the key-agreement context.

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { X25519_KEY_LENGTH } from "../../constants/index.js";
import type { AgreeEngine } from "../../engines/agree-engine.types.js";
import { agree, generateKeypair } from "./x25519.js";
describe("x25519.generateKeypair", () => {
it("generates a keypair", { timeout: 5000 }, () => {
const kp = generateKeypair();
expect(kp.secretKey.length).toBe(X25519_KEY_LENGTH);
expect(kp.publicKey.length).toBe(X25519_KEY_LENGTH);
});
it("uses an injected rng (deprecated)", { timeout: 5000 }, () => {
const kp = generateKeypair({ rng: (n) => new Uint8Array(n).fill(3) });
expect(kp.secretKey.every((b) => b === 3)).toBe(true);
});
it("uses injected byteSource", { timeout: 5000 }, () => {
const kp = generateKeypair({
byteSource: (n) => new Uint8Array(n).fill(5),
});
expect(kp.secretKey.every((b) => b === 5)).toBe(true);
});
});
describe("x25519 agree", () => {
it("derives a symmetric shared secret", { timeout: 5000 }, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const shared1 = agree(alice.secretKey, bob.publicKey);
const shared2 = agree(bob.secretKey, alice.publicKey);
expect(shared1).toEqual(shared2);
expect(shared1.length).toBe(X25519_KEY_LENGTH);
});
});
describe("x25519 — engine substitution", () => {
it("uses provided engine for generateKeypair and agree", {
timeout: 5000,
}, () => {
const calls: string[] = [];
const fixedPublic = new Uint8Array(32).fill(0x11);
const fixedSecret = new Uint8Array(32).fill(0x22);
const fixedShared = new Uint8Array(32).fill(0x33);
const stubEngine: AgreeEngine = {
generateKeypair: () => {
calls.push("generateKeypair");
return { publicKey: fixedPublic, secretKey: fixedSecret };
},
agree: () => {
calls.push("agree");
return fixedShared;
},
};
const kp = generateKeypair({ engine: stubEngine });
expect(calls).toEqual(["generateKeypair"]);
expect(kp.publicKey).toBe(fixedPublic);
const shared = agree(fixedSecret, fixedPublic, { engine: stubEngine });
expect(calls).toEqual(["generateKeypair", "agree"]);
expect(shared).toBe(fixedShared);
});
});

View File

@@ -0,0 +1,41 @@
import type {
AgreeEngine,
AgreeKeypair,
} from "../../engines/agree-engine.types.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
import { NobleX25519Engine } from "../noble-agree-engine/noble-agree-engine.js";
export type { AgreeKeypair as X25519Keypair };
export interface X25519Options {
readonly byteSource?: ByteSource;
readonly rng?: ByteSource;
readonly engine?: AgreeEngine;
}
const defaultEngine = new NobleX25519Engine();
/**
* Generate a fresh X25519 keypair.
*
* @param opts.byteSource - Inject a deterministic byte source (testing).
* @param opts.engine - Override the agreement engine.
*/
export function generateKeypair(opts: X25519Options = {}): AgreeKeypair {
const engine = opts.engine ?? defaultEngine;
return engine.generateKeypair(opts.byteSource ?? opts.rng);
}
/**
* ECDH agree: compute the 32-byte shared secret.
*
* @param opts.engine - Override the agreement engine.
*/
export function agree(
mySecret: Uint8Array,
theirPublic: Uint8Array,
opts: { engine?: AgreeEngine } = {}
): Uint8Array {
const engine = opts.engine ?? defaultEngine;
return engine.agree(mySecret, theirPublic);
}

View File

@@ -0,0 +1,103 @@
# auth-box
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [noble-auth-box-engine](./noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!-- begin:auto-docs --> _(n…
- [noble-auth-box-engine](./noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-auth-box-engine](./noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!--…
- [noble-auth-box-engine](./noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-auth-box-engine — <!-- begin:auto-docs --> _(…
- [noble-auth-box-engine](./noble-auth-box-engine/noble-auth-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-box](./auth-box.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
Provides authenticated sealed-box encryption following `crypto_box` semantics. Unlike the anonymous `sealedBox`, the auth-box binds the sender's identity to the ciphertext: the recipient can verify that only the holder of the sender's secret key could have produced it. The protocol uses X25519 ECDH key agreement, HKDF-SHA256 key derivation, and XChaCha20-Poly1305 encryption. Both parties must hold each other's public keys before communicating, making this suitable for protocols with established key exchange such as Signal-style messaging or server-to-server encryption.
## Features
- Authenticated sender — recipient verifies sender identity implicitly via ECDH
- X25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305 (crypto_box semantics)
- Caller-supplied 24-byte nonce for fine-grained nonce management
- Engine-pluggable via `AuthBoxEngine` interface
- Authenticated decryption — rejects tampered ciphertext with `CRYPTO.AEAD.DECRYPT_FAILED`
## Usage: Basic Example
```ts
import { authBox, x25519 } from "@suaveplan/crypto-core";
import { randomBytes } from "@suaveplan/crypto-core";
const alice = x25519.generateKeypair();
const bob = x25519.generateKeypair();
const nonce = randomBytes(24);
const msg = new TextEncoder().encode("authenticated message");
// Alice seals for Bob
const ct = authBox.seal(alice.secretKey, bob.publicKey, msg, nonce);
// Bob opens from Alice
const pt = authBox.open(alice.publicKey, bob.secretKey, ct, nonce);
```
## Usage: Advanced Example
```ts
import { authBox, x25519, randomBytes } from "@suaveplan/crypto-core";
import type { AuthBoxEngine } from "@suaveplan/crypto-core/engines";
// Custom engine stub for testing
const stubEngine: AuthBoxEngine = {
seal: (_senderSec, _recipPub, pt, _nonce) => pt.slice(),
open: (_senderPub, _recipSec, ct, _nonce) => ct.slice(),
};
const alice = x25519.generateKeypair();
const bob = x25519.generateKeypair();
const nonce = randomBytes(24);
const msg = new Uint8Array([0x42]);
const ct = authBox.seal(alice.secretKey, bob.publicKey, msg, nonce, { engine: stubEngine });
const pt = authBox.open(alice.publicKey, bob.secretKey, ct, nonce, { engine: stubEngine });
```
## API Reference
### `seal(senderSecret, recipientPublic, plaintext, nonce, opts?)`
```ts
function seal(
senderSecret: Uint8Array,
recipientPublic: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
opts?: AuthBoxEngineOptions
): Uint8Array
```
Encrypts and authenticates `plaintext`. Returns the XChaCha20-Poly1305 ciphertext + 16-byte tag.
| Param | Type | Description |
|-------|------|-------------|
| `senderSecret` | `Uint8Array` | Sender's X25519 secret key (32 bytes). |
| `recipientPublic` | `Uint8Array` | Recipient's X25519 public key (32 bytes). |
| `plaintext` | `Uint8Array` | Data to encrypt. |
| `nonce` | `Uint8Array` | 24-byte nonce. MUST be unique per (senderKey, recipientKey) pair. |
| `opts.engine` | `AuthBoxEngine?` | Override the default noble engine. |
### `open(senderPublic, recipientSecret, ciphertext, nonce, opts?)`
```ts
function open(
senderPublic: Uint8Array,
recipientSecret: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
opts?: AuthBoxEngineOptions
): Uint8Array
```
Verifies and decrypts. Throws on authentication failure.
**Throws:** `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` — tag mismatch or corrupted ciphertext.
## Implementation Notes
The HKDF info string `"suaveplan-auth-box-v1"` is domain-separated from the sealed-box protocol. Authentication works because the shared secret is derived from `ECDH(senderSecret, recipientPublic)` which equals `ECDH(recipientSecret, senderPublic)` — only the holders of the respective secret keys can compute this value. The caller is responsible for nonce uniqueness; a counter concatenated with a random prefix, or a large random nonce (24 bytes, safe for random generation), are both acceptable strategies.

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { generateKeypair } from "../agree/x25519/x25519.js";
import type { AuthBoxEngine } from "../engines/auth-box-engine.types.js";
import { CryptoError } from "../errors/crypto-error/crypto-error.js";
import { defaultByteSource } from "../primitives/random-bytes/random-bytes.js";
import { open, seal } from "./auth-box.js";
function nonce24(): Uint8Array {
return defaultByteSource(24);
}
describe("authBox seal + open", () => {
it("round-trips plaintext", { timeout: 5000 }, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const nonce = nonce24();
const msg = new TextEncoder().encode("authenticated hello");
const ct = seal(alice.secretKey, bob.publicKey, msg, nonce);
const pt = open(alice.publicKey, bob.secretKey, ct, nonce);
expect(pt).toEqual(msg);
});
it("ciphertext is longer than plaintext by 16 bytes (tag)", {
timeout: 5000,
}, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const nonce = nonce24();
const msg = new Uint8Array([1, 2, 3]);
const ct = seal(alice.secretKey, bob.publicKey, msg, nonce);
expect(ct.length).toBe(msg.length + 16);
});
it("tampered ciphertext rejected", { timeout: 5000 }, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const nonce = nonce24();
const ct = seal(
alice.secretKey,
bob.publicKey,
new Uint8Array([42]),
nonce
);
ct[ct.length - 1] ^= 0xff;
expect(() => open(alice.publicKey, bob.secretKey, ct, nonce)).toThrow(
CryptoError
);
});
it("wrong nonce rejected", { timeout: 5000 }, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const nonce1 = new Uint8Array(24).fill(0x01);
const nonce2 = new Uint8Array(24).fill(0x02);
const ct = seal(
alice.secretKey,
bob.publicKey,
new Uint8Array([1]),
nonce1
);
expect(() => open(alice.publicKey, bob.secretKey, ct, nonce2)).toThrow(
CryptoError
);
});
it("wrong sender public key rejected", { timeout: 5000 }, () => {
const alice = generateKeypair();
const carol = generateKeypair();
const bob = generateKeypair();
const nonce = nonce24();
const ct = seal(
alice.secretKey,
bob.publicKey,
new Uint8Array([1]),
nonce
);
expect(() => open(carol.publicKey, bob.secretKey, ct, nonce)).toThrow(
CryptoError
);
});
it("property: arbitrary plaintext round-trips", {
timeout: 30_000,
}, () => {
const alice = generateKeypair();
const bob = generateKeypair();
fc.assert(
fc.property(fc.uint8Array({ maxLength: 256 }), (msg) => {
const nonce = nonce24();
const ct = seal(alice.secretKey, bob.publicKey, msg, nonce);
const pt = open(alice.publicKey, bob.secretKey, ct, nonce);
return (
pt.length === msg.length && pt.every((v, i) => v === msg[i])
);
}),
{ numRuns: 20 }
);
});
});
describe("authBox — engine substitution", () => {
it("uses provided engine", { timeout: 5000 }, () => {
const calls: string[] = [];
const stubEngine: AuthBoxEngine = {
seal: (_ss, _rp, pt, _n) => {
calls.push("seal");
return pt.slice();
},
open: (_sp, _rs, ct, _n) => {
calls.push("open");
return ct.slice();
},
};
const alice = generateKeypair();
const bob = generateKeypair();
const nonce = nonce24();
const msg = new Uint8Array([0x55]);
const ct = seal(alice.secretKey, bob.publicKey, msg, nonce, {
engine: stubEngine,
});
expect(calls).toEqual(["seal"]);
const pt = open(alice.publicKey, bob.secretKey, ct, nonce, {
engine: stubEngine,
});
expect(calls).toEqual(["seal", "open"]);
expect(pt).toEqual(msg);
});
});

View File

@@ -0,0 +1,52 @@
import type { AuthBoxEngine } from "../engines/auth-box-engine.types.js";
import { NobleAuthBoxEngine } from "./noble-auth-box-engine/noble-auth-box-engine.js";
export interface AuthBoxEngineOptions {
readonly engine?: AuthBoxEngine;
}
const defaultEngine = new NobleAuthBoxEngine();
/**
* Seal `plaintext` with an authenticated sender keypair.
*
* The recipient can verify that only the holder of `senderSecret`'s
* corresponding public key could have sent the message.
*
* @param senderSecret - Sender's X25519 secret key.
* @param recipientPublic - Recipient's X25519 public key.
* @param plaintext - Data to encrypt.
* @param nonce - 24-byte XChaCha20-Poly1305 nonce. MUST be unique per (sender, recipient) pair.
* @param opts.engine - Override the auth-box engine.
*/
export function seal(
senderSecret: Uint8Array,
recipientPublic: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
opts: AuthBoxEngineOptions = {}
): Uint8Array {
const engine = opts.engine ?? defaultEngine;
return engine.seal(senderSecret, recipientPublic, plaintext, nonce);
}
/**
* Open an authenticated box sent by `senderPublic`.
*
* @throws {CryptoError} CRYPTO.AEAD.DECRYPT_FAILED on authentication failure.
* @param senderPublic - Sender's X25519 public key.
* @param recipientSecret - Recipient's X25519 secret key.
* @param ciphertext - The encrypted blob produced by {@link seal}.
* @param nonce - The same 24-byte nonce used when sealing.
* @param opts.engine - Override the auth-box engine.
*/
export function open(
senderPublic: Uint8Array,
recipientSecret: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
opts: AuthBoxEngineOptions = {}
): Uint8Array {
const engine = opts.engine ?? defaultEngine;
return engine.open(senderPublic, recipientSecret, ciphertext, nonce);
}

View File

@@ -0,0 +1,5 @@
export type { AuthBoxEngineOptions } from "./auth-box.js";
export {
open,
seal,
} from "./auth-box.js";

View File

@@ -0,0 +1 @@
export { NobleAuthBoxEngine } from "./noble-auth-box-engine.js";

View File

@@ -0,0 +1,111 @@
# NobleAuthBoxEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NobleAuthBoxEngine` implements the `AuthBoxEngine` interface using a `crypto_box`-style authenticated encryption protocol backed by `@noble/curves` and `@noble/ciphers` (via the `@suaveplan/crypto-curve` and `@suaveplan/crypto-cipher` subpaths). Unlike a sealed-box, an auth-box retains sender identity: the sender's long-term secret key is used in the ECDH step, allowing the recipient to verify that the message came from the expected party. The nonce is caller-supplied; callers are responsible for uniqueness per sender-recipient key pair.
## Features
- Implements `AuthBoxEngine` (`seal` / `open`) for two-party authenticated encryption
- Protocol: X25519 ECDH(senderSecret, recipientPublic) → HKDF-SHA256 → XChaCha20-Poly1305
- HKDF uses a fixed zero salt and a domain-separation info string `"suaveplan-auth-box-v1"`
- Decryption failures surface as `CryptoError` with code `AEAD.DECRYPT_FAILED`
- Caller supplies the nonce; the engine does not generate nonces internally
- Delegates to `@suaveplan/crypto-cipher/xchacha20-poly1305` and `@suaveplan/crypto-curve/x25519` for build-time primitive selection
## Usage: Basic Example
```typescript
import { NobleAuthBoxEngine } from "@suaveplan/crypto-core/auth-box/noble-auth-box-engine";
import { randomBytes } from "@suaveplan/crypto-core";
const engine = new NobleAuthBoxEngine();
// Generate sender and recipient keypairs (X25519)
const sender = { secretKey: randomBytes(32), publicKey: /* derive */ new Uint8Array(32) };
const recipient = { secretKey: randomBytes(32), publicKey: /* derive */ new Uint8Array(32) };
const nonce = randomBytes(24);
const plaintext = new TextEncoder().encode("hello from sender");
const ciphertext = engine.seal(sender.secretKey, recipient.publicKey, plaintext, nonce);
const recovered = engine.open(sender.publicKey, recipient.secretKey, ciphertext, nonce);
console.log(new TextDecoder().decode(recovered)); // "hello from sender"
```
## Usage: Advanced Example
```typescript
import { NobleAuthBoxEngine } from "@suaveplan/crypto-core/auth-box/noble-auth-box-engine";
import { NobleX25519Engine } from "@suaveplan/crypto-core/agree/noble-agree-engine";
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
import { randomBytes } from "@suaveplan/crypto-core";
const agreeEngine = new NobleX25519Engine();
const authBoxEngine = new NobleAuthBoxEngine();
const alice = agreeEngine.generateKeypair();
const bob = agreeEngine.generateKeypair();
const nonce = randomBytes(24);
const message = new TextEncoder().encode("authenticated message from alice");
// Alice seals a message to Bob
const ciphertext = authBoxEngine.seal(alice.secretKey, bob.publicKey, message, nonce);
// Bob opens it using Alice's public key for verification
const plaintext = authBoxEngine.open(alice.publicKey, bob.secretKey, ciphertext, nonce);
console.log(new TextDecoder().decode(plaintext));
// Tampered ciphertext is rejected
try {
const tampered = ciphertext.slice();
tampered[tampered.length - 1] ^= 0xff;
authBoxEngine.open(alice.publicKey, bob.secretKey, tampered, nonce);
} catch (err) {
if (err instanceof CryptoError && err.code === CRYPTO_CODES["AEAD.DECRYPT_FAILED"]) {
console.log("Tampered ciphertext correctly rejected");
}
}
```
## API Reference
### `NobleAuthBoxEngine`
```typescript
class NobleAuthBoxEngine implements AuthBoxEngine
```
**`seal(senderSecret, recipientPublic, plaintext, nonce): Uint8Array`**
| Parameter | Type | Description |
|------------------|--------------|----------------------------------------------------------|
| `senderSecret` | `Uint8Array` | Sender's 32-byte X25519 secret key. |
| `recipientPublic`| `Uint8Array` | Recipient's 32-byte X25519 public key. |
| `plaintext` | `Uint8Array` | Message bytes to encrypt. |
| `nonce` | `Uint8Array` | 24-byte nonce; must be unique per (senderKey, recipientKey) pair. |
Returns the ciphertext with a 16-byte Poly1305 authentication tag appended.
**`open(senderPublic, recipientSecret, ciphertext, nonce): Uint8Array`**
| Parameter | Type | Description |
|------------------|--------------|----------------------------------------------------------|
| `senderPublic` | `Uint8Array` | Sender's 32-byte X25519 public key. |
| `recipientSecret`| `Uint8Array` | Recipient's 32-byte X25519 secret key. |
| `ciphertext` | `Uint8Array` | Ciphertext to decrypt (must include Poly1305 tag). |
| `nonce` | `Uint8Array` | Same 24-byte nonce used during `seal`. |
Returns plaintext bytes. Throws `CryptoError` with code `AEAD.DECRYPT_FAILED` if authentication fails.
## Implementation Notes
The shared secret produced by X25519 is not uniformly random — it is a compressed curve point. Passing it directly as a cipher key would be unsafe. HKDF-SHA256 with a fixed zero salt and the domain string `"suaveplan-auth-box-v1"` stretches and randomises the material to produce a 32-byte key suitable for XChaCha20-Poly1305. The domain string provides separation from the analogous sealed-box derivation that uses `"suaveplan-sealed-box-v1"`, preventing cross-protocol confusion if the same keypair is used with both constructs.
XChaCha20-Poly1305 is preferred over ChaCha20-Poly1305 because its 24-byte nonce makes collision probability negligible over a large volume of messages with the same keypair. Even so, nonce uniqueness is the caller's responsibility — the engine does not track or enforce it.

View File

@@ -0,0 +1,68 @@
import * as xchacha from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import * as nobleX25519 from "@suaveplan/crypto-curve/x25519";
import { hkdf } from "@suaveplan/crypto-hash";
import type { AuthBoxEngine } from "../../engines/auth-box-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
const HKDF_SALT = new Uint8Array(32); // zero salt
const HKDF_INFO = new TextEncoder().encode("suaveplan-auth-box-v1");
/**
* Derive a 32-byte symmetric key from a raw X25519 shared secret using
* HKDF-SHA256.
*/
function deriveKey(sharedSecret: Uint8Array): Uint8Array {
return hkdf("SHA-256", sharedSecret, {
salt: HKDF_SALT,
info: HKDF_INFO,
length: 32,
});
}
/**
* Noble-backed authenticated-box engine.
*
* Protocol (crypto_box semantics):
* - seal: X25519 ECDH(senderSecret, recipientPublic) → HKDF-SHA256 → XChaCha20-Poly1305
* - open: X25519 ECDH(recipientSecret, senderPublic) → HKDF → XChaCha20-Poly1305 decrypt
*
* The nonce is supplied by the caller and must be unique per (senderKey, recipientKey) pair.
*/
export class NobleAuthBoxEngine implements AuthBoxEngine {
public seal(
senderSecret: Uint8Array,
recipientPublic: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array
): Uint8Array {
const shared = nobleX25519.getSharedSecret(
senderSecret,
recipientPublic
);
const key = deriveKey(shared);
return xchacha.encrypt(key, nonce, plaintext);
}
public open(
senderPublic: Uint8Array,
recipientSecret: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array
): Uint8Array {
const shared = nobleX25519.getSharedSecret(
recipientSecret,
senderPublic
);
const key = deriveKey(shared);
try {
return xchacha.decrypt(key, nonce, ciphertext);
} catch (cause) {
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed",
{ cause, context: { ciphertextLength: ciphertext.length } }
);
}
}
}

View File

@@ -0,0 +1,12 @@
/** Ed25519 secret key length in bytes. */
export const ED25519_SECRET_KEY_LENGTH = 32;
/** Ed25519 public key length in bytes. */
export const ED25519_PUBLIC_KEY_LENGTH = 32;
/** Ed25519 signature length in bytes. */
export const ED25519_SIGNATURE_LENGTH = 64;
/** X25519 key length in bytes (both secret and public). */
export const X25519_KEY_LENGTH = 32;
/** ECDSA-P256 signature length in bytes (compact r||s form). */
export const ECDSA_P256_SIGNATURE_LENGTH = 64;

View File

@@ -0,0 +1,366 @@
/**
* Spec-conformance tests for `@suaveplan/crypto`.
*
* These tests close the gap between the OpenSpec contract
* (`openspec/specs/crypto/spec.md`) and the implementation. Each block is
* keyed by the `**Verified by:**` lines in the spec and asserts a
* cross-cutting MUST clause that the per-feature unit tests do not
* already pin.
*
* No mocks: real Noble engines and structured stub engines (DI) only.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AEAD_KEY_LENGTH,
AEAD_NONCE_LENGTH,
type AeadEngine,
type AgreeEngine,
type AuthBoxEngine,
aeadDecrypt,
aeadEncrypt,
authBox,
CRYPTO_CODES,
type CryptoError,
constantTimeEqual,
ecdsaP256,
ed25519,
type SealedBoxEngine,
type SignEngine,
sealedBox,
x25519,
zeroize,
} from "./index.js";
const here = dirname(fileURLToPath(import.meta.url));
const SRC_DIR = resolve(here);
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const st = statSync(p);
if (st.isDirectory()) walk(p, out);
else if (
/\.tsx?$/.test(entry) &&
!/\.(test|spec|bench)\.tsx?$/.test(entry)
) {
out.push(p);
}
}
return out;
}
const PROD_FILES = walk(SRC_DIR);
describe("crypto.spec-conformance — engine pluggability", () => {
it("default engine wired when no engine option is supplied (aead, sign, agree, sealed-box, auth-box)", {
timeout: 5000,
}, () => {
const key = new Uint8Array(AEAD_KEY_LENGTH).fill(0x01);
const nonce = new Uint8Array(AEAD_NONCE_LENGTH).fill(0x02);
const pt = new Uint8Array([1, 2, 3]);
const ct = aeadEncrypt("AES-256-GCM", key, pt, { nonce });
expect(aeadDecrypt("AES-256-GCM", key, ct, { nonce })).toEqual(pt);
const eKp = ed25519.generateKeypair();
const eSig = ed25519.sign(eKp.secretKey, pt);
expect(ed25519.verify(eKp.publicKey, eSig, pt)).toBe(true);
const aKp = x25519.generateKeypair();
const bKp = x25519.generateKeypair();
const sharedA = x25519.agree(aKp.secretKey, bKp.publicKey);
const sharedB = x25519.agree(bKp.secretKey, aKp.publicKey);
expect(sharedA).toEqual(sharedB);
const sealedCt = sealedBox.seal(bKp.publicKey, pt);
expect(sealedBox.open(bKp.secretKey, bKp.publicKey, sealedCt)).toEqual(
pt
);
const authNonce = new Uint8Array(24).fill(0x03);
const authCt = authBox.seal(
aKp.secretKey,
bKp.publicKey,
pt,
authNonce
);
expect(
authBox.open(aKp.publicKey, bKp.secretKey, authCt, authNonce)
).toEqual(pt);
});
it("custom engine substitution suppresses the default for sign and agree namespaces", {
timeout: 5000,
}, () => {
const calls: string[] = [];
const fixedSig = new Uint8Array(64).fill(0x99);
const fixedShared = new Uint8Array(32).fill(0x77);
const signStub: SignEngine = {
generateKeypair: () => {
calls.push("sign.generateKeypair");
return {
publicKey: new Uint8Array(32).fill(0x10),
secretKey: new Uint8Array(32).fill(0x11),
};
},
sign: () => {
calls.push("sign.sign");
return fixedSig;
},
verify: () => {
calls.push("sign.verify");
return true;
},
};
const agreeStub: AgreeEngine = {
generateKeypair: () => {
calls.push("agree.generateKeypair");
return {
publicKey: new Uint8Array(32).fill(0x20),
secretKey: new Uint8Array(32).fill(0x21),
};
},
agree: () => {
calls.push("agree.agree");
return fixedShared;
},
};
const eKp = ed25519.generateKeypair({ engine: signStub });
const sig = ed25519.sign(eKp.secretKey, new Uint8Array([1]), {
engine: signStub,
});
expect(sig).toBe(fixedSig);
const ok = ed25519.verify(eKp.publicKey, sig, new Uint8Array([1]), {
engine: signStub,
});
expect(ok).toBe(true);
const xKp = x25519.generateKeypair({ engine: agreeStub });
const shared = x25519.agree(xKp.secretKey, xKp.publicKey, {
engine: agreeStub,
});
expect(shared).toBe(fixedShared);
expect(calls).toEqual([
"sign.generateKeypair",
"sign.sign",
"sign.verify",
"agree.generateKeypair",
"agree.agree",
]);
});
});
describe("crypto.spec-conformance — AEAD nonce invariant", () => {
it("AEAD façade source never consumes a byte source — nonce is caller-supplied", {
timeout: 5000,
}, () => {
const aeadFiles = PROD_FILES.filter((f) =>
f.includes(`${SRC_DIR}/aead/`)
);
expect(aeadFiles.length).toBeGreaterThan(0);
for (const f of aeadFiles) {
const c = readFileSync(f, "utf8");
expect(c).not.toMatch(/\brandomBytes\s*\(/);
expect(c).not.toMatch(/\bgetRandomValues\s*\(/);
expect(c).not.toMatch(/\bdefaultByteSource\b/);
}
});
it("AEAD decryption failure leaks no plaintext, key, or tag bytes in error context", {
timeout: 5000,
}, () => {
const key = new Uint8Array(AEAD_KEY_LENGTH).fill(0xa1);
const nonce = new Uint8Array(AEAD_NONCE_LENGTH).fill(0x07);
const pt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const ct = aeadEncrypt("AES-256-GCM", key, pt, { nonce });
ct[0] = (ct[0] as number) ^ 0xff;
try {
aeadDecrypt("AES-256-GCM", key, ct, { nonce });
expect.unreachable();
} catch (err) {
const ce = err as CryptoError;
expect(ce.code).toBe(CRYPTO_CODES["AEAD.DECRYPT_FAILED"]);
const ctx = (ce.context ?? {}) as Record<string, unknown>;
const allowed = new Set(["algorithm", "ciphertextLength"]);
for (const k of Object.keys(ctx)) {
expect(allowed.has(k)).toBe(true);
}
for (const v of Object.values(ctx)) {
expect(v instanceof Uint8Array).toBe(false);
}
}
});
});
describe("crypto.spec-conformance — signature verify never throws", () => {
it("ed25519 verify returns false (never throws) on a wrong message", {
timeout: 5000,
}, () => {
const kp = ed25519.generateKeypair();
const sig = ed25519.sign(kp.secretKey, new Uint8Array([1, 2, 3]));
expect(
ed25519.verify(kp.publicKey, sig, new Uint8Array([9, 9, 9]))
).toBe(false);
expect(
ed25519.verify(
kp.publicKey,
new Uint8Array(64).fill(0xff),
new Uint8Array([1, 2, 3])
)
).toBe(false);
});
it("ecdsaP256 verify returns false (never throws) on a wrong message", {
timeout: 5000,
}, () => {
const kp = ecdsaP256.generateKeypair();
const sig = ecdsaP256.sign(kp.secretKey, new Uint8Array([1, 2, 3]));
expect(
ecdsaP256.verify(kp.publicKey, sig, new Uint8Array([4, 5, 6]))
).toBe(false);
});
});
describe("crypto.spec-conformance — x25519 stability", () => {
it("x25519 agree is stable: repeated calls with the same inputs produce byte-identical output", {
timeout: 5000,
}, () => {
const a = x25519.generateKeypair();
const b = x25519.generateKeypair();
const s1 = x25519.agree(a.secretKey, b.publicKey);
const s2 = x25519.agree(a.secretKey, b.publicKey);
const s3 = x25519.agree(b.secretKey, a.publicKey);
expect(s1).toEqual(s2);
expect(s1).toEqual(s3);
expect(s1.length).toBe(32);
});
});
describe("crypto.spec-conformance — primitives", () => {
it("constantTimeEqual loop body never short-circuits — XOR accumulator only", {
timeout: 5000,
}, () => {
const src = readFileSync(
join(
SRC_DIR,
"primitives",
"constant-time-equal",
"constant-time-equal.ts"
),
"utf8"
);
const fnMatch = src.match(/export function constantTimeEqual[\s\S]+$/);
expect(fnMatch).not.toBeNull();
const body = fnMatch?.[0] ?? "";
const loopMatch = body.match(/for\s*\([\s\S]+?\}\s/);
expect(loopMatch).not.toBeNull();
const loop = loopMatch?.[0] ?? "";
expect(loop).not.toMatch(/\breturn\b/);
expect(loop).not.toMatch(/\bbreak\b/);
expect(loop).toMatch(/\^/);
});
it("zeroize mutates in place — same buffer reference, every byte 0", {
timeout: 5000,
}, () => {
const buf = new Uint8Array([1, 2, 3, 4, 5]);
const ref = buf;
zeroize(buf);
expect(buf).toBe(ref);
expect(Array.from(buf)).toEqual([0, 0, 0, 0, 0]);
const empty = new Uint8Array(0);
zeroize(empty);
expect(empty.length).toBe(0);
});
it("constantTimeEqual semantic equivalence over equal/unequal/length-mismatched inputs", {
timeout: 5000,
}, () => {
expect(
constantTimeEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 3])
)
).toBe(true);
expect(
constantTimeEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 4])
)
).toBe(false);
expect(
constantTimeEqual(new Uint8Array([1]), new Uint8Array([1, 2]))
).toBe(false);
});
});
describe("crypto.spec-conformance — single CSPRNG callsite", () => {
it("src/ contains exactly one crypto.getRandomValues callsite, in src/primitives/random-bytes/random-bytes.ts", {
timeout: 5000,
}, () => {
const callRe = /\bcrypto\.getRandomValues\s*\(/g;
const matches: string[] = [];
for (const f of PROD_FILES) {
const c = readFileSync(f, "utf8");
const stripped = c
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\*.*$/gm, "")
.replace(/\/\/.*$/gm, "");
callRe.lastIndex = 0;
let m = callRe.exec(stripped);
while (m) {
matches.push(f);
m = callRe.exec(stripped);
}
}
expect(matches.length).toBe(1);
expect(matches[0]).toBe(
join(SRC_DIR, "primitives", "random-bytes", "random-bytes.ts")
);
});
});
describe("crypto.spec-conformance — error registry", () => {
it("every CRYPTO_CODES entry is thrown from at least one non-test source file", {
timeout: 5000,
}, () => {
const codes = Object.keys(CRYPTO_CODES);
const sources = PROD_FILES.filter((f) => !f.endsWith("codes.ts"));
const blob = sources.map((f) => readFileSync(f, "utf8")).join("\n");
for (const code of codes) {
const re = new RegExp(
`CRYPTO_CODES\\[["']${code.replace(/\./g, "\\.")}["']\\]`
);
expect(re.test(blob), `code ${code} unused`).toBe(true);
}
});
it("CRYPTO_CODES is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(CRYPTO_CODES)).toBe(true);
});
});
describe("crypto.spec-conformance — type-level engine surface (compile-time)", () => {
it("AeadEngine, SignEngine, AgreeEngine, SealedBoxEngine, AuthBoxEngine are exported as types", {
timeout: 5000,
}, () => {
const a: AeadEngine | undefined = undefined;
const s: SignEngine | undefined = undefined;
const g: AgreeEngine | undefined = undefined;
const sb: SealedBoxEngine | undefined = undefined;
const ab: AuthBoxEngine | undefined = undefined;
expect([a, s, g, sb, ab]).toEqual([
undefined,
undefined,
undefined,
undefined,
undefined,
]);
});
});

View File

@@ -0,0 +1,26 @@
import type { ByteSource } from "../primitives/random-bytes/random-bytes.js";
/**
* Pluggable AEAD (Authenticated Encryption with Associated Data) engine.
*
* The default engine is backed by `@noble/ciphers`. Alternative engines
* (WebCrypto, libsodium) are provided by `@suaveplan/crypto-browser` and
* `@suaveplan/crypto-sodium` respectively.
*/
export interface AeadEngine {
encrypt(
key: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array;
decrypt(
key: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
aad?: Uint8Array
): Uint8Array;
}
// Re-export ByteSource so consumers only need to import from engines/
export type { ByteSource };

View File

@@ -0,0 +1,19 @@
import type { ByteSource } from "../primitives/random-bytes/random-bytes.js";
/** Keypair produced by an agreement engine. */
export interface AgreeKeypair {
readonly publicKey: Uint8Array;
readonly secretKey: Uint8Array;
}
/**
* Pluggable key-agreement engine.
*
* The default engine uses X25519 via `@noble/curves`. Alternative engines
* (WebCrypto ECDH, libsodium) are provided by `@suaveplan/crypto-browser` and
* `@suaveplan/crypto-sodium`.
*/
export interface AgreeEngine {
generateKeypair(byteSource?: ByteSource): AgreeKeypair;
agree(mySecret: Uint8Array, theirPublic: Uint8Array): Uint8Array;
}

View File

@@ -0,0 +1,21 @@
/**
* Pluggable authenticated sealed-box engine.
*
* The default engine implements `crypto_box` semantics: X25519 key agreement
* with XChaCha20-Poly1305 encryption where the sender's identity is known and
* authenticated to the recipient.
*/
export interface AuthBoxEngine {
seal(
senderSecret: Uint8Array,
recipientPublic: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array
): Uint8Array;
open(
senderPublic: Uint8Array,
recipientSecret: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array
): Uint8Array;
}

View File

@@ -0,0 +1,5 @@
export type { AeadEngine } from "./aead-engine.types.js";
export type { AgreeEngine, AgreeKeypair } from "./agree-engine.types.js";
export type { AuthBoxEngine } from "./auth-box-engine.types.js";
export type { SealedBoxEngine } from "./sealed-box-engine.types.js";
export type { SignEngine, SignKeypair } from "./sign-engine.types.js";

View File

@@ -0,0 +1,15 @@
/**
* Pluggable anonymous-sender sealed-box engine.
*
* The default engine implements `crypto_box_seal` semantics using X25519 key
* agreement and XChaCha20-Poly1305 encryption. The sender's identity is
* ephemeral and not authenticated.
*/
export interface SealedBoxEngine {
seal(recipientPublic: Uint8Array, plaintext: Uint8Array): Uint8Array;
open(
recipientSecret: Uint8Array,
recipientPublic: Uint8Array,
ciphertext: Uint8Array
): Uint8Array;
}

View File

@@ -0,0 +1,24 @@
import type { ByteSource } from "../primitives/random-bytes/random-bytes.js";
/** Keypair produced by a sign engine. */
export interface SignKeypair {
readonly publicKey: Uint8Array;
readonly secretKey: Uint8Array;
}
/**
* Pluggable signing engine.
*
* The default engine is backed by `@noble/curves`. Alternative engines
* (WebCrypto, libsodium) are provided by `@suaveplan/crypto-browser` and
* `@suaveplan/crypto-sodium` respectively.
*/
export interface SignEngine {
generateKeypair(byteSource?: ByteSource): SignKeypair;
sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array;
verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): boolean;
}

View File

@@ -0,0 +1,89 @@
# 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`. Each code is a namespaced string constant produced by `defineCodes` from `@suaveplan/error`, ensuring that codes are unique within the `"CRYPTO"` namespace and that the `CryptoCode` union type is always in sync with the registered values. Consumers can import `CRYPTO_CODES` and branch on `error.code` without inspecting message strings, making error handling robust across version upgrades.
## Features
- Seven codes covering the four main failure domains: key validity, AEAD nonce/decrypt, signing, and RNG
- Generated via `defineCodes("CRYPTO", [...])` which prefixes each entry and freezes the resulting object
- `CryptoCode` is derived as a `typeof` union so TypeScript narrows exhaustively in switch statements
- No runtime dependencies beyond `@suaveplan/error`
## Usage: Basic Example
```typescript
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
throw new CryptoError(
CRYPTO_CODES["RNG.UNAVAILABLE"],
"error.crypto.rng.unavailable"
);
```
## Usage: Advanced Example
```typescript
import { CRYPTO_CODES, type CryptoCode } from "@suaveplan/crypto-core/errors/codes";
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
function handleCryptoError(err: CryptoError): string {
switch (err.code as CryptoCode) {
case CRYPTO_CODES["AEAD.DECRYPT_FAILED"]:
return "Decryption failed — message may be corrupted or the key is wrong.";
case CRYPTO_CODES["SIGN.VERIFY_FAILED"]:
return "Signature verification failed — message may have been tampered with.";
case CRYPTO_CODES["KEY.INVALID_LENGTH"]:
return "Supplied key has an incorrect byte length for this algorithm.";
case CRYPTO_CODES["AEAD.INVALID_NONCE"]:
return "Nonce does not meet the length requirements for the chosen cipher.";
case CRYPTO_CODES["SIGN.INVALID_KEY"]:
return "Signing key is malformed or does not correspond to the expected curve.";
case CRYPTO_CODES["RNG.INVALID_LENGTH"]:
return "Requested byte count is outside the permissible range.";
case CRYPTO_CODES["RNG.UNAVAILABLE"]:
return "Cryptographically secure random source is not available in this environment.";
default:
return "Unknown crypto error.";
}
}
```
## API Reference
### `CRYPTO_CODES`
```typescript
const CRYPTO_CODES: Readonly<Record<string, string>>
```
A frozen map from short code keys to their namespaced string values. All values are prefixed with `"CRYPTO."` by `defineCodes`.
| Key | Namespaced value | When thrown |
|------------------------|-------------------------------|-------------------------------------------------------|
| `KEY.INVALID_LENGTH` | `"CRYPTO.KEY.INVALID_LENGTH"` | A key byte length does not match algorithm requirements |
| `AEAD.INVALID_NONCE` | `"CRYPTO.AEAD.INVALID_NONCE"` | Nonce byte length is wrong for the selected cipher |
| `AEAD.DECRYPT_FAILED` | `"CRYPTO.AEAD.DECRYPT_FAILED"` | Authentication tag verification failed during decrypt |
| `SIGN.INVALID_KEY` | `"CRYPTO.SIGN.INVALID_KEY"` | Signing key is malformed |
| `SIGN.VERIFY_FAILED` | `"CRYPTO.SIGN.VERIFY_FAILED"` | Signature does not match the message |
| `RNG.INVALID_LENGTH` | `"CRYPTO.RNG.INVALID_LENGTH"` | Byte count requested from RNG is invalid |
| `RNG.UNAVAILABLE` | `"CRYPTO.RNG.UNAVAILABLE"` | Secure random source is absent (rare, environment issue) |
### `CryptoCode`
```typescript
type CryptoCode = (typeof CRYPTO_CODES)[keyof typeof CRYPTO_CODES];
```
Union of all namespaced code strings. Use this type to annotate `err.code` when narrowing `CryptoError` in typed catch blocks.
## Implementation Notes
`defineCodes` returns a plain frozen object. The `CryptoCode` type is derived with `typeof CRYPTO_CODES[keyof typeof CRYPTO_CODES]`, which collapses to a union of string literals. This approach means TypeScript will warn at compile time if a switch statement is missing a case when the union is used with `noUncheckedIndexedAccess` and strict narrowing enabled.
The `"AEAD.DECRYPT_FAILED"` code is reused for both authenticated-encryption failures and protocol-level failures (e.g. truncated sealed-box ciphertext), because both represent the same caller-facing outcome: the ciphertext could not be opened.

View File

@@ -0,0 +1,13 @@
import { defineCodes } from "@suaveplan/error";
export const CRYPTO_CODES = defineCodes("CRYPTO", [
"KEY.INVALID_LENGTH",
"AEAD.INVALID_NONCE",
"AEAD.DECRYPT_FAILED",
"SIGN.INVALID_KEY",
"SIGN.VERIFY_FAILED",
"RNG.INVALID_LENGTH",
"RNG.UNAVAILABLE",
] as const);
export type CryptoCode = (typeof CRYPTO_CODES)[keyof typeof CRYPTO_CODES];

View File

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

View File

@@ -0,0 +1,81 @@
# CryptoError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CryptoError` is the package-level error class for `@suaveplan/crypto`. It extends `SuaveplanError` with the `CryptoCode` discriminant, giving every thrown error a stable, namespaced code that consumers can branch on without parsing message strings. All encryption, decryption, signing, and key-agreement failures surface as `CryptoError` instances so catch handlers can distinguish crypto faults from unrelated infrastructure errors.
## Features
- Extends `SuaveplanError<CryptoCode>` for consistent error taxonomy across the `@suaveplan/*` ecosystem
- Accepts the full `SuaveplanErrorOptions` bag (`cause`, `context`, `redact`) for cause-chain wrapping and structured context attachment
- Constructor is `public`, overriding the `protected` base, so external engine adapters in sibling packages can throw typed `CryptoError` values directly
- Compatible with OTel span-event adapters defined on `SuaveplanError`
- Wire-serialisable via the standard `SuaveplanError` Zod schema
## Usage: Basic Example
```typescript
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed"
);
```
## Usage: Advanced Example
```typescript
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
function decryptPayload(
key: Uint8Array,
nonce: Uint8Array,
ciphertext: Uint8Array
): Uint8Array {
try {
return lowLevelDecrypt(key, nonce, ciphertext);
} catch (cause) {
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed",
{
cause,
context: {
ciphertextLength: ciphertext.length,
nonceLength: nonce.length,
},
}
);
}
}
```
## API Reference
### `CryptoError`
```typescript
class CryptoError extends SuaveplanError<CryptoCode>
```
**Constructor**
| Parameter | Type | Description |
|--------------|------------------------|----------------------------------------------------------|
| `code` | `CryptoCode` | Stable namespaced error code from `CRYPTO_CODES`. |
| `messageKey` | `string` | i18n resolver key (e.g. `"error.crypto.aead.decrypt-failed"`). |
| `options` | `SuaveplanErrorOptions \| undefined` | Optional `cause`, `context`, and `redact` fields. |
All `SuaveplanError` instance methods are inherited, including `toJSON()`, `toWireFormat()`, and OTel helpers.
## Implementation Notes
The constructor body is intentionally trivial — it exists solely to widen the `protected` base constructor to `public`. This pattern is required because `SuaveplanError`'s constructor is `protected` (it enforces that error classes always provide a discriminating `code`), but engine adapters outside this package's internal module graph need to throw `CryptoError` directly. The biome lint suppression comment is included to silence the `noUselessConstructor` rule, which would otherwise flag the no-op forwarding call as dead code.
The class does not add any new instance properties or methods on top of `SuaveplanError`. All richness — cause-chain, context bag, i18n, wire format — lives in the base class and flows through automatically.

View File

@@ -0,0 +1,19 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_CODES } from "../codes/codes.js";
import { CryptoError } from "./crypto-error.js";
describe("CryptoError", () => {
it("extends SuaveplanError with CRYPTO.* code", {
timeout: 5000,
}, () => {
const err = new CryptoError(
CRYPTO_CODES["KEY.INVALID_LENGTH"],
"error.crypto.key.invalid-length",
{ context: { expected: 32, actual: 16 } }
);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe("CRYPTO.KEY.INVALID_LENGTH");
expect(err.context).toEqual({ expected: 32, actual: 16 });
});
});

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { CRYPTO_CODES, type CryptoCode } from "./codes/codes.js";
export { CryptoError } from "./crypto-error/crypto-error.js";

View File

@@ -0,0 +1,67 @@
// AEAD
export {
AEAD_KEY_LENGTH,
AEAD_NONCE_LENGTH,
AEAD_XCHACHA_NONCE_LENGTH,
type AeadAlgorithm,
type AeadDecryptOptions,
type AeadEncryptOptions,
type AeadEngineOptions,
aeadDecrypt,
aeadEncrypt,
} from "./aead/aead.js";
// Agree (x25519 namespace)
import * as x25519 from "./agree/x25519/x25519.js";
export type { X25519Keypair, X25519Options } from "./agree/x25519/x25519.js";
export { x25519 };
// Auth-box
import * as authBox from "./auth-box/auth-box.js";
export type { AuthBoxEngineOptions } from "./auth-box/auth-box.js";
// Constants
export {
ECDSA_P256_SIGNATURE_LENGTH,
ED25519_PUBLIC_KEY_LENGTH,
ED25519_SECRET_KEY_LENGTH,
ED25519_SIGNATURE_LENGTH,
X25519_KEY_LENGTH,
} from "./constants/index.js";
// Engine interfaces (types only)
export type { AeadEngine } from "./engines/aead-engine.types.js";
export type {
AgreeEngine,
AgreeKeypair,
} from "./engines/agree-engine.types.js";
export type { AuthBoxEngine } from "./engines/auth-box-engine.types.js";
export type { SealedBoxEngine } from "./engines/sealed-box-engine.types.js";
export type { SignEngine, SignKeypair } from "./engines/sign-engine.types.js";
// Errors
export { CRYPTO_CODES, type CryptoCode, CryptoError } from "./errors/index.js";
// Primitives
export { constantTimeEqual } from "./primitives/constant-time-equal/constant-time-equal.js";
export {
type ByteSource,
defaultByteSource,
defaultRng,
type RandomBytesOptions,
type Rng,
randomBytes,
} from "./primitives/random-bytes/random-bytes.js";
export { zeroize } from "./primitives/zeroize/zeroize.js";
export { authBox };
// Sealed-box
import * as sealedBox from "./sealed-box/sealed-box.js";
export type { SealedBoxEngineOptions } from "./sealed-box/sealed-box.js";
// Signing (ed25519 + ecdsaP256 namespaces)
export {
type EcdsaP256Keypair,
type Ed25519Keypair,
ecdsaP256,
ed25519,
} from "./sign/index.js";
export { sealedBox };

View File

@@ -0,0 +1,72 @@
# constant-time-equal
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compares two `Uint8Array` values in constant time to prevent timing side-channels. When comparing secrets such as authentication tags, HMAC outputs, or session tokens, a naive byte-by-byte comparison that returns early on the first mismatch leaks information about how many leading bytes matched — an attacker measuring response latency can exploit this to forge values one byte at a time. This implementation uses the XOR-accumulate pattern to ensure that branch-sensitive content (byte values) never causes an early return.
## Features
- Branch-free for content bytes — only the length check short-circuits (length is not secret in AEAD contexts)
- O(min(len(a), len(b))) — no early exit on byte mismatch
- Returns `true` only when lengths are equal AND all bytes match
- Zero dependencies, pure TypeScript
## Usage: Basic Example
```ts
import { constantTimeEqual } from "@suaveplan/crypto-core/primitives/constant-time-equal";
const tag1 = new Uint8Array([0x01, 0x02, 0x03]);
const tag2 = new Uint8Array([0x01, 0x02, 0x03]);
constantTimeEqual(tag1, tag2); // true
```
## Usage: Advanced Example
```ts
import { constantTimeEqual } from "@suaveplan/crypto-core/primitives/constant-time-equal";
import { aeadDecrypt, randomBytes } from "@suaveplan/crypto-core";
// Verify a server-generated token matches the expected token
// without leaking information via timing differences
function verifyToken(received: Uint8Array, expected: Uint8Array): boolean {
return constantTimeEqual(received, expected);
}
// Safe for use with auth tags, HMAC outputs, session tokens
const a = new Uint8Array(32).fill(0xff);
const b = new Uint8Array(32).fill(0xff);
b[31] = 0xfe; // single-bit difference at the end
// Returns false, but execution time is the same as if all bytes matched
verifyToken(a, b); // false
```
## API Reference
### `constantTimeEqual(a, b)`
```ts
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean
```
Returns `true` iff `a.length === b.length` and every byte of `a` equals the corresponding byte of `b`.
| Param | Type | Description |
|-------|------|-------------|
| `a` | `Uint8Array` | First buffer. |
| `b` | `Uint8Array` | Second buffer to compare against. |
**Returns:** `boolean` — `true` when buffers are identical, `false` otherwise.
**Does not throw.**
## Implementation Notes
The XOR-accumulate pattern (`diff |= a[i] ^ b[i]`) ensures the loop body never branches on byte values. At the end, `diff === 0` iff every XOR was zero (i.e., all bytes matched). The length check at the start does short-circuit, but buffer length is not considered secret in standard AEAD or HMAC comparison contexts — lengths are visible from ciphertext structure. If your protocol requires hiding lengths, pad the buffers before calling this function.
JavaScript's JIT may theoretically produce variable-timing code despite the branch-free pattern; production deployments should combine this with server-side rate limiting to bound oracle value.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { constantTimeEqual } from "./constant-time-equal.js";
describe("constantTimeEqual", () => {
it("equal buffers return true", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 3]);
expect(constantTimeEqual(a, b)).toBe(true);
});
it("different content returns false", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 4]);
expect(constantTimeEqual(a, b)).toBe(false);
});
it("different lengths return false", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3]);
const b = new Uint8Array([1, 2, 3, 4]);
expect(constantTimeEqual(a, b)).toBe(false);
});
it("empty buffers return true", { timeout: 5000 }, () => {
expect(constantTimeEqual(new Uint8Array(0), new Uint8Array(0))).toBe(
true
);
});
it("single byte equal", { timeout: 5000 }, () => {
expect(
constantTimeEqual(new Uint8Array([0xff]), new Uint8Array([0xff]))
).toBe(true);
});
it("single byte different", { timeout: 5000 }, () => {
expect(
constantTimeEqual(new Uint8Array([0xff]), new Uint8Array([0xfe]))
).toBe(false);
});
it("all-zero buffers of same length return true", { timeout: 5000 }, () => {
expect(constantTimeEqual(new Uint8Array(32), new Uint8Array(32))).toBe(
true
);
});
it("one-bit difference at the last byte", { timeout: 5000 }, () => {
const a = new Uint8Array(32).fill(0xff);
const b = new Uint8Array(32).fill(0xff);
b[31] = 0xfe;
expect(constantTimeEqual(a, b)).toBe(false);
});
it("property: reflexivity — a buffer always equals itself", {
timeout: 15_000,
}, () => {
fc.assert(
fc.property(fc.uint8Array({ maxLength: 64 }), (arr) => {
return constantTimeEqual(arr, arr) === true;
}),
{ numRuns: 100 }
);
});
it("property: symmetry — equal(a,b) === equal(b,a)", {
timeout: 15_000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ maxLength: 32 }),
fc.uint8Array({ maxLength: 32 }),
(a, b) => {
return constantTimeEqual(a, b) === constantTimeEqual(b, a);
}
),
{ numRuns: 100 }
);
});
it("property: clone always equals original", {
timeout: 15_000,
}, () => {
fc.assert(
fc.property(fc.uint8Array({ maxLength: 64 }), (arr) => {
return constantTimeEqual(arr, arr.slice()) === true;
}),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,16 @@
/**
* Constant-time byte comparison.
*
* Returns `true` iff both buffers have the same length AND every byte is
* identical. Execution time is O(min(len(a), len(b))) and does not
* short-circuit on the first difference, preventing timing side-channels
* when comparing authentication tags or MAC values.
*/
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= (a[i] as number) ^ (b[i] as number);
}
return diff === 0;
}

View File

@@ -0,0 +1 @@
export { constantTimeEqual } from "./constant-time-equal.js";

View File

@@ -0,0 +1,10 @@
export { constantTimeEqual } from "./constant-time-equal";
export {
type ByteSource,
defaultByteSource,
defaultRng,
type RandomBytesOptions,
type Rng,
randomBytes,
} from "./random-bytes";
export { zeroize } from "./zeroize";

View File

@@ -0,0 +1,8 @@
export {
type ByteSource,
defaultByteSource,
defaultRng,
type RandomBytesOptions,
type Rng,
randomBytes,
} from "./random-bytes.js";

View File

@@ -0,0 +1,97 @@
# random-bytes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the single authorised callsite for `crypto.getRandomValues` within `@suaveplan/crypto`. Every other module that needs random bytes calls `randomBytes()` or accepts a `ByteSource` injection — this design concentrates the randomness surface to one file, making it trivially auditable and testable. The `ByteSource` type replaces the old `Rng` alias (which conflicted with `@suaveplan/types`'s number-producing `Rng` interface) while preserving backwards compatibility via a deprecated re-export.
## Features
- `ByteSource = (n: number) => Uint8Array` — the injected randomness type used across key-generation functions
- `Rng` deprecated alias for `ByteSource` for main-branch migration
- `defaultByteSource` — calls `crypto.getRandomValues`; works in Bun, Node, and browsers
- `defaultRng` — deprecated alias for `defaultByteSource`
- `randomBytes(n, opts?)` — validated facade that rejects non-integer or negative `n`
- `RandomBytesOptions.byteSource` for deterministic injection; `rng` kept as deprecated alias
## Usage: Basic Example
```ts
import { randomBytes } from "@suaveplan/crypto-core/primitives/random-bytes";
const key = randomBytes(32); // 32 random bytes, CSPRNG-backed
```
## Usage: Advanced Example
```ts
import { randomBytes, type ByteSource } from "@suaveplan/crypto-core/primitives/random-bytes";
// Deterministic source for reproducible tests
let counter = 0;
const seeded: ByteSource = (n) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = (counter++ & 0xff);
return out;
};
const key = randomBytes(32, { byteSource: seeded });
```
## API Reference
### `ByteSource`
```ts
type ByteSource = (n: number) => Uint8Array
```
A function that returns `n` cryptographically-suitable bytes. The default implementation calls `crypto.getRandomValues`.
### `Rng` (deprecated)
```ts
/** @deprecated */ type Rng = ByteSource
```
Backwards-compatibility alias. Prefer `ByteSource` in new code.
### `defaultByteSource`
```ts
const defaultByteSource: ByteSource
```
The production byte source. Wraps `crypto.getRandomValues`. Works in Bun, Node ≥18, and browsers.
### `defaultRng` (deprecated)
```ts
/** @deprecated */ const defaultRng: ByteSource
```
Alias for `defaultByteSource`.
### `randomBytes(n, options?)`
```ts
function randomBytes(n: number, options?: RandomBytesOptions): Uint8Array
```
Returns `n` random bytes. Throws `CryptoError("CRYPTO.RNG.INVALID_LENGTH")` if `n` is not a non-negative integer.
| Param | Type | Description |
|-------|------|-------------|
| `n` | `number` | Number of bytes to generate. Must be a non-negative integer. |
| `options.byteSource` | `ByteSource?` | Override the default source. |
| `options.rng` | `ByteSource?` | Deprecated alias for `byteSource`. |
**Throws:** `CryptoError("CRYPTO.RNG.INVALID_LENGTH")` — `n` is negative, non-integer, or NaN.
## Implementation Notes
The `crypto.getRandomValues` restriction is enforced at code review and by `tests/no-disallowed.test.ts`, which walks every `.ts` source file under `src/` and asserts that `crypto.getRandomValues` appears only in this file. This guards against accidental randomness leakage in library code that should be injecting `ByteSource` for testability.
The `ByteSource` / `Rng` rename was necessary because `@suaveplan/types` exports `Rng = { nextUint32(): number; nextFloat(): number }` — a fundamentally different interface. Shipping both would cause confusing type errors for consumers who import from both packages.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import type { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import {
type ByteSource,
defaultByteSource,
defaultRng,
type Rng,
randomBytes,
} from "./random-bytes.js";
describe("randomBytes", () => {
it("returns a Uint8Array of the requested length", {
timeout: 5000,
}, () => {
const buf = randomBytes(16);
expect(buf).toBeInstanceOf(Uint8Array);
expect(buf.length).toBe(16);
});
it("returns zero bytes when n=0", { timeout: 5000 }, () => {
const buf = randomBytes(0);
expect(buf).toBeInstanceOf(Uint8Array);
expect(buf.length).toBe(0);
});
it("uses injected byteSource", { timeout: 5000 }, () => {
const fixed: ByteSource = (n) => new Uint8Array(n).fill(0xab);
const buf = randomBytes(8, { byteSource: fixed });
expect(buf).toEqual(new Uint8Array(8).fill(0xab));
});
it("uses deprecated rng option as fallback", { timeout: 5000 }, () => {
const fixed: Rng = (n) => new Uint8Array(n).fill(0xcd);
const buf = randomBytes(4, { rng: fixed });
expect(buf).toEqual(new Uint8Array(4).fill(0xcd));
});
it("byteSource takes precedence over rng", { timeout: 5000 }, () => {
const byteSource: ByteSource = (n) => new Uint8Array(n).fill(0x11);
const rng: Rng = (n) => new Uint8Array(n).fill(0x22);
const buf = randomBytes(4, { byteSource, rng });
expect(buf).toEqual(new Uint8Array(4).fill(0x11));
});
it("throws CRYPTO.RNG.INVALID_LENGTH for negative n", {
timeout: 5000,
}, () => {
try {
randomBytes(-1);
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["RNG.INVALID_LENGTH"]
);
}
});
it("throws CRYPTO.RNG.INVALID_LENGTH for non-integer n", {
timeout: 5000,
}, () => {
try {
randomBytes(1.5);
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["RNG.INVALID_LENGTH"]
);
}
});
it("throws CRYPTO.RNG.INVALID_LENGTH for NaN", { timeout: 5000 }, () => {
try {
randomBytes(Number.NaN);
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["RNG.INVALID_LENGTH"]
);
}
});
});
describe("defaultByteSource", () => {
it("produces a Uint8Array of length n", { timeout: 5000 }, () => {
const buf = defaultByteSource(32);
expect(buf).toBeInstanceOf(Uint8Array);
expect(buf.length).toBe(32);
});
it("two calls produce different values", { timeout: 5000 }, () => {
const a = defaultByteSource(16);
const b = defaultByteSource(16);
// Probabilistically never equal for 16 bytes of CSPRNG output
expect(a).not.toEqual(b);
});
it("throws CRYPTO.RNG.UNAVAILABLE when crypto.getRandomValues is missing", {
timeout: 5000,
}, () => {
const original = globalThis.crypto;
// Force the unavailable branch by shadowing globalThis.crypto.
Object.defineProperty(globalThis, "crypto", {
value: { ...original, getRandomValues: undefined },
configurable: true,
});
try {
defaultByteSource(8);
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["RNG.UNAVAILABLE"]
);
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
});
}
});
});
describe("defaultRng (deprecated alias)", () => {
it("is the same as defaultByteSource", { timeout: 5000 }, () => {
expect(defaultRng).toBe(defaultByteSource);
});
});

View File

@@ -0,0 +1,64 @@
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
/**
* A function that produces `n` cryptographically-random bytes.
* Replaces the old `Rng` type from `@suaveplan/crypto` (which conflicted with
* `@suaveplan/types`'s `Rng = { nextUint32(): number; nextFloat(): number }`).
*/
export type ByteSource = (n: number) => Uint8Array;
/** Backwards-compatibility alias for {@link ByteSource}. */
export type Rng = ByteSource;
/**
* The single callsite for `crypto.getRandomValues` in this package.
* Every other file that needs random bytes MUST call `randomBytes()` or
* accept a `ByteSource` injection — never call `crypto.getRandomValues` directly.
*/
export const defaultByteSource: ByteSource = (n) => {
if (
typeof crypto === "undefined" ||
typeof crypto.getRandomValues !== "function"
) {
throw new CryptoError(
CRYPTO_CODES["RNG.UNAVAILABLE"],
"error.crypto.rng.unavailable",
{ context: { requested: n } }
);
}
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
};
/** Backwards-compatibility alias for {@link defaultByteSource}. */
export const defaultRng: ByteSource = defaultByteSource;
export interface RandomBytesOptions {
readonly byteSource?: ByteSource;
readonly rng?: ByteSource;
}
/**
* Return `n` cryptographically-random bytes.
*
* The default source is `crypto.getRandomValues`. Pass `{ byteSource }` to
* inject a deterministic source in tests.
*
* @throws {CryptoError} CRYPTO.RNG.INVALID_LENGTH if `n` is not a non-negative integer.
*/
export function randomBytes(
n: number,
options: RandomBytesOptions = {}
): Uint8Array {
if (!Number.isInteger(n) || n < 0) {
throw new CryptoError(
CRYPTO_CODES["RNG.INVALID_LENGTH"],
"error.crypto.rng.invalid-length",
{ context: { requested: n } }
);
}
const source = options.byteSource ?? options.rng ?? defaultByteSource;
return source(n);
}

View File

@@ -0,0 +1 @@
export { zeroize } from "./zeroize.js";

View File

@@ -0,0 +1,68 @@
# zeroize
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Overwrites a `Uint8Array` buffer with zeros in-place to minimise the lifetime of sensitive data on the JavaScript heap. Callers should invoke `zeroize` as soon as a secret key, derived key, or plaintext is no longer needed. While JavaScript's garbage collector may have already relocated the backing `ArrayBuffer` to a new memory region — making this a best-effort operation — it is still considered good practice because it reduces the window during which a memory-inspection attack or heap dump could expose the sensitive bytes.
## Features
- Fills every byte of the buffer with `0x00` using `Uint8Array.fill`
- In-place mutation — no allocation, no return value
- Works on any `Uint8Array` subview regardless of byte offset or length
- Zero dependencies
## Usage: Basic Example
```ts
import { zeroize } from "@suaveplan/crypto-core/primitives/zeroize";
const secretKey = new Uint8Array(32);
// ... use secretKey for cryptographic operations ...
zeroize(secretKey); // clear sensitive bytes
```
## Usage: Advanced Example
```ts
import { zeroize } from "@suaveplan/crypto-core/primitives/zeroize";
import { randomBytes } from "@suaveplan/crypto-core/primitives/random-bytes";
import { aeadEncrypt } from "@suaveplan/crypto-core/aead";
function encryptAndForget(plaintext: Uint8Array): Uint8Array {
const key = randomBytes(32);
const nonce = randomBytes(12);
try {
return aeadEncrypt("AES-256-GCM", key, plaintext, { nonce });
} finally {
// Always clear keys even if encrypt throws
zeroize(key);
zeroize(nonce);
}
}
```
## API Reference
### `zeroize(buf)`
```ts
function zeroize(buf: Uint8Array): void
```
Fills every byte of `buf` with `0x00` in-place.
| Param | Type | Description |
|-------|------|-------------|
| `buf` | `Uint8Array` | Buffer to zero out. Modified in-place. |
**Returns:** `void`
**Does not throw.** Calling on a zero-length buffer is a no-op.
## Implementation Notes
`Uint8Array.fill(0)` is the most direct approach. Some runtimes optimise this to a `memset` call at the C level, which is typically not subject to compiler dead-store elimination. However, JavaScript engines may still optimise away stores to unreachable buffers — this is a known limitation of in-process zeroing in managed runtimes. For high-security environments where in-process zeroing is insufficient, consider using hardware security modules or OS-level secure memory (not available from pure JavaScript).

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { zeroize } from "./zeroize.js";
describe("zeroize", () => {
it("fills the buffer with zeros", { timeout: 5000 }, () => {
const buf = new Uint8Array([1, 2, 3, 4, 5]);
zeroize(buf);
expect(buf).toEqual(new Uint8Array([0, 0, 0, 0, 0]));
});
it("works on a zero-length buffer", { timeout: 5000 }, () => {
const buf = new Uint8Array(0);
expect(() => zeroize(buf)).not.toThrow();
expect(buf.length).toBe(0);
});
it("works on a 32-byte key", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0xff);
zeroize(key);
expect(key).toEqual(new Uint8Array(32));
});
it("mutates in-place (same reference)", { timeout: 5000 }, () => {
const buf = new Uint8Array([0xde, 0xad]);
const ref = buf;
zeroize(buf);
expect(ref[0]).toBe(0);
expect(ref[1]).toBe(0);
});
it("can be called multiple times safely", { timeout: 5000 }, () => {
const buf = new Uint8Array([1, 2]);
zeroize(buf);
zeroize(buf);
expect(buf).toEqual(new Uint8Array([0, 0]));
});
});

View File

@@ -0,0 +1,11 @@
/**
* Overwrite `buf` with zero bytes in-place.
*
* JavaScript cannot guarantee the garbage collector has not already relocated
* the underlying memory, so this is a best-effort operation. Its purpose is to
* minimise the time window during which sensitive bytes (secret keys, plaintext)
* remain readable on the heap after they are no longer needed.
*/
export function zeroize(buf: Uint8Array): void {
buf.fill(0);
}

View File

@@ -0,0 +1,5 @@
export type { SealedBoxEngineOptions } from "./sealed-box.js";
export {
open,
seal,
} from "./sealed-box.js";

View File

@@ -0,0 +1 @@
export { NobleSealedBoxEngine } from "./noble-sealed-box-engine.js";

View File

@@ -0,0 +1,117 @@
# NobleSealedBoxEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NobleSealedBoxEngine` implements the `SealedBoxEngine` interface using a `crypto_box_seal`-style anonymous-sender encryption protocol. The sender generates a fresh ephemeral X25519 keypair per message, performs ECDH with the recipient's long-term public key, derives a symmetric key via HKDF-SHA256, and encrypts with XChaCha20-Poly1305. The ephemeral public key and nonce are prepended to the output so the recipient can recover the shared secret without any out-of-band communication. Sender anonymity is preserved because the ephemeral secret key is discarded immediately after sealing.
## Features
- Implements `SealedBoxEngine` (`seal` / `open`) for anonymous authenticated encryption
- Protocol output layout: `ephemeralPub (32) || nonce (24) || ciphertext || tag (16)`
- HKDF-SHA256 key derivation with zero salt and info `"suaveplan-sealed-box-v1"` for domain separation
- Ephemeral keypair generated fresh per `seal` call using `defaultByteSource` (secure random)
- Minimum ciphertext length validated during `open`; short ciphertexts throw `CryptoError` before attempting decryption
- All decryption failures wrapped in `CryptoError` with code `AEAD.DECRYPT_FAILED`
## Usage: Basic Example
```typescript
import { NobleSealedBoxEngine } from "@suaveplan/crypto-core/sealed-box/noble-sealed-box-engine";
import { NobleX25519Engine } from "@suaveplan/crypto-core/agree/noble-agree-engine";
const agreeEngine = new NobleX25519Engine();
const sealedBoxEngine = new NobleSealedBoxEngine();
const recipient = agreeEngine.generateKeypair();
// Any party can seal to recipient without knowing sender's identity
const plaintext = new TextEncoder().encode("anonymous message");
const ciphertext = sealedBoxEngine.seal(recipient.publicKey, plaintext);
// Only recipient can open
const recovered = sealedBoxEngine.open(
recipient.secretKey,
recipient.publicKey,
ciphertext
);
console.log(new TextDecoder().decode(recovered)); // "anonymous message"
```
## Usage: Advanced Example
```typescript
import { NobleSealedBoxEngine } from "@suaveplan/crypto-core/sealed-box/noble-sealed-box-engine";
import { NobleX25519Engine } from "@suaveplan/crypto-core/agree/noble-agree-engine";
import { CryptoError } from "@suaveplan/crypto-core/errors/crypto-error";
import { CRYPTO_CODES } from "@suaveplan/crypto-core/errors/codes";
const agreeEngine = new NobleX25519Engine();
const engine = new NobleSealedBoxEngine();
const recipient = agreeEngine.generateKeypair();
// Each seal call produces a different ciphertext (fresh ephemeral key + nonce)
const msg = new TextEncoder().encode("one-time sealed value");
const ct1 = engine.seal(recipient.publicKey, msg);
const ct2 = engine.seal(recipient.publicKey, msg);
console.log(ct1.length === ct2.length); // true (same size)
// Truncated ciphertext is caught before decryption attempt
try {
engine.open(recipient.secretKey, recipient.publicKey, new Uint8Array(10));
} catch (err) {
if (err instanceof CryptoError && err.code === CRYPTO_CODES["AEAD.DECRYPT_FAILED"]) {
console.log("Short ciphertext rejected early");
}
}
// Tampered ciphertext also fails at authentication
try {
const tampered = ct1.slice();
tampered[40] ^= 0x01;
engine.open(recipient.secretKey, recipient.publicKey, tampered);
} catch (err) {
if (err instanceof CryptoError && err.code === CRYPTO_CODES["AEAD.DECRYPT_FAILED"]) {
console.log("Tampered ciphertext rejected");
}
}
```
## API Reference
### `NobleSealedBoxEngine`
```typescript
class NobleSealedBoxEngine implements SealedBoxEngine
```
**`seal(recipientPublic, plaintext): Uint8Array`**
| Parameter | Type | Description |
|-------------------|--------------|--------------------------------------------------------|
| `recipientPublic` | `Uint8Array` | Recipient's 32-byte X25519 public key. |
| `plaintext` | `Uint8Array` | Message bytes to encrypt. |
Returns a self-contained blob: `ephemeralPublicKey (32) || nonce (24) || ciphertext || poly1305Tag (16)`. Total overhead is 72 bytes beyond plaintext length.
**`open(recipientSecret, recipientPublic, ciphertext): Uint8Array`**
| Parameter | Type | Description |
|-------------------|--------------|----------------------------------------------------------------|
| `recipientSecret` | `Uint8Array` | Recipient's 32-byte X25519 secret key. |
| `recipientPublic` | `Uint8Array` | Recipient's 32-byte X25519 public key (currently unused but kept for API symmetry). |
| `ciphertext` | `Uint8Array` | Sealed blob produced by `seal`. |
Returns plaintext bytes. Throws `CryptoError` (`AEAD.DECRYPT_FAILED`) if:
- The ciphertext is shorter than 72 bytes (minimum: 32 ephKey + 24 nonce + 16 tag)
- Authentication tag verification fails
## Implementation Notes
The `_recipientPublic` parameter in `open` is accepted to keep the `SealedBoxEngine` interface consistent with engines that do require it (e.g. the libsodium adapter), but is not used in the computation — the recipient public key is only needed to reconstruct the shared secret on the _sender_ side. The Noble implementation derives everything from `recipientSecret` and the extracted `ephemeralPublic` alone.
Each `seal` call draws fresh randomness for both the ephemeral secret (32 bytes) and the XChaCha20-Poly1305 nonce (24 bytes) from `defaultByteSource`. This means two invocations with identical plaintext and recipient key will always produce different ciphertexts — forward-secrecy is automatic. The trade-off is that the nonce does not need to be managed by the caller for this primitive, unlike `authBox` where nonce uniqueness is the caller's responsibility.

View File

@@ -0,0 +1,101 @@
import * as xchacha from "@suaveplan/crypto-cipher/xchacha20-poly1305";
import * as nobleX25519 from "@suaveplan/crypto-curve/x25519";
import { hkdf } from "@suaveplan/crypto-hash";
import type { SealedBoxEngine } from "../../engines/sealed-box-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import { defaultByteSource } from "../../primitives/random-bytes/random-bytes.js";
/** Ephemeral public key prefix length (X25519 = 32 bytes). */
const EPHEMERAL_PUB_LENGTH = 32;
/** XChaCha20-Poly1305 nonce length. */
const XCHACHA_NONCE_LENGTH = 24;
/** XChaCha20-Poly1305 tag length. */
const TAG_LENGTH = 16;
const HKDF_SALT = new Uint8Array(32); // zero salt
const HKDF_INFO = new TextEncoder().encode("suaveplan-sealed-box-v1");
/**
* Derive a 32-byte symmetric key from a raw X25519 shared secret using
* HKDF-SHA256.
*/
function deriveKey(sharedSecret: Uint8Array): Uint8Array {
return hkdf("SHA-256", sharedSecret, {
salt: HKDF_SALT,
info: HKDF_INFO,
length: 32,
});
}
/**
* Noble-backed sealed-box engine.
*
* Protocol (crypto_box_seal semantics):
* - seal: generate ephemeral X25519 keypair → ECDH with recipient → HKDF-SHA256 → XChaCha20-Poly1305
* Output: ephemeralPub (32) || nonce (24) || ciphertext || tag (16)
* - open: extract ephemeralPub → ECDH → HKDF → XChaCha20-Poly1305 decrypt
*/
export class NobleSealedBoxEngine implements SealedBoxEngine {
public seal(
recipientPublic: Uint8Array,
plaintext: Uint8Array
): Uint8Array {
const ephemeralSecret = defaultByteSource(32);
const ephemeralPublic = nobleX25519.getPublicKey(ephemeralSecret);
const shared = nobleX25519.getSharedSecret(
ephemeralSecret,
recipientPublic
);
const key = deriveKey(shared);
const nonce = defaultByteSource(XCHACHA_NONCE_LENGTH);
const ciphertext = xchacha.encrypt(key, nonce, plaintext);
const out = new Uint8Array(
EPHEMERAL_PUB_LENGTH + XCHACHA_NONCE_LENGTH + ciphertext.length
);
out.set(ephemeralPublic, 0);
out.set(nonce, EPHEMERAL_PUB_LENGTH);
out.set(ciphertext, EPHEMERAL_PUB_LENGTH + XCHACHA_NONCE_LENGTH);
return out;
}
public open(
recipientSecret: Uint8Array,
_recipientPublic: Uint8Array,
ciphertext: Uint8Array
): Uint8Array {
const minLength =
EPHEMERAL_PUB_LENGTH + XCHACHA_NONCE_LENGTH + TAG_LENGTH;
if (ciphertext.length < minLength) {
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed",
{ context: { ciphertextLength: ciphertext.length, minLength } }
);
}
const ephemeralPublic = ciphertext.subarray(0, EPHEMERAL_PUB_LENGTH);
const nonce = ciphertext.subarray(
EPHEMERAL_PUB_LENGTH,
EPHEMERAL_PUB_LENGTH + XCHACHA_NONCE_LENGTH
);
const payload = ciphertext.subarray(
EPHEMERAL_PUB_LENGTH + XCHACHA_NONCE_LENGTH
);
const shared = nobleX25519.getSharedSecret(
recipientSecret,
ephemeralPublic
);
const key = deriveKey(shared);
try {
return xchacha.decrypt(key, nonce, payload);
} catch (cause) {
throw new CryptoError(
CRYPTO_CODES["AEAD.DECRYPT_FAILED"],
"error.crypto.aead.decrypt-failed",
{ cause, context: { ciphertextLength: ciphertext.length } }
);
}
}
}

View File

@@ -0,0 +1,94 @@
# sealed-box
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [noble-sealed-box-engine](./noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!-- begin:auto-docs --> _…
- [noble-sealed-box-engine](./noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- [noble-sealed-box-engine](./noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!…
- [noble-sealed-box-engine](./noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_- noble-sealed-box-engine — <!-- begin:auto-docs -->…
- [noble-sealed-box-engine](./noble-sealed-box-engine/noble-sealed-box-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [sealed-box](./sealed-box.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
Provides anonymous-sender sealed-box encryption following `crypto_box_seal` semantics. The sender generates an ephemeral X25519 keypair, performs ECDH with the recipient's public key, derives a symmetric key via HKDF-SHA256, then encrypts with XChaCha20-Poly1305. The ephemeral public key is prepended to the ciphertext blob so the recipient can derive the same symmetric key without the sender needing to reveal their identity. This pattern is commonly used for one-way encrypted messages where sender anonymity is desired.
## Features
- Anonymous sender — no sender keypair required
- X25519 ECDH + HKDF-SHA256 + XChaCha20-Poly1305 (crypto_box_seal semantics)
- Self-contained output: `ephemeralPub (32) || nonce (24) || ciphertext || tag (16)`
- Engine-pluggable via `SealedBoxEngine` interface
- Authenticated decryption — rejects tampered ciphertext with `CRYPTO.AEAD.DECRYPT_FAILED`
## Usage: Basic Example
```ts
import { sealedBox } from "@suaveplan/crypto-core";
import { x25519 } from "@suaveplan/crypto-core";
const recipient = x25519.generateKeypair();
// Seal (any sender, no keypair needed)
const ciphertext = sealedBox.seal(recipient.publicKey, new TextEncoder().encode("secret"));
// Open (recipient only)
const plaintext = sealedBox.open(recipient.secretKey, recipient.publicKey, ciphertext);
```
## Usage: Advanced Example
```ts
import { sealedBox } from "@suaveplan/crypto-core";
import { x25519 } from "@suaveplan/crypto-core";
import type { SealedBoxEngine } from "@suaveplan/crypto-core/engines";
// Custom engine for testing (deterministic, not secure)
const stubEngine: SealedBoxEngine = {
seal: (_pub, pt) => pt.slice(),
open: (_sec, _pub, ct) => ct.slice(),
};
const recipient = x25519.generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const ct = sealedBox.seal(recipient.publicKey, msg, { engine: stubEngine });
const pt = sealedBox.open(recipient.secretKey, recipient.publicKey, ct, { engine: stubEngine });
```
## API Reference
### `seal(recipientPublic, plaintext, opts?)`
```ts
function seal(
recipientPublic: Uint8Array,
plaintext: Uint8Array,
opts?: SealedBoxEngineOptions
): Uint8Array
```
Encrypts and authenticates `plaintext` for the recipient. Returns a self-contained blob containing the ephemeral public key, nonce, ciphertext, and authentication tag.
| Param | Type | Description |
|-------|------|-------------|
| `recipientPublic` | `Uint8Array` | Recipient's X25519 public key (32 bytes). |
| `plaintext` | `Uint8Array` | Data to encrypt. |
| `opts.engine` | `SealedBoxEngine?` | Override the default noble engine. |
### `open(recipientSecret, recipientPublic, ciphertext, opts?)`
```ts
function open(
recipientSecret: Uint8Array,
recipientPublic: Uint8Array,
ciphertext: Uint8Array,
opts?: SealedBoxEngineOptions
): Uint8Array
```
Decrypts a sealed box. Throws on authentication failure.
**Throws:** `CryptoError("CRYPTO.AEAD.DECRYPT_FAILED")` — ciphertext is too short, or authentication tag verification fails.
## Implementation Notes
The HKDF info string `"suaveplan-sealed-box-v1"` is domain-separated to prevent key reuse across protocols. A zero salt is used because the X25519 shared secret already provides sufficient entropy for HKDF extraction. The nonce is randomly generated per seal operation and stored in the ciphertext blob, so the same key can be reused across many messages without nonce-reuse risk. The 24-byte XChaCha20-Poly1305 nonce is large enough that random generation is safe for up to 2^96 messages per recipient key before birthday collisions become plausible.

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { generateKeypair } from "../agree/x25519/x25519.js";
import type { SealedBoxEngine } from "../engines/sealed-box-engine.types.js";
import { CryptoError } from "../errors/crypto-error/crypto-error.js";
import { open, seal } from "./sealed-box.js";
describe("sealedBox seal + open", () => {
it("round-trips plaintext", { timeout: 5000 }, () => {
const recipient = generateKeypair();
const msg = new TextEncoder().encode("hello sealed world");
const ct = seal(recipient.publicKey, msg);
const pt = open(recipient.secretKey, recipient.publicKey, ct);
expect(pt).toEqual(msg);
});
it("returns a blob longer than plaintext", { timeout: 5000 }, () => {
const recipient = generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const ct = seal(recipient.publicKey, msg);
// 32 ephemeral pub + 24 nonce + 3 plaintext + 16 tag = 75
expect(ct.length).toBe(75);
});
it("two seals of the same plaintext produce different ciphertext (random nonce)", {
timeout: 5000,
}, () => {
const recipient = generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const ct1 = seal(recipient.publicKey, msg);
const ct2 = seal(recipient.publicKey, msg);
expect(ct1).not.toEqual(ct2);
});
it("tampered ciphertext rejected", { timeout: 5000 }, () => {
const recipient = generateKeypair();
const ct = seal(recipient.publicKey, new Uint8Array([42]));
ct[ct.length - 1] ^= 0xff;
expect(() =>
open(recipient.secretKey, recipient.publicKey, ct)
).toThrow(CryptoError);
});
it("wrong recipient secret key rejected", { timeout: 5000 }, () => {
const alice = generateKeypair();
const bob = generateKeypair();
const ct = seal(alice.publicKey, new Uint8Array([1, 2, 3]));
expect(() => open(bob.secretKey, bob.publicKey, ct)).toThrow(
CryptoError
);
});
it("too-short ciphertext rejected", { timeout: 5000 }, () => {
const recipient = generateKeypair();
expect(() =>
open(recipient.secretKey, recipient.publicKey, new Uint8Array(10))
).toThrow(CryptoError);
});
it("property: arbitrary plaintext round-trips", {
timeout: 30_000,
}, () => {
const recipient = generateKeypair();
fc.assert(
fc.property(fc.uint8Array({ maxLength: 256 }), (msg) => {
const ct = seal(recipient.publicKey, msg);
const pt = open(recipient.secretKey, recipient.publicKey, ct);
return (
pt.length === msg.length && pt.every((v, i) => v === msg[i])
);
}),
{ numRuns: 20 }
);
});
});
describe("sealedBox — engine substitution", () => {
it("uses provided engine", { timeout: 5000 }, () => {
const calls: string[] = [];
const stubEngine: SealedBoxEngine = {
seal: (_pub, pt) => {
calls.push("seal");
return pt.slice();
},
open: (_sec, _pub, ct) => {
calls.push("open");
return ct.slice();
},
};
const recipient = generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const ct = seal(recipient.publicKey, msg, { engine: stubEngine });
expect(calls).toEqual(["seal"]);
const pt = open(recipient.secretKey, recipient.publicKey, ct, {
engine: stubEngine,
});
expect(calls).toEqual(["seal", "open"]);
expect(pt).toEqual(msg);
});
});

View File

@@ -0,0 +1,40 @@
import type { SealedBoxEngine } from "../engines/sealed-box-engine.types.js";
import { NobleSealedBoxEngine } from "./noble-sealed-box-engine/noble-sealed-box-engine.js";
export interface SealedBoxEngineOptions {
readonly engine?: SealedBoxEngine;
}
const defaultEngine = new NobleSealedBoxEngine();
/**
* Seal `plaintext` for `recipientPublic`. The sender is anonymous — no
* keypair is required. Returns a self-contained blob:
* `ephemeralPub (32) || nonce (24) || ciphertext || tag (16)`.
*
* @param opts.engine - Override the sealed-box engine.
*/
export function seal(
recipientPublic: Uint8Array,
plaintext: Uint8Array,
opts: SealedBoxEngineOptions = {}
): Uint8Array {
const engine = opts.engine ?? defaultEngine;
return engine.seal(recipientPublic, plaintext);
}
/**
* Open a sealed box. Returns the plaintext.
*
* @throws {CryptoError} CRYPTO.AEAD.DECRYPT_FAILED on authentication failure.
* @param opts.engine - Override the sealed-box engine.
*/
export function open(
recipientSecret: Uint8Array,
recipientPublic: Uint8Array,
ciphertext: Uint8Array,
opts: SealedBoxEngineOptions = {}
): Uint8Array {
const engine = opts.engine ?? defaultEngine;
return engine.open(recipientSecret, recipientPublic, ciphertext);
}

View File

@@ -0,0 +1,89 @@
# ecdsa-p256
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides ECDSA-P256 digital signature operations — key generation, signing, and verification — backed by `@noble/curves`. ECDSA over NIST P-256 (secp256r1) is widely deployed in TLS certificates, WebAuthn, and enterprise PKI infrastructure. Signatures are computed over the SHA-256 digest of the message and encoded in compact (r||s) 64-byte form. The facade is engine-pluggable: pass `{ engine }` to substitute a WebCrypto-backed implementation that may leverage hardware acceleration.
## Features
- Key generation with P-256 scalar validation retry loop (not all 32-byte values are valid scalars)
- Signs over SHA-256 digest — callers do not need to pre-hash
- Compact 64-byte (r||s) signature format
- Verification returns `boolean`, never throws on malformed input
- Engine-pluggable via `SignEngine` interface
- `ByteSource` / deprecated `rng` option for key-generation injection
## Usage: Basic Example
```ts
import { ecdsaP256 } from "@suaveplan/crypto-core";
const keypair = ecdsaP256.generateKeypair();
const message = new TextEncoder().encode("document content");
const sig = ecdsaP256.sign(keypair.secretKey, message);
const ok = ecdsaP256.verify(keypair.publicKey, sig, message);
// ok === true
```
## Usage: Advanced Example
```ts
import { ecdsaP256 } from "@suaveplan/crypto-core";
import type { ByteSource } from "@suaveplan/crypto-core/primitives/random-bytes";
// Deterministic key generation for testing
const seeded: ByteSource = (() => {
let v = 1;
return (n) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = (v++ & 0xff);
return out;
};
})();
const kp = ecdsaP256.generateKeypair({ byteSource: seeded });
const msg = new Uint8Array([0xde, 0xad]);
const sig = ecdsaP256.sign(kp.secretKey, msg);
console.log(ecdsaP256.verify(kp.publicKey, sig, msg)); // true
```
## API Reference
### `EcdsaP256Keypair`
```ts
type EcdsaP256Keypair = { publicKey: Uint8Array; secretKey: Uint8Array }
```
Public key is a 33-byte compressed point; secret key is a 32-byte scalar.
### `generateKeypair(opts?)`
```ts
function generateKeypair(opts?: EcdsaP256Options): EcdsaP256Keypair
```
Generates a fresh keypair. Retries key generation until the random bytes form a valid P-256 private scalar (probability of retry is negligible — about 2^-128).
### `sign(secretKey, message, opts?)`
```ts
function sign(secretKey: Uint8Array, message: Uint8Array, opts?: { engine?: SignEngine }): Uint8Array
```
Signs `SHA-256(message)` with `secretKey`. Returns a compact 64-byte (r||s) signature.
### `verify(publicKey, signature, message, opts?)`
```ts
function verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array, opts?: { engine?: SignEngine }): boolean
```
Verifies compact signature against `SHA-256(message)`. Returns `false` (never throws) on malformed inputs.
## Implementation Notes
P-256 private key generation requires checking that the random scalar is in `[1, n-1]` where `n` is the curve order. The probability that a uniformly random 32-byte value is out of range is approximately `1 - n/2^256 ≈ 2^-128`, so the retry loop terminates in one iteration with overwhelming probability. The SHA-256 pre-hash is applied by the facade (not the noble library's internal hashing) to keep the engine interface consistent with Ed25519 where the full message is hashed internally. Compact signature format omits the DER encoding overhead and is interoperable with most modern P-256 consumers.

View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { ECDSA_P256_SIGNATURE_LENGTH } from "../../constants/index.js";
import type { SignEngine } from "../../engines/sign-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import type { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import { generateKeypair, sign, verify } from "./ecdsa-p256.js";
describe("ecdsaP256.generateKeypair", () => {
it("generates a keypair", { timeout: 5000 }, () => {
const kp = generateKeypair();
expect(kp.secretKey.length).toBe(32);
expect(kp.publicKey.length).toBeGreaterThan(0);
});
it("uses an injected rng (deprecated) — eventually produces a valid key", {
timeout: 5000,
}, () => {
let n = 0;
const kp = generateKeypair({
rng: (len) => {
const out = new Uint8Array(len);
out[0] = 0x01;
out[len - 1] = ++n & 0xff;
return out;
},
});
expect(kp.secretKey.length).toBe(32);
});
it("uses injected byteSource", { timeout: 5000 }, () => {
let n = 0;
const kp = generateKeypair({
byteSource: (len) => {
const out = new Uint8Array(len);
out[0] = 0x01;
out[len - 1] = ++n & 0xff;
return out;
},
});
expect(kp.secretKey.length).toBe(32);
});
});
describe("ecdsaP256 sign + verify", () => {
it("sign/verify round-trip", { timeout: 5000 }, () => {
const kp = generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const sig = sign(kp.secretKey, msg);
expect(sig.length).toBe(ECDSA_P256_SIGNATURE_LENGTH);
expect(verify(kp.publicKey, sig, msg)).toBe(true);
});
it("verify false for wrong message", { timeout: 5000 }, () => {
const kp = generateKeypair();
const sig = sign(kp.secretKey, new Uint8Array([1]));
expect(verify(kp.publicKey, sig, new Uint8Array([9]))).toBe(false);
});
it("verify returns false on malformed signature", {
timeout: 5000,
}, () => {
const kp = generateKeypair();
expect(
verify(kp.publicKey, new Uint8Array(3), new Uint8Array([1]))
).toBe(false);
});
it("verify throws CRYPTO.SIGN.INVALID_KEY on malformed public key length", {
timeout: 5000,
}, () => {
const kp = generateKeypair();
const sig = sign(kp.secretKey, new Uint8Array([1]));
try {
verify(new Uint8Array(3), sig, new Uint8Array([1]));
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.INVALID_KEY"]
);
}
});
it("sign throws CRYPTO.SIGN.INVALID_KEY on malformed secret key length", {
timeout: 5000,
}, () => {
try {
sign(new Uint8Array(16), new Uint8Array([1]));
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.INVALID_KEY"]
);
}
});
it("sign throws CRYPTO.SIGN.VERIFY_FAILED when engine returns wrong-length signature", {
timeout: 5000,
}, () => {
const stubEngine: SignEngine = {
generateKeypair: () => ({
publicKey: new Uint8Array(33),
secretKey: new Uint8Array(32),
}),
sign: () => new Uint8Array(48), // wrong length
verify: () => false,
};
try {
sign(new Uint8Array(32), new Uint8Array([1]), {
engine: stubEngine,
});
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.VERIFY_FAILED"]
);
}
});
it("property: sign/verify round-trip for arbitrary messages", {
timeout: 30_000,
}, () => {
const kp = generateKeypair();
fc.assert(
fc.property(fc.uint8Array({ maxLength: 128 }), (msg) =>
verify(kp.publicKey, sign(kp.secretKey, msg), msg)
),
{ numRuns: 15 }
);
});
});
describe("ecdsaP256 — engine substitution", () => {
it("uses provided engine", { timeout: 5000 }, () => {
const calls: string[] = [];
const stubEngine: SignEngine = {
generateKeypair: () => {
calls.push("generateKeypair");
return {
publicKey: new Uint8Array(33).fill(2),
secretKey: new Uint8Array(32).fill(1),
};
},
sign: () => {
calls.push("sign");
return new Uint8Array(64);
},
verify: () => {
calls.push("verify");
return true;
},
};
generateKeypair({ engine: stubEngine });
sign(new Uint8Array(32), new Uint8Array([1]), { engine: stubEngine });
verify(new Uint8Array(33), new Uint8Array(64), new Uint8Array([1]), {
engine: stubEngine,
});
expect(calls).toEqual(["generateKeypair", "sign", "verify"]);
});
});

View File

@@ -0,0 +1,132 @@
import { ECDSA_P256_SIGNATURE_LENGTH } from "../../constants/index.js";
import type {
SignEngine,
SignKeypair,
} from "../../engines/sign-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
import { NobleEcdsaP256Engine } from "../noble-sign-engine/noble-sign-engine.js";
export type { SignKeypair as EcdsaP256Keypair };
const ECDSA_P256_SECRET_KEY_LENGTH = 32;
// P-256 public keys are 33 bytes (compressed) or 65 bytes (uncompressed SEC1).
const ECDSA_P256_PUBLIC_KEY_COMPRESSED = 33;
const ECDSA_P256_PUBLIC_KEY_UNCOMPRESSED = 65;
export interface EcdsaP256Options {
readonly byteSource?: ByteSource;
readonly rng?: ByteSource;
readonly engine?: SignEngine;
}
const defaultEngine = new NobleEcdsaP256Engine();
function validateSecretKey(secretKey: Uint8Array): void {
if (secretKey.length !== ECDSA_P256_SECRET_KEY_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["SIGN.INVALID_KEY"],
"error.crypto.sign.invalid-key",
{
context: {
role: "secretKey",
algorithm: "ECDSA-P256",
expected: ECDSA_P256_SECRET_KEY_LENGTH,
actual: secretKey.length,
},
}
);
}
}
function validatePublicKey(publicKey: Uint8Array): void {
if (
publicKey.length !== ECDSA_P256_PUBLIC_KEY_COMPRESSED &&
publicKey.length !== ECDSA_P256_PUBLIC_KEY_UNCOMPRESSED
) {
throw new CryptoError(
CRYPTO_CODES["SIGN.INVALID_KEY"],
"error.crypto.sign.invalid-key",
{
context: {
role: "publicKey",
algorithm: "ECDSA-P256",
expected: `${ECDSA_P256_PUBLIC_KEY_COMPRESSED} or ${ECDSA_P256_PUBLIC_KEY_UNCOMPRESSED}`,
actual: publicKey.length,
},
}
);
}
}
/**
* Generate a fresh ECDSA-P256 keypair.
*
* Key generation retries until `noble.utils.isValidPrivateKey` passes —
* a uniformly random 32-byte value is not always a valid P-256 scalar.
*
* @param opts.byteSource - Inject a deterministic byte source (testing).
* @param opts.engine - Override the signing engine.
*/
export function generateKeypair(opts: EcdsaP256Options = {}): SignKeypair {
const engine = opts.engine ?? defaultEngine;
return engine.generateKeypair(opts.byteSource ?? opts.rng);
}
/**
* Sign the SHA-256 digest of `message` with `secretKey`.
* Returns a compact 64-byte (r||s) signature.
*
* @throws {CryptoError} CRYPTO.SIGN.INVALID_KEY if `secretKey.length !== 32`.
* @throws {CryptoError} CRYPTO.SIGN.VERIFY_FAILED if the underlying engine
* produces a signature whose length is not 64 bytes (engine fault).
* @param opts.engine - Override the signing engine.
*/
export function sign(
secretKey: Uint8Array,
message: Uint8Array,
opts: { engine?: SignEngine } = {}
): Uint8Array {
validateSecretKey(secretKey);
const engine = opts.engine ?? defaultEngine;
const sig = engine.sign(secretKey, message);
if (sig.length !== ECDSA_P256_SIGNATURE_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["SIGN.VERIFY_FAILED"],
"error.crypto.sign.verify-failed",
{
context: {
algorithm: "ECDSA-P256",
expected: ECDSA_P256_SIGNATURE_LENGTH,
actual: sig.length,
},
}
);
}
return sig;
}
/**
* Verify a compact ECDSA-P256 signature over the SHA-256 digest of `message`.
* Always returns a boolean — never throws on a bad signature.
*
* Malformed `publicKey` length throws `CRYPTO.SIGN.INVALID_KEY`. Malformed
* `signature` length returns `false`.
*
* @throws {CryptoError} CRYPTO.SIGN.INVALID_KEY if `publicKey.length` is not 33 or 65.
* @param opts.engine - Override the signing engine.
*/
export function verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array,
opts: { engine?: SignEngine } = {}
): boolean {
validatePublicKey(publicKey);
if (signature.length !== ECDSA_P256_SIGNATURE_LENGTH) {
return false;
}
const engine = opts.engine ?? defaultEngine;
return engine.verify(publicKey, signature, message);
}

View File

@@ -0,0 +1,7 @@
export {
type EcdsaP256Keypair,
type EcdsaP256Options,
generateKeypair,
sign,
verify,
} from "./ecdsa-p256.js";

View File

@@ -0,0 +1,92 @@
# ed25519
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Ed25519 digital signature operations — key generation, signing, and verification — backed by `@noble/curves`. Ed25519 is a high-speed elliptic curve signature algorithm over Curve25519, offering 128-bit security with compact 64-byte signatures and 32-byte keys. It is deterministic (no per-signature randomness needed) and is the recommended choice for new protocols. The facade is engine-pluggable: pass `{ engine }` to substitute a WebCrypto or libsodium-backed implementation.
## Features
- Key generation using injected `ByteSource` (testable) or `crypto.getRandomValues` (production)
- Deterministic signing — same key + message always produces the same signature
- Verification returns `boolean`, never throws on malformed input
- Engine-pluggable via `SignEngine` interface
- `ByteSource` / deprecated `rng` option for key-generation injection
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-core";
const keypair = ed25519.generateKeypair();
const message = new TextEncoder().encode("hello");
const sig = ed25519.sign(keypair.secretKey, message);
const ok = ed25519.verify(keypair.publicKey, sig, message);
// ok === true
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-core";
import type { SignEngine } from "@suaveplan/crypto-core/engines";
import type { ByteSource } from "@suaveplan/crypto-core/primitives/random-bytes";
// Deterministic key generation for reproducible tests
let seed = 0;
const seeded: ByteSource = (n) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) out[i] = (seed++ & 0xff);
return out;
};
const kp = ed25519.generateKeypair({ byteSource: seeded });
const msg = new Uint8Array([1, 2, 3]);
const sig = ed25519.sign(kp.secretKey, msg);
console.log(ed25519.verify(kp.publicKey, sig, msg)); // true
console.log(ed25519.verify(kp.publicKey, sig, new Uint8Array([99]))); // false
```
## API Reference
### `Ed25519Keypair`
```ts
type Ed25519Keypair = { publicKey: Uint8Array; secretKey: Uint8Array }
```
### `generateKeypair(opts?)`
```ts
function generateKeypair(opts?: Ed25519Options): Ed25519Keypair
```
Generates a fresh keypair. Every 32-byte value is a valid Ed25519 secret key, so no retry loop is needed.
| Param | Type | Description |
|-------|------|-------------|
| `opts.byteSource` | `ByteSource?` | Byte source for key material. |
| `opts.rng` | `ByteSource?` | Deprecated alias for `byteSource`. |
| `opts.engine` | `SignEngine?` | Override the default noble engine. |
### `sign(secretKey, message, opts?)`
```ts
function sign(secretKey: Uint8Array, message: Uint8Array, opts?: { engine?: SignEngine }): Uint8Array
```
Signs `message` with `secretKey`. Returns a 64-byte signature.
### `verify(publicKey, signature, message, opts?)`
```ts
function verify(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array, opts?: { engine?: SignEngine }): boolean
```
Verifies `signature` over `message` against `publicKey`. Returns `false` (never throws) on malformed inputs.
## Implementation Notes
Ed25519 uses the Ristretto255 group internally in some implementations; `@noble/curves` uses the standard Edwards25519 form. The 64-byte signature is the standard (R, S) encoding where R is a compressed point and S is a scalar. Noble's `verify` returns `false` for many malformed inputs that other libraries throw on — the facade wraps the call in a try/catch to guarantee boolean-only returns from all implementations.

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
ED25519_PUBLIC_KEY_LENGTH,
ED25519_SECRET_KEY_LENGTH,
ED25519_SIGNATURE_LENGTH,
} from "../../constants/index.js";
import type {
SignEngine,
SignKeypair,
} from "../../engines/sign-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import type { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
import { generateKeypair, sign, verify } from "./ed25519.js";
function deterministicSource(fill: number): ByteSource {
return (n) => new Uint8Array(n).fill(fill);
}
describe("ed25519.generateKeypair", () => {
it("generates a valid keypair", { timeout: 5000 }, () => {
const kp = generateKeypair();
expect(kp.secretKey.length).toBe(ED25519_SECRET_KEY_LENGTH);
expect(kp.publicKey.length).toBe(ED25519_PUBLIC_KEY_LENGTH);
});
it("uses an injected rng (deprecated option)", { timeout: 5000 }, () => {
const kp = generateKeypair({ rng: deterministicSource(0x01) });
expect(kp.secretKey.every((b) => b === 1)).toBe(true);
});
it("uses injected byteSource", { timeout: 5000 }, () => {
const kp = generateKeypair({ byteSource: deterministicSource(0x42) });
expect(kp.secretKey.every((b) => b === 0x42)).toBe(true);
});
it("two calls produce different keys", { timeout: 5000 }, () => {
const a = generateKeypair();
const b = generateKeypair();
expect(a.secretKey).not.toEqual(b.secretKey);
});
});
describe("ed25519 sign + verify", () => {
it("sign/verify round-trip", { timeout: 5000 }, () => {
const kp = generateKeypair();
const msg = new Uint8Array([1, 2, 3, 4]);
const sig = sign(kp.secretKey, msg);
expect(sig.length).toBe(ED25519_SIGNATURE_LENGTH);
expect(verify(kp.publicKey, sig, msg)).toBe(true);
});
it("verify returns false for wrong message", { timeout: 5000 }, () => {
const kp = generateKeypair();
const sig = sign(kp.secretKey, new Uint8Array([1]));
expect(verify(kp.publicKey, sig, new Uint8Array([2]))).toBe(false);
});
it("verify returns false on malformed signature", {
timeout: 5000,
}, () => {
const kp = generateKeypair();
expect(
verify(kp.publicKey, new Uint8Array(3), new Uint8Array([1]))
).toBe(false);
});
it("sign throws CRYPTO.SIGN.INVALID_KEY on malformed secret key length", {
timeout: 5000,
}, () => {
try {
sign(new Uint8Array(16), new Uint8Array([1]));
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.INVALID_KEY"]
);
}
});
it("verify throws CRYPTO.SIGN.INVALID_KEY on malformed public key length", {
timeout: 5000,
}, () => {
try {
verify(new Uint8Array(16), new Uint8Array(64), new Uint8Array([1]));
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.INVALID_KEY"]
);
}
});
it("sign throws CRYPTO.SIGN.VERIFY_FAILED when engine returns wrong-length signature", {
timeout: 5000,
}, () => {
const stubEngine: SignEngine = {
generateKeypair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32),
}),
sign: () => new Uint8Array(48), // wrong length
verify: () => false,
};
try {
sign(new Uint8Array(32), new Uint8Array([1]), {
engine: stubEngine,
});
expect.unreachable();
} catch (err) {
expect((err as CryptoError).code).toBe(
CRYPTO_CODES["SIGN.VERIFY_FAILED"]
);
}
});
it("verify returns false for tampered signature", { timeout: 5000 }, () => {
const kp = generateKeypair();
const msg = new Uint8Array([1, 2, 3]);
const sig = sign(kp.secretKey, msg);
sig[0] ^= 0xff;
expect(verify(kp.publicKey, sig, msg)).toBe(false);
});
it("property: sign/verify round-trip for arbitrary messages", {
timeout: 30_000,
}, () => {
const kp = generateKeypair();
fc.assert(
fc.property(fc.uint8Array({ maxLength: 128 }), (msg) =>
verify(kp.publicKey, sign(kp.secretKey, msg), msg)
),
{ numRuns: 25 }
);
});
});
describe("ed25519 — engine substitution", () => {
it("uses provided engine for all operations", { timeout: 5000 }, () => {
const calls: string[] = [];
const stubEngine: SignEngine = {
generateKeypair: () => {
calls.push("generateKeypair");
return {
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32),
};
},
sign: (_sk, msg) => {
calls.push("sign");
return msg.slice(0, 64).length === 64
? msg.slice(0, 64)
: new Uint8Array(64);
},
verify: () => {
calls.push("verify");
return true;
},
};
const kp: SignKeypair = generateKeypair({ engine: stubEngine });
expect(calls).toContain("generateKeypair");
sign(kp.secretKey, new Uint8Array([1]), { engine: stubEngine });
expect(calls).toContain("sign");
verify(kp.publicKey, new Uint8Array(64), new Uint8Array([1]), {
engine: stubEngine,
});
expect(calls).toContain("verify");
});
});

View File

@@ -0,0 +1,125 @@
import {
ED25519_PUBLIC_KEY_LENGTH,
ED25519_SECRET_KEY_LENGTH,
ED25519_SIGNATURE_LENGTH,
} from "../../constants/index.js";
import type {
SignEngine,
SignKeypair,
} from "../../engines/sign-engine.types.js";
import { CRYPTO_CODES } from "../../errors/codes/codes.js";
import { CryptoError } from "../../errors/crypto-error/crypto-error.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
import { NobleEd25519Engine } from "../noble-sign-engine/noble-sign-engine.js";
export type { SignKeypair as Ed25519Keypair };
export interface Ed25519Options {
readonly byteSource?: ByteSource;
readonly rng?: ByteSource;
readonly engine?: SignEngine;
}
const defaultEngine = new NobleEd25519Engine();
function validateSecretKey(secretKey: Uint8Array): void {
if (secretKey.length !== ED25519_SECRET_KEY_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["SIGN.INVALID_KEY"],
"error.crypto.sign.invalid-key",
{
context: {
role: "secretKey",
algorithm: "Ed25519",
expected: ED25519_SECRET_KEY_LENGTH,
actual: secretKey.length,
},
}
);
}
}
function validatePublicKey(publicKey: Uint8Array): void {
if (publicKey.length !== ED25519_PUBLIC_KEY_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["SIGN.INVALID_KEY"],
"error.crypto.sign.invalid-key",
{
context: {
role: "publicKey",
algorithm: "Ed25519",
expected: ED25519_PUBLIC_KEY_LENGTH,
actual: publicKey.length,
},
}
);
}
}
/**
* Generate a fresh Ed25519 keypair.
*
* @param opts.byteSource - Inject a deterministic byte source (testing).
* @param opts.engine - Override the signing engine.
*/
export function generateKeypair(opts: Ed25519Options = {}): SignKeypair {
const engine = opts.engine ?? defaultEngine;
return engine.generateKeypair(opts.byteSource ?? opts.rng);
}
/**
* Sign `message` with `secretKey`. Returns a 64-byte signature.
*
* @throws {CryptoError} CRYPTO.SIGN.INVALID_KEY if `secretKey.length !== 32`.
* @throws {CryptoError} CRYPTO.SIGN.VERIFY_FAILED if the underlying engine
* produces a signature whose length is not 64 bytes (engine fault).
* @param opts.engine - Override the signing engine.
*/
export function sign(
secretKey: Uint8Array,
message: Uint8Array,
opts: { engine?: SignEngine } = {}
): Uint8Array {
validateSecretKey(secretKey);
const engine = opts.engine ?? defaultEngine;
const sig = engine.sign(secretKey, message);
if (sig.length !== ED25519_SIGNATURE_LENGTH) {
throw new CryptoError(
CRYPTO_CODES["SIGN.VERIFY_FAILED"],
"error.crypto.sign.verify-failed",
{
context: {
algorithm: "Ed25519",
expected: ED25519_SIGNATURE_LENGTH,
actual: sig.length,
},
}
);
}
return sig;
}
/**
* Verify `signature` over `message` with `publicKey`.
* Always returns a boolean — never throws on a bad signature.
*
* Malformed `publicKey` length throws `CRYPTO.SIGN.INVALID_KEY`
* (a boundary error, not a verification error). Malformed `signature`
* length returns `false`.
*
* @throws {CryptoError} CRYPTO.SIGN.INVALID_KEY if `publicKey.length !== 32`.
* @param opts.engine - Override the signing engine.
*/
export function verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array,
opts: { engine?: SignEngine } = {}
): boolean {
validatePublicKey(publicKey);
if (signature.length !== ED25519_SIGNATURE_LENGTH) {
return false;
}
const engine = opts.engine ?? defaultEngine;
return engine.verify(publicKey, signature, message);
}

View File

@@ -0,0 +1,7 @@
export {
type Ed25519Keypair,
type Ed25519Options,
generateKeypair,
sign,
verify,
} from "./ed25519.js";

View File

@@ -0,0 +1,6 @@
import * as ecdsaP256 from "./ecdsa-p256/ecdsa-p256.js";
import * as ed25519 from "./ed25519/ed25519.js";
export type { EcdsaP256Keypair } from "./ecdsa-p256/ecdsa-p256.js";
export type { Ed25519Keypair } from "./ed25519/ed25519.js";
export { ecdsaP256, ed25519 };

View File

@@ -0,0 +1,4 @@
export {
NobleEcdsaP256Engine,
NobleEd25519Engine,
} from "./noble-sign-engine.js";

View File

@@ -0,0 +1,117 @@
# NobleEd25519Engine / NobleEcdsaP256Engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides two `SignEngine` implementations for the `@suaveplan/crypto` facade: `NobleEd25519Engine` for Ed25519 and `NobleEcdsaP256Engine` for ECDSA-P256. Both delegate through `@suaveplan/crypto-curve` subpaths, ensuring the build-time primitive selector in `crypto.config.ts` applies uniformly. The engines are consumed by the `ed25519Sign`, `ed25519Verify`, `ecdsaP256Sign`, and `ecdsaP256Verify` facade functions, and can be supplied directly via engine options for testing or custom engine composition.
## Features
- `NobleEd25519Engine`: implements `SignEngine` for Ed25519 (64-byte signatures, 32-byte keys)
- `NobleEcdsaP256Engine`: implements `SignEngine` for ECDSA-P256 (compact 64-byte r‖s signatures over SHA-256 digests)
- Both accept an optional `ByteSource` in `generateKeypair` for deterministic test key generation
- Ed25519 key generation needs no retry loop — every 32-byte value is a valid secret
- P-256 key generation uses `p256.randomPrivateKey()` which internally handles the rejection-sampling retry; injected byte sources bypass this and must supply a valid scalar
- ECDSA signs over the SHA-256 digest of the message, so callers do not need to pre-hash
- Delegates through `@suaveplan/crypto-curve/ed25519` and `@suaveplan/crypto-curve/p256`
## Usage: Basic Example
```typescript
import { NobleEd25519Engine } from "@suaveplan/crypto-core/sign/noble-sign-engine";
const engine = new NobleEd25519Engine();
const keypair = engine.generateKeypair();
const message = new TextEncoder().encode("hello world");
const signature = engine.sign(keypair.secretKey, message);
const valid = engine.verify(keypair.publicKey, signature, message);
console.log(valid); // true
```
## Usage: Advanced Example
```typescript
import {
NobleEd25519Engine,
NobleEcdsaP256Engine,
} from "@suaveplan/crypto-core/sign/noble-sign-engine";
// Ed25519 with a deterministic byte source
const fixedSeed = (n: number) => new Uint8Array(n).fill(0x42);
const ed25519Engine = new NobleEd25519Engine();
const deterministicKeypair = ed25519Engine.generateKeypair(fixedSeed);
// ECDSA-P256 — signs over SHA-256 digest internally
const p256Engine = new NobleEcdsaP256Engine();
const p256Keypair = p256Engine.generateKeypair();
const message = new TextEncoder().encode("transaction payload");
const sig256 = p256Engine.sign(p256Keypair.secretKey, message);
// Verification returns boolean — does not throw on invalid signatures
const ok = p256Engine.verify(p256Keypair.publicKey, sig256, message);
console.log(ok); // true
// Cross-engine verification fails gracefully
const wrongSig = ed25519Engine.sign(deterministicKeypair.secretKey, message);
const crossOk = p256Engine.verify(p256Keypair.publicKey, wrongSig, message);
console.log(crossOk); // false
```
## API Reference
### `NobleEd25519Engine`
```typescript
class NobleEd25519Engine implements SignEngine
```
**`generateKeypair(byteSource?): SignKeypair`**
| Parameter | Type | Description |
|--------------|-------------------------|-----------------------------------------------------------------|
| `byteSource` | `ByteSource \| undefined` | Optional random byte source. When omitted, uses `ed25519.randomPrivateKey()`. |
Returns `SignKeypair { publicKey: Uint8Array; secretKey: Uint8Array }` (both 32 bytes).
**`sign(secretKey, message): Uint8Array`**
Signs `message` directly (Ed25519 is a pure signature scheme — no pre-hashing). Returns a 64-byte signature.
**`verify(publicKey, signature, message): boolean`**
Returns `true` if the signature is valid for the message and public key. Never throws on an invalid signature — returns `false` instead.
---
### `NobleEcdsaP256Engine`
```typescript
class NobleEcdsaP256Engine implements SignEngine
```
**`generateKeypair(byteSource?): SignKeypair`**
| Parameter | Type | Description |
|--------------|-------------------------|------------------------------------------------------------------------------|
| `byteSource` | `ByteSource \| undefined` | Optional byte source. When omitted, `p256.randomPrivateKey()` handles rejection sampling. When supplied, the 32 bytes are used directly and must form a valid P-256 scalar. |
Returns `SignKeypair { publicKey: Uint8Array; secretKey: Uint8Array }`. The public key is 33-byte compressed form.
**`sign(secretKey, message): Uint8Array`**
Hashes `message` with SHA-256, then signs the digest. Returns a compact 64-byte signature (r‖s, without DER encoding).
**`verify(publicKey, signature, message): boolean`**
Hashes `message` with SHA-256 and verifies the compact signature. Returns `true` on success, `false` on any verification failure.
## Implementation Notes
Ed25519 and P-256 differ in their key-space properties. Ed25519 uses a 32-byte seed that is internally hashed to produce the actual scalar, so every seed value is valid. P-256 requires the private scalar to be in the range `[1, n-1]` where `n` is the curve order, which excludes a negligibly small fraction of 32-byte values. The `p256.randomPrivateKey()` function handles rejection-sampling internally; however, when a `byteSource` is injected (for testing), the engine passes the bytes directly without rejection-sampling, placing the validity constraint on the caller.
ECDSA-P256 signs over the SHA-256 digest of the message. This pre-hashing is performed inside the engine rather than in the facade so that all code paths — including direct engine use — apply the hash consistently. The `@noble/curves` P-256 `sign` function also accepts a `lowS` option (enforced by default in newer versions) to produce canonical low-S signatures, which improves compatibility with verification libraries that reject high-S signatures.

View File

@@ -0,0 +1,72 @@
import * as ed25519 from "@suaveplan/crypto-curve/ed25519";
import * as p256 from "@suaveplan/crypto-curve/p256";
import { sha256 } from "@suaveplan/crypto-hash";
import type {
SignEngine,
SignKeypair,
} from "../../engines/sign-engine.types.js";
import type { ByteSource } from "../../primitives/random-bytes/random-bytes.js";
/**
* Ed25519 signing engine, delegating through `@suaveplan/crypto-curve`.
*
* Key generation picks 32 random bytes directly — every 32-byte value is a
* valid Ed25519 secret key, so no retry loop is needed.
*/
export class NobleEd25519Engine implements SignEngine {
public generateKeypair(byteSource?: ByteSource): SignKeypair {
const secretKey =
byteSource === undefined
? ed25519.randomPrivateKey()
: byteSource(32);
const publicKey = ed25519.getPublicKey(secretKey);
return { publicKey, secretKey };
}
public sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array {
return ed25519.sign(message, secretKey);
}
public verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): boolean {
return ed25519.verify(signature, message, publicKey);
}
}
/**
* ECDSA-P256 signing engine, delegating through `@suaveplan/crypto-curve`.
*
* Key generation retries until a valid private key is found (required because
* a random 32-byte value is not always a valid P-256 scalar).
* Signs over the SHA-256 digest of the message and returns a compact 64-byte
* (r||s) signature.
*/
export class NobleEcdsaP256Engine implements SignEngine {
public generateKeypair(byteSource?: ByteSource): SignKeypair {
// The curve module's randomPrivateKey already performs the retry loop.
// When byteSource is provided (tests), we still use it to seed the secret
// even if the first draw happens to be invalid — we accept that injected
// test byte sources must themselves produce a valid P-256 scalar.
const secretKey =
byteSource === undefined ? p256.randomPrivateKey() : byteSource(32);
const publicKey = p256.getPublicKey(secretKey);
return { publicKey, secretKey };
}
public sign(secretKey: Uint8Array, message: Uint8Array): Uint8Array {
const digest = sha256(message);
return p256.sign(digest, secretKey);
}
public verify(
publicKey: Uint8Array,
signature: Uint8Array,
message: Uint8Array
): boolean {
const digest = sha256(message);
return p256.verify(signature, digest, publicKey);
}
}

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime deps are locked to the approved set", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@suaveplan/crypto-cipher",
"@suaveplan/crypto-curve",
"@suaveplan/crypto-hash",
"@suaveplan/crypto-utils",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
]);
});
it("no peerDependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(pkg.peerDependencies).toBeUndefined();
});
it("does not depend on any @noble/* package directly", {
timeout: 5000,
}, () => {
// Every @noble/* import must go through one of the @suaveplan/crypto-*
// selector packages. check-crypto-selects enforces the impl routing.
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const nobleDeps = Object.keys(pkg.dependencies ?? {}).filter((d) =>
d.startsWith("@noble/")
);
expect(nobleDeps).toEqual([]);
});
});

View File

@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const APPROVED_RANDOM = join(
SRC,
"primitives",
"random-bytes",
"random-bytes.ts"
);
const BANNED_GLOBAL: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\.[a-z]/,
/\bwindow\.[a-z]/,
];
const BANNED_OUTSIDE_APPROVED: ReadonlyArray<RegExp> = [
/\bMath\.random\b/,
/\bDate\.now\b/,
/\bcrypto\.getRandomValues\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ avoids Node + browser built-ins", { timeout: 5000 }, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED_GLOBAL) {
if (pattern.test(content)) hits.push(`${file} ${pattern}`);
}
}
expect(hits).toEqual([]);
});
it("random sources live only in the approved file", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
if (file === APPROVED_RANDOM) continue;
const content = readFileSync(file, "utf8");
for (const pattern of BANNED_OUTSIDE_APPROVED) {
if (pattern.test(content)) hits.push(`${file} ${pattern}`);
}
}
expect(hits).toEqual([]);
});
});

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,19 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exposes per-primitive subpath exports", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(8);
});
});

View File

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

View File

@@ -0,0 +1,61 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"primitives/random-bytes/random-bytes":
"src/primitives/random-bytes/random-bytes.ts",
"primitives/constant-time-equal/constant-time-equal":
"src/primitives/constant-time-equal/constant-time-equal.ts",
"primitives/zeroize/zeroize":
"src/primitives/zeroize/zeroize.ts",
"aead/aead": "src/aead/aead.ts",
"sign/index": "src/sign/index.ts",
"agree/index": "src/agree/index.ts",
"sealed-box/index": "src/sealed-box/index.ts",
"auth-box/index": "src/auth-box/index.ts",
"engines/index": "src/engines/index.ts",
"errors/index": "src/errors/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/crypto-cipher",
/^@suaveplan\/crypto-cipher\//,
"@suaveplan/crypto-curve",
/^@suaveplan\/crypto-curve\//,
"@suaveplan/crypto-hash",
"@suaveplan/crypto-utils",
/^@suaveplan\/crypto-utils\//,
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

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