@vibedout/web-crypto
v0.1.1
Published
WebCrypto-only primitives for Workers, browsers and Node: byte encodings (hex, base64, strict and lenient base64url), HMAC-SHA256, padded constant-time compare, SHA-256 digests, random tokens, peppered secret minting, webhook signature-header parsing with
Maintainers
Readme
@vibedout/web-crypto
The crypto every package and Worker needs, written once: byte encodings, HMAC-SHA256, constant-time compare, SHA-256 digests, random tokens, minting API keys, the steps every signed-webhook scheme repeats, and an at-most-once receipt store.
- No dependencies, and no Node. Everything is
crypto.subtle,crypto.getRandomValues,btoaandTextEncoder, so the same code runs in a Worker, a browser and Node. - ESM only, with types. Each module is also a subpath (
@vibedout/web-crypto/hmac) for a narrower import.
import { hmacSha256Hex, timingSafeEqual, sha256Hex, randomToken } from "@vibedout/web-crypto";Modules
bytes
UTF-8, hex, base64 and base64url.
import { utf8Encode, toHex, fromHex, toBase64, fromBase64, base64UrlEncode, base64UrlDecode, fromBase64Url, toBytes, concatBytes } from "@vibedout/web-crypto/bytes";
toHex(new Uint8Array([255, 0])); // "ff00"
toBase64(bytes); // padded, built in chunks: safe on a multi-megabyte asset
base64UrlEncode(bytes); // unpadded, URL and cookie safeThere are two base64url decoders, and the names say which:
| Function | Accepts | Use it for |
|---|---|---|
| base64UrlDecode | Only the URL-safe alphabet, and only the one canonical spelling of each byte string. Throws on +, /, =, whitespace and non-zero trailing bits. | A token or signature you compare, dedupe or log |
| fromBase64Url | Padded or unpadded, and the standard alphabet too. | Reading a value someone else produced |
toBase64Url is base64UrlEncode under the name the Workers have always used.
hmac
import { hmacSha256, hmacSha256Hex, hmacSha256Base64, hmacSha256Base64Url, verifyHmacSha256 } from "@vibedout/web-crypto/hmac";
const mac = await hmacSha256Hex("whsec_...", `${timestamp}.${body}`); // text or bytes, for both
const ok = await verifyHmacSha256(secret, message, signatureBytes); // constant timeAn empty secret throws: an unset webhook secret must fail loudly, not sign with nothing.
compare
import { timingSafeEqual } from "@vibedout/web-crypto/compare";
if (!timingSafeEqual(presentedToken, storedToken)) return unauthorized();Always padded: both sides are walked to the length of the longer one, and a length difference is folded into the result. There is no early return on length. The walk length is the longer input's, so when even the length of the stored secret must stay private, hash both sides first and compare the digests.
digest
import { sha256Hex, sha256B64u, hashParts } from "@vibedout/web-crypto/digest";
await sha256Hex("text or bytes"); // 64 hex characters: a hashed lookup key
await sha256B64u(verifier); // 43 characters: a PKCE challenge, a hashed token
await hashParts([email, ip]); // sha256B64u(JSON.stringify(parts)): no part can be smuggled across a boundaryrandom
import { randomBytes, randomToken, randomHex, randomString, prefixedId } from "@vibedout/web-crypto/random";
randomToken(); // 32 random bytes as 43 URL-safe characters
prefixedId("mi"); // "mi_" + 22 URL-safe characters (16 random bytes): an id that says what it is
randomString("23456789ABCDEFGHJKMNPQRSTUVWXYZ", 8); // a pairing code, no modulo biasEvery function that draws randomness takes an optional source as its last argument, so a test can feed it fixed bytes.
mint
API keys and bearer secrets: shown once, stored as a hash, found again by a short unhashed prefix.
import { mintPrefixedSecret, parsePrefixedSecret, hashSecret } from "@vibedout/web-crypto/mint";
const { plaintext, lookupPrefix, hash } = await mintPrefixedSecret({ prefix: "ve_sec_", bytes: 32, pepper: env.KEY_PEPPER });
// show `plaintext` once; store `lookupPrefix` and `hash`
const parsed = parsePrefixedSecret(presented, { prefix: "ve_sec_" });
if (!parsed) return unauthorized();
const row = await findByPrefix(parsed.lookupPrefix);
if (!timingSafeEqual(await hashSecret(parsed.plaintext, env.KEY_PEPPER), row.hash)) return unauthorized();peppermixes a server secret into the hash assha256(pepper + ":" + value), so a stolen database cannot be brute-forced without it.hashPart: "body"hashes only the random part, for a format that carries its own id after the prefix (vkn_<id>_<secret>).- At least 24 bytes (192 bits), and a lookup prefix of at most half the body, so the default 12-character prefix is valid for every size accepted.
hashSecret(value, pepper?)is the same hash, for re-hashing a presented secret.
webhook
A signed webhook is three steps. Each adapter keeps only what is its own: what is signed, which headers, how long a delivery stays valid.
import { parseSignatureHeader, verifyHmacCandidates, checkFreshness } from "@vibedout/web-crypto/webhook";
// Stripe: `Stripe-Signature: t=1700000000,v1=abc,v1=def`
const parts = parseSignatureHeader(header); // Map { t: ["1700000000"], v1: ["abc", "def"] }
const t = parts.get("t")?.[0];
if (!t) throw badSignature();
const signed = await verifyHmacCandidates({ secret, message: `${t}.${rawBody}`, candidates: parts.get("v1") ?? [] });
if (!signed.ok) throw badSignature();
const fresh = checkFreshness(t, { unit: "s", toleranceSeconds: 300 });
if (!fresh.ok) throw stale(); // "malformed" or "expired"- Signature first, clock second is the safer order when the two outcomes are told apart (a genuine but late delivery is
expired, a forgery is never reported as late). A scheme that treats both as one failure may check either first. parseSignatureHeadertakes{ separator, lowercaseKeys }; a key may repeat (a rotation window signs with both secrets).verifyHmacCandidatestakes one message or several (when the provider's wording of what it signed is uncertain, try the raw body and the re-serialised one), andencodings("hex"by default,"base64","base64url"). Every comparison is constant time.checkFreshnesstakesunit: "s" | "ms" | "auto". In seconds the clock is read to the whole second, the way Stripe documents the check, so a window of N seconds is N whole seconds either side of the signing time; in milliseconds it is exact.WebhookInput,createHeaderReaderandwebhookInputFromRequestgive every adapter the same view of a delivery, body bytes untouched.
receipts
Webhooks arrive at least once. handleOnce claims every key a delivery is known by, then runs the
handler, and releases the claims if it throws so the provider's retry is processed.
import { D1ReceiptStore, handleOnce, receiptsMigrationSql } from "@vibedout/web-crypto/receipts";
const store = new D1ReceiptStore(env.DB); // table `webhook_receipts`; see receiptsMigrationSql()
const outcome = await handleOnce({
store,
namespace: "envia",
keys: [deliveryId, `content:${contentHash}`], // a replay under a fresh delivery id is still recognised by its content
handle: () => applyEvent(event),
});
// { status: "processed", result } or { status: "duplicate" }: answer 2xx either way- Verify the signature before calling it, so an unsigned request can never poison the receipts.
claimisINSERT ... ON CONFLICT DO NOTHINGand a check of the changed-row count: exactly one of any number of concurrent claims wins.- Copy
receiptsMigrationSql()into the consuming Worker'smigrations/. Keep receipts at least as long as the provider's replay window, and runpurgeClaimedBefore(ms)from a cron trigger. - A claim or the handler that throws releases every claim this call made, so the provider's retry is processed. If a release fails too the claim is stuck and the retry would be answered
duplicate, so that is reported, not swallowed: anAggregateErrorwith the original error first. - If the process dies between the claim and the end of the handler, the claim stays. Where that matters, use
BatchedD1ReceiptStore(below): the claim is written in the same D1 batch as the effects. createMemoryReceiptStore()backs tests and single-isolate use. Any object withclaimandreleaseis a store.
A claim that cannot outlive its effects. D1ReceiptStore writes the claim first, so a Worker that dies before the handler finishes leaves a claim with no effect, and the retry is answered duplicate. BatchedD1ReceiptStore only looks at claim and remembers the pair; the INSERTs come out of statements() for the one db.batch that writes the effects (D1 runs a batch as one transaction), so a Worker that dies before it leaves nothing and the retry is processed.
import { BatchedD1ReceiptStore, handleOnce } from "@vibedout/web-crypto/receipts";
const store = new BatchedD1ReceiptStore(env.DB, { table: "webhook_receipts" }); // one per delivery
await handleOnce({
store, namespace: "envia", keys: [deliveryId],
handle: () => env.DB.batch([...effectsOf(event), ...store.statements()]), // the claim and the effects, together
});statementsIfChanged()writes each claim only if the statement before it in the batch changed a row (changes() > 0): put it after a compare-and-swap and its event, so a swap that lost claims nothing. A claim it did not write is still returned bystatements().- Two deliveries of one event at one moment can both pass
claim's look: the effects must be idempotent (a unique index, a compare-and-swap, an event with a dedupe key). - A delivery found to be a duplicate writes nothing, so a key that was new in a call that found another key seen is not recorded.
release(the handler threw) forgets the claim, and deletes the row when a statement for it was handed out.
testing
dyingD1(db, { dieAfter }) (@vibedout/web-crypto/testing, not on the barrel) wraps a D1 binding that answers dieAfter calls and then dies: the next call and every call after it throw WorkerDied, before they reach the database, and nothing the handler would clean up afterwards runs. That is what a Worker that is killed does, and no test that throws an ordinary error can show it: the error is caught and the handler releases its claim. A test finds the number of places to kill at with a large dieAfter and calls(), then runs the delivery once for each place and sends it again to a healthy database.
Testing
pnpm test (vitest). Digests, HMACs and encodings are checked against Node's own crypto and Buffer, the
published test vectors (SHA-256, RFC 4231, RFC 7636) and fixed expected values, and the D1 receipt store runs
its real SQL on SQLite. Webhook fixtures are signed with Node's own HMAC, so a bug here cannot hide on both
sides.
