Initial Commit
This commit is contained in:
7
packages/security-stack/crypto/crypto-hash/CHANGELOG.md
Normal file
7
packages/security-stack/crypto/crypto-hash/CHANGELOG.md
Normal 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.
|
||||
128
packages/security-stack/crypto/crypto-hash/README.md
Normal file
128
packages/security-stack/crypto/crypto-hash/README.md
Normal file
File diff suppressed because one or more lines are too long
110
packages/security-stack/crypto/crypto-hash/package.json
Normal file
110
packages/security-stack/crypto/crypto-hash/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
ARGON2ID_MIN_MEMORY_COST_KIB,
|
||||
ARGON2ID_MIN_PARALLELISM,
|
||||
ARGON2ID_MIN_TIME_COST,
|
||||
PBKDF2_MIN_ITERATIONS,
|
||||
} from "../kdf/index.js";
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fromBase64, fromBase64Url, toBase64, toBase64Url } from "./base64.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fromHex, toHex } from "./hex.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fromBase64,
|
||||
fromBase64Url,
|
||||
toBase64,
|
||||
toBase64Url,
|
||||
} from "./base64/base64.js";
|
||||
export { fromHex, toHex } from "./hex/hex.js";
|
||||
@@ -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.
|
||||
@@ -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];
|
||||
@@ -0,0 +1 @@
|
||||
export { HASH_CODES, type HashCode } from "./codes.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HashError } from "./hash-error.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { HASH_CODES, type HashCode } from "./codes/codes.js";
|
||||
export { HashError } from "./hash-error/hash-error.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type HashAlgorithm, hash, sha256, sha384, sha512 } from "./sha2/sha2";
|
||||
@@ -0,0 +1 @@
|
||||
export { type HashAlgorithm, hash, sha256, sha384, sha512 } from "./sha2.js";
|
||||
111
packages/security-stack/crypto/crypto-hash/src/hash/sha2/sha2.md
Normal file
111
packages/security-stack/crypto/crypto-hash/src/hash/sha2/sha2.md
Normal 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.
|
||||
@@ -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]);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
35
packages/security-stack/crypto/crypto-hash/src/index.ts
Normal file
35
packages/security-stack/crypto/crypto-hash/src/index.ts
Normal 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";
|
||||
@@ -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`.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
ARGON2ID_MIN_MEMORY_COST_KIB,
|
||||
ARGON2ID_MIN_PARALLELISM,
|
||||
ARGON2ID_MIN_TIME_COST,
|
||||
type Argon2idOptions,
|
||||
argon2id,
|
||||
} from "./argon2id.js";
|
||||
106
packages/security-stack/crypto/crypto-hash/src/kdf/hkdf/hkdf.md
Normal file
106
packages/security-stack/crypto/crypto-hash/src/kdf/hkdf/hkdf.md
Normal 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.
|
||||
@@ -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]);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HkdfOptions, hkdf } from "./hkdf.js";
|
||||
13
packages/security-stack/crypto/crypto-hash/src/kdf/index.ts
Normal file
13
packages/security-stack/crypto/crypto-hash/src/kdf/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { PBKDF2_MIN_ITERATIONS, type Pbkdf2Options, pbkdf2 } from "./pbkdf2.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type HashPasswordArgon2idOptions,
|
||||
hashPasswordArgon2id,
|
||||
verifyPasswordArgon2id,
|
||||
} from "./argon2id-password.js";
|
||||
@@ -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)`.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
DEFAULT_COST_FACTOR,
|
||||
HASH_LENGTH,
|
||||
type HashPasswordBlake2bOptions,
|
||||
hashPasswordBlake2b,
|
||||
SALT_LENGTH,
|
||||
verifyPasswordBlake2b,
|
||||
} from "./blake2b-password.js";
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { hmac } from "./hmac.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { hmac } from "./hmac/hmac";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
|
||||
export * from "../impl/argon2id.noble/argon2id.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./argon2id.select.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type Argon2idOptions, argon2id } from "./argon2id.native.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type Argon2idOptions, argon2id } from "./argon2id.noble.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type Argon2idOptions,
|
||||
argon2id,
|
||||
} from "./argon2id.select/argon2id.select";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
|
||||
export * from "../impl/blake2b.noble/blake2b.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type Blake2bOptions, blake2b } from "./blake2b.select.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type Blake2bOptions,
|
||||
blake2b,
|
||||
} from "./blake2b.select/blake2b.select.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type Blake2bOptions, blake2b } from "./blake2b.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type Blake2bOptions, blake2b } from "./blake2b.js";
|
||||
@@ -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")`.
|
||||
@@ -0,0 +1,2 @@
|
||||
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
|
||||
export * from "../impl/hkdf.noble/hkdf.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./hkdf.select.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HkdfAlgorithm, type HkdfOptions, hkdf } from "./hkdf.native.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HkdfAlgorithm, type HkdfOptions, hkdf } from "./hkdf.noble.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type HkdfAlgorithm,
|
||||
type HkdfOptions,
|
||||
hkdf,
|
||||
} from "./hkdf.select/hkdf.select";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */
|
||||
export * from "../impl/hmac.noble/hmac.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./hmac.select.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HmacAlgorithm, hmac } from "./hmac.native.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HmacAlgorithm, hmac } from "./hmac.noble.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { type HmacAlgorithm, hmac } from "./hmac.select/hmac.select";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type Pbkdf2Algorithm,
|
||||
type Pbkdf2Options,
|
||||
pbkdf2,
|
||||
} from "./pbkdf2.native.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type Pbkdf2Algorithm,
|
||||
type Pbkdf2Options,
|
||||
pbkdf2,
|
||||
} from "./pbkdf2.noble.js";
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user