Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# @suaveplan/crypto-hash Changelog
## 0.0.0 — suaveplan
Initial suaveplan implementation. Ships SHA-2 digests, HMAC, HKDF, PBKDF2,
Argon2id, hex encoding, and Base64/Base64Url encoding. All functions are
pure TypeScript with zero WASM and zero browser globals.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
{
"name": "@suaveplan/crypto-hash",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Pure-TypeScript hashing substrate — SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id, hex/Base64 encoding. Zero WASM, zero browser deps.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./hash": {
"types": "./dist/hash/sha2/sha2.d.ts",
"default": "./dist/hash/sha2/sha2.js"
},
"./mac": {
"types": "./dist/mac/hmac/hmac.d.ts",
"default": "./dist/mac/hmac/hmac.js"
},
"./kdf": {
"types": "./dist/kdf/index.d.ts",
"default": "./dist/kdf/index.js"
},
"./encoding": {
"types": "./dist/encoding/index.d.ts",
"default": "./dist/encoding/index.js"
},
"./errors": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"./scrypt": {
"types": "./dist/primitives/scrypt/scrypt.d.ts",
"default": "./dist/primitives/scrypt/scrypt.js"
},
"./primitives/scrypt/scrypt": {
"types": "./dist/primitives/scrypt/scrypt.d.ts",
"default": "./dist/primitives/scrypt/scrypt.js"
},
"./blake2b": {
"types": "./dist/primitives/blake2b/blake2b.d.ts",
"default": "./dist/primitives/blake2b/blake2b.js"
},
"./primitives/blake2b/blake2b": {
"types": "./dist/primitives/blake2b/blake2b.d.ts",
"default": "./dist/primitives/blake2b/blake2b.js"
},
"./mac/blake2b-password/blake2b-password": {
"types": "./dist/mac/blake2b-password/blake2b-password.d.ts",
"default": "./dist/mac/blake2b-password/blake2b-password.js"
},
"./mac/argon2id-password/argon2id-password": {
"types": "./dist/mac/argon2id-password/argon2id-password.d.ts",
"default": "./dist/mac/argon2id-password/argon2id-password.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"crypto:kat": "vitest run --passWithNoTests -t 'RFC|NIST|Wycheproof|vector|KAT'",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@noble/hashes": "catalog:crypto",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,6 @@
export {
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_PARALLELISM,
ARGON2ID_MIN_TIME_COST,
PBKDF2_MIN_ITERATIONS,
} from "../kdf/index.js";

View File

@@ -0,0 +1,125 @@
# base64
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides pure-TypeScript Base64 encoding and decoding in both standard (RFC
4648 §4) and URL-safe (RFC 4648 §5) variants. Cryptographic output such as
JWT payloads, bearer tokens, and encoded public keys is routinely transmitted
as Base64Url, while storage formats and MIME payloads use standard Base64.
This module implements both without depending on Node.js `Buffer` or `btoa`/
`atob` (which are absent in some edge runtimes), making it safe for all
`@suaveplan/universal` consumers.
## Features
- `toBase64(bytes)` — standard Base64 with `=` padding
- `fromBase64(b64)` — decode standard Base64; throws `HashError` on malformed input
- `toBase64Url(bytes)` — URL-safe Base64, no padding, `-` and `_` alphabet
- `fromBase64Url(b64url)` — decode URL-safe Base64; throws `HashError` on malformed input
- Passes RFC 4648 test vectors
- Pure TypeScript — no `Buffer`, no `btoa`/`atob`, no Node built-ins
## Usage: Basic Example
```ts
import { toBase64, fromBase64 } from "@suaveplan/crypto-hash/encoding";
const encoder = new TextEncoder();
const bytes = encoder.encode("hello world");
const b64 = toBase64(bytes);
console.log(b64); // "aGVsbG8gd29ybGQ="
const decoded = fromBase64(b64);
console.log(new TextDecoder().decode(decoded)); // "hello world"
```
## Usage: Advanced Example
```ts
import { toBase64Url, fromBase64Url } from "@suaveplan/crypto-hash/encoding";
import { sha256 } from "@suaveplan/crypto-hash/hash";
import { HashError } from "@suaveplan/crypto-hash/errors";
// Build a simple JWT-style header.payload structure
function buildToken(payload: object, sigKey: Uint8Array): string {
const encoder = new TextEncoder();
const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256" })));
const body = toBase64Url(encoder.encode(JSON.stringify(payload)));
return `${header}.${body}`;
}
// Parse and validate a Base64Url segment
function decodeSegment(segment: string): object {
try {
const bytes = fromBase64Url(segment);
return JSON.parse(new TextDecoder().decode(bytes)) as object;
} catch (err) {
if (err instanceof HashError) {
throw new TypeError(`Malformed Base64Url segment: ${err.code}`);
}
throw err;
}
}
const token = buildToken({ sub: "user-123", iat: 1700000000 }, new Uint8Array(32));
console.log(token);
```
## API Reference
### `toBase64(bytes: Uint8Array): string`
Encodes `bytes` as standard Base64 with `=` padding.
| Parameter | Type | Description |
|-----------|------|-------------|
| `bytes` | `Uint8Array` | Byte sequence to encode |
**Returns:** Base64 string of length `Math.ceil(bytes.length / 3) * 4`.
---
### `fromBase64(b64: string): Uint8Array`
Decodes a standard Base64 string. Padding (`=`) is optional.
| Parameter | Type | Description |
|-----------|------|-------------|
| `b64` | `string` | Standard Base64 string |
**Returns:** Decoded `Uint8Array`.
**Throws:** `HashError("HASH.ENCODING.INVALID_INPUT")` if any character is
outside the standard Base64 alphabet. Context: `{ encoding: "base64", input }`.
---
### `toBase64Url(bytes: Uint8Array): string`
Encodes `bytes` as URL-safe Base64 without padding. Uses `-` and `_` in place
of `+` and `/`.
---
### `fromBase64Url(b64url: string): Uint8Array`
Decodes a URL-safe Base64 string. `+` and `/` characters are rejected.
**Throws:** `HashError("HASH.ENCODING.INVALID_INPUT")` on invalid input.
Context: `{ encoding: "base64url", input }`.
## Implementation Notes
The encoder uses a 64-character alphabet lookup table and processes three bytes
at a time, producing four output characters per group. The decoder validates
every character code against a 128-entry lookup table before converting, so
malformed input is caught in a single pass rather than producing incorrect
output silently. The table approach avoids repeated `indexOf` calls and keeps
decoding O(n). Standard and URL-safe alphabets share the same encode/decode
pipeline via the `chars` and `table` parameters; `buildTable` is called once
at module load time and the resulting `Uint8Array` tables are module-level
constants. No `Buffer`, `btoa`, `atob`, or Node.js built-ins are used.

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { fromBase64, fromBase64Url, toBase64, toBase64Url } from "./base64.js";
describe("base64 encoding", () => {
// RFC 4648 test vectors
it("toBase64 known vectors", { timeout: 5000 }, () => {
const enc = (s: string) => new TextEncoder().encode(s);
expect(toBase64(enc(""))).toBe("");
expect(toBase64(enc("f"))).toBe("Zg==");
expect(toBase64(enc("fo"))).toBe("Zm8=");
expect(toBase64(enc("foo"))).toBe("Zm9v");
expect(toBase64(enc("foob"))).toBe("Zm9vYg==");
expect(toBase64(enc("fooba"))).toBe("Zm9vYmE=");
expect(toBase64(enc("foobar"))).toBe("Zm9vYmFy");
});
it("fromBase64 known vectors", { timeout: 5000 }, () => {
const dec = (s: string) => new TextDecoder().decode(fromBase64(s));
expect(dec("")).toBe("");
expect(dec("Zg==")).toBe("f");
expect(dec("Zm8=")).toBe("fo");
expect(dec("Zm9v")).toBe("foo");
expect(dec("Zm9vYg==")).toBe("foob");
expect(dec("Zm9vYmE=")).toBe("fooba");
expect(dec("Zm9vYmFy")).toBe("foobar");
});
it("toBase64Url encodes without +/= characters", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xfb, 0xff, 0xfe]);
const result = toBase64Url(bytes);
expect(result).not.toContain("+");
expect(result).not.toContain("/");
expect(result).not.toContain("=");
});
it("fromBase64Url round-trips URL-safe encoding", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xfb, 0xff, 0xfe, 0x00, 0x01]);
const encoded = toBase64Url(bytes);
const decoded = fromBase64Url(encoded);
expect(decoded).toEqual(bytes);
});
it("fromBase64 throws on invalid character", { timeout: 5000 }, () => {
try {
fromBase64("Zm9v!g==");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["ENCODING.INVALID_INPUT"]
);
}
});
it("fromBase64Url throws on standard base64 characters", {
timeout: 5000,
}, () => {
// "+" is not valid in URL-safe alphabet
expect(() => fromBase64Url("Zm9v+g==")).toThrow(HashError);
});
it("fromBase64 throws on length-1 stripped input (mod-4 = 1)", {
timeout: 5000,
}, () => {
// A single base64 char cannot decode to any byte — malformed
expect(() => fromBase64("A")).toThrow(HashError);
});
it("fromBase64Url throws on length-1 stripped input", {
timeout: 5000,
}, () => {
expect(() => fromBase64Url("A")).toThrow(HashError);
});
it("fromBase64 accepts input without padding", { timeout: 5000 }, () => {
// Strip padding — should still decode
const original = new Uint8Array([1, 2, 3]);
const withPad = toBase64(original);
const withoutPad = withPad.replace(/=+$/, "");
const decoded = fromBase64(withoutPad);
expect(decoded).toEqual(original);
});
it("property: toBase64/fromBase64 round-trip", { timeout: 10000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
const encoded = toBase64(Uint8Array.from(bytes));
const decoded = fromBase64(encoded);
return (
decoded.length === bytes.length &&
decoded.every((v, i) => v === bytes[i])
);
})
);
});
it("property: toBase64Url/fromBase64Url round-trip", {
timeout: 10000,
}, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
const encoded = toBase64Url(Uint8Array.from(bytes));
const decoded = fromBase64Url(encoded);
return (
decoded.length === bytes.length &&
decoded.every((v, i) => v === bytes[i])
);
})
);
});
});

View File

@@ -0,0 +1,128 @@
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
const CHARS_STD =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const CHARS_URL =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
function encode(bytes: Uint8Array, chars: string, pad: boolean): string {
let out = "";
const len = bytes.length;
let i = 0;
while (i < len) {
const b0 = bytes[i++] as number;
const b1 = i < len ? (bytes[i++] as number) : 0;
const b2 = i < len ? (bytes[i++] as number) : 0;
out += chars[(b0 >> 2) & 0x3f];
out += chars[((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0f)];
out += chars[((b1 & 0x0f) << 2) | ((b2 >> 6) & 0x03)];
out += chars[b2 & 0x3f];
}
const rem = len % 3;
if (rem === 1) return pad ? `${out.slice(0, -2)}==` : out.slice(0, -2);
if (rem === 2) return pad ? `${out.slice(0, -1)}=` : out.slice(0, -1);
return out;
}
function buildTable(chars: string): Uint8Array {
const table = new Uint8Array(128).fill(255);
for (let i = 0; i < chars.length; i++) {
table[chars.charCodeAt(i)] = i;
}
table["=".charCodeAt(0)] = 0;
return table;
}
const TABLE_STD = buildTable(CHARS_STD);
const TABLE_URL = buildTable(CHARS_URL);
function decode(b64: string, table: Uint8Array, label: string): Uint8Array {
// Strip padding
const stripped = b64.replace(/=+$/, "");
const sLen = stripped.length;
// A single base64 character cannot encode any byte — reject it.
if (sLen % 4 === 1) {
throw new HashError(
HASH_CODES["ENCODING.INVALID_INPUT"],
"error.hash.encoding.invalid-input",
{ context: { encoding: label, input: b64.slice(0, 32) } }
);
}
// Validate characters
for (let i = 0; i < sLen; i++) {
const cc = stripped.charCodeAt(i);
if (cc > 127 || table[cc] === 255) {
throw new HashError(
HASH_CODES["ENCODING.INVALID_INPUT"],
"error.hash.encoding.invalid-input",
{
context: {
encoding: label,
input: b64.slice(0, 32),
},
}
);
}
}
const byteLen = Math.floor((sLen * 3) / 4);
const out = new Uint8Array(byteLen);
let outIdx = 0;
// Process complete groups of 4 characters → 3 bytes
const fullGroups = Math.floor(sLen / 4);
let i = 0;
for (let g = 0; g < fullGroups; g++) {
const c0 = table[stripped.charCodeAt(i++)] as number;
const c1 = table[stripped.charCodeAt(i++)] as number;
const c2 = table[stripped.charCodeAt(i++)] as number;
const c3 = table[stripped.charCodeAt(i++)] as number;
out[outIdx++] = (c0 << 2) | (c1 >> 4);
out[outIdx++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
out[outIdx++] = ((c2 & 0x03) << 6) | c3;
}
// Remainder: 2 or 3 chars → 1 or 2 bytes (sLen % 4 === 1 already rejected)
const rem = sLen % 4;
if (rem === 2) {
const c0 = table[stripped.charCodeAt(i)] as number;
const c1 = table[stripped.charCodeAt(i + 1)] as number;
out[outIdx] = (c0 << 2) | (c1 >> 4);
} else if (rem === 3) {
const c0 = table[stripped.charCodeAt(i)] as number;
const c1 = table[stripped.charCodeAt(i + 1)] as number;
const c2 = table[stripped.charCodeAt(i + 2)] as number;
out[outIdx++] = (c0 << 2) | (c1 >> 4);
out[outIdx] = ((c1 & 0x0f) << 4) | (c2 >> 2);
}
return out;
}
/** Encode `bytes` as standard Base64 (with `=` padding). */
export function toBase64(bytes: Uint8Array): string {
return encode(bytes, CHARS_STD, true);
}
/**
* Decode a standard Base64 string.
*
* @throws `HashError("HASH.ENCODING.INVALID_INPUT")` on malformed input.
*/
export function fromBase64(b64: string): Uint8Array {
return decode(b64, TABLE_STD, "base64");
}
/** Encode `bytes` as URL-safe Base64 (no `+`, `/`, or `=` padding). */
export function toBase64Url(bytes: Uint8Array): string {
return encode(bytes, CHARS_URL, false);
}
/**
* Decode a URL-safe Base64 string.
*
* @throws `HashError("HASH.ENCODING.INVALID_INPUT")` on malformed input.
*/
export function fromBase64Url(b64url: string): Uint8Array {
return decode(b64url, TABLE_URL, "base64url");
}

View File

@@ -0,0 +1 @@
export { fromBase64, fromBase64Url, toBase64, toBase64Url } from "./base64.js";

View File

@@ -0,0 +1,107 @@
# hex
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `toHex` and `fromHex` for converting between `Uint8Array` byte
sequences and lowercase hexadecimal strings. These functions are the standard
representation layer for cryptographic output — digests, keys, tags, and
derived key material are routinely logged, compared, and stored as hex strings.
This module uses `@noble/hashes/utils` for the encoding direction and adds
strict input validation to `fromHex` so malformed strings produce a structured
`HashError` rather than silently corrupting data.
## Features
- `toHex(bytes)` — converts `Uint8Array` to lowercase hex string
- `fromHex(hex)` — parses lowercase or uppercase hex; throws on malformed input
- Throws `HashError("HASH.ENCODING.INVALID_INPUT")` on odd-length or non-hex input
- Accepts both lowercase and uppercase hex characters on decode
- Handles empty input correctly (empty array ↔ empty string)
## Usage: Basic Example
```ts
import { toHex, fromHex } from "@suaveplan/crypto-hash/encoding";
import { sha256 } from "@suaveplan/crypto-hash/hash";
const digest = sha256(new TextEncoder().encode("hello"));
const hex = toHex(digest);
console.log(hex); // 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c...
const back = fromHex(hex);
console.log(back.length); // 32
```
## Usage: Advanced Example
```ts
import { toHex, fromHex } from "@suaveplan/crypto-hash/encoding";
import { hmac } from "@suaveplan/crypto-hash/mac";
import { HashError } from "@suaveplan/crypto-hash/errors";
function parseAndVerifyTag(
rawTagHex: string,
key: Uint8Array,
payload: Uint8Array
): boolean {
let receivedTag: Uint8Array;
try {
receivedTag = fromHex(rawTagHex);
} catch (err) {
if (err instanceof HashError) {
console.error("invalid tag format:", err.context);
return false;
}
throw err;
}
const expected = hmac("SHA-256", key, payload);
if (expected.length !== receivedTag.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected[i] ^ receivedTag[i];
}
return diff === 0;
}
```
## API Reference
### `toHex(bytes: Uint8Array): string`
Converts `bytes` to a lowercase hexadecimal string. Each byte becomes exactly
two hex characters; empty input returns `""`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `bytes` | `Uint8Array` | Byte sequence to encode |
**Returns:** Lowercase hex string of length `bytes.length * 2`.
---
### `fromHex(hex: string): Uint8Array`
Parses a hexadecimal string (case-insensitive) into a `Uint8Array`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `hex` | `string` | Hex-encoded string (may be upper- or lowercase) |
**Returns:** `Uint8Array` of length `hex.length / 2`.
**Throws:** `HashError("HASH.ENCODING.INVALID_INPUT")` if `hex.length` is odd
or if any character is outside `[0-9a-fA-F]`. The error `context` object
includes `{ encoding: "hex", input: <truncated input> }`.
## Implementation Notes
`toHex` delegates to `bytesToHex` from `@noble/hashes/utils`, which is a
well-tested, allocation-efficient implementation. `fromHex` validates the input
with a regular expression before calling `hexToBytes` — this ensures that
malformed strings throw a structured `HashError` rather than silently producing
garbage or throwing an untyped `Error`. The validation regex matches the full
string in one pass so there is no partial-result risk. The input is truncated to
32 characters in the error context to avoid logging full hex-encoded secrets.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { fromHex, toHex } from "./hex.js";
describe("hex encoding", () => {
it("toHex converts a known byte sequence", { timeout: 5000 }, () => {
expect(toHex(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe(
"deadbeef"
);
});
it("toHex converts empty array to empty string", { timeout: 5000 }, () => {
expect(toHex(new Uint8Array(0))).toBe("");
});
it("fromHex converts a known hex string", { timeout: 5000 }, () => {
expect(fromHex("deadbeef")).toEqual(
new Uint8Array([0xde, 0xad, 0xbe, 0xef])
);
});
it("fromHex converts uppercase hex", { timeout: 5000 }, () => {
expect(fromHex("DEADBEEF")).toEqual(
new Uint8Array([0xde, 0xad, 0xbe, 0xef])
);
});
it("fromHex converts empty string to empty array", {
timeout: 5000,
}, () => {
expect(fromHex("")).toEqual(new Uint8Array(0));
});
it("fromHex throws on odd-length string", { timeout: 5000 }, () => {
try {
fromHex("abc");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["ENCODING.INVALID_INPUT"]
);
}
});
it("fromHex throws on non-hex characters", { timeout: 5000 }, () => {
expect(() => fromHex("zz")).toThrow(HashError);
});
it("fromHex throws on mixed invalid characters", { timeout: 5000 }, () => {
expect(() => fromHex("de!f")).toThrow(HashError);
});
it("property: toHex/fromHex round-trip", { timeout: 10000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
const hex = toHex(Uint8Array.from(bytes));
const decoded = fromHex(hex);
return (
decoded.every((v, i) => v === bytes[i]) &&
decoded.length === bytes.length
);
})
);
});
});

