sdxc

Type to search, or start from one of these:

@sdxc/crypto

Web Crypto primitives — hashing, HMAC, tokens, TOTP, AES-GCM — plus scrypt passwords

npm add @sdxc/crypto
pnpm add @sdxc/crypto
yarn add @sdxc/crypto
bun add @sdxc/crypto
Installs with
@sdxc/result
Used by
uptime, auth-saas, reader, blog

WebCrypto primitives — encoding, digests, HMAC, tokens, password hashing, TOTP, and authenticated encryption — with Result-based errors.

Installation

npm add @sdxc/crypto

Everything runs on the Web Crypto API, through crypto.subtle and crypto.getRandomValues, with one exception: scrypt has no Web Crypto equivalent, so password hashing reaches for node:crypto, which Node, Bun, and Cloudflare Workers each implement natively. Operations that can fail return a Result from @sdxc/result, which installs with this package and supplies unwrap, isSuccess, and isFailure.

Usage

Encoding and digests

import { Base64, Base64Url, Hex, sha256 } from "@sdxc/crypto";
import { isSuccess } from "@sdxc/result";

let digest = await sha256(apiKey);
if (isSuccess(digest)) {
	let lookupHash = Hex.encode(digest.data); // "9f86d081..."
}

Base64Url.encode(new Uint8Array([255, 224])); // "_-A", no padding
Base64.encode("Aladdin:open sesame"); // "QWxhZGRpbjpvcGVuIHNlc2FtZQ==", padded
Hex.decode("zz"); // failure(InvalidEncodingError)

Signing and verifying a payload

import { hmac, randomToken } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";

let secret = randomToken({ bytes: 32, prefix: "whsec" });
let signature = await hmac.sign(secret, body);
let valid = unwrap(await hmac.verify(secret, body, request.headers.get("x-signature") ?? ""));

Passwords

import { password } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";

let stored = unwrap(await password.hash(form.password));
// "$scrypt$ln=15,r=8,p=3$<salt>$<key>"

let valid = unwrap(await password.verify(stored, form.password));

Second factor

import { totp } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";

let secret = totp.generateSecret();
let uri = totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
let valid = unwrap(await totp.verify(secret, form.code, { window: 1 }));

Encryption at rest

import { importKey, open, seal } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";

let key = unwrap(await importKey(sealKey)); // base64url, 16, 24, or 32 bytes
let sealed = unwrap(await seal(key, refreshToken)); // "v1.<iv>.<ciphertext>"
let plaintext = unwrap(await open(key, sealed));

API

Encoding

Hex.encode(data: BinaryLike): string

Encodes bytes as lowercase hexadecimal, two characters per byte: Hex.encode(new Uint8Array([0, 255])) is "00ff". Strings are read as UTF-8.

Hex.decode(text: string): Result<Bytes, InvalidEncodingError>

Decodes a hex string, accepting either letter case. An odd length or a non-hex character fails the whole input, so a truncated signature compares unequal to a prefix.

Base64Url.encode(data: BinaryLike): string

Encodes bytes as base64url without = padding, using only A-Z, a-z, 0-9, -, and _. Safe in URLs, headers, and file names.

let text = Base64Url.encode(bytes);
// same as
let text = btoa(String.fromCharCode(...bytes))
	.replaceAll("+", "-")
	.replaceAll("/", "_")
	.replaceAll("=", "");

A string is read as its UTF-8 bytes first, and the encoder runs in a single pass over the input, so a multi-megabyte payload encodes without an intermediate binary string.

Base64Url.decode(text: string): Result<Bytes, InvalidEncodingError>

Decodes base64url text with or without padding. A short final group's leftover bits must be zero, so two accepted strings decode to the same bytes exactly when they differ only in trailing =.

Base64.encode(data: BinaryLike): string

Encodes bytes as standard base64 with = padding, over the A-Z, a-z, 0-9, +, / alphabet RFC 4648 §4 defines. Strings become their UTF-8 bytes first, which is what the user:password credentials of HTTP Basic authentication require (RFC 7617 §2.1).

Base64.decode(text: string): Result<Bytes, InvalidEncodingError>

Decodes standard base64 text carrying its full padding. One byte string has exactly one accepted spelling.

