wats.sh
Reference

`@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.

ParameterTypesNotes
keyUint8Array (non-empty)byteLength >= 1
keystring (non-empty)UTF-8 encoded at the seam
bodyUint8Arrayzero-length accepted (empty-body HMAC)
bodystringUTF-8 encoded at the seam; "" accepted

Validation errors:

  • InvalidKeyError (code: "invalid_key"): null, undefined, number, object, array, symbol, function, empty string, empty Uint8Array.
  • 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-Uint8Array input → throws CryptoProviderError (code: "invalid_body"), never a raw TypeError.

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" });
  1. If options.prefer is supplied, try that adapter first.
  2. Otherwise, prefer the Node adapter on Node or Bun (globalThis.process?.versions?.node defined OR globalThis.Bun defined); otherwise WebCrypto.
  3. If the chosen adapter fails capability detection with UnsupportedCapabilityError, fall back to the other.
  4. If both fail, throw UnsupportedCapabilityError with both underlying errors attached via cause.
  5. Unknown prefer values (anything other than "node" / "webcrypto") throw UnsupportedCapabilityError synchronously.

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 failureSurface error
invalid key type / empty keyInvalidKeyError
invalid body typeInvalidBodyError
invalid n for randomBytesInvalidLengthError
SubtleCrypto.importKey rejectsInvalidKeyError
SubtleCrypto.sign rejectsCryptoProviderError (invalid_body) with cause
node:crypto not availableUnsupportedCapabilityError
globalThis.crypto.subtle missingUnsupportedCapabilityError
both adapters fail detectionUnsupportedCapabilityError

Subpath export map

SpecifierPurpose
@wats/cryptobarrel: factory + adapters + error classes
@wats/crypto/providerCryptoProvider interface + capability types
@wats/crypto/errorserror class hierarchy + validation helpers
@wats/crypto/nodecreateNodeCryptoProvider (direct adapter)
@wats/crypto/webcryptocreateWebCryptoProvider (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

RuntimeDefault adapterNotes
Bun ≥ 1.3nodeprocess.versions.node + globalThis.Bun defined; node:crypto available natively
Node ≥ 20nodenode:crypto dynamic import succeeds
Node 18nodenode:crypto available; WebCrypto also present
DenowebcryptoglobalThis.crypto.subtle present; node:* not statically imported
Cloudflare WorkerswebcryptoSubtleCrypto available; node:* forbidden by bundler
Vercel EdgewebcryptoSubtleCrypto available; node:* forbidden
Modern browserswebcryptoSubtleCrypto 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) — asserts Uint8Array | string (non-empty).
  • assertValidBody(body) — asserts Uint8Array | string (empty OK).
  • assertUint8Array(value, label) — asserts plain Uint8Array.
  • 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.
  • randomBytes boundary acceptance at 1_048_576, rejection at 1_048_577, probabilistic distinct-output test.
  • Consumer-fixture round-trip imports all five subpaths, runs live HMAC / timingSafeEqual / randomBytes calls, and asserts InvalidKeyError surfaces across the package boundary.
  • Workspace policy test pins the dynamic-import pattern and forbids static node:* imports in src/.

On this page