@codefusion-cc/workers-crypto
v0.1.2
Published
Bytes and text for tokens, signatures and webhooks on WebCrypto: base64url and base64 both ways, hex, digests (MD5 too in Workers, for payment gateways), HMAC-SHA256 signing and constant-time checking, labelled keyed hashes, a constant-time comparison and
Maintainers
Readme
@codefusion-cc/workers-crypto
Bytes and text the way Workers code needs them for tokens, signatures and webhooks. Built on WebCrypto only, so it runs in Workers, Node and the browser. The one exception is MD5, which only Workers have.
npm install @codefusion-cc/workers-cryptoimport { base64UrlToBytes, digestHex, hmacMatches, hmacSha256, keyedHash, randomToken, safeEqual } from '@codefusion-cc/workers-crypto'
const token = randomToken() // 32 random bytes, 43 characters of base64url
const bytes = base64UrlToBytes(header) // null when it is not base64url: no exception to catch
const ok = safeEqual(await digestHex('SHA-384', JSON.stringify(fields)), notification.sign)
// Sign a webhook body, and check the signature a provider sent, in constant time.
headers.set('x-signature', await hmacSha256(env.WEBHOOK_SECRET, body, 'hex'))
const genuine = await hmacMatches(env.WEBHOOK_SECRET, body, request.headers.get('x-signature') ?? '', 'hex')
// An id that cannot be traced back to the visitor, and matches nothing else the secret signs.
const visitor = await keyedHash(env.VISITOR_HASH_SECRET, 'console-visitor', visitorId, { bytes: 8 })- Every decoder returns null for text it does not read:
base64UrlToBytes,base64ToBytes,hexToBytesandbase64UrlToUtf8. Each value has one spelling. Whitespace is refused, and so are unused bits set in the last character, which a lenient decoder would read as the same bytes. base64UrlToBytesrefuses=padding unless{ padded: true }is passed, because tokens and JWTs never have it.base64ToBytesreads standard base64 and requires its padding unless{ padding: 'optional' }is passed.hmacSha256(secret, data, encoding?)takes the secret as text (UTF-8, as Worker secrets are set), bytes, or a CryptoKey. It returns 32 bytes, or text in'hex','base64'or'base64url'. A text secret is imported once per isolate. An empty or missing secret, or a CryptoKey for another algorithm or hash, rejects with a TypeError.hmacMatches(secret, data, mac, encoding?)compares in constant time. Never compare two MACs with===. A text MAC needs its encoding (a TypeError without one); a MAC in the wrong encoding or of the wrong length does not match.keyedHash(secret, label, value, { bytes, encoding })is the HMAC oflabel\nvalue, cut tobytes. The label keeps one use's hashes apart from another's under the same secret. It must be one line.- Never use one secret for both
keyedHashandhmacSha256.keyedHashis thehmacSha256of the label, a line break and the value, so a MAC over data shaped that way equals a keyed hash. Give each its own secret. safeEqualtakes time that does not depend on where two texts differ. Only a length difference shows.digestHex('MD5' | 'SHA-1', …)is only for providers whose signatures require those algorithms. Some payment gateways do.
Code built on WebCrypto
Two things every package that imports keys or opens sealed data needs, kept here once.
import { createPromiseCache, nullIfRefused } from '@codefusion-cc/workers-crypto'
// Import each key once per isolate (or page), at most 64 of them.
const imported = createPromiseCache<CryptoKey>(64)
const keyFor = (raw: string) => imported(raw, () => crypto.subtle.importKey('raw', decode(raw), 'AES-GCM', false, ['decrypt']))
// null when the tag does not match (tampered, or another key); an outage rejects as itself.
const plain = await nullIfRefused(crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await keyFor(raw), data), 'OperationError')createPromiseCache<T>(limit)returns(key, make) => Promise<T>. Calls for a key share one run ofmake(key), and its result serves every later call. It keeps at mostlimitresults, work still running among them, and lets the least recently used go first. A rejection is never kept: the callers waiting on it see it, and the next call runsmakeagain, so one failed import does not break that key for the rest of the isolate's life. A null result is an answer and is kept.nullIfRefused(call, ...names)resolves what a WebCrypto call resolves, or null when WebCrypto refuses the input with one ofnames:OperationError(an AES-GCM tag that does not match, an ECDH or X25519 point that gives no secret),DataError(key data that is no key),InvalidAccessError(a key of another kind than the operation). Every other failure (an algorithm the runtime lacks, a broken binding, an outage) rejects as itself. Catching everything instead turns an outage into "no key", "tampered" or "forged", and code that acts on those (writes new keys, deletes the data, signs the person out) then does damage.refusedWith(error, ...names)is the same test for acatchblock. Both read the error's name alone, so a DOMException from another realm still counts.
