sdxc

[ Identity & security ]

@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, books

Web Crypto primitives — encoding, hashing, HMAC, tokens, TOTP, AES-GCM — plus scrypt passwords, with Result-based errors.

Installation

npm add @sdxc/crypto

Operations that can fail answer a Result from @sdxc/result, which installs with this package and supplies unwrap, isSuccess and isFailure. Everything runs on the Web Crypto API except password hashing, which uses scrypt from node:crypto (native in Node, Bun and Cloudflare Workers).

Usage

Hash and encode

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

let digest = unwrap(await sha256("hello")); // 32 bytes
Hex.encode(digest); // "2cf24dba..."
Base64Url.encode(digest); // unpadded, URL-safe
Hex.decode("zz"); // failure(InvalidEncodingError)

Inputs accept a string (read as UTF-8), a Uint8Array or an ArrayBuffer. For browser code, the codecs alone ship as @sdxc/crypto/encoding, which imports only Web APIs:

import { Base64Url } from "@sdxc/crypto/encoding";

Sign and verify a payload

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

let secret = randomToken({ prefix: "whsec" }); // "whsec_..."
let signature = Hex.encode(unwrap(await hmac.sign(secret, body)));
let valid = unwrap(await hmac.verify(secret, body, signature)); // true

Hash a password

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));

Encrypt a value at rest

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

let material = randomToken({ bytes: 32 }); // keep this in a secret store
let key = unwrap(await importKey(material));
let sealed = unwrap(await seal(key, "refresh-token")); // "v1.<iv>.<ciphertext>"
let plaintext = unwrap(await open(key, sealed));

API

Encoding

Each codec has one output shape and a decoder that answers Result<Bytes, InvalidEncodingError> for malformed input. All four also ship from @sdxc/crypto/encoding.

Hex.encode(data: BinaryLike): string / Hex.decode(text: string)

Lowercase hex, two characters per byte. Decoding accepts either case and fails on an odd length or a non-hex character.

Base64Url.encode(data: BinaryLike): string / Base64Url.decode(text: string)

Unpadded base64url over A-Z a-z 0-9 - _, safe in URLs, headers and file names. Decoding accepts the text with or without trailing =.

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

Base64.encode(data: BinaryLike): string / Base64.decode(text: string)

Standard padded base64 (RFC 4648 §4). Strings encode as UTF-8, as HTTP Basic credentials require. Decoding requires the full padding.

Base32.encode(data: BinaryLike): string / Base32.decode(text: string)

Unpadded uppercase base32 over A-Z 2-7, for values typed by hand or scanned from a QR code. Decoding ignores case, padding, whitespace and dashes.

Pem.encode(der: BinaryLike, label: string): Result<string, InvalidEncodingError>

