Skip to Content
@exortek/cryptohash — digests, MAC, KDFs

hash

Digests, keyed message authentication, key derivation, timing-safe compare, signed cookies, and content-addressed fingerprints. Everything a non-encrypting checksum-shaped primitive can be.

import { hash, hmac, compare, verifyHmac, pbkdf2, hkdf, scrypt, signValue, unsignValue, fingerprint } from '@exortek/crypto'

When to use what

Use caseFunction
Fingerprint a file / blobhash
Verify a Stripe / GitHub webhook signatureverifyHmac
Sign a cookie valuesignValue
Derive an encryption key from a passphrasepbkdf2 or scrypt
Derive N keys from one master secrethkdf
Compare two secrets (timing-safe)compare
Cache key / ETag for a request bodyfingerprint

Digests

hash

hash(data: string | Buffer | Uint8Array, options?: HashOptions): string | Buffer interface HashOptions { algo?: 'sha256' | 'sha384' | 'sha512' | 'sha3-256' | 'sha3-384' | 'sha3-512' | 'blake2b512' | 'blake2s256' | 'sha1' | 'md5' // legacy, interop only encoding?: 'hex' | 'base64' | 'base64url' | 'buffer' // default 'hex' }

Compute a cryptographic hash. SHA-256 by default; see the algorithm table below for the full whitelist.

hash('hello world') // sha256 hex hash(fileBuf, { algo: 'sha512' }) // sha512 hex hash(buf, { encoding: 'base64url' }) // sha256 URL-safe hash(buf, { encoding: 'buffer' }) // raw digest bytes

Algorithm groups:

FamilyMembersUse for
SHA-2sha256 · sha384 · sha512Modern default. sha256 is the sane choice for almost everything.
SHA-3sha3-256 · sha3-384 · sha3-512Structurally distinct from SHA-2 (Keccak sponge). Hedge against SHA-2 cryptanalysis.
BLAKE2blake2b512 · blake2s256Faster than SHA-3 on modern CPUs, comparable security.
Legacysha1 · md5Broken. Included only for interop with existing digests you can’t change.

Never use md5 or sha1 on untrusted input. They are collision- vulnerable — an attacker can craft two different inputs with the same digest. Fine for a checksum on a file you control; catastrophic anywhere security matters.

hmac

hmac(data: string | Buffer | Uint8Array, secret: string | Buffer | Uint8Array, options?: HashOptions): string | Buffer

Compute a keyed HMAC (RFC 2104) — the standard way to prove “I know the key and this data has not been tampered with”. Suitable for signed cookies, webhook signatures, cache keys derived from user input.

hmac('user:42', COOKIE_SECRET) // sha256 hex hmac(webhookBody, WHSEC, { algo: 'sha256' }) // Stripe / GitHub style hmac(data, key, { encoding: 'base64url' }) // JOSE / URL contexts

Never use md5 or sha1 for HMAC of untrusted input. HMAC is more resilient than the underlying hash but prefer sha256+ for security- sensitive verification.

For verification, prefer verifyHmac — it wraps this call in a timing-safe compare so you never accidentally use ===.

compare

compare(a: string | Buffer | Uint8Array, b: string | Buffer | Uint8Array): boolean

Timing-safe equality check for secrets. Returns false for different- length inputs instead of throwing (unlike crypto.timingSafeEqual).

compare(givenToken, expectedToken) // true / false, no timing leak

Do not use === to compare an HMAC, a token, or a password hash. Character-by-character comparison leaks how many prefix characters matched, which is enough to brute-force the value across many requests.

verifyHmac

verifyHmac(data: string | Buffer | Uint8Array, expected: string | Buffer | Uint8Array, secret: string | Buffer | Uint8Array, options?: HashOptions): boolean

Compute the HMAC of data under secret and compare it timing-safely to expected. This is the primitive Stripe, GitHub, Slack, Twilio, Vercel and every webhook-shipping SaaS use.

// Express handler for a Stripe webhook app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => { const ok = verifyHmac( req.body, req.headers['stripe-signature'], process.env.STRIPE_WHSEC, ) if (!ok) return res.status(401).end() // ... handle the event })

expected can be a Buffer or a string. Strings are decoded per options.encoding (default hex). Match the format your webhook provider uses.

Key derivation (KDFs)

Three KDFs, three different jobs. Pick by input entropy:

KDFInput entropySpeedUse for
hkdfhigh (already-random secret)fastderive N subkeys from a master key
pbkdf2low (human passphrase)tunable slowinteroperable password KDF
scryptlow (human passphrase)memory-hard slowGPU-resistant password KDF

These KDFs derive keys from passwords, they don’t verify user logins. If you’re building password authentication, reach for a memory-hard, purpose-built password hasher (Argon2id or bcrypt). PBKDF2 and scrypt are here for deriving cipher key material from a passphrase, not for storing password verifiers.

hkdf

hkdf(ikm: string | Buffer | Uint8Array, options?: HkdfOptions): string | Buffer interface HkdfOptions { salt?: string | Buffer | Uint8Array // default empty info?: string | Buffer | Uint8Array // default empty — DOMAIN SEPARATION length?: number // default 32 hash?: 'sha256' | 'sha384' | 'sha512' // default 'sha256' encoding?: 'hex' | 'base64' | 'base64url' | 'buffer' // default 'buffer' }

HKDF (RFC 5869) — extract-and-expand key derivation from high-entropy input keying material. The info parameter provides domain separation — same IKM + different info = cryptographically independent subkeys.

// Two keys from one master secret, guaranteed independent const encKey = hkdf(masterSecret, { info: 'encryption', length: 32 }) const macKey = hkdf(masterSecret, { info: 'authentication', length: 32 }) // Session key with salt + application context const sessionKey = hkdf(sharedSecret, { salt: userId, info: 'session-v1', length: 32, })

Rule of thumb. Any time you’re about to use the same secret for two different purposes, run each through HKDF with a unique info string first. This is what TLS 1.3, Signal, and Age all do internally.

pbkdf2

pbkdf2(password: string | Buffer | Uint8Array, options: Pbkdf2Options): Promise<string | Buffer> interface Pbkdf2Options { salt: string | Buffer | Uint8Array // required, ≥ 16 bytes iterations?: number // OWASP 2023 defaults per digest keyLength?: number // default 32 digest?: 'sha256' | 'sha384' | 'sha512' // default 'sha512' encoding?: 'hex' | 'base64' | 'base64url' | 'buffer' // default 'buffer' }

PBKDF2 (RFC 8018) — derives a fixed-size key from a passphrase. Deliberately slow to make brute-force expensive. Defaults are OWASP 2023 minimum iteration counts per digest:

DigestDefault iterations
sha256600,000
sha384250,000
sha512210,000
const salt = random.bytes(16) const key = await pbkdf2('user passphrase', { salt }) // 32-byte AES-256 key // Explicit tuning const key = await pbkdf2('secret', { salt, iterations: 600_000, keyLength: 64, digest: 'sha512', })

iterations, keyLength, digest are part of the recipe. To recover the key later you need the exact same values — treat them as part of the ciphertext framing (that’s how encryptWithPassphrase handles it).

scrypt

scrypt(password: string | Buffer | Uint8Array, options: ScryptOptions): Promise<string | Buffer> interface ScryptOptions { salt: string | Buffer | Uint8Array keyLength?: number // default 32 N?: number // CPU/memory cost, default 2^15 (32768) r?: number // block size, default 8 p?: number // parallelisation, default 1 maxmem?: number // memory ceiling encoding?: 'hex' | 'base64' | 'base64url' | 'buffer' // default 'buffer' }

scrypt (RFC 7914) — a memory-hard KDF. Unlike PBKDF2, scrypt makes GPU / ASIC attacks expensive because each derivation needs a large chunk of RAM.

const key = await scrypt('user passphrase', { salt: random.bytes(16), }) // 32-byte key, ~64 MB peak RAM, ~100 ms on a modern laptop

N must be a power of two; the defaults (N=32768, r=8, p=1) match the scrypt paper’s “interactive login” tuning.

Signed values

signValue / unsignValue

signValue(value: string, secret: string | Buffer | Uint8Array, options?: { algo?: 'sha256' | 'sha384' | 'sha512' }): string unsignValue(signed: string, secret: string | Buffer | Uint8Array, options?: { algo?: 'sha256' | 'sha384' | 'sha512' }): string | null

Sign an opaque string value with a secret, producing a self-verifying string you can round-trip through a cookie, header, URL fragment, or link.

Output shape is <value>.<base64url_hmac> — the exact scheme Express’s cookie-signature and Django’s signing module use.

// Sign a session cookie value const cookie = signValue('sid:8f2a', process.env.COOKIE_SECRET) res.cookie('sid', cookie, { httpOnly: true, sameSite: 'lax' }) // Verify on the next request const sid = unsignValue(req.cookies.sid, process.env.COOKIE_SECRET) if (sid === null) return res.status(401).end() // tampered / wrong secret

Not encryption. The value stays visible in the output — the signature only proves the client hasn’t tampered with it. If the value itself is sensitive, use cipher.seal instead.

Rotation. Sign new cookies with the newest secret; verify by trying each candidate secret until one succeeds:

const value = SECRETS.map(s => unsignValue(cookie, s)).find(v => v !== null)

Constraints:

  • value must be a string.
  • value must not contain . (the separator). Encode as base64url or hex first for arbitrary bytes.
  • unsignValue returns null on any failure — bad separator, tampered MAC, wrong secret, unsupported algo. This is the boundary-check convention used by cookie-signature and Django; do not try/catch.

Fingerprint

fingerprint

fingerprint(value: unknown, options?: HashOptions): string | Buffer

Deterministic content-addressed hash of any JSON-shaped value.

JSON.stringify doesn’t guarantee key order — two equivalent objects can serialise differently. fingerprint first canonicalises the input (sorted keys per RFC 8785 UTF-16 code-unit order, no whitespace, .toJSON() unwrap, Date handling) and then hashes.

fingerprint({ b: 2, a: 1 }) === fingerprint({ a: 1, b: 2 }) // true fingerprint({ items: [{ id: 2 }, { id: 1 }] }) // stable across runtimes fingerprint(payload, { algo: 'sha512', encoding: 'base64url' })

Use cases:

  • Cache keys / ETags derived from a request body.
  • Idempotency keys for at-least-once APIs — reject a duplicate POST if the body fingerprint matches a previous request.
  • Dedup IDs in an event stream — compute once at the producer.

Accepted: null, boolean, finite number, string, Array, plain object, any object with a .toJSON() method (e.g. Date).

Rejected (throws INVALID_ARGUMENT): undefined, bigint, symbol, function, NaN, ±Infinity, Buffer / Uint8Array at any depth (encode as base64 or hex first), cyclic references.

Security notes

  • HMAC is timing-safe by construction in verifyHmac and compare. Use those, not ===.
  • Never use md5 or sha1 on untrusted input. Available for legacy interop only.
  • KDF outputs are keys, not password verifiers. PBKDF2 and scrypt derive cipher key material from a passphrase — they are not the right tool for storing a user’s login credentials. Use a memory-hard password hasher (Argon2id) for that.
  • Fingerprint canonicalisation is JSON-shaped only. It rejects BigInt, Symbol, Buffer at any depth — encode them explicitly first to make the canonical form part of your contract, not ours.
  • Domain-separate everything. If you’re deriving multiple keys from one secret, run each through HKDF with a distinct info string. This is the single most under-used precaution in production systems.

Errors

See the errors page for the full ErrorCode enum. From this module you may see:

  • INVALID_ARGUMENT — bad input type, non-finite number, missing salt.
  • UNSUPPORTED_ALGORITHM — algo not in the whitelist.
  • INVALID_ENCODING (via Buffer.from(str, encoding)) — malformed encoded strings.
Last updated on