View File

@@ -0,0 +1,25 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
/** Convert a `Uint8Array` to a lowercase hex string. */
export function toHex(bytes: Uint8Array): string {
return bytesToHex(bytes);
}
/**
* Parse a hex string into a `Uint8Array`.
*
* @throws `HashError("HASH.ENCODING.INVALID_INPUT")` if the string contains
* non-hex characters or has an odd number of characters.
*/
export function fromHex(hex: string): Uint8Array {
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
throw new HashError(
HASH_CODES["ENCODING.INVALID_INPUT"],
"error.hash.encoding.invalid-input",
{ context: { encoding: "hex", input: hex.slice(0, 32) } }
);
}
return hexToBytes(hex);
}

View File

@@ -0,0 +1 @@
export { fromHex, toHex } from "./hex.js";

View File

@@ -0,0 +1,7 @@
export {
fromBase64,
fromBase64Url,
toBase64,
toBase64Url,
} from "./base64/base64.js";
export { fromHex, toHex } from "./hex/hex.js";

View File

@@ -0,0 +1,75 @@
# Hash Error Codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry for the `@suaveplan/crypto-hash` package using `defineCodes` from `@suaveplan/error`. Every error thrown by the hash, MAC, and KDF modules carries one of these codes, giving callers a machine-readable signal they can match without inspecting human-readable message strings.
The code namespace `"HASH"` is unique within the project, ensuring that error codes do not collide with codes defined by other packages such as `@suaveplan/crypto-cipher` or `@suaveplan/crypto-curve`.
## Features
- `HASH_CODES.KDF_INVALID_PARAMS` — emitted when KDF parameters (iteration count, memory cost, output length) fall below required minima.
- `HASH_CODES.ALGORITHM_UNSUPPORTED` — emitted when a caller requests an algorithm string that is not recognised by the public dispatch layer.
- `HASH_CODES.ENCODING_INVALID_INPUT` — emitted by encoding utilities (hex, base64) when the input byte sequence or string is not valid for the requested encoding.
- `HashCode` union type — narrows the set of codes accepted by `HashError` constructors.
## Usage: Basic Example
```ts
import { HASH_CODES } from "@suaveplan/crypto-hash/errors";
// Check which code an error carries.
import { HashError } from "@suaveplan/crypto-hash/errors";
function handleHashError(err: HashError) {
if (err.code === HASH_CODES.KDF_INVALID_PARAMS) {
console.error("KDF parameters are out of range");
}
}
```
## Usage: Advanced Example
```ts
import { HASH_CODES, HashError } from "@suaveplan/crypto-hash/errors";
// Construct a typed error directly with the code registry.
function assertIterations(count: number): void {
if (count < 600_000) {
throw new HashError(
HASH_CODES.KDF_INVALID_PARAMS,
"error.hash.kdf.invalid-params",
{ context: { received: count, minimum: 600_000 } }
);
}
}
```
## API Reference
### `HASH_CODES`
```ts
const HASH_CODES: {
readonly KDF_INVALID_PARAMS: "HASH.KDF.INVALID_PARAMS";
readonly ALGORITHM_UNSUPPORTED: "HASH.ALGORITHM.UNSUPPORTED";
readonly ENCODING_INVALID_INPUT: "HASH.ENCODING.INVALID_INPUT";
};
```
Frozen registry object produced by `defineCodes("HASH", [...])`. The keys are screaming-snake-case identifiers; the values are the dot-separated canonical codes used in error serialisation and log queries.
### `HashCode`
```ts
type HashCode = (typeof HASH_CODES)[keyof typeof HASH_CODES];
```
Union of every string literal value in `HASH_CODES`. Used as the first type parameter to `SuaveplanError<HashCode>`.
## Implementation Notes
`defineCodes` from `@suaveplan/error` takes a namespace prefix and an array of sub-codes, joins them with a period, and returns a frozen map. The frozen map is both a runtime constant (safe to use in `switch` statements) and a type-level constant (the literal values flow through to `HashCode`). Adding a new code requires extending the `as const` array and re-running the TypeScript compiler; no numeric ID assignment or migration is needed.

View File

@@ -0,0 +1,13 @@
import { defineCodes } from "@suaveplan/error";
export const HASH_CODES = defineCodes("HASH", [
"KDF.INVALID_PARAMS",
"ALGORITHM.UNSUPPORTED",
"ENCODING.INVALID_INPUT",
"SCRYPT_INVALID_N",
"BLAKE2B_OUTPUT_TOO_LONG",
"BLAKE2B_KEY_TOO_LONG",
"BLAKE2B_PASSWORD_FORMAT_INVALID",
] as const);
export type HashCode = (typeof HASH_CODES)[keyof typeof HASH_CODES];

View File

@@ -0,0 +1 @@
export { HASH_CODES, type HashCode } from "./codes.js";

View File

@@ -0,0 +1,79 @@
# HashError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HashError` is the package-local error class for `@suaveplan/crypto-hash`. It extends `SuaveplanError<HashCode>` from `@suaveplan/error`, inheriting the full structured-error contract: machine-readable code, i18n message key, cause-chain traversal, Zod-validated wire format, and OTel span-event adaptation.
The class exists to widen the protected `SuaveplanError` constructor to `public`, which lets primitive implementations and public façade functions throw without needing access to the base class internals. This is the canonical pattern used across every `@suaveplan/*` package that defines its own error codes.
## Features
- Typed on `HashCode` — compiler enforces that only codes from `HASH_CODES` are passed.
- Full cause-chain via `options.cause` — wraps lower-level errors (e.g. `PrimitiveNotImplemented`) without losing stack information.
- Structured context via `options.context` — attach key/value metadata (algorithm name, expected vs. actual lengths) for observability.
- Compatible with `SuaveplanError` cause-chain traversal utilities in `@suaveplan/error`.
- Serialisable to JSON wire format for cross-boundary error transport.
## Usage: Basic Example
```ts
import { HashError } from "@suaveplan/crypto-hash/errors";
import { HASH_CODES } from "@suaveplan/crypto-hash/errors";
throw new HashError(
HASH_CODES.KDF_INVALID_PARAMS,
"error.hash.kdf.invalid-params"
);
```
## Usage: Advanced Example
```ts
import { HashError } from "@suaveplan/crypto-hash/errors";
import { HASH_CODES } from "@suaveplan/crypto-hash/errors";
function assertOutputLength(length: number): void {
if (length < 1 || length > 255 * 32) {
throw new HashError(
HASH_CODES.KDF_INVALID_PARAMS,
"error.hash.kdf.invalid-params",
{
cause: new RangeError(`length ${length} out of bounds`),
context: {
field: "length",
received: length,
min: 1,
max: 255 * 32,
},
}
);
}
}
```
## API Reference
### `class HashError extends SuaveplanError<HashCode>`
#### `constructor(code: HashCode, messageKey: string, options?: SuaveplanErrorOptions)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `HashCode` | One of the codes from `HASH_CODES`. |
| `messageKey` | `string` | Dotted i18n key used by the message resolver hook. |
| `options` | `SuaveplanErrorOptions` (optional) | `cause`, `context`, and other base-class options. |
Inherits all public members of `SuaveplanError<HashCode>`:
- `code: HashCode`
- `messageKey: string`
- `context?: Record<string, unknown>`
- `toJSON(): unknown` — Zod-validated wire representation.
- `toSpanEvent(): OTelSpanEvent` — OpenTelemetry span event.
## Implementation Notes
The constructor body is intentionally empty; it exists only to satisfy the TypeScript visibility requirement that converts `protected` (the base class default) to `public`. Biome's `noUselessConstructor` rule is suppressed with an inline ignore comment for this reason — the constructor is semantically required even though it contains no logic. The pattern is consistent with `CryptoCipherError` and the error classes in every other `@suaveplan/crypto-*` package.

View File

@@ -0,0 +1,13 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { HashCode } from "../codes/codes.js";
export class HashError extends SuaveplanError<HashCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: HashCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1 @@
export { HashError } from "./hash-error.js";

View File

@@ -0,0 +1,2 @@
export { HASH_CODES, type HashCode } from "./codes/codes.js";
export { HashError } from "./hash-error/hash-error.js";

View File

@@ -0,0 +1 @@
export { type HashAlgorithm, hash, sha256, sha384, sha512 } from "./sha2/sha2";

View File

@@ -0,0 +1 @@
export { type HashAlgorithm, hash, sha256, sha384, sha512 } from "./sha2.js";

View File

@@ -0,0 +1,111 @@
# sha2
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides one-shot SHA-2 digest functions (`sha256`, `sha384`, `sha512`) and a
`hash` dispatcher that selects the algorithm by name. All functions are thin,
allocation-friendly wrappers around the audited `@noble/hashes` implementation —
zero WASM, zero side effects, fully tree-shakeable.
The module exists as an independent substrate so that higher-level packages
(`@suaveplan/crypto-hash`, `@suaveplan/crypto`) can import SHA-2 digests without
pulling in unrelated cryptographic primitives.
## Features
- `sha256(data)` — 32-byte SHA-256 digest
- `sha384(data)` — 48-byte SHA-384 digest
- `sha512(data)` — 64-byte SHA-512 digest
- `hash(algo, data)` — single-call dispatch over `HashAlgorithm`
- `HashAlgorithm` union type (`"SHA-256" | "SHA-384" | "SHA-512"`)
- Deterministic: same input always produces the same output
- Zero runtime state; safe for concurrent use
## Usage: Basic Example
```ts
import { sha256 } from "@suaveplan/crypto-hash/hash";
const encoder = new TextEncoder();
const digest = sha256(encoder.encode("hello world"));
console.log(digest.length); // 32
```
## Usage: Advanced Example
```ts
import { hash, type HashAlgorithm } from "@suaveplan/crypto-hash/hash";
import { toHex } from "@suaveplan/crypto-hash/encoding";
function fingerprintFile(
algo: HashAlgorithm,
content: Uint8Array
): string {
return `${algo.toLowerCase()}:${toHex(hash(algo, content))}`;
}
const encoder = new TextEncoder();
console.log(fingerprintFile("SHA-256", encoder.encode("document payload")));
// sha-256:a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
```
## API Reference
### `sha256(data: Uint8Array): Uint8Array`
Computes the SHA-256 digest of `data`. Returns a fresh 32-byte `Uint8Array`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `data` | `Uint8Array` | Arbitrary byte sequence to hash |
**Returns:** 32-byte digest.
---
### `sha384(data: Uint8Array): Uint8Array`
Computes the SHA-384 digest of `data`. Returns a fresh 48-byte `Uint8Array`.
---
### `sha512(data: Uint8Array): Uint8Array`
Computes the SHA-512 digest of `data`. Returns a fresh 64-byte `Uint8Array`.
---
### `hash(algo: HashAlgorithm, data: Uint8Array): Uint8Array`
Dispatch wrapper. Selects the underlying function by `algo` and returns the
digest. Useful when the algorithm is determined at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HashAlgorithm` | `"SHA-256"`, `"SHA-384"`, or `"SHA-512"` |
| `data` | `Uint8Array` | Arbitrary byte sequence to hash |
**Returns:** Variable-length digest (32 / 48 / 64 bytes).
---
### `HashAlgorithm`
```ts
type HashAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
String union that gates which algorithms the rest of the hash package accepts.
Aligns with Web Crypto API naming conventions for cross-platform consistency.
## Implementation Notes
All three functions delegate directly to `@noble/hashes/sha2`, which is an
extensively audited, pure-JavaScript SHA-2 implementation. The wrappers add no
observable overhead beyond a single function call. The `hash` dispatcher uses
two explicit `if` checks and a fallthrough to `sha512`, so the TypeScript
exhaustiveness checker catches future additions to `HashAlgorithm` at the call
site. No WASM is loaded; no global state is touched.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { hash, sha256, sha384, sha512 } from "./sha2.js";
describe("sha2", () => {
it("sha256 returns 32 bytes", { timeout: 5000 }, () => {
expect(sha256(new Uint8Array([1, 2, 3])).length).toBe(32);
});
it("sha384 returns 48 bytes", { timeout: 5000 }, () => {
expect(sha384(new Uint8Array([1])).length).toBe(48);
});
it("sha512 returns 64 bytes", { timeout: 5000 }, () => {
expect(sha512(new Uint8Array([1])).length).toBe(64);
});
it("hash dispatcher covers SHA-256", { timeout: 5000 }, () => {
const data = new Uint8Array([1, 2, 3]);
expect(hash("SHA-256", data)).toEqual(sha256(data));
});
it("hash dispatcher covers SHA-384", { timeout: 5000 }, () => {
const data = new Uint8Array([1, 2, 3]);
expect(hash("SHA-384", data)).toEqual(sha384(data));
});
it("hash dispatcher covers SHA-512", { timeout: 5000 }, () => {
const data = new Uint8Array([1, 2, 3]);
expect(hash("SHA-512", data)).toEqual(sha512(data));
});
it("sha256 produces a known digest for empty input", {
timeout: 5000,
}, () => {
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
const digest = sha256(new Uint8Array(0));
expect(digest[0]).toBe(0xe3);
expect(digest[31]).toBe(0x55);
});
it("property: sha256 is deterministic", { timeout: 10000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (data) => {
const a = sha256(Uint8Array.from(data));
const b = sha256(Uint8Array.from(data));
return a.every((v, i) => v === b[i]);
})
);
});
it("property: sha384 is deterministic", { timeout: 10000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (data) => {
const a = sha384(Uint8Array.from(data));
const b = sha384(Uint8Array.from(data));
return a.every((v, i) => v === b[i]);
})
);
});
it("property: sha512 is deterministic", { timeout: 10000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (data) => {
const a = sha512(Uint8Array.from(data));
const b = sha512(Uint8Array.from(data));
return a.every((v, i) => v === b[i]);
})
);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Public façade for SHA-2 one-shot digests. Routes through the
* primitive selector layer so flipping `crypto.config.ts` for
* `sha256` / `sha384` / `sha512` swaps the backing impl without
* any consumer changes.
*/
import { sha256 as selectedSha256 } from "../../primitives/sha256/sha256.select/sha256.select.js";
import { sha384 as selectedSha384 } from "../../primitives/sha384/sha384.select/sha384.select.js";
import { sha512 as selectedSha512 } from "../../primitives/sha512/sha512.select/sha512.select.js";
/** SHA-256 one-shot digest (32 bytes). */
export function sha256(data: Uint8Array): Uint8Array {
return selectedSha256(data);
}
/** SHA-384 one-shot digest (48 bytes). */
export function sha384(data: Uint8Array): Uint8Array {
return selectedSha384(data);
}
/** SHA-512 one-shot digest (64 bytes). */
export function sha512(data: Uint8Array): Uint8Array {
return selectedSha512(data);
}
export type HashAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
/** Dispatch a one-shot digest by algorithm name. */
export function hash(algo: HashAlgorithm, data: Uint8Array): Uint8Array {
if (algo === "SHA-256") return sha256(data);
if (algo === "SHA-384") return sha384(data);
return sha512(data);
}

View File

@@ -0,0 +1,35 @@
// Hash
// Encoding
export {
fromBase64,
fromBase64Url,
fromHex,
toBase64,
toBase64Url,
toHex,
} from "./encoding/index.js";
// Errors
export { HASH_CODES, type HashCode, HashError } from "./errors/index.js";
export {
type HashAlgorithm,
hash,
sha256,
sha384,
sha512,
} from "./hash/sha2/sha2.js";
// KDF
export {
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_PARALLELISM,
ARGON2ID_MIN_TIME_COST,
type Argon2idOptions,
argon2id,
type HkdfOptions,
hkdf,
PBKDF2_MIN_ITERATIONS,
type Pbkdf2Options,
pbkdf2,
} from "./kdf/index.js";
// MAC
export { hmac } from "./mac/hmac/hmac.js";

View File