Bytes

concatBytes(...parts: BinaryLike[]): Bytes

Joins payloads end to end into one buffer, in the order they are given. Every part is read as bytes first, so a label written as text concatenates with the binary around it:

import { concatBytes } from "@sdxc/crypto";

let info = concatBytes("Content-Encoding: aes128gcm", new Uint8Array([0]));

That covers the framed records and info strings key derivations are built from, where a label and its terminator are spelled inline.

Hashing and HMAC

sha256(data: BinaryLike): Promise<Result<Bytes, CryptoError>>

Hashes a payload with SHA-256, returning 32 bytes. Deterministic and unsalted, which makes it right for lookups and fingerprints — an API key stored as a digest is still found by hashing the presented key — and wrong for passwords, which belong to password.hash.

let digest = unwrap(await sha256(apiKey));
// same as
let digest = new Uint8Array(
	await crypto.subtle.digest("SHA-256", new TextEncoder().encode(apiKey)),
);

The wrapper supplies the UTF-8 encoding a string input needs and the view over the returned buffer. sha384 and sha512 are the same call under their own algorithm name.

Both sides throw on a runtime refusal, since unwrap raises the Failure. Reading the Result with isSuccess instead keeps that refusal a value, which is the difference every expansion here spells out with unwrap.

sha384(data: BinaryLike): Promise<Result<Bytes, CryptoError>>

Hashes a payload with SHA-384, returning 48 bytes.

sha512(data: BinaryLike): Promise<Result<Bytes, CryptoError>>

Hashes a payload with SHA-512, returning 64 bytes. The three digests share one signature, so a protocol that picks its hash at runtime — such as an OpenID Connect at_hash following the ID token's alg — selects from a map built at the call site.

hmac.sign(secret: BinaryLike, payload: BinaryLike, options?: hmac.Options): Promise<Result<Bytes, CryptoError>>

Signs a payload with a secret and returns the raw MAC bytes. options.hash is "SHA-1", "SHA-256" (default), "SHA-384", or "SHA-512"; any other value gives an UnsupportedAlgorithmError.

let signature = unwrap(await hmac.sign(secret, body));
// same as
let key = await crypto.subtle.importKey(
	"raw",
	new TextEncoder().encode(secret),
	{ name: "HMAC", hash: "SHA-256" },
	false,
	["sign"],
);
let signature = new Uint8Array(
	await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)),
);

The key is imported per call and non-extractable, and lives only as long as the signature it produces.

hmac.verify(secret: BinaryLike, payload: BinaryLike, signature: BinaryLike, options?: hmac.Options): Promise<Result<boolean, CryptoError>>

Recomputes the MAC and compares it in constant time, taking the same options used to sign. A signature given as a string decodes as hex — the form Hex.encode produces and the form signature headers usually carry — and a string that is not hex reads as a plain mismatch, so a malformed header fails closed.

let valid = unwrap(await hmac.verify(secret, body, signature));
// same as
let expected = unwrap(await hmac.sign(secret, body));
let valid = timingSafeEqual(expected, unwrap(Hex.decode(signature)));

The hex decode is where the two part ways: verify reads an undecodable string as success(false), where unwrap on the last line raises the InvalidEncodingError.

timingSafeEqual(left: BinaryLike, right: BinaryLike): boolean

Compares two values byte for byte with no early exit, so the running time depends only on the input lengths. Lengths are assumed public; the content stays protected.

let equal = timingSafeEqual(expected, provided);
// same as
let mismatch = expected.length ^ provided.length;
for (let index = 0; index < expected.length; index++) {
	mismatch |= expected[index] ^ provided[index % provided.length];
}
let equal = mismatch === 0;

The length difference seeds the accumulator, and the shorter input is read cyclically, so a wrong length is already a mismatch while the loop still walks every byte of expected. Strings compare as their UTF-8 bytes, and an empty input is settled before the loop.

Random values and tokens

randomBytes(size: number): Bytes

Fills a new buffer with cryptographically strong random bytes. size is an integer from 0 to 65536, the most crypto.getRandomValues fills in one call; anything else throws a RangeError.

let bytes = randomBytes(32);
// same as
let bytes = crypto.getRandomValues(new Uint8Array(32));