Armors DER bytes as PEM (RFC 7468) under a label such as PUBLIC KEY (SPKI) or PRIVATE KEY (PKCS#8). Fails for a label RFC 7468 does not allow.

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

Answers the DER bytes of the first PEM block in text, ready for crypto.subtle.importKey. Fails when the block carries a different label, so a private key pasted where a public key belongs never imports.

Bytes

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

Joins payloads end to end, reading strings as UTF-8, so a text label and its binary terminator combine in one call: concatBytes("Content-Encoding: aes128gcm", new Uint8Array([0])).

Hashing and HMAC

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

SHA-256 digest (32 bytes). Deterministic and unsalted: right for lookups and fingerprints, wrong for passwords.

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

sha384(data: BinaryLike) / sha512(data: BinaryLike)

The same call for SHA-384 (48 bytes) and SHA-512 (64 bytes).

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

Answers the raw MAC bytes. options.hash is "SHA-1", "SHA-256" (default), "SHA-384" or "SHA-512"; any other value fails with UnsupportedAlgorithmError.

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

Recomputes the MAC and compares it in constant time. A string signature is read as hex, and a string that is not hex answers success(false), so a malformed header fails closed.

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

Compares byte for byte with no early exit, so the running time depends only on the input lengths. Strings compare as their UTF-8 bytes.

Random values

randomBytes(size: number): Bytes

Cryptographically strong random bytes, the same as crypto.getRandomValues(new Uint8Array(size)). Throws a RangeError unless size is an integer from 0 to 65536.

randomToken(options?: randomToken.Options): string

A base64url token from options.bytes of entropy (default 32). options.prefix is joined with _, so a leaked token is recognizable by kind: randomToken({ prefix: "sk" }) is "sk_...".

Passwords

New hashes use scrypt at ln=15, r=8, p=3 with a 16-byte salt and a 32-byte key, stored as $scrypt$ln=15,r=8,p=3$<salt>$<key>. Each hash records its own parameters, so a policy change needs no schema change.

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

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

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

A wrong password is success(false). A stored value in another format fails with MalformedHashError, and a well-formed one tagged with another algorithm with UnsupportedAlgorithmError.

password.needsRehash(stored: string): boolean

true when the stored hash uses weaker parameters than current policy, or is not in this package's format.

password.recognizes(stored: string): boolean

true when the value is this package's scrypt format under any parameters; false for a foreign hash such as bcrypt.

TOTP

RFC 6238 one-time passwords, defaulting to what authenticator apps assume: SHA-1, 6 digits, a 30-second step.

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

A random base32 secret from options.bytes of entropy (default 20).

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

The code for a secret at options.at (a Date or epoch milliseconds, default now), with options.step (seconds), options.digits (at most 10) and options.algorithm ("SHA-1", "SHA-256" or "SHA-512").

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

Accepts a code from the current step or options.window steps either side (default 1), plus every totp.code option, which must match how the code was generated. Runs in constant time, and a code that is not all digits is a mismatch.

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

The otpauth:// enrollment URI for a QR code. options.issuer and options.account name the entry; digits, step and algorithm are carried along so the app generates matching codes.

Symmetric encryption

AES-GCM with a random IV per call, in a versioned v1.<iv>.<ciphertext> envelope that carries the authentication tag.

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

Imports base64url key material of 16, 24 or 32 bytes as a non-extractable AES-GCM key. Fails with InvalidEncodingError for text that is not base64url and InvalidKeyError for the wrong size.

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

Encrypts a string into an envelope. Sealing the same value twice gives different envelopes, so pair it with sha256 when the value must also be looked up.

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

Decrypts an envelope. Fails with InvalidEnvelopeError for a malformed or unknown-version envelope, and DecryptionError for a wrong key or tampered ciphertext alike.

Errors

Every failure extends CryptoError, so one instanceof check covers the package.

ErrorWhen
CryptoErrorBase class, and runtime Web Crypto failures
InvalidEncodingErrorText is not valid hex, base64, base64url, base32 or PEM
MalformedHashErrorA stored password hash is in another format
UnsupportedAlgorithmErrorAn algorithm name is well-formed but unsupported
InvalidKeyErrorKey material is the wrong size or rejected by the runtime
InvalidEnvelopeErrorA sealed value is not a v1.<iv>.<ciphertext> 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, and accepted directly by crypto.subtle.

hmac.Options, hmac.Hash

The hash option and its allowed names.

randomToken.Options

bytes and prefix.

totp.SecretOptions, totp.CodeOptions, totp.VerifyOptions, totp.UriOptions, totp.Algorithm

The option objects of each totp function, and the allowed hash names.

Pattern: Upgrade Password Hashes On Login

Verify with the parameters the stored hash records, then re-hash under current policy while the plaintext is at hand. Accounts migrate on their next login.

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

async function login(user: { id: string; passwordHash: string }, secret: string) {
	if (!password.recognizes(user.passwordHash)) return verifyLegacy(user, secret);

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

	if (password.needsRehash(user.passwordHash)) {
		let rehashed = await password.hash(secret);
		if (isSuccess(rehashed)) await savePasswordHash(user.id, rehashed.data);
	}
	return true;
}

Pattern: API Keys With Hashed Lookup And Sealed Storage

A sealed value can be read back but never searched, because its IV changes every time. Store the digest to find the key and the envelope to show it again.

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

let key = unwrap(await importKey(sealKeyMaterial));

let apiKey = randomToken({ prefix: "sk" });
await saveApiKey({
	lookupHash: Hex.encode(unwrap(await sha256(apiKey))),
	sealedKey: unwrap(await seal(key, apiKey)),
});

let presented = request.headers.get("authorization")?.replace("Bearer ", "") ?? "";
let record = await findApiKey(Hex.encode(unwrap(await sha256(presented))));
let revealed = record ? unwrap(await open(key, record.sealedKey)) : null;

Pattern: Verify An Inbound Webhook Signature

Fail closed when the signing secret is missing; a malformed signature header is already a plain mismatch.

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

async function handleWebhook(request: Request, secret: string | undefined) {
	if (!secret) return new Response(null, { status: 500 });

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

	let valid = await hmac.verify(secret, body, signature, { hash: "SHA-256" });
	if (isFailure(valid) || !valid.data) return new Response(null, { status: 401 });

	return new Response(null, { status: 204 });
}

Pattern: Enroll A Second Factor With Recovery Codes

Show the URI as a QR code, confirm the factor once the user proves possession, and hand out base32 recovery codes stored as digests.

import { Base32, Hex, randomBytes, sha256, totp } from "@sdxc/crypto";
import { isSuccess, unwrap } from "@sdxc/result";

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

let confirmed = await totp.verify(secret, form.code, { window: 1 });
if (isSuccess(confirmed) && confirmed.data) {
	let recoveryCodes = Array.from({ length: 8 }, () => Base32.encode(randomBytes(10)));
	let hashes = await Promise.all(
		recoveryCodes.map(async (code) => Hex.encode(unwrap(await sha256(code)))),
	);
	await enableSecondFactor({ secret, recoveryCodeHashes: hashes });
}

Pattern: Publish And Import A Public Key As PEM

Export a Web Crypto key as SPKI, armor it for a profile or a config file, and turn the PEM back into a verification key on the other side.

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

let algorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };

let spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
let publicKeyPem = unwrap(Pem.encode(spki, "PUBLIC KEY"));

let der = unwrap(Pem.decode(publicKeyPem, "PUBLIC KEY"));
let publicKey = await crypto.subtle.importKey("spki", der, algorithm, false, ["verify"]);

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.