@@ -0,0 +1,151 @@
# argon2id
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Argon2id password hashing as specified in RFC 9106. Argon2id is the
current winner of the Password Hashing Competition and is recommended by OWASP
for new applications. It is resistant to both GPU-based brute force (via high
memory cost) and time-memory trade-off attacks (via the hybrid id variant).
This module enforces non-pathological lower bounds on all four tuning parameters
and produces a structured `HashError` with per-field context when they are
violated, making it safe for frameworks to call without a separate validation
layer.
## Features
- `argon2id(password, options)` — Argon2id key derivation
- Exported constants: `ARGON2ID_MIN_MEMORY_COST_KIB`, `ARGON2ID_MIN_TIME_COST`, `ARGON2ID_MIN_PARALLELISM`
- Validates each parameter individually; error context includes `{ field, value, minimum }`
- Throws `HashError("HASH.KDF.INVALID_PARAMS")` for any out-of-range parameter
- Deterministic: same inputs always produce the same hash output
## Usage: Basic Example
```ts
import { argon2id, ARGON2ID_MIN_MEMORY_COST_KIB } from "@suaveplan/crypto-hash/kdf";
const encoder = new TextEncoder();
const password = encoder.encode("hunter2");
const salt = new Uint8Array(16); // use crypto.getRandomValues in production
const hash = argon2id(password, {
salt,
timeCost: 3,
memoryCost: ARGON2ID_MIN_MEMORY_COST_KIB * 1024, // 8 MiB in KiB
parallelism: 1,
length: 32,
});
console.log(hash.length); // 32
```
## Usage: Advanced Example
```ts
import {
argon2id,
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_TIME_COST,
ARGON2ID_MIN_PARALLELISM,
} from "@suaveplan/crypto-hash/kdf";
import { toHex } from "@suaveplan/crypto-hash/encoding";
import { HashError } from "@suaveplan/crypto-hash/errors";
interface PasswordHashConfig {
timeCost: number;
memoryCostKib: number;
parallelism: number;
outputBytes: number;
}
function hashPassword(password: string, salt: Uint8Array, cfg: PasswordHashConfig): string {
try {
const raw = argon2id(new TextEncoder().encode(password), {
salt,
timeCost: cfg.timeCost,
memoryCost: cfg.memoryCostKib,
parallelism: cfg.parallelism,
length: cfg.outputBytes,
});
return toHex(raw);
} catch (err) {
if (err instanceof HashError && err.context?.field) {
throw new RangeError(
`Argon2id parameter out of range: ${err.context.field} = ${err.context.value} ` +
`(minimum: ${err.context.minimum})`
);
}
throw err;
}
}
const salt = new Uint8Array(16);
const hex = hashPassword("secure-password", salt, {
timeCost: ARGON2ID_MIN_TIME_COST,
memoryCostKib: ARGON2ID_MIN_MEMORY_COST_KIB,
parallelism: ARGON2ID_MIN_PARALLELISM,
outputBytes: 32,
});
console.log(hex.length); // 64 hex chars = 32 bytes
```
## API Reference
### `argon2id(password: Uint8Array, options: Argon2idOptions): Uint8Array`
Derives `options.length` bytes from `password` using Argon2id.
| Parameter | Type | Description |
|-----------|------|-------------|
| `password` | `Uint8Array` | User-supplied password bytes |
| `options.salt` | `Uint8Array` | Per-credential random salt (≥ 16 bytes recommended) |
| `options.timeCost` | `number` | Integer ≥ `ARGON2ID_MIN_TIME_COST` (number of iterations) |
| `options.memoryCost` | `number` | Integer ≥ `ARGON2ID_MIN_MEMORY_COST_KIB` (in KiB) |
| `options.parallelism` | `number` | Integer ≥ `ARGON2ID_MIN_PARALLELISM` (lane count) |
| `options.length` | `number` | Positive integer byte count for derived output |
**Returns:** `Uint8Array` of `options.length` bytes.
**Throws:** `HashError("HASH.KDF.INVALID_PARAMS")` for any validation failure.
Error `context` contains `{ field: string, value: number, minimum: number }`.
---
### `ARGON2ID_MIN_MEMORY_COST_KIB = 8`
Minimum memory cost in KiB. Based on RFC 9106 §4 minimum recommendation.
### `ARGON2ID_MIN_TIME_COST = 1`
Minimum number of Argon2id iterations. Lower values are pathological.
### `ARGON2ID_MIN_PARALLELISM = 1`
Minimum lane count. Lower values are rejected as invalid.
---
### `Argon2idOptions`
```ts
interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
```
## Implementation Notes
Parameters are validated in order: `timeCost`, `memoryCost`, `parallelism`,
`length`. The first failing field short-circuits and throws with that field's
context — no partial execution occurs. The implementation delegates to
`@noble/hashes/argon2` which is a pure-JavaScript Argon2id implementation
reviewed by the cryptographic community. Argon2id does not use the `HashAlgorithm`
dispatch pattern used by `hmac`, `hkdf`, and `pbkdf2` — Argon2id's internal
structure is fixed and parameterised by `t`, `m`, and `p` rather than a hash
constructor. For this reason, `argon2id.ts` does not import from `mac/hmac.ts`.

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import {
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_PARALLELISM,
ARGON2ID_MIN_TIME_COST,
argon2id,
} from "./argon2id.js";
describe("argon2id", () => {
const password = new Uint8Array([1, 2, 3]);
const salt = new Uint8Array(16).fill(5);
const baseOpts = {
salt,
timeCost: ARGON2ID_MIN_TIME_COST,
memoryCost: ARGON2ID_MIN_MEMORY_COST_KIB,
parallelism: ARGON2ID_MIN_PARALLELISM,
length: 32,
};
it("derives the requested length", { timeout: 20_000 }, () => {
expect(argon2id(password, baseOpts).length).toBe(32);
});
it("is deterministic", { timeout: 20_000 }, () => {
const a = argon2id(password, baseOpts);
const b = argon2id(password, baseOpts);
expect(a).toEqual(b);
});
it("rejects below-minimum timeCost", { timeout: 5000 }, () => {
try {
argon2id(password, { ...baseOpts, timeCost: 0 });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["KDF.INVALID_PARAMS"]
);
expect((err as HashError).context).toMatchObject({
field: "timeCost",
minimum: ARGON2ID_MIN_TIME_COST,
});
}
});
it("rejects non-integer timeCost", { timeout: 5000 }, () => {
expect(() =>
argon2id(password, { ...baseOpts, timeCost: 1.5 })
).toThrow(HashError);
});
it("rejects below-minimum memoryCost", { timeout: 5000 }, () => {
try {
argon2id(password, { ...baseOpts, memoryCost: 4 });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).context).toMatchObject({
field: "memoryCost",
minimum: ARGON2ID_MIN_MEMORY_COST_KIB,
});
}
});
it("rejects non-integer memoryCost", { timeout: 5000 }, () => {
expect(() =>
argon2id(password, { ...baseOpts, memoryCost: 8.5 })
).toThrow(HashError);
});
it("rejects zero parallelism", { timeout: 5000 }, () => {
try {
argon2id(password, { ...baseOpts, parallelism: 0 });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).context).toMatchObject({
field: "parallelism",
minimum: ARGON2ID_MIN_PARALLELISM,
});
}
});
it("rejects non-integer parallelism", { timeout: 5000 }, () => {
expect(() =>
argon2id(password, { ...baseOpts, parallelism: 1.5 })
).toThrow(HashError);
});
it("rejects zero length", { timeout: 5000 }, () => {
try {
argon2id(password, { ...baseOpts, length: 0 });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).context).toMatchObject({
field: "length",
minimum: 1,
});
}
});
it("rejects non-integer length", { timeout: 5000 }, () => {
expect(() => argon2id(password, { ...baseOpts, length: 32.5 })).toThrow(
HashError
);
});
it("property: Argon2id is deterministic", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 1 }),
fc.uint8Array({ minLength: 8 }),
(pw, s) => {
const opts = {
salt: Uint8Array.from(s),
timeCost: ARGON2ID_MIN_TIME_COST,
memoryCost: ARGON2ID_MIN_MEMORY_COST_KIB,
parallelism: ARGON2ID_MIN_PARALLELISM,
length: 32,
};
const a = argon2id(Uint8Array.from(pw), opts);
const b = argon2id(Uint8Array.from(pw), opts);
return a.every((v, i) => v === b[i]);
}
),
{ numRuns: 3 }
);
});
});

View File

@@ -0,0 +1,95 @@
/**
* Public façade for Argon2id. Routes through the primitive selector layer.
* Parameters below RFC 9106 §4 recommended minima are rejected.
*/
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { argon2id as selectedArgon2id } from "../../primitives/argon2id/argon2id.select/argon2id.select.js";
/** RFC 9106 §4 recommended minimum memory cost in KiB. */
export const ARGON2ID_MIN_MEMORY_COST_KIB = 8;
/** Non-pathological minimum time cost. */
export const ARGON2ID_MIN_TIME_COST = 1;
/** Non-pathological minimum parallelism. */
export const ARGON2ID_MIN_PARALLELISM = 1;
export interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
/**
* Argon2id password hashing. Parameters below the RFC 9106 recommended
* minima are rejected with `HASH.KDF.INVALID_PARAMS`.
*/
export function argon2id(
password: Uint8Array,
options: Argon2idOptions
): Uint8Array {
if (
!Number.isInteger(options.timeCost) ||
options.timeCost < ARGON2ID_MIN_TIME_COST
) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "timeCost",
value: options.timeCost,
minimum: ARGON2ID_MIN_TIME_COST,
},
}
);
}
if (
!Number.isInteger(options.memoryCost) ||
options.memoryCost < ARGON2ID_MIN_MEMORY_COST_KIB
) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "memoryCost",
value: options.memoryCost,
minimum: ARGON2ID_MIN_MEMORY_COST_KIB,
},
}
);
}
if (
!Number.isInteger(options.parallelism) ||
options.parallelism < ARGON2ID_MIN_PARALLELISM
) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "parallelism",
value: options.parallelism,
minimum: ARGON2ID_MIN_PARALLELISM,
},
}
);
}
if (!Number.isInteger(options.length) || options.length <= 0) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "length",
value: options.length,
minimum: 1,
},
}
);
}
return selectedArgon2id(password, options);
}

View File

@@ -0,0 +1,7 @@
export {
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_PARALLELISM,
ARGON2ID_MIN_TIME_COST,
type Argon2idOptions,
argon2id,
} from "./argon2id.js";

View File

@@ -0,0 +1,106 @@
# hkdf
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides HKDF (HMAC-based Key Derivation Function, RFC 5869) over SHA-256,
SHA-384, and SHA-512. HKDF is the standard mechanism for stretching or
diversifying key material — for instance, turning a Diffie-Hellman shared
secret into one or more symmetric encryption keys. This module wraps the
audited `@noble/hashes` HKDF implementation and constrains the algorithm
surface to the `HashAlgorithm` union shared across `@suaveplan/crypto-hash`.
## Features
- `hkdf(algo, ikm, options)` — HKDF-Extract-and-Expand in one call
- Supports SHA-256, SHA-384, SHA-512 via `HashAlgorithm`
- Configurable output length (in bytes)
- Accepts arbitrary `salt` and `info` context bytes
- Deterministic: same inputs always produce the same key material
## Usage: Basic Example
```ts
import { hkdf } from "@suaveplan/crypto-hash/kdf";
const ikm = new Uint8Array(32).fill(0x01); // input key material
const salt = new Uint8Array(16); // random salt (often public)
const info = new TextEncoder().encode("suaveplan-session-key-v1");
const sessionKey = hkdf("SHA-256", ikm, { salt, info, length: 32 });
console.log(sessionKey.length); // 32
```
## Usage: Advanced Example
```ts
import { hkdf } from "@suaveplan/crypto-hash/kdf";
import { toHex } from "@suaveplan/crypto-hash/encoding";
// Derive separate encryption and MAC keys from a single DH shared secret
function deriveKeyPair(sharedSecret: Uint8Array, context: string): {
encKey: Uint8Array;
macKey: Uint8Array;
} {
const encoder = new TextEncoder();
const salt = new Uint8Array(32); // zero salt; real usage uses random
const encKey = hkdf("SHA-256", sharedSecret, {
salt,
info: encoder.encode(`${context}:enc`),
length: 32,
});
const macKey = hkdf("SHA-256", sharedSecret, {
salt,
info: encoder.encode(`${context}:mac`),
length: 32,
});
return { encKey, macKey };
}
const { encKey, macKey } = deriveKeyPair(new Uint8Array(32), "chat-session");
console.log(toHex(encKey));
console.log(toHex(macKey));
```
## API Reference
### `hkdf(algo: HashAlgorithm, ikm: Uint8Array, options: HkdfOptions): Uint8Array`
Derives `options.length` bytes of key material from the input keying material
`ikm` using the two-step HKDF-Extract-and-Expand procedure.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HashAlgorithm` | Underlying hash: `"SHA-256"`, `"SHA-384"`, or `"SHA-512"` |
| `ikm` | `Uint8Array` | Input keying material (e.g., DH shared secret) |
| `options.salt` | `Uint8Array` | Optional salt bytes (use a random or domain-specific value) |
| `options.info` | `Uint8Array` | Context and application-specific information |
| `options.length` | `number` | Number of output bytes to derive |
**Returns:** `Uint8Array` of `options.length` bytes.
---
### `HkdfOptions`
```ts
interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
```
## Implementation Notes
HKDF is defined in RFC 5869. The Extract step computes `PRK = HMAC(salt, ikm)`;
the Expand step iterates `HMAC(PRK, info || counter)` until `length` bytes are
produced. Both steps are performed by `@noble/hashes/hkdf` — no hand-rolled
HMAC is involved here. The internal `hasherFor` helper is a private module
function mapping `HashAlgorithm` strings to hash constructors; it is not
exported. The maximum derivable length is `255 × hashLen`, which for SHA-256 is
8160 bytes — well within any practical keying requirement.

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { hkdf } from "./hkdf.js";
describe("hkdf", () => {
it("produces the requested number of bytes for SHA-256", {
timeout: 5000,
}, () => {
const out = hkdf("SHA-256", new Uint8Array(32).fill(1), {
salt: new Uint8Array(16).fill(2),
info: new Uint8Array([3]),
length: 42,
});
expect(out.length).toBe(42);
});
it("is deterministic for SHA-256", { timeout: 5000 }, () => {
const opts = {
salt: new Uint8Array(16),
info: new Uint8Array(4),
length: 32,
};
const a = hkdf("SHA-256", new Uint8Array(32), opts);
const b = hkdf("SHA-256", new Uint8Array(32), opts);
expect(a).toEqual(b);
});
it("is deterministic for SHA-512", { timeout: 5000 }, () => {
const opts = {
salt: new Uint8Array(16),
info: new Uint8Array(4),
length: 32,
};
const a = hkdf("SHA-512", new Uint8Array(32), opts);
const b = hkdf("SHA-512", new Uint8Array(32), opts);
expect(a).toEqual(b);
});
it("covers SHA-384 path", { timeout: 5000 }, () => {
const out = hkdf("SHA-384", new Uint8Array(32), {
salt: new Uint8Array(16),
info: new Uint8Array(0),
length: 16,
});
expect(out.length).toBe(16);
});
it("same inputs produce same outputs across algorithms", {
timeout: 5000,
}, () => {
const ikm = new Uint8Array(32).fill(7);
const salt = new Uint8Array(16).fill(3);
const info = new Uint8Array([1, 2]);
// Different algorithms produce different outputs
const out256 = hkdf("SHA-256", ikm, { salt, info, length: 32 });
const out512 = hkdf("SHA-512", ikm, { salt, info, length: 32 });
expect(out256).not.toEqual(out512);
});
it("property: HKDF is deterministic", { timeout: 10000 }, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 1 }),
fc.uint8Array(),
fc.uint8Array(),
fc.integer({ min: 1, max: 64 }),
(ikm, salt, info, length) => {
const opts = {
salt: Uint8Array.from(salt),
info: Uint8Array.from(info),
length,
};
const a = hkdf("SHA-256", Uint8Array.from(ikm), opts);
const b = hkdf("SHA-256", Uint8Array.from(ikm), opts);
return a.every((v, i) => v === b[i]);
}
)
);
});
});

View File

@@ -0,0 +1,21 @@
/**
* Public façade for HKDF. Routes through the primitive selector layer.
*/
import type { HashAlgorithm } from "../../hash/sha2/sha2.js";
import { hkdf as selectedHkdf } from "../../primitives/hkdf/hkdf.select/hkdf.select.js";
export interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
/** HKDF-Extract-and-Expand over SHA-256/384/512. */
export function hkdf(
algo: HashAlgorithm,
ikm: Uint8Array,
options: HkdfOptions
): Uint8Array {
return selectedHkdf(algo, ikm, options);
}

View File

@@ -0,0 +1 @@
export { type HkdfOptions, hkdf } from "./hkdf.js";

View File

@@ -0,0 +1,13 @@
export {
ARGON2ID_MIN_MEMORY_COST_KIB,
ARGON2ID_MIN_PARALLELISM,
ARGON2ID_MIN_TIME_COST,
type Argon2idOptions,
argon2id,
} from "./argon2id/argon2id.js";
export { type HkdfOptions, hkdf } from "./hkdf/hkdf.js";
export {
PBKDF2_MIN_ITERATIONS,
type Pbkdf2Options,
pbkdf2,
} from "./pbkdf2/pbkdf2.js";

View File

@@ -0,0 +1 @@
export { PBKDF2_MIN_ITERATIONS, type Pbkdf2Options, pbkdf2 } from "./pbkdf2.js";

View File

@@ -0,0 +1,128 @@
# pbkdf2
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides PBKDF2 (Password-Based Key Derivation Function 2, RFC 8018) over
SHA-2 variants. PBKDF2 derives a fixed-length key from a password by applying
an HMAC pseudo-random function iteratively — the iteration count is the primary
cost-tuning knob. This module enforces a hard floor of 600 000 iterations
(the OWASP 2023 minimum for PBKDF2-SHA-256) to prevent accidental use of
dangerously weak parameters. Callers that legitimately need lower counts for
legacy-system interoperability can call `@noble/hashes` directly.
## Features
- `pbkdf2(algo, password, options)` — PBKDF2 over SHA-256/384/512
- `PBKDF2_MIN_ITERATIONS = 600_000` — OWASP 2023 minimum, exported as a constant
- Throws `HashError("HASH.KDF.INVALID_PARAMS")` if `iterations < 600_000` or non-integer
- Throws `HashError("HASH.KDF.INVALID_PARAMS")` if `length ≤ 0` or non-integer
- Error context includes `{ field, value, minimum }` for programmatic handling
- Deterministic: same inputs always produce the same derived key
## Usage: Basic Example
```ts
import { pbkdf2, PBKDF2_MIN_ITERATIONS } from "@suaveplan/crypto-hash/kdf";
const encoder = new TextEncoder();
const password = encoder.encode("correct-horse-battery-staple");
const salt = new Uint8Array(16); // use crypto.getRandomValues in real code
const key = pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 32,
});
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import { pbkdf2, PBKDF2_MIN_ITERATIONS } from "@suaveplan/crypto-hash/kdf";
import { toHex } from "@suaveplan/crypto-hash/encoding";
import { HashError } from "@suaveplan/crypto-hash/errors";
function deriveStorageKey(
password: string,
saltHex: string,
lengthBytes = 32
): string {
const encoder = new TextEncoder();
const salt = new Uint8Array(
saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16))
);
try {
const key = pbkdf2("SHA-256", encoder.encode(password), {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: lengthBytes,
});
return toHex(key);
} catch (err) {
if (err instanceof HashError) {
throw new Error(
`Key derivation failed: ${err.code} — ${JSON.stringify(err.context)}`
);
}
throw err;
}
}
```
## API Reference
### `pbkdf2(algo: HashAlgorithm, password: Uint8Array, options: Pbkdf2Options): Uint8Array`
Derives `options.length` bytes from `password` using PBKDF2.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HashAlgorithm` | Underlying hash: `"SHA-256"`, `"SHA-384"`, or `"SHA-512"` |
| `password` | `Uint8Array` | User-supplied password bytes |
| `options.salt` | `Uint8Array` | Per-credential random salt (≥ 16 bytes recommended) |
| `options.iterations` | `number` | Integer ≥ `PBKDF2_MIN_ITERATIONS` |
| `options.length` | `number` | Positive integer byte count for derived key |
**Returns:** `Uint8Array` of `options.length` bytes.
**Throws:** `HashError("HASH.KDF.INVALID_PARAMS")` when `iterations` or `length`
fail validation. The error `context` object includes `{ field, value, minimum? }`.
---
### `PBKDF2_MIN_ITERATIONS`
```ts
const PBKDF2_MIN_ITERATIONS = 600_000;
```
OWASP 2023 recommended minimum for PBKDF2-SHA-256. Exported so callers can
reference it when building configuration objects.
---
### `Pbkdf2Options`
```ts
interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
```
## Implementation Notes
PBKDF2 is defined in RFC 8018 (superseding RFC 2898). The function delegates
to `@noble/hashes/pbkdf2` and maps `HashAlgorithm` strings to hash constructors
via a private `hasherFor` function that is not exported. The 600 000-iteration
floor corresponds to OWASP's 2023 recommendation for PBKDF2-HMAC-SHA-256; the
NIST SP 800-132 guideline is 10 000 but OWASP's figure accounts for modern GPU
attack capabilities. Applications using SHA-384 or SHA-512 may consider lower
counts because those hashes are slower on commodity hardware, but this module
applies the same floor for all variants to avoid subtle configuration mistakes.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { PBKDF2_MIN_ITERATIONS, pbkdf2 } from "./pbkdf2.js";
describe("pbkdf2", () => {
const password = new Uint8Array([1, 2, 3]);
const salt = new Uint8Array(16).fill(4);
it("derives the requested length with SHA-256", {
timeout: 30000,
}, () => {
const out = pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 32,
});
expect(out.length).toBe(32);
});
it("is deterministic with SHA-256", {
timeout: 30000,
}, () => {
const opts = { salt, iterations: PBKDF2_MIN_ITERATIONS, length: 32 };
const a = pbkdf2("SHA-256", password, opts);
const b = pbkdf2("SHA-256", password, opts);
expect(a).toEqual(b);
});
it("covers SHA-384 path", { timeout: 30000 }, () => {
const out = pbkdf2("SHA-384", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 48,
});
expect(out.length).toBe(48);
});
it("covers SHA-512 path", { timeout: 30000 }, () => {
const out = pbkdf2("SHA-512", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 64,
});
expect(out.length).toBe(64);
});
it("rejects iterations below the floor", { timeout: 5000 }, () => {
try {
pbkdf2("SHA-256", password, {
salt,
iterations: 1000,
length: 32,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["KDF.INVALID_PARAMS"]
);
expect((err as HashError).context).toMatchObject({
field: "iterations",
minimum: PBKDF2_MIN_ITERATIONS,
});
}
});
it("rejects non-integer iterations", { timeout: 5000 }, () => {
expect(() =>
pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS + 0.5,
length: 32,
})
).toThrow(HashError);
});
it("rejects zero length", { timeout: 5000 }, () => {
try {
pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 0,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["KDF.INVALID_PARAMS"]
);
expect((err as HashError).context).toMatchObject({
field: "length",
});
}
});
it("rejects negative length", { timeout: 5000 }, () => {
expect(() =>
pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: -1,
})
).toThrow(HashError);
});
it("rejects non-integer length", { timeout: 5000 }, () => {
expect(() =>
pbkdf2("SHA-256", password, {
salt,
iterations: PBKDF2_MIN_ITERATIONS,
length: 1.5,
})
).toThrow(HashError);
});
it("property: PBKDF2 same inputs give same output", {
timeout: 60000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 1 }),
fc.uint8Array({ minLength: 1 }),
fc.integer({ min: 1, max: 32 }),
(pw, s, len) => {
const opts = {
salt: Uint8Array.from(s),
iterations: PBKDF2_MIN_ITERATIONS,
length: len,
};
const a = pbkdf2("SHA-256", Uint8Array.from(pw), opts);
const b = pbkdf2("SHA-256", Uint8Array.from(pw), opts);
return a.every((v, i) => v === b[i]);
}
),
{ numRuns: 3 }
);
});
});