randomToken(options?: randomToken.Options): string

Generates a URL-safe unpadded base64url token, randomToken({ bytes: 32, prefix: "sk" }) giving "sk_...".

  • options.bytes: bytes of entropy, default 32

  • options.prefix: prefix joined with _, so a token found in a log is recognizable and revocable by kind

Password hashing

scrypt through node:crypto, at ln=15, r=8, p=3 for 32 MiB of scratch memory, with a 16-byte salt and a 32-byte derived key, stored as $scrypt$ln=15,r=8,p=3$<salt>$<key> with the salt and key in unpadded base64url. Each hash carries the parameters it was made with, so verification uses the stored parameters and current policy rises without a schema change or a mass reset.

password.hash(secret: string): Promise<Result<string, CryptoError>>

Hashes a password with the current policy and a fresh random salt. The same password hashes differently every time.

password.verify(stored: string, secret: string): Promise<Result<boolean, CryptoError>>

Checks a password against an encoded hash using the hash's own parameters. A wrong password is success(false), which keeps it apart from a Failure: MalformedHashError for a value written by another scheme, UnsupportedAlgorithmError for a well-formed value carrying another algorithm tag.

password.needsRehash(stored: string): boolean

Reports whether a stored hash is behind current policy: a lower iteration count, a shorter salt or key, or a value this package parses as foreign.

TOTP

RFC 6238 one-time passwords, checked against the RFC's published test vectors for SHA-1, SHA-256, and SHA-512. Defaults match what authenticator apps assume: a 30 second step, 6 digits, SHA-1, and a drift window of one step.

totp.generateSecret(options?: totp.SecretOptions): string

Generates a random shared secret as unpadded uppercase base32 — the encoding authenticator apps accept in a QR code or a typed setup key. options.bytes defaults to 20, the 160 bits RFC 4226 recommends.

totp.code(secret: string, options?: totp.CodeOptions): Promise<Result<string, CryptoError>>

Generates the code for a base32 secret at a point in time.

  • options.at: Date or epoch milliseconds, default now

  • options.step: step in seconds, default 30

  • options.digits: digits in the code, default 6, at most 10

  • options.algorithm: "SHA-1" (default), "SHA-256", or "SHA-512"

totp.verify(secret: string, code: string, options?: totp.VerifyOptions): Promise<Result<boolean, CryptoError>>

Checks a submitted code against the current step and a drift window of options.window steps on either side (default 1), plus every option totp.code takes, which must match how the code was generated. Every step in the window is evaluated even after a match, and each comparison runs in constant time, so neither the total work nor the timing reveals which step matched. A code of the wrong shape is a plain mismatch.

totp.uri(secret: string, options: totp.UriOptions): string

Builds the otpauth:// URI an authenticator app scans during enrollment. options.issuer and options.account name the entry; options.digits, options.step, and options.algorithm are reflected so the app mirrors them.

totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });
// "otpauth://totp/Acme:ada%40example.com?secret=...&issuer=Acme&algorithm=SHA1&digits=6&period=30"

Symmetric encryption

AES-GCM with a random 96-bit IV per call, wrapped in a versioned v1.<iv>.<ciphertext> envelope so an algorithm change keeps stored data readable.

importKey(raw: string): Promise<Result<CryptoKey, CryptoError>>

Imports base64url key material of 16, 24, or 32 bytes as a non-extractable AES-GCM key, so a leaked reference stays unusable as bytes. Returns InvalidKeyError for the wrong size or rejected material, and InvalidEncodingError when the string is not base64url. Generate material with randomToken({ bytes: 32 }).

seal(key: CryptoKey, plaintext: string): Promise<Result<string, CryptoError>>

Encrypts a string into an envelope. The IV is random per call, so sealing the same plaintext twice yields different envelopes; hash a value with sha256 when it also has to be searchable.

let sealed = unwrap(await seal(key, refreshToken));
// same as
let iv = crypto.getRandomValues(new Uint8Array(12));
let ciphertext = await crypto.subtle.encrypt(
	{ name: "AES-GCM", iv },
	key,
	new TextEncoder().encode(refreshToken),
);
let sealed = `v1.${Base64Url.encode(iv)}.${Base64Url.encode(new Uint8Array(ciphertext))}`;

