@strimz/shared-crypto
v0.1.1
Published
Web Crypto primitives for Strimz. HMAC webhook signing, API key generation, hashing, AES-256-GCM envelope encryption, and constant-time comparison. Runs in Node 22 and Edge runtimes.
Readme
@strimz/shared-crypto
Web Crypto primitives for Strimz. HMAC webhook signing, API key generation, AES-256-GCM envelope encryption, and constant-time comparison. Runs anywhere Web Crypto runs.
Every primitive is built on the Web Crypto API
(globalThis.crypto.subtle), so the same code runs unchanged in
Node 22, Vercel Edge, Cloudflare Workers, Deno, Bun, and any modern
browser. There is no node:crypto, no Buffer, no platform-specific
shim.
Install
pnpm add @strimz/shared-crypto
# npm install @strimz/shared-crypto
# yarn add @strimz/shared-cryptoWhat's inside
Every group is also available as a subpath import.
| Subpath | Contents |
| -------------- | ------------------------------------------------------------------------------------------------------- |
| /encoding | toHex, fromHex, toBase64Url, utf8ToBytes, bytesToUtf8 |
| /hash | sha256, sha256Hex |
| /hmac | hmacSha256, hmacSha256Hex |
| /random | randomBytes, randomHex, randomBase64Url, uuid |
| /timing-safe | timingSafeEqualBytes, timingSafeEqualString |
| /webhook | signWebhookPayload, verifyWebhookSignature (timestamp + HMAC scheme) |
| /api-key | generateApiKey, hashApiKey, redactApiKey |
| /aes-gcm | encryptAesGcm, decryptAesGcm, generateAesGcmKey (envelope encryption for webhook secrets at rest) |
The package root re-exports every subpath.
Webhook signing
import { signWebhookPayload, verifyWebhookSignature } from '@strimz/shared-crypto'
// Sender
const header = await signWebhookPayload(JSON.stringify(body), secret)
// → "t=1735123456,v1=abc123...def"
// Receiver
const result = await verifyWebhookSignature(rawBodyString, header, secret)
if (!result.valid) {
throw new Error(`invalid signature: ${result.reason}`)
}Signatures are HMAC-SHA256 over "${t}.${payload}". The timestamp is
bound to the signature so replays are detectable. Verification rejects
any timestamp outside the tolerance window (default 5 minutes,
configured in @strimz/shared-config/webhooks).
API key generation
import { generateApiKey, hashApiKey } from '@strimz/shared-crypto'
const { secret, hash, prefix, lastFour } = await generateApiKey('secret', 'test')
// Persist { hash, prefix, lastFour, kind: 'secret', mode: 'test' }.
// Return `secret` to the caller exactly once, then drop it from memory.
// On every authenticated request:
const incomingHash = await hashApiKey(req.headers.authorization.slice('Bearer '.length))
const row = await db.apiKey.findUnique({ where: { hash: incomingHash } })AES-256-GCM envelope encryption
import { encryptAesGcm, decryptAesGcm, generateAesGcmKey } from '@strimz/shared-crypto'
const key = await generateAesGcmKey() // 32 bytes, hex-encoded
const ciphertext = await encryptAesGcm('hello world', key)
const plaintext = await decryptAesGcm(ciphertext, key)The ciphertext encoding is v1:<nonce-hex>:<ciphertext-and-tag-hex>,
safe to store in a text column. Suitable for envelope-encrypting
small secrets where the key is held outside the data store.
Runtime support
| Runtime | Supported | | ------------------ | --------- | | Node 22+ | Yes | | Browsers | Yes | | Vercel Edge | Yes | | Cloudflare Workers | Yes | | Deno / Bun | Yes |
Design notes
- No secrets are logged.
redactApiKeyexists specifically so any diagnostic that must include a key logs only the safe prefix and the last four characters. - No Node
Bufferornode:crypto. Every primitive usesglobalThis.crypto.subtleandUint8Array. - Constant-time compare on signatures. Signature verification never short-circuits on the first mismatched byte.
- Policy lives elsewhere. Tolerance windows, key prefixes, and tier thresholds come from
@strimz/shared-config; this package consumes them.
Links
License
MIT © Strimz