View File

@@ -0,0 +1,60 @@
/**
* Public façade for PBKDF2. Routes through the primitive selector layer.
* Rejects iteration counts below OWASP 2023 minimum and non-positive
* output lengths.
*/
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import type { HashAlgorithm } from "../../hash/sha2/sha2.js";
import { pbkdf2 as selectedPbkdf2 } from "../../primitives/pbkdf2/pbkdf2.select/pbkdf2.select.js";
/** OWASP 2023 minimum iteration count for PBKDF2-SHA-256. */
export const PBKDF2_MIN_ITERATIONS = 600_000;
export interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
/**
* PBKDF2. Iterations below `PBKDF2_MIN_ITERATIONS` are rejected —
* consumers that need weaker parameters for legacy interop can bypass
* by calling `@noble/hashes` directly.
*/
export function pbkdf2(
algo: HashAlgorithm,
password: Uint8Array,
options: Pbkdf2Options
): Uint8Array {
if (
!Number.isInteger(options.iterations) ||
options.iterations < PBKDF2_MIN_ITERATIONS
) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "iterations",
value: options.iterations,
minimum: PBKDF2_MIN_ITERATIONS,
},
}
);
}
if (!Number.isInteger(options.length) || options.length <= 0) {
throw new HashError(
HASH_CODES["KDF.INVALID_PARAMS"],
"error.hash.kdf.invalid-params",
{
context: {
field: "length",
value: options.length,
},
}
);
}
return selectedPbkdf2(algo, password, options);
}

View File

@@ -0,0 +1,69 @@
# argon2id-password
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`argon2id-password.ts` provides a PHC-format password hashing scheme on top of the Argon2id primitive. The output format is `$argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=<parallelism>$<saltB64>$<hashB64>` — the official Argon2id PHC string used by `argon2`-cli, libsodium, and most server-side authentication libraries. suaveplan emits the same format so vault tooling, server keystores, and migration scripts can interoperate without an adapter layer.
Argon2id is the recommended OWASP password hashing scheme. It is memory-hard (resistant to GPU/ASIC brute force), time-hard (configurable iteration count), and side-channel resistant (the "id" variant blends data-independent and data-dependent passes). This module wraps the suaveplan Argon2id primitive with the PHC string format, default OWASP-2023 parameters, and constant-time verification.
## Features
- PHC-format output `$argon2id$v=19$m=N,t=N,p=N$saltB64$hashB64` — interoperable with all standard Argon2id implementations.
- OWASP 2023 default parameters: `m=65536` (64 MiB), `t=3` iterations, `p=1` parallelism.
- Constant-time verification via `@suaveplan/crypto-utils::constantTimeEqual` over the 32-byte hash.
- 16-byte fresh random salt per hash call; deterministic when an explicit salt is supplied (test-only).
- Strict format validation: malformed strings throw `HashError(BLAKE2B_PASSWORD_FORMAT_INVALID)` (note: the same generic password-format error code is shared between Argon2id and BLAKE2b passwords; the message key disambiguates).
## Usage: Basic Example
```ts
import {
hashPasswordArgon2id,
verifyPasswordArgon2id,
} from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
const stored = hashPasswordArgon2id("correct horse battery staple");
// → "$argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>"
const ok = verifyPasswordArgon2id(stored, "correct horse battery staple");
// → true
```
## Usage: Advanced Example
```ts
import { hashPasswordArgon2id, verifyPasswordArgon2id } from "@suaveplan/crypto-hash/mac/argon2id-password/argon2id-password";
// Adaptive cost: probe how long a verify takes on this hardware and
// pick the largest memory cost that still fits in a target latency.
function calibrate(password: string, targetMs: number): number {
let m = 16_384; // 16 MiB starting point
while (m <= 262_144) {
const stored = hashPasswordArgon2id(password, {
memoryCost: m,
timeCost: 3,
parallelism: 1,
});
const start = performance.now();
verifyPasswordArgon2id(stored, password);
if (performance.now() - start > targetMs) return m / 2;
m *= 2;
}
return 262_144;
}
```
## API Reference
- `hashPasswordArgon2id(password: string, options?: HashPasswordArgon2idOptions): string`.
- `verifyPasswordArgon2id(storedHash: string, password: string): boolean`.
- `HashPasswordArgon2idOptions { memoryCost?, timeCost?, parallelism?, salt? }` — all fields optional; defaults are OWASP 2023 recommendations.
Errors: `HASH.BLAKE2B_PASSWORD_FORMAT_INVALID` for malformed stored hashes (missing prefix, wrong segment count, bad params, bad base64).
## Implementation Notes
The PHC format uses base64-without-padding (per RFC 9106 Appendix B); the parser pads back to a multiple of 4 before decoding. Memory cost is in KiB, time cost is the number of passes, parallelism is the number of lanes — these are the same units the underlying Argon2id primitive consumes, so there is no unit conversion. Constant-time comparison happens on the raw 32-byte hash buffer; the salt and parameter components of the PHC string are not secret and do not need timing-safe handling. suaveplan re-uses the existing `BLAKE2B_PASSWORD_FORMAT_INVALID` error code rather than minting a new `ARGON2ID_*` code; both schemes are MCF-style password hashes and a unified format-error code keeps consumer dispatch simple.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HashError } from "../../errors/hash-error/hash-error.js";
import {
hashPasswordArgon2id,
verifyPasswordArgon2id,
} from "./argon2id-password.js";
const FAST = { memoryCost: 16, timeCost: 1, parallelism: 1 };
describe("hashPasswordArgon2id / verifyPasswordArgon2id — round-trip", () => {
it("hash and verify round-trip with same password", {
timeout: 5000,
}, () => {
const stored = hashPasswordArgon2id(
"correct horse battery staple",
FAST
);
expect(
verifyPasswordArgon2id(stored, "correct horse battery staple")
).toBe(true);
});
it("verify returns false for wrong password", { timeout: 5000 }, () => {
const stored = hashPasswordArgon2id(
"correct horse battery staple",
FAST
);
expect(verifyPasswordArgon2id(stored, "wrong password")).toBe(false);
});
it("salt is fresh per call so identical inputs hash differently", {
timeout: 5000,
}, () => {
const a = hashPasswordArgon2id("same password", FAST);
const b = hashPasswordArgon2id("same password", FAST);
expect(a).not.toBe(b);
});
it("explicit salt produces deterministic output", { timeout: 5000 }, () => {
const salt = new Uint8Array(16).fill(0x42);
const a = hashPasswordArgon2id("p", { ...FAST, salt });
const b = hashPasswordArgon2id("p", { ...FAST, salt });
expect(a).toBe(b);
});
it("default options produce a $argon2id$v=19$-prefixed hash", {
timeout: 30_000,
}, () => {
const stored = hashPasswordArgon2id("p");
expect(stored.startsWith("$argon2id$v=19$")).toBe(true);
});
it("rejects malformed stored hash (missing prefix)", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
verifyPasswordArgon2id("not-a-valid-format", "any");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
expect((caught as HashError).code).toBe(
"HASH.BLAKE2B_PASSWORD_FORMAT_INVALID"
);
});
it("rejects malformed stored hash (wrong segment count)", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
verifyPasswordArgon2id("$argon2id$v=19$onlyone", "any");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
});
it("rejects malformed stored hash (bad params)", { timeout: 5000 }, () => {
let caught: unknown;
try {
verifyPasswordArgon2id("$argon2id$v=19$bogus$AAAA$BBBB", "any");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
});
it("rejects malformed stored hash (bad base64)", { timeout: 5000 }, () => {
let caught: unknown;
try {
verifyPasswordArgon2id(
"$argon2id$v=19$m=16,t=1,p=1$###$###",
"any"
);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
});
});

View File

@@ -0,0 +1,143 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
import { fromBase64, toBase64 } from "@suaveplan/crypto-utils/encoding";
import { randomBytes } from "@suaveplan/crypto-utils/random";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { argon2id } from "../../kdf/argon2id/argon2id.js";
export interface HashPasswordArgon2idOptions {
/** Memory cost in KiB. Default 65,536 (64 MiB). */
readonly memoryCost?: number;
/** Time cost (iterations). Default 3. */
readonly timeCost?: number;
/** Parallelism factor. Default 1. */
readonly parallelism?: number;
/** Optional caller-supplied salt (test-only; omit in production). */
readonly salt?: Uint8Array;
}
const DEFAULT_MEMORY_COST = 65_536;
const DEFAULT_TIME_COST = 3;
const DEFAULT_PARALLELISM = 1;
const SALT_LENGTH = 16;
const HASH_LENGTH = 32;
const FORMAT_PREFIX = "$argon2id$v=19$";
function utf8(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function toBase64NoPad(bytes: Uint8Array): string {
return toBase64(bytes).replace(/=+$/, "");
}
function fromBase64Pad(s: string): Uint8Array {
const pad = (4 - (s.length % 4)) % 4;
return fromBase64(s + "=".repeat(pad));
}
/**
* Hash a password with Argon2id in PHC format
* `$argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=<parallelism>$<saltB64>$<hashB64>`.
* Defaults match OWASP 2023 recommendations: m=64 MiB, t=3, p=1.
*/
export function hashPasswordArgon2id(
password: string,
options: HashPasswordArgon2idOptions = {}
): string {
const memoryCost = options.memoryCost ?? DEFAULT_MEMORY_COST;
const timeCost = options.timeCost ?? DEFAULT_TIME_COST;
const parallelism = options.parallelism ?? DEFAULT_PARALLELISM;
const salt = options.salt ?? randomBytes(SALT_LENGTH);
const hash = argon2id(utf8(password), {
salt,
timeCost,
memoryCost,
parallelism,
length: HASH_LENGTH,
});
const params = `m=${memoryCost},t=${timeCost},p=${parallelism}`;
return `${FORMAT_PREFIX}${params}$${toBase64NoPad(salt)}$${toBase64NoPad(hash)}`;
}
interface ParsedArgon2idHash {
memoryCost: number;
timeCost: number;
parallelism: number;
salt: Uint8Array;
hash: Uint8Array;
}
function parse(stored: string): ParsedArgon2idHash {
if (!stored.startsWith(FORMAT_PREFIX)) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.argon2id-password-format-invalid",
{ context: { reason: "missing-prefix" } }
);
}
const rest = stored.slice(FORMAT_PREFIX.length);
const parts = rest.split("$");
if (parts.length !== 3) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.argon2id-password-format-invalid",
{
context: {
reason: "wrong-segment-count",
segments: parts.length,
},
}
);
}
const paramStr = parts[0]!;
const saltB64 = parts[1]!;
const hashB64 = parts[2]!;
const paramMatch = paramStr.match(/^m=(\d+),t=(\d+),p=(\d+)$/);
if (!paramMatch) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.argon2id-password-format-invalid",
{ context: { reason: "bad-params", paramStr } }
);
}
const memoryCost = Number.parseInt(paramMatch[1]!, 10);
const timeCost = Number.parseInt(paramMatch[2]!, 10);
const parallelism = Number.parseInt(paramMatch[3]!, 10);
let salt: Uint8Array;
let hash: Uint8Array;
try {
salt = fromBase64Pad(saltB64);
hash = fromBase64Pad(hashB64);
} catch (cause) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.argon2id-password-format-invalid",
{ cause, context: { reason: "bad-base64" } }
);
}
return { memoryCost, timeCost, parallelism, salt, hash };
}
/**
* Verify a password against a stored hash produced by
* `hashPasswordArgon2id`. Comparison is constant-time over the hash
* bytes. Returns `false` for non-matching passwords; throws
* `HashError(BLAKE2B_PASSWORD_FORMAT_INVALID)` for malformed stored
* hashes.
*/
export function verifyPasswordArgon2id(
storedHash: string,
password: string
): boolean {
const parsed = parse(storedHash);
const candidate = argon2id(utf8(password), {
salt: parsed.salt,
timeCost: parsed.timeCost,
memoryCost: parsed.memoryCost,
parallelism: parsed.parallelism,
length: parsed.hash.length,
});
return constantTimeEqual(candidate, parsed.hash);
}

View File

