@magnaboy/webcrypto
v0.1.0
Published
WebCrypto-only primitives: byte codecs, hashing and HMAC, CSPRNG helpers, AES-GCM sealing and prefixed API tokens.
Readme
@magnaboy/webcrypto
WebCrypto-only primitives: byte codecs, hashing and HMAC, CSPRNG helpers, AES-GCM sealing and prefixed API tokens.
No node: import, no Buffer, no process, no dependencies. The same code runs on Node 20+, in a
browser and inside workerd.
Bytes
utf8Encode / utf8Decode, base64Encode / base64Decode, base64UrlEncode / base64UrlDecode,
utf8ToBase64 / base64ToUtf8, hexEncode / hexDecode.
hexDecode returns null rather than throwing, because callers decode attacker-supplied input and
branch on the result. Base64 encoding chunks its input, so a multi-megabyte array does not blow the
argument limit of String.fromCharCode.
Every returned array is a Bytes (Uint8Array<ArrayBuffer>), which WebCrypto accepts without a
cast. toBytes normalises anything a caller passes in, copying a view over a SharedArrayBuffer
rather than rejecting it.
Hashing
sha256Bytes / sha256Hex, hmacSha256Bytes / hmacSha256Hex, and hmacSha256Verify for webhook
signatures. Verification accepts hex, sha256=-prefixed hex, base64 and base64url, because
providers disagree, and compares every candidate without short-circuiting.
timingSafeEqualBytes is constant time in the contents but not in the length: a length mismatch
returns immediately. Compare fixed-size digests when the length itself is secret.
Randomness
randomBytes fills in 64 KiB chunks, since getRandomValues rejects a larger request in one call.
randomString uses rejection sampling — bytes at or above the largest multiple of the alphabet
length are discarded rather than folded — so every character stays equally likely. randomId
defaults to an alphabet with no i, l or o, so an id survives being read aloud.
Sealing
encryptSecret / decryptSecret are AES-GCM over a string, returning base64 with the IV prepended.
sealToken / openToken seal a JSON payload with an expiry into a base64url token — safe in a
path, a query value or a header with no escaping — stamping created_at, expires_at and a random
nonce onto it.
Both take a context string as additional authenticated data, so a value sealed for password reset
will not open as an email verification. Pass now to make expiry deterministic in tests.
import { generateEncryptionSecret, openToken, sealToken } from '@magnaboy/webcrypto';
const secret = generateEncryptionSecret();
const token = await sealToken(secret, { userId: 'u_1' }, { ttlMs: 900_000, context: 'password-reset' });
const claims = await openToken<{ userId: string }>(secret, token, { context: 'password-reset' });openToken throws for a bad key, a tampered token, unreadable JSON or an expired expires_at. The
message names the stage it failed at without echoing the plaintext.
Tokens
generateToken('cxt', 'prod') produces cxt_prod_<random><checksum>. The prefix makes a leaked
token greppable and the 6-character SHA-256 checksum lets a scanner reject a lookalike without a
database round trip. parseToken splits one and isValidTokenFormat re-derives the checksum, which
covers the prefix and environment, so a relabelled token fails.
The checksum is an integrity check on a public string, not a secret, so it is compared with ===.
Overlap with @magnaboy/oauth-core
@magnaboy/oauth-core exports its own base64ToBytes, bytesToHex, randomBytes and
timingSafeEqual from encoding.ts, under different names. The two are independent today. If
oauth-core ever takes a dependency here, that duplication should go.