AES-GCM appends its authentication tag to the ciphertext, so the envelope carries the tag along with it and open gets an integrity check for free.

open(key: CryptoKey, sealed: string): Promise<Result<string, CryptoError>>

Decrypts an envelope produced by seal. Returns InvalidEnvelopeError for a malformed or unknown-version envelope, and DecryptionError when authentication fails. A wrong key and a tampered ciphertext produce the same DecryptionError with the same message, so failures stay useless as an oracle.

let plaintext = unwrap(await open(key, sealed));
// same as, once the version tag and the field sizes check out
let [, encodedIv, encodedCiphertext] = sealed.split(".");
let iv = unwrap(Base64Url.decode(encodedIv));
let plaintext = new TextDecoder().decode(
	await crypto.subtle.decrypt(
		{ name: "AES-GCM", iv },
		key,
		unwrap(Base64Url.decode(encodedCiphertext)),
	),
);

Errors

Every failure extends CryptoError, so one instanceof check covers the package while the subclasses let callers branch on the cause. Messages carry only the shape of the problem, and algorithm identifiers read back from stored values are sanitized to a short tag first.

ErrorRaised when
CryptoErrorBase class, and unexpected WebCrypto failures
InvalidEncodingErrorA string is not valid hex, base64, base64url, or base32
MalformedHashErrorA stored password hash follows another format
UnsupportedAlgorithmErrorAn algorithm identifier is valid but unsupported here
InvalidKeyErrorKey material is the wrong size or rejected by the runtime
InvalidEnvelopeErrorA sealed value diverges from the versioned envelope
DecryptionErrorAuthenticated decryption failed for a well-formed envelope

Types

BinaryLike

string | Uint8Array | ArrayBuffer, accepted wherever the package takes "bytes". Strings are read as UTF-8.

Bytes

Uint8Array<ArrayBuffer>, returned wherever the package produces bytes. WebCrypto requires views over a non-shared ArrayBuffer, so output feeds straight back in.

Pattern: upgrade on login

Verify with the parameters the stored hash records, then re-hash with current policy once the password is known to be correct. Accounts migrate on their next login.

import { password } from "@sdxc/crypto";
import { isFailure, isSuccess } from "@sdxc/result";

let valid = await password.verify(user.passwordHash, form.password);
if (isFailure(valid) || !valid.data) return unauthorized();

if (password.needsRehash(user.passwordHash)) {
	let rehashed = await password.hash(form.password);
	if (isSuccess(rehashed)) await updatePasswordHash(user.id, rehashed.data);
}

Pattern: hashed lookup, sealed storage

Sealed values are neither comparable nor searchable, because the IV changes every time. Anything that has to be looked up stays hashed; anything that has to be read back gets sealed, and a credential needing both gets both columns.

import { Hex, importKey, randomToken, seal, sha256 } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";

let key = unwrap(await importKey(sealKey));
let token = randomToken({ bytes: 32, prefix: "sk" });

await store({
	lookupHash: Hex.encode(unwrap(await sha256(token))),
	sealedToken: unwrap(await seal(key, token)),
});

Pattern: verifying an inbound signature

Fail closed when the signing secret is missing, and let a malformed header fall through as a mismatch instead of a separate branch.

import { hmac } from "@sdxc/crypto";
import { isFailure } from "@sdxc/result";

if (!webhookSecret) return new Response(null, { status: 500 });

let body = await request.text();
let signature = request.headers.get("x-signature") ?? "";

let valid = await hmac.verify(webhookSecret, body, signature);
if (isFailure(valid) || !valid.data) return new Response(null, { status: 401 });

Pattern: enrolling a second factor

Store the secret, show the URI as a QR code, and mark the factor confirmed once the user proves possession with a code.

import { totp } from "@sdxc/crypto";
import { isSuccess } from "@sdxc/result";

let secret = totp.generateSecret();
let uri = totp.uri(secret, { issuer: "Acme", account: "ada@example.com" });

// Once the user submits a code from their authenticator app:
let confirmed = await totp.verify(secret, form.code, { window: 1 });
if (isSuccess(confirmed) && confirmed.data) await enableSecondFactor(secret);