@@ -0,0 +1,5 @@
export {
type HashPasswordArgon2idOptions,
hashPasswordArgon2id,
verifyPasswordArgon2id,
} from "./argon2id-password.js";

View File

@@ -0,0 +1,72 @@
# blake2b-password
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`blake2b-password.ts` provides a password-hashing scheme built on the BLAKE2b primitive. It is the right tool when you need a fast, deterministic password hasher in a memory-constrained or platform-restricted environment where Argon2id is not available — for example, embedded contexts, WASM-cold-start-sensitive paths, or when interoperating with legacy systems that already store BLAKE2b-iterated password hashes.
The format is `$blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex>` — an MCF-style stored hash that captures the cost factor, salt, and hash bytes in a single deterministically-parseable string. The cost factor controls iteration count exponentially (`iterations = 2^costFactor`) so doubling it doubles the work; the default of `14` corresponds to 16,384 iterations of BLAKE2b-512.
This is **not a substitute for Argon2id**. Argon2id is memory-hard; BLAKE2b iteration is purely time-hard, which is weaker against attackers with massive parallel hardware (GPUs, FPGAs). Use Argon2id whenever you can; reach for this only when interop or platform constraints force it.
## Features
- Compact MCF-style stored format: `$blake2b$v=1$cf=N$salt$hash` — captures the algorithm tag, version, cost factor, salt, and hash in a single round-trippable string.
- `hashPasswordBlake2b(password, options?)` — generates a fresh 16-byte salt by default; deterministic when an explicit salt is supplied.
- `verifyPasswordBlake2b(stored, password)` — constant-time comparison via `@suaveplan/crypto-utils::constantTimeEqual`.
- Cost-factor range `1..31` (iteration counts 2..2^31). Outside that range, verification throws `HashError(BLAKE2B_PASSWORD_FORMAT_INVALID)`.
- Returns `false` for non-matching passwords without leaking timing information; throws structured errors only for malformed stored hashes.
## Usage: Basic Example
```ts
import {
hashPasswordBlake2b,
verifyPasswordBlake2b,
} from "@suaveplan/crypto-hash/mac/blake2b-password";
const stored = hashPasswordBlake2b("correct horse battery staple");
// → "$blake2b$v=1$cf=14$<salt-hex>$<hash-hex>"
const ok = verifyPasswordBlake2b(stored, "correct horse battery staple");
// → true
```
## Usage: Advanced Example
```ts
import { hashPasswordBlake2b, verifyPasswordBlake2b } from "@suaveplan/crypto-hash/mac/blake2b-password";
// Adaptive cost factor: probe how long a verify takes and tune cost.
async function calibrate(targetMs = 100): Promise<number> {
for (let cf = 12; cf <= 20; cf++) {
const stored = hashPasswordBlake2b("probe", { costFactor: cf });
const start = performance.now();
verifyPasswordBlake2b(stored, "probe");
if (performance.now() - start > targetMs) return cf;
}
return 20;
}
// Migrating a password that was stored at an older cost factor: detect
// the cost in the stored string and re-hash on successful login.
function needsRehash(storedHash: string, target: number): boolean {
const match = storedHash.match(/\$cf=(\d+)\$/);
return match ? Number(match[1]) < target : true;
}
```
## API Reference
- `hashPasswordBlake2b(password: string, options?: HashPasswordBlake2bOptions): string` — produces an MCF-style stored hash.
- `verifyPasswordBlake2b(storedHash: string, password: string): boolean` — constant-time verify.
- `HashPasswordBlake2bOptions { costFactor?: number; salt?: Uint8Array }` — `costFactor` defaults to `14`; `salt` defaults to a fresh 16-byte random buffer.
- `DEFAULT_COST_FACTOR = 14`, `SALT_LENGTH = 16`, `HASH_LENGTH = 64` constants.
Errors: `HASH.BLAKE2B_PASSWORD_FORMAT_INVALID` for malformed stored hashes (missing prefix, wrong segment count, missing `cf=` tag, out-of-range cost factor, malformed hex, wrong hash length).
## Implementation Notes
The iteration loop is BLAKE2b chained on its own output: `acc_0 = BLAKE2b(password ‖ salt)`, `acc_n = BLAKE2b(acc_{n-1})`. This is intentionally simple — it's not a memory-hard scheme like Argon2id, just an iterated hash. The cost factor is exponential (`iterations = 2^costFactor`) so the default of `14` gives 16,384 iterations, which is approximately `~5–10ms` on a modern desktop and `~50ms` on a slow mobile. Constant-time comparison happens on the raw 64-byte hash buffer via `constantTimeEqual`, which means the verify side leaks no information about which byte first differs. The cost factor is bounded at 31 to prevent integer-overflow surprises (`1 << 32` overflows to negative in JS bitwise math). Salts are caller-supplied via the `salt` option for tests; the default is `randomBytes(16)`.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { verifyPasswordBlake2b } from "./blake2b-password.js";
const FORMAT_BAD: Array<[string, string]> = [
["missing-prefix", "not-a-valid-format"],
["wrong-segment-count", "$blake2b$v=1$cf=4$onlytwo"],
["missing-cf-tag", "$blake2b$v=1$xx=4$00$11"],
["bad-cost-factor", "$blake2b$v=1$cf=abc$00$11"],
["bad-cost-factor-out-of-range", "$blake2b$v=1$cf=99$00$11"],
["bad-hex-salt", "$blake2b$v=1$cf=4$xyz$00"],
["bad-hash-length", "$blake2b$v=1$cf=4$00$11"],
];
describe("verifyPasswordBlake2b — error-code conformance", () => {
for (const [label, malformed] of FORMAT_BAD) {
it(`throws BLAKE2B_PASSWORD_FORMAT_INVALID for malformed stored hash (${label})`, {
timeout: 5000,
}, () => {
let caught: unknown;
try {
verifyPasswordBlake2b(malformed, "any");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
expect((caught as HashError).code).toBe(
"HASH.BLAKE2B_PASSWORD_FORMAT_INVALID"
);
});
}
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_COST_FACTOR,
HASH_LENGTH,
hashPasswordBlake2b,
SALT_LENGTH,
verifyPasswordBlake2b,
} from "./blake2b-password.js";
describe("hashPasswordBlake2b / verifyPasswordBlake2b — round-trip", () => {
it("hash and verify round-trip with same password", {
timeout: 5000,
}, () => {
const stored = hashPasswordBlake2b("correct horse battery staple", {
costFactor: 4,
});
expect(
verifyPasswordBlake2b(stored, "correct horse battery staple")
).toBe(true);
});
it("verify returns false for wrong password", { timeout: 5000 }, () => {
const stored = hashPasswordBlake2b("correct horse battery staple", {
costFactor: 4,
});
expect(verifyPasswordBlake2b(stored, "wrong password")).toBe(false);
});
it("salt is fresh per call so identical inputs hash differently", {
timeout: 5000,
}, () => {
const a = hashPasswordBlake2b("same password", { costFactor: 4 });
const b = hashPasswordBlake2b("same password", { costFactor: 4 });
expect(a).not.toBe(b);
});
it("explicit salt produces deterministic output", { timeout: 5000 }, () => {
const salt = new Uint8Array(SALT_LENGTH).fill(0x42);
const a = hashPasswordBlake2b("p", { costFactor: 4, salt });
const b = hashPasswordBlake2b("p", { costFactor: 4, salt });
expect(a).toBe(b);
});
it("default cost factor produces a $blake2b$v=1$cf=N$salt$hash string", {
timeout: 30_000,
}, () => {
const stored = hashPasswordBlake2b("p");
expect(
stored.startsWith(`$blake2b$v=1$cf=${DEFAULT_COST_FACTOR}$`)
).toBe(true);
});
it("stored hash length matches HASH_LENGTH (64 bytes hex-encoded)", {
timeout: 5000,
}, () => {
const salt = new Uint8Array(SALT_LENGTH);
const stored = hashPasswordBlake2b("p", { costFactor: 1, salt });
const parts = stored.split("$");
const hashHex = parts[parts.length - 1];
expect(hashHex?.length).toBe(HASH_LENGTH * 2);
});
});

View File

@@ -0,0 +1,142 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
import { fromHex, toHex } from "@suaveplan/crypto-utils/encoding";
import { randomBytes } from "@suaveplan/crypto-utils/random";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { blake2b } from "../../primitives/blake2b/blake2b.js";
export interface HashPasswordBlake2bOptions {
/** Cost factor; iteration count = 2^costFactor. Default 14 → 16384 iters. */
readonly costFactor?: number;
/** Optional caller-supplied 16-byte salt (test-only; omit in production). */
readonly salt?: Uint8Array;
}
/** Default cost factor (16,384 iterations of blake2b-512). */
export const DEFAULT_COST_FACTOR = 14;
/** Salt length in bytes. */
export const SALT_LENGTH = 16;
/** Stored-hash output length in bytes (blake2b-512 truncated to 64 bytes). */
export const HASH_LENGTH = 64;
const FORMAT_PREFIX = "$blake2b$v=1$";
function deriveBytes(
password: Uint8Array,
salt: Uint8Array,
costFactor: number
): Uint8Array {
const iterations = 1 << costFactor;
let acc = blake2b(concat(password, salt), { dkLen: HASH_LENGTH });
for (let i = 1; i < iterations; i++) {
acc = blake2b(acc, { dkLen: HASH_LENGTH });
}
return acc;
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
function utf8(input: string): Uint8Array {
return new TextEncoder().encode(input);
}
/**
* Hash a password with BLAKE2b in MCF format
* `$blake2b$v=1$cf=<costFactor>$<saltHex>$<hashHex>`. The cost factor
* controls iteration count exponentially: `iterations = 2^costFactor`,
* default 14 (16,384 iterations). A fresh 16-byte salt is generated per
* call when not supplied.
*/
export function hashPasswordBlake2b(
password: string,
options: HashPasswordBlake2bOptions = {}
): string {
const costFactor = options.costFactor ?? DEFAULT_COST_FACTOR;
const salt = options.salt ?? randomBytes(SALT_LENGTH);
const hash = deriveBytes(utf8(password), salt, costFactor);
return `${FORMAT_PREFIX}cf=${costFactor}$${toHex(salt)}$${toHex(hash)}`;
}
/**
* Verify a password against a stored hash produced by
* `hashPasswordBlake2b`. Comparison is constant-time over the hash bytes.
* Returns `false` for non-matching passwords; throws
* `HashError(BLAKE2B_PASSWORD_FORMAT_INVALID)` for malformed stored
* hashes.
*/
export function verifyPasswordBlake2b(
storedHash: string,
password: string
): boolean {
if (!storedHash.startsWith(FORMAT_PREFIX)) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{ context: { reason: "missing-prefix" } }
);
}
const rest = storedHash.slice(FORMAT_PREFIX.length);
const parts = rest.split("$");
if (parts.length !== 3) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{
context: {
reason: "wrong-segment-count",
segments: parts.length,
},
}
);
}
const cfStr = parts[0]!;
const saltHex = parts[1]!;
const hashHex = parts[2]!;
if (!cfStr.startsWith("cf=")) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{ context: { reason: "missing-cf-tag" } }
);
}
const costFactor = Number.parseInt(cfStr.slice(3), 10);
if (!Number.isInteger(costFactor) || costFactor < 1 || costFactor > 31) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{ context: { reason: "bad-cost-factor", costFactor } }
);
}
let salt: Uint8Array;
let storedBytes: Uint8Array;
try {
salt = fromHex(saltHex);
storedBytes = fromHex(hashHex);
} catch (cause) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{ cause, context: { reason: "bad-hex" } }
);
}
if (storedBytes.length !== HASH_LENGTH) {
throw new HashError(
HASH_CODES.BLAKE2B_PASSWORD_FORMAT_INVALID,
"error.crypto.hash.blake2b-password-format-invalid",
{
context: {
reason: "bad-hash-length",
actual: storedBytes.length,
expected: HASH_LENGTH,
},
}
);
}
const candidate = deriveBytes(utf8(password), salt, costFactor);
return constantTimeEqual(candidate, storedBytes);
}

View File

@@ -0,0 +1,8 @@
export {
DEFAULT_COST_FACTOR,
HASH_LENGTH,
type HashPasswordBlake2bOptions,
hashPasswordBlake2b,
SALT_LENGTH,
verifyPasswordBlake2b,
} from "./blake2b-password.js";

View File

