`@wats/crypto` reference
The CryptoProvider seam and its Node and WebCrypto adapters with a typed error hierarchy.
stable
@wats/crypto ships a single interface and two adapters. Every failure surfaces as a typed CryptoProviderError subclass — raw TypeError, RangeError, and DOMException never escape.
import type { CryptoProvider } from "@wats/crypto/provider";
export interface CryptoProvider {
readonly name: string;
hmacSha256(
key: Uint8Array | string,
body: Uint8Array | string
): Promise<Uint8Array>;
timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
randomBytes(byteLength: number): Promise<Uint8Array>;
rsaOaepDecrypt?(
privateKey: JsonWebKey | Uint8Array,
ciphertext: Uint8Array
): Promise<Uint8Array>;
aesGcmDecrypt?(
key: Uint8Array,
iv: Uint8Array,
ciphertext: Uint8Array,
aad?: Uint8Array
): Promise<Uint8Array>;
}rsaOaepDecrypt and aesGcmDecrypt are forward-declared for Flows / Media / Encrypted Payloads work. The current adapters do not attach them — the properties are undefined, and a direct call yields a runtime TypeError. Optional-chain (provider.rsaOaepDecrypt?.(...)) and treat undefined as "capability unavailable". When they land, real implementations or UnsupportedCapabilityError-throwing stubs attach additively; the interface already marks them optional.
Methods
hmacSha256(key, body)
Computes HMAC-SHA256 over body using key. Returns a fresh 32-byte Uint8Array.
| Parameter | Types | Notes |
|---|---|---|
key | Uint8Array (non-empty) | byteLength >= 1 |
key | string (non-empty) | UTF-8 encoded at the seam |
body | Uint8Array | zero-length accepted (empty-body HMAC) |
body | string | UTF-8 encoded at the seam; "" accepted |
Validation errors:
InvalidKeyError(code: "invalid_key"):null,undefined, number, object, array, symbol, function, empty string, emptyUint8Array.InvalidBodyError(code: "invalid_body"):null,undefined, number, object, plain array, symbol, function.
Underlying SubtleCrypto.sign / node:crypto.createHmac failures are wrapped in a CryptoProviderError with a cause field.
timingSafeEqual(a, b)
Constant-time equality of two Uint8Array values.
- Length mismatch → returns
false, never throws. Length is public. - Equal length → constant-time XOR accumulator scan (WebCrypto adapter) or
crypto.timingSafeEqual(Node adapter). - Non-
Uint8Arrayinput → throwsCryptoProviderError(code: "invalid_body"), never a rawTypeError.
randomBytes(n)
Returns a Uint8Array of length n, backed by crypto.getRandomValues (WebCrypto) or node:crypto.randomBytes (Node/Bun). The returned buffer is detached from any runtime identity — not a Buffer, not a pooled slab.
Validation (InvalidLengthError, code: "invalid_length"): n must be a finite integer in [1, 1_048_576]. Rejects 0, negative, NaN, ±Infinity, non-integer, non-number. The boundary n = 1_048_576 is accepted.
Adapter selection
import { createCryptoProvider } from "@wats/crypto";
const provider = await createCryptoProvider();
// ...or force a specific adapter
const web = await createCryptoProvider({ prefer: "webcrypto" });
const node = await createCryptoProvider({ prefer: "node" });- If
options.preferis supplied, try that adapter first. - Otherwise, prefer the Node adapter on Node or Bun (
globalThis.process?.versions?.nodedefined ORglobalThis.Bundefined); otherwise WebCrypto. - If the chosen adapter fails capability detection with
UnsupportedCapabilityError, fall back to the other. - If both fail, throw
UnsupportedCapabilityErrorwith both underlying errors attached viacause. - Unknown
prefervalues (anything other than"node"/"webcrypto") throwUnsupportedCapabilityErrorsynchronously.
node:crypto is never statically imported anywhere in packages/crypto/src. The Node adapter loads it via dynamic await import(specifier) inside the factory body, so bundlers targeting Cloudflare Workers / Vercel Edge / Deno never resolve node:*. packages/testing/tests/workspace-policy.test.ts enforces this.
Error taxonomy
CryptoProviderError (Error)
├── InvalidKeyError code: "invalid_key"
├── InvalidBodyError code: "invalid_body"
├── InvalidLengthError code: "invalid_length"
└── UnsupportedCapabilityError code: "unsupported_capability"Branch on err.code for individual codes, or instanceof CryptoProviderError to separate crypto failures from everything else.
| Underlying failure | Surface error |
|---|---|
| invalid key type / empty key | InvalidKeyError |
| invalid body type | InvalidBodyError |
invalid n for randomBytes | InvalidLengthError |
SubtleCrypto.importKey rejects | InvalidKeyError |
SubtleCrypto.sign rejects | CryptoProviderError (invalid_body) with cause |
node:crypto not available | UnsupportedCapabilityError |
globalThis.crypto.subtle missing | UnsupportedCapabilityError |
| both adapters fail detection | UnsupportedCapabilityError |
Subpath export map
| Specifier | Purpose |
|---|---|
@wats/crypto | barrel: factory + adapters + error classes |
@wats/crypto/provider | CryptoProvider interface + capability types |
@wats/crypto/errors | error class hierarchy + validation helpers |
@wats/crypto/node | createNodeCryptoProvider (direct adapter) |
@wats/crypto/webcrypto | createWebCryptoProvider (direct adapter) |
All subpaths are listed in packages/crypto/package.json's exports map; each has at least one consumer-fixture assertion in packages/testing/fixtures/crypto-consumer/verify-imports.ts.
Runtime portability matrix
| Runtime | Default adapter | Notes |
|---|---|---|
| Bun ≥ 1.3 | node | process.versions.node + globalThis.Bun defined; node:crypto available natively |
| Node ≥ 20 | node | node:crypto dynamic import succeeds |
| Node 18 | node | node:crypto available; WebCrypto also present |
| Deno | webcrypto | globalThis.crypto.subtle present; node:* not statically imported |
| Cloudflare Workers | webcrypto | SubtleCrypto available; node:* forbidden by bundler |
| Vercel Edge | webcrypto | SubtleCrypto available; node:* forbidden |
| Modern browsers | webcrypto | SubtleCrypto in secure contexts |
Override the default via prefer. Cross-adapter hmacSha256 output is byte-for-byte identical (RFC 4231 TC1/TC2/TC4 pinned in tests against both adapters).
Validation helpers
@wats/crypto/errors re-exports the validation helpers the adapters use; they are stable for downstream consumers:
assertValidKey(key)— assertsUint8Array | string(non-empty).assertValidBody(body)— assertsUint8Array | string(empty OK).assertUint8Array(value, label)— asserts plainUint8Array.assertFiniteLength(n, min, max)— asserts finite integer in range.
Each throws the corresponding typed error on failure.
Test-pinned guarantees
- RFC 4231 HMAC-SHA256 known-answer vectors (TC1, TC2, TC4) run against both adapters via a shared contract suite.
- Full input-rejection matrix per method; every branch asserted as a typed error subclass.
- Constant-time compare verified with first-byte and last-byte divergence tests and non-throwing length-mismatch behavior.
randomBytesboundary acceptance at1_048_576, rejection at1_048_577, probabilistic distinct-output test.- Consumer-fixture round-trip imports all five subpaths, runs live HMAC / timingSafeEqual / randomBytes calls, and asserts
InvalidKeyErrorsurfaces across the package boundary. - Workspace policy test pins the dynamic-import pattern and forbids static
node:*imports insrc/.