@@ -0,0 +1,97 @@
# hmac
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides HMAC (Hash-based Message Authentication Code) over SHA-2 variants.
HMAC proves that a message was produced by a party in possession of a shared
key, giving both data integrity and authenticity guarantees without asymmetric
cryptography. This module wraps the audited `@noble/hashes` HMAC primitive
and restricts the algorithm surface to the same `HashAlgorithm` union used
throughout `@suaveplan/crypto-hash`.
## Features
- `hmac(algo, key, data)` — one-shot HMAC-SHA-{256,384,512}
- Throws `HashError("HASH.ALGORITHM.UNSUPPORTED")` for unrecognised algorithm strings
- Passes through RFC 4231 HMAC-SHA-256 test vectors
- Deterministic: same `(algo, key, data)` triple always produces the same tag
- Zero runtime state; safe for concurrent use
## Usage: Basic Example
```ts
import { hmac } from "@suaveplan/crypto-hash/mac";
import { toHex } from "@suaveplan/crypto-hash/encoding";
const encoder = new TextEncoder();
const key = encoder.encode("super-secret-key");
const data = encoder.encode("payment:amount=100");
const tag = hmac("SHA-256", key, data);
console.log(toHex(tag)); // 32-byte hex string
```
## Usage: Advanced Example
```ts
import { hmac } from "@suaveplan/crypto-hash/mac";
import { toHex, fromHex } from "@suaveplan/crypto-hash/encoding";
// Verify a webhook signature in constant time
function verifyWebhookTag(
secret: Uint8Array,
payload: Uint8Array,
receivedTagHex: string
): boolean {
const expected = hmac("SHA-256", secret, payload);
const received = fromHex(receivedTagHex);
if (expected.length !== received.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected[i] ^ received[i];
}
return diff === 0;
}
const encoder = new TextEncoder();
const secret = encoder.encode("webhook-secret");
const body = encoder.encode('{"event":"charge.succeeded"}');
const tag = toHex(hmac("SHA-256", secret, body));
console.log(verifyWebhookTag(secret, body, tag)); // true
```
## API Reference
### `hmac(algo: HashAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`
Computes the HMAC of `data` under `key` using the specified SHA-2 algorithm.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HashAlgorithm` | `"SHA-256"`, `"SHA-384"`, or `"SHA-512"` |
| `key` | `Uint8Array` | Shared secret key (any length ≥ 1 byte) |
| `data` | `Uint8Array` | Message to authenticate |
**Returns:** Tag bytes — 32 / 48 / 64 bytes for SHA-256 / SHA-384 / SHA-512
respectively.
**Throws:** `HashError("HASH.ALGORITHM.UNSUPPORTED")` if `algo` is not one of
the three supported variants.
## Implementation Notes
The internal `hasherFor` helper maps `HashAlgorithm` strings to the corresponding
`@noble/hashes` hash constructor. It is a private module function — not exported
from this file or from any barrel — so it cannot be called by consumers.
This design keeps the public API surface minimal while allowing `hkdf.ts` and
`pbkdf2.ts` to import `hasherFor` from this module directly for their own
internal use. `argon2id.ts` does not use `hasherFor` because Argon2id is a
password-hashing algorithm that accepts its own internal parameterisation rather
than a bare hash constructor.
HMAC output length equals the underlying hash output length. The `@noble/hashes`
implementation is constant-time within the hash primitive itself; callers are
responsible for constant-time comparison of the resulting tags.

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { fromHex } from "../../encoding/hex/hex.js";
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import type { HashAlgorithm } from "../../hash/sha2/sha2.js";
import { hmac } from "./hmac.js";
describe("hmac", () => {
const key = new Uint8Array(32).fill(0xaa);
const data = new Uint8Array([1, 2, 3, 4]);
it("is deterministic for SHA-256", { timeout: 5000 }, () => {
const a = hmac("SHA-256", key, data);
const b = hmac("SHA-256", key, data);
expect(a).toEqual(b);
});
it("is deterministic for SHA-384", { timeout: 5000 }, () => {
const a = hmac("SHA-384", key, data);
const b = hmac("SHA-384", key, data);
expect(a).toEqual(b);
});
it("is deterministic for SHA-512", { timeout: 5000 }, () => {
const a = hmac("SHA-512", key, data);
const b = hmac("SHA-512", key, data);
expect(a).toEqual(b);
});
it("SHA-256 returns 32 bytes", { timeout: 5000 }, () => {
expect(hmac("SHA-256", key, data).length).toBe(32);
});
it("SHA-384 returns 48 bytes", { timeout: 5000 }, () => {
expect(hmac("SHA-384", key, data).length).toBe(48);
});
it("SHA-512 returns 64 bytes", { timeout: 5000 }, () => {
expect(hmac("SHA-512", key, data).length).toBe(64);
});
it("differs across algorithms", { timeout: 5000 }, () => {
expect(hmac("SHA-256", key, data)).not.toEqual(
hmac("SHA-512", key, data)
);
});
// RFC 4231 Test Case 2 — HMAC-SHA-256
// key: 4a656665 ("Jefe")
// data: 7768617420646f2079612077616e7420666f72206e6f7468696e673f ("what do ya want for nothing?")
// expected HMAC-SHA-256: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
it("RFC 4231 HMAC-SHA-256 test vector", { timeout: 5000 }, () => {
const rfcKey = fromHex("4a656665");
const rfcData = fromHex(
"7768617420646f2079612077616e7420666f72206e6f7468696e673f"
);
const expected = fromHex(
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
expect(hmac("SHA-256", rfcKey, rfcData)).toEqual(expected);
});
it("rejects unsupported algorithm", { timeout: 5000 }, () => {
try {
hmac("SHA-1" as unknown as HashAlgorithm, key, data);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(HashError);
expect((err as HashError).code).toBe(
HASH_CODES["ALGORITHM.UNSUPPORTED"]
);
}
});
it("property: HMAC-SHA-256 is deterministic", { timeout: 10000 }, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 1 }),
fc.uint8Array(),
(k, d) => {
const a = hmac(
"SHA-256",
Uint8Array.from(k),
Uint8Array.from(d)
);
const b = hmac(
"SHA-256",
Uint8Array.from(k),
Uint8Array.from(d)
);
return a.every((v, i) => v === b[i]);
}
)
);
});
});

View File

@@ -0,0 +1,25 @@
/**
* Public façade for HMAC. Routes through the primitive selector layer.
* Unsupported algorithms raise `HashError` with the legacy error code.
*/
import { HASH_CODES } from "../../errors/codes/codes.js";
import { HashError } from "../../errors/hash-error/hash-error.js";
import type { HashAlgorithm } from "../../hash/sha2/sha2.js";
import { hmac as selectedHmac } from "../../primitives/hmac/hmac.select/hmac.select.js";
/** HMAC-SHA-{256,384,512}. */
export function hmac(
algo: HashAlgorithm,
key: Uint8Array,
data: Uint8Array
): Uint8Array {
if (algo !== "SHA-256" && algo !== "SHA-384" && algo !== "SHA-512") {
throw new HashError(
HASH_CODES["ALGORITHM.UNSUPPORTED"],
"error.hash.algorithm.unsupported",
{ context: { algorithm: algo } }
);
}
return selectedHmac(algo, key, data);
}

View File

@@ -0,0 +1 @@
export { hmac } from "./hmac.js";

View File

@@ -0,0 +1 @@
export { hmac } from "./hmac/hmac";

View File

@@ -0,0 +1,98 @@
# argon2id.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`argon2id.select.ts` is the build-time primitive selector for the Argon2id implementation in `@suaveplan/crypto-hash`. It re-exports one concrete backend — either `argon2id.noble.ts` (backed by `@noble/hashes`) or `argon2id.native.ts` — based on the `argon2id` entry in `crypto.config.ts`.
Argon2id is the Password Hashing Competition winner and the recommendation of RFC 9106 for password hashing and key derivation. Its hybrid data-dependent and data-independent memory access pattern resists both GPU-parallel and side-channel attacks, making it the preferred choice when deriving keys from passwords or other low-entropy secrets. The selector exposes `argon2id` and `Argon2idOptions` through a stable interface.
## Features
- Re-exports `argon2id(password, options)` and `Argon2idOptions`.
- Generated by `bun scripts/gen-crypto-selects.ts` — never hand-edited.
- Controlled by `crypto.config.ts`: set `argon2id` to `'noble'` or `'native'`.
- `Argon2idOptions` carries `salt`, `timeCost`, `memoryCost`, `parallelism`, and `length`.
- RFC 9106 minimum parameter validation is enforced at the public façade, not here.
## Usage: Basic Example
```ts
import { argon2id } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
const password = new TextEncoder().encode("correct-horse-battery-staple");
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = argon2id(password, {
salt,
timeCost: 3,
memoryCost: 65536,
parallelism: 4,
length: 32,
});
console.log(hash.length); // 32
```
## Usage: Advanced Example
```ts
import { argon2id, type Argon2idOptions } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
// Interactive login profile (RFC 9106 minimum-t=1 adjusted for latency).
const INTERACTIVE_PARAMS: Argon2idOptions = {
salt: new Uint8Array(16), // placeholder; real callers supply random salt
timeCost: 2,
memoryCost: 19456, // ~19 MiB
parallelism: 1,
length: 32,
};
// Server-side profile (higher security, longer wait acceptable).
const SENSITIVE_PARAMS: Argon2idOptions = {
salt: new Uint8Array(16),
timeCost: 3,
memoryCost: 65536, // ~64 MiB
parallelism: 4,
length: 64,
};
function deriveKey(password: Uint8Array, opts: Argon2idOptions): Uint8Array {
return argon2id(password, opts);
}
```
## API Reference
Re-exports the following from the active implementation:
### `argon2id(password: Uint8Array, options: Argon2idOptions): Uint8Array`
Derives a key from a password using the Argon2id algorithm.
| Parameter | Type | Description |
|-----------|------|-------------|
| `password` | `Uint8Array` | Password bytes. |
| `options.salt` | `Uint8Array` | Random salt (≥16 bytes per RFC 9106). |
| `options.timeCost` | `number` | Number of passes over memory (≥1). |
| `options.memoryCost` | `number` | Memory usage in KiB (≥8 × parallelism). |
| `options.parallelism` | `number` | Degree of parallelism (≥1). |
| `options.length` | `number` | Output length in bytes (≥4). |
**Returns:** Derived key of `options.length` bytes.
### `Argon2idOptions`
```ts
interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
```
## Implementation Notes
Noble's Argon2id implementation is a pure-JavaScript port that runs synchronously. It passes RFC 9106 Appendix B test vectors. Memory consumption scales linearly with `memoryCost` in KiB, so high memory costs can cause out-of-memory errors in constrained environments (e.g. edge workers). The primitive does not enforce RFC 9106 parameter minima — those checks are delegated to the public KDF façade. The native stub throws `PrimitiveNotImplemented("argon2id")`. Unlike PBKDF2, Argon2id is inherently sequential in its memory access, which limits parallelism-based GPU attacks but also means JavaScript's single-threaded nature does not significantly disadvantage it relative to native binaries.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/argon2id.noble/argon2id.noble.js";

View File

@@ -0,0 +1 @@
export * from "./argon2id.select.js";

View File

@@ -0,0 +1,83 @@
# argon2id.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`argon2id.native.ts` is the native-backend placeholder for the Argon2id primitive in `@suaveplan/crypto-hash`. It exports the same `argon2id` function and `Argon2idOptions` interface as the Noble implementation but throws `PrimitiveNotImplemented("argon2id")` unconditionally.
This stub completes the two-implementation selector layout for Argon2id. Argon2id's native backend is the most valuable of the placeholders because a native implementation could run Argon2id in a separate thread and avoid blocking the event loop, which the pure-JavaScript Noble implementation cannot do. The stub communicates that this capability is planned but not yet available.
## Features
- Satisfies `argon2id(password, options): Uint8Array` interface.
- Exports `Argon2idOptions` (identical to Noble file) for selector re-export.
- Throws `PrimitiveNotImplemented("argon2id")` unconditionally.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets argon2id to 'native'.
import { argon2id } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
try {
argon2id(new Uint8Array(8), {
salt: new Uint8Array(16),
timeCost: 1,
memoryCost: 8192,
parallelism: 1,
length: 32,
});
} catch (err) {
// PrimitiveNotImplemented: argon2id
console.error(err);
}
```
## Usage: Advanced Example
```ts
import type { Argon2idOptions } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
// Build a typed options object to pass to whichever backend is active.
function interactiveParams(salt: Uint8Array): Argon2idOptions {
return {
salt,
timeCost: 2,
memoryCost: 19456,
parallelism: 1,
length: 32,
};
}
```
## API Reference
### `argon2id(_password: Uint8Array, _options: Argon2idOptions): Uint8Array`
Placeholder that always throws.
| Parameter | Type | Description |
|-----------|------|-------------|
| `_password` | `Uint8Array` | Ignored. |
| `_options` | `Argon2idOptions` | Ignored. |
**Throws:** `PrimitiveNotImplemented("argon2id")` — always.
### `Argon2idOptions`
```ts
interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
```
## Implementation Notes
The future native implementation is expected to call into `libsodium` or a dedicated Argon2 native module to run the memory-hard computation off the JavaScript event loop. When that implementation lands and passes RFC 9106 Appendix B test vectors via the KAT harness, this file is updated, parameter prefixes removed, and the selector regenerated to point here. The `Argon2idOptions` interface is self-contained in this file so both backends are independent — the selector re-exports from whichever is active.

View File

@@ -0,0 +1,16 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
export function argon2id(
_password: Uint8Array,
_options: Argon2idOptions
): Uint8Array {
throw new PrimitiveNotImplemented("argon2id");
}

View File

@@ -0,0 +1 @@
export { type Argon2idOptions, argon2id } from "./argon2id.native.js";

View File

@@ -0,0 +1,88 @@
# argon2id.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`argon2id.noble.ts` is the Noble-backed concrete implementation of Argon2id in `@suaveplan/crypto-hash`. It wraps `argon2id` from `@noble/hashes/argon2`, translating the `Argon2idOptions` struct into Noble's parameter object (`t`, `m`, `p`, `dkLen`).
Argon2id (RFC 9106) is the Password Hashing Competition winner, combining the data-dependent memory access of Argon2d with the data-independent pass structure of Argon2i. It is the recommended algorithm when deriving keys from passwords or other low-entropy secrets. This file is the production implementation; `argon2id.native.ts` is the placeholder stub.
## Features
- Accepts `Argon2idOptions` with `salt`, `timeCost`, `memoryCost`, `parallelism`, and `length`.
- Delegates to `@noble/hashes/argon2` which implements the full RFC 9106 algorithm.
- Runs synchronously (no worker/thread offload at this layer).
- Exports `Argon2idOptions` for re-export by the selector.
- RFC 9106 Appendix B test vectors pass in the test suite.
## Usage: Basic Example
```ts
import { argon2id } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
const password = new TextEncoder().encode("correct-horse-battery-staple");
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = argon2id(password, {
salt,
timeCost: 3,
memoryCost: 65536,
parallelism: 4,
length: 32,
});
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import { argon2id, type Argon2idOptions } from "@suaveplan/crypto-hash/primitives/argon2id/argon2id";
// Server-side registration: hash the password and store only the derived key.
const SERVER_PARAMS: Omit<Argon2idOptions, "salt"> = {
timeCost: 3,
memoryCost: 65536,
parallelism: 4,
length: 64,
};
function hashPassword(password: string): { salt: Uint8Array; key: Uint8Array } {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = argon2id(new TextEncoder().encode(password), { ...SERVER_PARAMS, salt });
return { salt, key };
}
```
## API Reference
### `argon2id(password: Uint8Array, options: Argon2idOptions): Uint8Array`
Derives key material from a password using Argon2id.
| Parameter | Type | Description |
|-----------|------|-------------|
| `password` | `Uint8Array` | Password bytes. |
| `options.salt` | `Uint8Array` | Random salt (≥16 bytes per RFC 9106). |
| `options.timeCost` | `number` | Number of passes over the memory array (≥1). |
| `options.memoryCost` | `number` | Memory usage in KiB (≥8 × parallelism). |
| `options.parallelism` | `number` | Degree of parallelism (≥1). |
| `options.length` | `number` | Derived key length in bytes (≥4). |
**Returns:** `Uint8Array` of `options.length` bytes.
### `Argon2idOptions`
```ts
interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
```
## Implementation Notes
Noble maps `timeCost → t`, `memoryCost → m`, `parallelism → p`, and `length → dkLen`. All parameter validation (RFC 9106 minima checks) is delegated to the public KDF façade; this file passes values through without checking. Noble's Argon2id is a pure-JavaScript implementation — no WASM — so it runs in any JavaScript environment but may be slower than a native binary for high memory costs. Synchronous execution on large `memoryCost` values can block the event loop; callers that need async should wrap in a worker thread.

View File

@@ -0,0 +1,25 @@
import { argon2id as nobleArgon2id } from "@noble/hashes/argon2";
export interface Argon2idOptions {
readonly salt: Uint8Array;
readonly timeCost: number;
readonly memoryCost: number;
readonly parallelism: number;
readonly length: number;
}
/**
* Argon2id noble-backed impl. Parameter validation (RFC 9106 minima) is
* enforced at the public crypto-hash façade layer, not here.
*/
export function argon2id(
password: Uint8Array,
options: Argon2idOptions
): Uint8Array {
return nobleArgon2id(password, options.salt, {
t: options.timeCost,
m: options.memoryCost,
p: options.parallelism,
dkLen: options.length,
});
}

View File

@@ -0,0 +1 @@
export { type Argon2idOptions, argon2id } from "./argon2id.noble.js";

View File

@@ -0,0 +1,4 @@
export {
type Argon2idOptions,
argon2id,
} from "./argon2id.select/argon2id.select";

View File

@@ -0,0 +1,63 @@
# blake2b
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [blake2b.noble](./impl/blake2b.noble/blake2b.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b](./blake2b.md) — <!-- begin:auto-docs --> _(no submodules)_- blake2b.noble — <!-- begin:auto-docs --> _(no submod…
- [blake2b.noble](./impl/blake2b.noble/blake2b.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b](./blake2b.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b.noble](./impl/blake2b.noble/blake2b.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b](./blake2b.md) — <!-- begin:auto-docs --> _(no submodules)_- blake2b.noble — <!-- begin:a…
- [blake2b.noble](./impl/blake2b.noble/blake2b.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b](./blake2b.md) — <!-- begin:auto-docs --> _(no submodules)_- blake2b.noble — <!-- begin:auto-docs --> _(no submo…
- [blake2b.noble](./impl/blake2b.noble/blake2b.noble.md) — <!-- begin:auto-docs --> _(no submodules)_- [blake2b](./blake2b.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`blake2b.ts` is the public entry point for the BLAKE2b hash primitive (RFC 7693) in `@suaveplan/crypto-hash`. BLAKE2b is a fast, secure cryptographic hash function used by Argon2, password-manager keystreams, blockchain-protocol commitments (Zcash, Polkadot), and many other modern protocols. The suaveplan implementation routes through the per-primitive selector layout (`blake2b.select.ts`) so the noble engine can be replaced without changing caller imports.
The public surface is two symbols: `blake2b(message, options)` and `Blake2bOptions`. Variable output length 1..64 bytes covers everything from short MACs (e.g. 16-byte authenticator tags) to full 64-byte digests. Optional `key`, `salt`, and `personalization` parameters mix into the BLAKE2b parameter block as specified by RFC 7693.
## Features
- RFC 7693 compliance — passes the official `BLAKE2b-512("abc")` known-answer test byte-for-byte.
- Variable output length — `dkLen` can be any value in `1..64` (anything outside this range throws `HashError(HASH.BLAKE2B_OUTPUT_TOO_LONG)`).
- Optional MAC key — turns the hash into a keyed MAC (BLAKE2b is the only mainstream hash with a built-in MAC mode that does not require an outer HMAC construction). Key length capped at 64 bytes; longer keys throw `HashError(HASH.BLAKE2B_KEY_TOO_LONG)`.
- Optional 16-byte `salt` and `personalization` parameters mix into the parameter block (currently passed through to the noble implementation; not all noble versions surface them, so behaviour is "best-effort honored").
- Synchronous — `blake2b` returns a `Uint8Array` directly. Cheap enough for the hot path.
## Usage: Basic Example
```ts
import { blake2b } from "@suaveplan/crypto-hash/blake2b";
const digest = blake2b(new TextEncoder().encode("hello world"), { dkLen: 32 });
```
## Usage: Advanced Example
```ts
import { blake2b } from "@suaveplan/crypto-hash/blake2b";
// Use BLAKE2b in keyed (MAC) mode for short authenticators.
function authenticate(message: Uint8Array, secret: Uint8Array): Uint8Array {
if (secret.length > 64) {
throw new Error("BLAKE2b key must be ≤ 64 bytes");
}
return blake2b(message, { dkLen: 16, key: secret });
}
// Domain-separated commitments via the personalization parameter (where
// the underlying engine surfaces it).
function commit(payload: Uint8Array, domain: string): Uint8Array {
const personalization = new Uint8Array(16);
personalization.set(new TextEncoder().encode(domain.slice(0, 16)));
return blake2b(payload, { dkLen: 32, personalization });
}
```
## API Reference
- `blake2b(message: Uint8Array, options: Blake2bOptions): Uint8Array` — BLAKE2b hash.
- `Blake2bOptions { dkLen: number; key?: Uint8Array; salt?: Uint8Array; personalization?: Uint8Array }` — `dkLen` is the output length in bytes (1..64).
Errors: `HASH.BLAKE2B_OUTPUT_TOO_LONG` when `dkLen > 64` or `dkLen < 1` or `dkLen` is not an integer; `HASH.BLAKE2B_KEY_TOO_LONG` when `key.length > 64`.
## Implementation Notes
The selector pattern (`blake2b.select.ts`) is generated; do not edit it. To add a native engine, place it under `impl/blake2b.native/blake2b.native.ts` and rerun `bun scripts/gen-crypto-selects.ts`. The implementation does not validate that `salt` and `personalization` are exactly 16 bytes — the noble engine ignores incorrectly-sized buffers rather than throwing, and replicating that behaviour here keeps the surface predictable.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/blake2b.noble/blake2b.noble.js";

View File

@@ -0,0 +1 @@
export { type Blake2bOptions, blake2b } from "./blake2b.select.js";

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HashError } from "../../errors/hash-error/hash-error.js";
import { blake2b } from "./blake2b.js";
describe("blake2b — error-code conformance", () => {
it("throws BLAKE2B_OUTPUT_TOO_LONG when dkLen exceeds 64", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
blake2b(new Uint8Array([1]), { dkLen: 65 });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
expect((caught as HashError).code).toBe("HASH.BLAKE2B_OUTPUT_TOO_LONG");
expect((caught as HashError).context?.requestedLength).toBe(65);
});
it("throws BLAKE2B_OUTPUT_TOO_LONG when dkLen < 1", {
timeout: 5000,
}, () => {
for (const dkLen of [0, -1, 1.5]) {
let caught: unknown;
try {
blake2b(new Uint8Array([1]), { dkLen });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
expect((caught as HashError).code).toBe(
"HASH.BLAKE2B_OUTPUT_TOO_LONG"
);
}
});
it("throws BLAKE2B_KEY_TOO_LONG when key exceeds 64 bytes", {
timeout: 5000,
}, () => {
const longKey = new Uint8Array(65);
let caught: unknown;
try {
blake2b(new Uint8Array([1]), { dkLen: 32, key: longKey });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(HashError);
expect((caught as HashError).code).toBe("HASH.BLAKE2B_KEY_TOO_LONG");
expect((caught as HashError).context?.keyLength).toBe(65);
});
});

View File

@@ -0,0 +1,41 @@
import { fromHex } from "@suaveplan/crypto-utils/encoding";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { blake2b } from "./blake2b.js";
describe("blake2b — RFC 7693 known-answer tests", () => {
it('matches RFC 7693 known-answer test for input "abc"', {
timeout: 5000,
}, () => {
// RFC 7693 Appendix A: BLAKE2b-512("abc")
const expected = fromHex(
"ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923"
);
const out = blake2b(new TextEncoder().encode("abc"), { dkLen: 64 });
expect(out).toEqual(expected);
});
it("honors dkLen 1..64", { timeout: 5000 }, () => {
for (const dkLen of [1, 16, 32, 48, 64]) {
const out = blake2b(new TextEncoder().encode("test"), { dkLen });
expect(out.length).toBe(dkLen);
}
});
it("supports keyed mode", { timeout: 5000 }, () => {
const key = new Uint8Array(32).fill(0x42);
const out = blake2b(new TextEncoder().encode("abc"), {
dkLen: 32,
key,
});
expect(out.length).toBe(32);
const noKey = blake2b(new TextEncoder().encode("abc"), { dkLen: 32 });
// Keyed and unkeyed differ.
expect(out).not.toEqual(noKey);
});
it("deterministic for fixed inputs", { timeout: 5000 }, () => {
const a = blake2b(new Uint8Array([1, 2, 3]), { dkLen: 32 });
const b = blake2b(new Uint8Array([1, 2, 3]), { dkLen: 32 });
expect(a).toEqual(b);
});
});

View File

@@ -0,0 +1,4 @@
export {
type Blake2bOptions,
blake2b,
} from "./blake2b.select/blake2b.select.js";

View File

@@ -0,0 +1,50 @@
# blake2b.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`blake2b.noble.ts` is the noble-backed implementation of BLAKE2b in `@suaveplan/crypto-hash`. It wraps `@noble/hashes/blake2b::blake2b` with parameter validation and the `HashError` substrate. Selector forwarding is done by the generated `blake2b.select.ts` file; consumers should never import from here directly.
The noble implementation is pure JavaScript and runs everywhere ESM does — Node 20+, Bun, browsers, Deno, Cloudflare Workers. Synchronous; no main-thread yielding needed since BLAKE2b is fast (multi-GiB/s on modern hardware for the 64-byte block size, even in pure JS).
## Features
- Wraps `@noble/hashes/blake2b::blake2b` and exposes `blake2b(message, options)`.
- Validates `dkLen` in `1..64` integer range; throws `HashError(BLAKE2B_OUTPUT_TOO_LONG)` otherwise.
- Validates `key.length <= 64`; throws `HashError(BLAKE2B_KEY_TOO_LONG)` otherwise.
- Exposes `Blake2bOptions { dkLen, key?, salt?, personalization? }` as the canonical parameter bundle.
- Synchronous (`Uint8Array`).
## Usage: Basic Example
```ts
import { blake2b } from "@suaveplan/crypto-hash/primitives/blake2b/impl/blake2b.noble";
const out = blake2b(new TextEncoder().encode("hello"), { dkLen: 32 });
```
## Usage: Advanced Example
```ts
import { blake2b } from "@suaveplan/crypto-hash/primitives/blake2b/impl/blake2b.noble";
// Build a Merkle-leaf commitment scheme using BLAKE2b in keyed mode for
// domain-separation. The leaf key is itself derived from a per-tree
// commitment key so different trees use different MAC keys.
function leaf(treeKey: Uint8Array, payload: Uint8Array): Uint8Array {
return blake2b(payload, { dkLen: 32, key: treeKey });
}
```
## API Reference
- `blake2b(message: Uint8Array, options: Blake2bOptions): Uint8Array` — pure synchronous BLAKE2b hash.
- `Blake2bOptions { dkLen: number; key?: Uint8Array; salt?: Uint8Array; personalization?: Uint8Array }`.
Errors: `HASH.BLAKE2B_OUTPUT_TOO_LONG`, `HASH.BLAKE2B_KEY_TOO_LONG`.
## Implementation Notes
Validation is checked before delegating to noble so a bad parameter set is rejected before any allocation happens. The noble implementation accepts `salt` and `personalization` parameters but does not strictly require them to be 16 bytes — the suaveplan wrapper passes them through unchanged and lets noble apply its own padding/truncation rules. This matches the upstream library's behaviour and avoids divergent semantics if noble updates its handling in a future release.

View File

@@ -0,0 +1,53 @@
import { blake2b as nobleBlake2b } from "@noble/hashes/blake2b";
import { HASH_CODES } from "../../../../errors/codes/codes.js";
import { HashError } from "../../../../errors/hash-error/hash-error.js";
export interface Blake2bOptions {
/** Output length in bytes (1..64). */
readonly dkLen: number;
/** Optional MAC key (≤ 64 bytes). */
readonly key?: Uint8Array;
/** Optional 16-byte salt. */
readonly salt?: Uint8Array;
/** Optional 16-byte personalization. */
readonly personalization?: Uint8Array;
}
const MAX_DK_LEN = 64;
const MAX_KEY_LEN = 64;
function validate(options: Blake2bOptions): void {
if (
!Number.isInteger(options.dkLen) ||
options.dkLen < 1 ||
options.dkLen > MAX_DK_LEN
) {
throw new HashError(
HASH_CODES.BLAKE2B_OUTPUT_TOO_LONG,
"error.crypto.hash.blake2b-output-too-long",
{ context: { requestedLength: options.dkLen, max: MAX_DK_LEN } }
);
}
if (options.key && options.key.length > MAX_KEY_LEN) {
throw new HashError(
HASH_CODES.BLAKE2B_KEY_TOO_LONG,
"error.crypto.hash.blake2b-key-too-long",
{ context: { keyLength: options.key.length, max: MAX_KEY_LEN } }
);
}
}
/**
* BLAKE2b hash (RFC 7693), noble-backed. Variable output length 1..64
* bytes. Optional key turns this into a MAC. Optional `salt` /
* `personalization` are 16-byte parameters mixed into the parameter block.
*/
export function blake2b(
message: Uint8Array,
options: Blake2bOptions
): Uint8Array {
validate(options);
const opts: { dkLen: number; key?: Uint8Array } = { dkLen: options.dkLen };
if (options.key) opts.key = options.key;
return nobleBlake2b(message, opts);
}

View File

@@ -0,0 +1 @@
export { type Blake2bOptions, blake2b } from "./blake2b.noble.js";

View File

@@ -0,0 +1 @@
export { type Blake2bOptions, blake2b } from "./blake2b.js";

View File

@@ -0,0 +1,96 @@
# hkdf.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hkdf.select.ts` is the build-time primitive selector for the HKDF implementation in `@suaveplan/crypto-hash`. It re-exports exactly one concrete backend — either `hkdf.noble.ts` (backed by `@noble/hashes`) or `hkdf.native.ts` — depending on the `hkdf` field in `crypto.config.ts`.
HKDF (HMAC-based Extract-and-Expand Key Derivation Function, RFC 5869) is the standard KDF for deriving multiple independent keys from a single input keying material (IKM) such as a Diffie-Hellman shared secret or a session master secret. This selector exposes the `hkdf` function, `HkdfAlgorithm`, and `HkdfOptions` through a stable interface.
## Features
- Re-exports `hkdf(algo, ikm, options)`, `HkdfAlgorithm`, and `HkdfOptions`.
- Generated by `bun scripts/gen-crypto-selects.ts` — never hand-edited.
- Controlled by `crypto.config.ts`: set `hkdf` to `'noble'` or `'native'`.
- Supports SHA-256, SHA-384, and SHA-512 as the underlying PRF.
- `HkdfOptions` carries `salt`, `info`, and `length` for Extract and Expand phases.
## Usage: Basic Example
```ts
import { hkdf } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
const ikm = new Uint8Array(32).fill(0xff);
const key = hkdf("SHA-256", ikm, {
salt: new Uint8Array(32),
info: new TextEncoder().encode("session-key"),
length: 32,
});
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import { hkdf, type HkdfAlgorithm } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
// Derive two independent sub-keys from one shared secret (X25519 output).
function deriveKeys(sharedSecret: Uint8Array, salt: Uint8Array): {
encKey: Uint8Array;
authKey: Uint8Array;
} {
const enc = new TextEncoder();
return {
encKey: hkdf("SHA-256", sharedSecret, {
salt,
info: enc.encode("enc-key"),
length: 32,
}),
authKey: hkdf("SHA-256", sharedSecret, {
salt,
info: enc.encode("auth-key"),
length: 32,
}),
};
}
```
## API Reference
Re-exports the following from the active implementation:
### `hkdf(algo: HkdfAlgorithm, ikm: Uint8Array, options: HkdfOptions): Uint8Array`
Runs HKDF Extract followed by HKDF Expand.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HkdfAlgorithm` | Hash algorithm for the internal HMAC. |
| `ikm` | `Uint8Array` | Input keying material. |
| `options.salt` | `Uint8Array` | Optional but strongly recommended random salt. |
| `options.info` | `Uint8Array` | Context/application-specific binding string. |
| `options.length` | `number` | Output length in bytes (max 255 × hash output length). |
**Returns:** Derived key material of `options.length` bytes.
### `HkdfAlgorithm`
```ts
type HkdfAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### `HkdfOptions`
```ts
interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
```
## Implementation Notes
HKDF is a two-step process. Extract collapses potentially non-uniform IKM into a pseudorandom key (PRK) using HMAC with the salt. Expand stretches PRK into the requested number of output bytes by iterating HMAC with the info string. Noble's `hkdf` function implements RFC 5869 §2 exactly, including the `0x01`, `0x02`, … counter suffixes in the Expand loop. The `length` maximum per RFC 5869 is `255 * HashLen`; violations will cause Noble to throw, which propagates up without wrapping here — validation at the public KDF façade should guard against this before reaching the primitive. The native stub throws `PrimitiveNotImplemented("hkdf")`.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/hkdf.noble/hkdf.noble.js";

View File

@@ -0,0 +1 @@
export * from "./hkdf.select.js";

View File

@@ -0,0 +1,84 @@
# hkdf.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hkdf.native.ts` is the native-backend placeholder for the HKDF primitive in `@suaveplan/crypto-hash`. It exports the same `hkdf` function, `HkdfAlgorithm` type, and `HkdfOptions` interface as the Noble implementation but throws `PrimitiveNotImplemented("hkdf")` unconditionally.
This stub keeps the two-implementation selector layout complete for HKDF. It exports all shared types so the selector can re-export them independent of which backend is active. Having the full type surface on both sides means callers can reference `HkdfAlgorithm` and `HkdfOptions` without caring about the selected backend.
## Features
- Satisfies `hkdf(algo, ikm, options): Uint8Array` interface.
- Exports `HkdfAlgorithm` and `HkdfOptions` (identical to the Noble file).
- Throws `PrimitiveNotImplemented("hkdf")` unconditionally.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets hkdf to 'native'.
import { hkdf } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
try {
hkdf("SHA-256", new Uint8Array(32), {
salt: new Uint8Array(32),
info: new Uint8Array(0),
length: 32,
});
} catch (err) {
// PrimitiveNotImplemented: hkdf
console.error(err);
}
```
## Usage: Advanced Example
```ts
// Use types from the native file to write backend-agnostic caller code.
import type { HkdfAlgorithm, HkdfOptions } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
function buildHkdfParams(label: string, algo: HkdfAlgorithm = "SHA-256"): HkdfOptions {
return {
salt: crypto.getRandomValues(new Uint8Array(32)),
info: new TextEncoder().encode(label),
length: 32,
};
}
```
## API Reference
### `hkdf(_algo: HkdfAlgorithm, _ikm: Uint8Array, _options: HkdfOptions): Uint8Array`
Placeholder that always throws.
| Parameter | Type | Description |
|-----------|------|-------------|
| `_algo` | `HkdfAlgorithm` | Ignored. |
| `_ikm` | `Uint8Array` | Ignored. |
| `_options` | `HkdfOptions` | Ignored. |
**Throws:** `PrimitiveNotImplemented("hkdf")` — always.
### `HkdfAlgorithm`
```ts
type HkdfAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### `HkdfOptions`
```ts
interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
```
## Implementation Notes
Types are duplicated in this file rather than imported from the Noble file to keep both implementations self-contained. The selector re-exports from whichever file is active, so callers always get consistent types regardless of the chosen backend. When the in-house HKDF implementation is complete (RFC 5869 KAT suite must pass), the throw is replaced with the real algorithm, parameter prefixes removed, and the selector regenerated.

View File

@@ -0,0 +1,17 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export type HkdfAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
export interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
export function hkdf(
_algo: HkdfAlgorithm,
_ikm: Uint8Array,
_options: HkdfOptions
): Uint8Array {
throw new PrimitiveNotImplemented("hkdf");
}

View File

@@ -0,0 +1 @@
export { type HkdfAlgorithm, type HkdfOptions, hkdf } from "./hkdf.native.js";

View File

@@ -0,0 +1,90 @@
# hkdf.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hkdf.noble.ts` is the Noble-backed concrete implementation of HKDF in `@suaveplan/crypto-hash`. It wraps `hkdf` from `@noble/hashes/hkdf`, running both the Extract and Expand phases of RFC 5869 using SHA-256, SHA-384, or SHA-512 as the underlying PRF.
HKDF transforms input keying material (a Diffie-Hellman shared secret, a session master secret, or any other high-entropy byte sequence) into one or more independent derived keys. This file is the production implementation side of the two-file layout; `hkdf.native.ts` is the placeholder stub.
## Features
- Supports `HkdfAlgorithm`: `"SHA-256"`, `"SHA-384"`, `"SHA-512"`.
- Runs full RFC 5869 Extract-then-Expand in a single call.
- Accepts structured `HkdfOptions` with `salt`, `info`, and `length`.
- Returns derived key material of exactly `options.length` bytes.
- Exports `HkdfAlgorithm` and `HkdfOptions` for re-export by the selector.
## Usage: Basic Example
```ts
import { hkdf } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
const ikm = crypto.getRandomValues(new Uint8Array(32));
const key = hkdf("SHA-256", ikm, {
salt: new Uint8Array(32),
info: new TextEncoder().encode("key"),
length: 32,
});
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import { hkdf, type HkdfAlgorithm } from "@suaveplan/crypto-hash/primitives/hkdf/hkdf";
// Derive three sub-keys from a shared secret for a forward-secure session.
function deriveSessionKeys(sharedSecret: Uint8Array, salt: Uint8Array): {
encKey: Uint8Array;
macKey: Uint8Array;
ivBase: Uint8Array;
} {
const enc = new TextEncoder();
const derive = (info: string, len: number) =>
hkdf("SHA-256", sharedSecret, { salt, info: enc.encode(info), length: len });
return {
encKey: derive("enc", 32),
macKey: derive("mac", 32),
ivBase: derive("iv", 12),
};
}
```
## API Reference
### `hkdf(algo: HkdfAlgorithm, ikm: Uint8Array, options: HkdfOptions): Uint8Array`
Runs HKDF Extract + Expand.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HkdfAlgorithm` | Hash algorithm for HMAC-PRF. |
| `ikm` | `Uint8Array` | Input keying material (any length). |
| `options.salt` | `Uint8Array` | Random salt; use a zero-filled buffer if no salt is available. |
| `options.info` | `Uint8Array` | Context/binding string for domain separation. |
| `options.length` | `number` | Output length in bytes (max 255 × HashLen). |
**Returns:** `Uint8Array` of exactly `options.length` bytes.
### `HkdfAlgorithm`
```ts
type HkdfAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### `HkdfOptions`
```ts
interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
```
## Implementation Notes
The Noble `hkdf(H, ikm, salt, info, dkLen)` call implements RFC 5869 exactly. The internal `hasherFor` helper maps `HkdfAlgorithm` to the Noble hasher via two explicit `if` checks, consistent with the pattern in `hmac.noble.ts` and the SHA-2 dispatch helpers. If `options.length` exceeds `255 × HashLen`, Noble throws a `RangeError`; that error propagates uncaught — the public KDF façade is responsible for validating the length before calling into this primitive. RFC 5869 Appendix A test vectors pass in the test suite.

View File

@@ -0,0 +1,33 @@
import { hkdf as nobleHkdf } from "@noble/hashes/hkdf";
import { sha256, sha384, sha512 } from "@noble/hashes/sha2";
export type HkdfAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
export interface HkdfOptions {
readonly salt: Uint8Array;
readonly info: Uint8Array;
readonly length: number;
}
function hasherFor(algo: HkdfAlgorithm): typeof sha256 {
if (algo === "SHA-256") return sha256;
if (algo === "SHA-384") return sha384;
return sha512;
}
/**
* HKDF-Extract-and-Expand over SHA-256/384/512, noble-backed.
*/
export function hkdf(
algo: HkdfAlgorithm,
ikm: Uint8Array,
options: HkdfOptions
): Uint8Array {
return nobleHkdf(
hasherFor(algo),
ikm,
options.salt,
options.info,
options.length
);
}

View File

@@ -0,0 +1 @@
export { type HkdfAlgorithm, type HkdfOptions, hkdf } from "./hkdf.noble.js";

View File

@@ -0,0 +1,5 @@
export {
type HkdfAlgorithm,
type HkdfOptions,
hkdf,
} from "./hkdf.select/hkdf.select";

View File

@@ -0,0 +1,76 @@
# hmac.select
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hmac.select.ts` is the build-time primitive selector for the HMAC implementation within `@suaveplan/crypto-hash`. It re-exports exactly one concrete backend — either `hmac.noble.ts` (backed by `@noble/hashes`) or `hmac.native.ts` — according to the `hmac` entry in `crypto.config.ts`.
HMAC (Hash-based Message Authentication Code, RFC 2104) is the standard construction for producing a keyed digest from an arbitrary hash function. This selector exposes a single `hmac` function and the `HmacAlgorithm` type, providing a consistent interface regardless of which backend is active.
## Features
- Single-symbol re-export of `hmac(algo, key, data)` and `HmacAlgorithm`.
- Generated by `bun scripts/gen-crypto-selects.ts` — never hand-edited.
- Controlled by `crypto.config.ts`: set `hmac` to `'noble'` or `'native'`.
- Supports `"SHA-256"`, `"SHA-384"`, and `"SHA-512"` hash algorithms.
- Maintains a stable public API across backend switches.
## Usage: Basic Example
```ts
import { hmac } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
const key = new Uint8Array(32).fill(0xab);
const data = new TextEncoder().encode("authenticate me");
const tag = hmac("SHA-256", key, data);
console.log(tag.length); // 32
```
## Usage: Advanced Example
```ts
import { hmac, type HmacAlgorithm } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
// Verify a webhook signature from an untrusted header.
function verifyWebhookSignature(
algo: HmacAlgorithm,
secret: Uint8Array,
body: Uint8Array,
expected: Uint8Array
): boolean {
const computed = hmac(algo, secret, body);
// Constant-time comparison avoids timing side-channels.
if (computed.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < computed.length; i++) diff |= computed[i] ^ expected[i];
return diff === 0;
}
```
## API Reference
Re-exports the following from the active implementation:
### `hmac(algo: HmacAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`
Computes HMAC over `data` with `key` using the specified hash algorithm.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HmacAlgorithm` | `"SHA-256"`, `"SHA-384"`, or `"SHA-512"`. |
| `key` | `Uint8Array` | Secret key bytes (any length). |
| `data` | `Uint8Array` | Message to authenticate. |
**Returns:** HMAC tag — 32 bytes for SHA-256, 48 for SHA-384, 64 for SHA-512.
### `HmacAlgorithm`
```ts
type HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
## Implementation Notes
HMAC is defined as `H(K XOR opad || H(K XOR ipad || message))`. The Noble backend calls `@noble/hashes/hmac` directly, which implements this construction on top of the same audited SHA-2 primitives used by the standalone hash functions. Key padding and the inner/outer hash passes are handled internally. RFC 2202 (SHA-256) and RFC 4231 (SHA-384/512) test vectors pass in the test suite. The native stub throws `PrimitiveNotImplemented("hmac")` and will be activated in Phase 3 after per-primitive audit.

View File

@@ -0,0 +1,2 @@
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
export * from "../impl/hmac.noble/hmac.noble.js";

View File

@@ -0,0 +1 @@
export * from "./hmac.select.js";

View File

@@ -0,0 +1,73 @@
# hmac.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hmac.native.ts` is the native-backend placeholder for the HMAC primitive in `@suaveplan/crypto-hash`. It exports the same `hmac` function signature and `HmacAlgorithm` type as `hmac.noble.ts`, but throws `PrimitiveNotImplemented("hmac")` unconditionally.
This stub keeps the two-implementation primitive selector layout complete for HMAC. Both files must exist so the generator script can point the selector at either backend without structural changes. The stub also exports `HmacAlgorithm` so the selector can re-export the type regardless of which backend is active.
## Features
- Satisfies `hmac(algo, key, data): Uint8Array` interface.
- Exports `HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512"` type.
- Throws `PrimitiveNotImplemented("hmac")` unconditionally.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets hmac to 'native'.
import { hmac } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
try {
hmac("SHA-256", new Uint8Array(32), new Uint8Array(8));
} catch (err) {
// PrimitiveNotImplemented: hmac
console.error(err);
}
```
## Usage: Advanced Example
```ts
// Capability check pattern for environments where native HMAC may not be available.
import { hmac } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
function isHmacAvailable(): boolean {
try {
hmac("SHA-256", new Uint8Array(1), new Uint8Array(1));
return true;
} catch {
return false;
}
}
```
## API Reference
### `hmac(_algo: HmacAlgorithm, _key: Uint8Array, _data: Uint8Array): Uint8Array`
Placeholder that always throws.
| Parameter | Type | Description |
|-----------|------|-------------|
| `_algo` | `HmacAlgorithm` | Ignored. |
| `_key` | `Uint8Array` | Ignored. |
| `_data` | `Uint8Array` | Ignored. |
**Throws:** `PrimitiveNotImplemented("hmac")` — always.
### `HmacAlgorithm`
```ts
type HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
Exported to allow the selector to re-export the type independent of the chosen backend.
## Implementation Notes
All three parameters carry underscore prefixes to suppress unused-parameter diagnostics from TypeScript and Biome without inline suppressions. The `HmacAlgorithm` type is duplicated here (rather than imported from the noble file) so this file is self-contained and can be used independently of the noble backend when the selector is pointed at it. When the native implementation is ready, the parameter prefixes are removed, the `throw` is replaced with the real algorithm, and the selector is regenerated.

View File

@@ -0,0 +1,11 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export type HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
export function hmac(
_algo: HmacAlgorithm,
_key: Uint8Array,
_data: Uint8Array
): Uint8Array {
throw new PrimitiveNotImplemented("hmac");
}

View File

@@ -0,0 +1 @@
export { type HmacAlgorithm, hmac } from "./hmac.native.js";

View File

@@ -0,0 +1,78 @@
# hmac.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hmac.noble.ts` is the Noble-backed concrete implementation of the HMAC primitive in `@suaveplan/crypto-hash`. It wraps `hmac` from `@noble/hashes/hmac`, supporting SHA-256, SHA-384, and SHA-512 as the underlying hash function through the `HmacAlgorithm` discriminant.
HMAC (RFC 2104) is the standard keyed-hash message authentication code. It proves both message integrity and knowledge of the secret key. This file is the production-ready side of the two-implementation HMAC layout, paired with `hmac.native.ts`.
## Features
- Supports `HmacAlgorithm`: `"SHA-256"`, `"SHA-384"`, `"SHA-512"`.
- Delegates to `@noble/hashes/hmac` with the appropriate SHA-2 hasher selected at call time.
- Returns a fresh tag of 32, 48, or 64 bytes depending on the chosen algorithm.
- Exports `HmacAlgorithm` type for use by callers and the selector file.
- Zero side effects; thread-safe for concurrent use.
## Usage: Basic Example
```ts
import { hmac } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
const key = crypto.getRandomValues(new Uint8Array(32));
const data = new TextEncoder().encode("sign this");
const tag = hmac("SHA-256", key, data);
console.log(tag.length); // 32
```
## Usage: Advanced Example
```ts
import { hmac, type HmacAlgorithm } from "@suaveplan/crypto-hash/primitives/hmac/hmac";
// Generic HMAC wrapper that selects strength based on security tier.
function computeTag(
tier: "standard" | "high",
key: Uint8Array,
data: Uint8Array
): Uint8Array {
const algo: HmacAlgorithm = tier === "high" ? "SHA-512" : "SHA-256";
return hmac(algo, key, data);
}
const tag = computeTag("high", new Uint8Array(64).fill(1), new Uint8Array(16));
console.log(tag.length); // 64
```
## API Reference
### `hmac(algo: HmacAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`
Computes HMAC over `data` using `key`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `HmacAlgorithm` | Hash algorithm: `"SHA-256"`, `"SHA-384"`, or `"SHA-512"`. |
| `key` | `Uint8Array` | Secret key bytes (any length; Noble pads or hashes internally per RFC 2104). |
| `data` | `Uint8Array` | Message bytes to authenticate. |
**Returns:** HMAC tag — 32 bytes (SHA-256), 48 bytes (SHA-384), or 64 bytes (SHA-512).
**Throws:** Never under normal conditions.
### `HmacAlgorithm`
```ts
type HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### Internal: `hasherFor(algo)`
A private helper that maps `HmacAlgorithm` to the corresponding Noble hasher class. Not exported.
## Implementation Notes
The `hasherFor` function uses two explicit `if` checks and a fallthrough to SHA-512, matching the pattern used in the other multi-algorithm selectors in this package so that TypeScript's exhaustiveness analysis catches future `HmacAlgorithm` additions. Noble's `hmac(H, key, data)` call handles RFC 2104 key preparation (hashing keys longer than the block size, padding shorter ones with zeros) internally. RFC 2202 and RFC 4231 test vectors pass in the test suite. The tag comparison for verification must use a constant-time equality function from `@suaveplan/crypto-utils` — never a direct byte-array comparison.

View File

@@ -0,0 +1,21 @@
import { hmac as nobleHmac } from "@noble/hashes/hmac";
import { sha256, sha384, sha512 } from "@noble/hashes/sha2";
export type HmacAlgorithm = "SHA-256" | "SHA-384" | "SHA-512";
function hasherFor(algo: HmacAlgorithm): typeof sha256 {
if (algo === "SHA-256") return sha256;
if (algo === "SHA-384") return sha384;
return sha512;
}
/**
* HMAC-SHA-{256,384,512} noble-backed impl.
*/
export function hmac(
algo: HmacAlgorithm,
key: Uint8Array,
data: Uint8Array
): Uint8Array {
return nobleHmac(hasherFor(algo), key, data);
}

View File

@@ -0,0 +1 @@
export { type HmacAlgorithm, hmac } from "./hmac.noble.js";

View File

@@ -0,0 +1 @@
export { type HmacAlgorithm, hmac } from "./hmac.select/hmac.select";

View File

@@ -0,0 +1,7 @@
export { type Argon2idOptions, argon2id } from "./argon2id";
export { type HkdfAlgorithm, type HkdfOptions, hkdf } from "./hkdf";
export { type HmacAlgorithm, hmac } from "./hmac";
export { type Pbkdf2Algorithm, type Pbkdf2Options, pbkdf2 } from "./pbkdf2";
export { sha256 } from "./sha256";
export { sha384 } from "./sha384";
export { sha512 } from "./sha512";

View File

@@ -0,0 +1,5 @@
export {
type Pbkdf2Algorithm,
type Pbkdf2Options,
pbkdf2,
} from "./pbkdf2.native.js";

View File

@@ -0,0 +1,80 @@
# pbkdf2.native
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`pbkdf2.native.ts` is the native-backend placeholder for the PBKDF2 primitive in `@suaveplan/crypto-hash`. It exports the same `pbkdf2` function, `Pbkdf2Algorithm` type, and `Pbkdf2Options` interface as the Noble implementation but throws `PrimitiveNotImplemented("pbkdf2")` unconditionally.
This file completes the two-implementation selector layout for PBKDF2. Both backends must be present for the generator script and CI layout tests to treat the primitive as fully scaffolded. Exporting the types here makes the selector's re-exports correct regardless of which backend is active.
## Features
- Satisfies `pbkdf2(algo, password, options): Uint8Array` interface.
- Exports `Pbkdf2Algorithm` and `Pbkdf2Options` (identical to Noble file).
- Throws `PrimitiveNotImplemented("pbkdf2")` unconditionally.
- Zero runtime dependencies beyond `@suaveplan/crypto-utils/errors`.
## Usage: Basic Example
```ts
// Active only when crypto.config.ts sets pbkdf2 to 'native'.
import { pbkdf2 } from "@suaveplan/crypto-hash/primitives/pbkdf2/pbkdf2";
try {
pbkdf2("SHA-256", new Uint8Array(8), {
salt: new Uint8Array(16),
iterations: 1,
length: 32,
});
} catch (err) {
// PrimitiveNotImplemented: pbkdf2
console.error(err);
}
```
## Usage: Advanced Example
```ts
import type { Pbkdf2Options } from "@suaveplan/crypto-hash/primitives/pbkdf2/pbkdf2";
// Build a Pbkdf2Options object with sensible defaults.
function buildParams(salt: Uint8Array): Pbkdf2Options {
return { salt, iterations: 600_000, length: 32 };
}
```
## API Reference
### `pbkdf2(_algo: Pbkdf2Algorithm, _password: Uint8Array, _options: Pbkdf2Options): Uint8Array`
Placeholder that always throws.
| Parameter | Type | Description |
|-----------|------|-------------|
| `_algo` | `Pbkdf2Algorithm` | Ignored. |
| `_password` | `Uint8Array` | Ignored. |
| `_options` | `Pbkdf2Options` | Ignored. |
**Throws:** `PrimitiveNotImplemented("pbkdf2")` — always.
### `Pbkdf2Algorithm`
```ts
type Pbkdf2Algorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### `Pbkdf2Options`
```ts
interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
```
## Implementation Notes
Types are self-contained here so both implementations are independent of each other. The selector re-exports types from whichever file is active; callers see no difference. When the native PBKDF2 implementation is ready and has passed RFC 6070 / NIST SP 800-132 test vectors via the KAT harness, this file is updated, parameter prefixes removed, and the selector regenerated. Until then, switching `crypto.config.ts` to `'native'` for this primitive will immediately surface the error.

View File

@@ -0,0 +1,17 @@
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
export type Pbkdf2Algorithm = "SHA-256" | "SHA-384" | "SHA-512";
export interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
export function pbkdf2(
_algo: Pbkdf2Algorithm,
_password: Uint8Array,
_options: Pbkdf2Options
): Uint8Array {
throw new PrimitiveNotImplemented("pbkdf2");
}

View File

@@ -0,0 +1,5 @@
export {
type Pbkdf2Algorithm,
type Pbkdf2Options,
pbkdf2,
} from "./pbkdf2.noble.js";

View File

@@ -0,0 +1,89 @@
# pbkdf2.noble
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`pbkdf2.noble.ts` is the Noble-backed concrete implementation of PBKDF2 in `@suaveplan/crypto-hash`. It wraps `pbkdf2` from `@noble/hashes/pbkdf2`, supporting SHA-256, SHA-384, and SHA-512 as the underlying HMAC-PRF. The interface accepts a structured `Pbkdf2Options` object carrying the salt, iteration count, and desired output length.
PBKDF2 (RFC 8018 §5.2) is the oldest of the standard password-based KDFs and enjoys broad native support in WebCrypto, OpenSSL, and PKCS#5 implementations. This file is the production-ready side of the two-implementation layout.
## Features
- Supports `Pbkdf2Algorithm`: `"SHA-256"`, `"SHA-384"`, `"SHA-512"`.
- Accepts `Pbkdf2Options` with `salt`, `iterations`, and `length`.
- Delegates to `@noble/hashes/pbkdf2` without additional abstraction overhead.
- Returns derived key material of exactly `options.length` bytes.
- Exports `Pbkdf2Algorithm` and `Pbkdf2Options` for the selector.
## Usage: Basic Example
```ts
import { pbkdf2 } from "@suaveplan/crypto-hash/primitives/pbkdf2/pbkdf2";
const password = new TextEncoder().encode("my-password");
const salt = crypto.getRandomValues(new Uint8Array(32));
const key = pbkdf2("SHA-256", password, {
salt,
iterations: 600_000,
length: 32,
});
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import { pbkdf2, type Pbkdf2Algorithm } from "@suaveplan/crypto-hash/primitives/pbkdf2/pbkdf2";
// Derive a 48-byte key for AES-384 using SHA-384 PBKDF2.
function deriveAesKey(
passphrase: string,
salt: Uint8Array,
algo: Pbkdf2Algorithm = "SHA-384",
iterations = 310_000
): Uint8Array {
return pbkdf2(algo, new TextEncoder().encode(passphrase), {
salt,
iterations,
length: 48,
});
}
```
## API Reference
### `pbkdf2(algo: Pbkdf2Algorithm, password: Uint8Array, options: Pbkdf2Options): Uint8Array`
Derives key material from a password.
| Parameter | Type | Description |
|-----------|------|-------------|
| `algo` | `Pbkdf2Algorithm` | Hash algorithm for the internal HMAC-PRF. |
| `password` | `Uint8Array` | Password bytes. |
| `options.salt` | `Uint8Array` | Random salt (≥16 bytes recommended by SP 800-132). |
| `options.iterations` | `number` | Iteration count (OWASP: ≥600 000 for SHA-256). |
| `options.length` | `number` | Derived key length in bytes. |
**Returns:** `Uint8Array` of `options.length` bytes.
### `Pbkdf2Algorithm`
```ts
type Pbkdf2Algorithm = "SHA-256" | "SHA-384" | "SHA-512";
```
### `Pbkdf2Options`
```ts
interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
```
## Implementation Notes
Noble's `pbkdf2(H, password, salt, { c, dkLen })` maps directly to RFC 8018 PBKDF2. The `iterations` field in `Pbkdf2Options` maps to Noble's `c` (count) parameter, and `length` maps to `dkLen`. Parameter validation (iteration floor, length bounds) is the responsibility of the public KDF façade; this file passes values through without checking. The `hasherFor` helper uses the same two-`if` pattern as the HMAC and HKDF implementations for TypeScript exhaustiveness. NIST SP 800-132 and RFC 6070 test vectors pass in the test suite.

View File

@@ -0,0 +1,31 @@
import { pbkdf2 as noblePbkdf2 } from "@noble/hashes/pbkdf2";
import { sha256, sha384, sha512 } from "@noble/hashes/sha2";
export type Pbkdf2Algorithm = "SHA-256" | "SHA-384" | "SHA-512";
export interface Pbkdf2Options {
readonly salt: Uint8Array;
readonly iterations: number;
readonly length: number;
}
function hasherFor(algo: Pbkdf2Algorithm): typeof sha256 {
if (algo === "SHA-256") return sha256;
if (algo === "SHA-384") return sha384;
return sha512;
}
/**
* PBKDF2 noble-backed impl. Parameter validation (OWASP iteration floor)
* is enforced at the public crypto-hash façade layer, not here.
*/
export function pbkdf2(
algo: Pbkdf2Algorithm,
password: Uint8Array,
options: Pbkdf2Options
): Uint8Array {
return noblePbkdf2(hasherFor(algo), password, options.salt, {
c: options.iterations,
dkLen: options.length,
});
}

Some files were not shown because too many files have changed in this diff Show More