Initial Commit
This commit is contained in:
14
packages/security-stack/crypto/crypto-utils/CHANGELOG.md
Normal file
14
packages/security-stack/crypto/crypto-utils/CHANGELOG.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @suaveplan/crypto-utils
|
||||
|
||||
## 0.0.0
|
||||
|
||||
- Initial scaffold under `crypto-primitive-replaceability` Phase 0.
|
||||
- Encoding: hex, base64, base64url, base58.
|
||||
- Bytes helpers: `concatBytes`, `xor`, `i2osp`, `os2ip`.
|
||||
- Constant-time equality.
|
||||
- Random bytes (wraps `globalThis.crypto.getRandomValues`).
|
||||
- Zeroize.
|
||||
- Merkle-root / Merkle-proof (generic over any hash primitive).
|
||||
- Test harnesses: `runVectors` (KAT), `diffFuzz` (differential fuzz).
|
||||
- `PrimitiveId` union + registry of every primitive across `crypto-hash`, `crypto-curve`, `crypto-cipher`.
|
||||
- `PrimitiveNotImplemented` error (code `CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED`).
|
||||
65
packages/security-stack/crypto/crypto-utils/README.md
Normal file
65
packages/security-stack/crypto/crypto-utils/README.md
Normal file
File diff suppressed because one or more lines are too long
153
packages/security-stack/crypto/crypto-utils/package.json
Normal file
153
packages/security-stack/crypto/crypto-utils/package.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"name": "@suaveplan/crypto-utils",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Shared cryptographic utilities: encoding (hex/base64/base64url/base58), byte helpers (concat/xor/i2osp/os2ip), constant-time equality, random bytes, zeroize, Merkle tree helpers, KAT/differential-fuzz test harnesses, PrimitiveId registry, PrimitiveNotImplemented error. Zero runtime crypto dependencies.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./encoding": {
|
||||
"import": {
|
||||
"types": "./dist/encoding/index.d.ts",
|
||||
"default": "./dist/encoding/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/encoding/index.d.ts",
|
||||
"default": "./dist/encoding/index.js"
|
||||
}
|
||||
},
|
||||
"./bytes": {
|
||||
"import": {
|
||||
"types": "./dist/bytes/index.d.ts",
|
||||
"default": "./dist/bytes/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/bytes/index.d.ts",
|
||||
"default": "./dist/bytes/index.js"
|
||||
}
|
||||
},
|
||||
"./constant-time": {
|
||||
"import": {
|
||||
"types": "./dist/constant-time/index.d.ts",
|
||||
"default": "./dist/constant-time/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/constant-time/index.d.ts",
|
||||
"default": "./dist/constant-time/index.js"
|
||||
}
|
||||
},
|
||||
"./random": {
|
||||
"import": {
|
||||
"types": "./dist/random/index.d.ts",
|
||||
"default": "./dist/random/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/random/index.d.ts",
|
||||
"default": "./dist/random/index.js"
|
||||
}
|
||||
},
|
||||
"./zeroize": {
|
||||
"import": {
|
||||
"types": "./dist/zeroize/index.d.ts",
|
||||
"default": "./dist/zeroize/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/zeroize/index.d.ts",
|
||||
"default": "./dist/zeroize/index.js"
|
||||
}
|
||||
},
|
||||
"./merkle": {
|
||||
"import": {
|
||||
"types": "./dist/merkle/index.d.ts",
|
||||
"default": "./dist/merkle/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/merkle/index.d.ts",
|
||||
"default": "./dist/merkle/index.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"default": "./dist/testing/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/testing/index.d.ts",
|
||||
"default": "./dist/testing/index.js"
|
||||
}
|
||||
},
|
||||
"./primitive-id": {
|
||||
"import": {
|
||||
"types": "./dist/primitive-id/index.d.ts",
|
||||
"default": "./dist/primitive-id/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/primitive-id/index.d.ts",
|
||||
"default": "./dist/primitive-id/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.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",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/bytes": "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",
|
||||
"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,50 @@
|
||||
# concatBytes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Concatenate any number of `Uint8Array` buffers into a single freshly-allocated result. Used extensively by crypto protocols that frame authenticated data by length-prefix or that split a plaintext across record boundaries. This module re-exports `concatUint8Arrays` from `@suaveplan/bytes` under the crypto-friendly name `concatBytes`, centralising the helper so every downstream cipher, MAC, or encoding never has to handwrite the `set`-at-offset loop and all packages share a single, tested implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- Variadic: accepts any number of parts (including zero).
|
||||
- Returns a new `Uint8Array` even for single-arg calls (never aliases an input).
|
||||
- Delegates to `@suaveplan/bytes` — one implementation, zero duplication.
|
||||
- Zero implementation code in this module — pure delegation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { concatBytes } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
const frame = concatBytes(header, payload, footer);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { concatBytes } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
// Build a TLS-style length-prefixed message.
|
||||
function frame(...parts: Uint8Array[]): Uint8Array {
|
||||
const body = concatBytes(...parts);
|
||||
const lenPrefix = new Uint8Array([(body.length >> 8) & 0xff, body.length & 0xff]);
|
||||
return concatBytes(lenPrefix, body);
|
||||
}
|
||||
|
||||
const out = frame(new Uint8Array([0x01]), new Uint8Array([0x02, 0x03]));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `concatBytes(...parts: Uint8Array[]): Uint8Array` — Returns a new `Uint8Array` whose contents are the concatenation of every `part` in order. Never throws. Alias for `concatUint8Arrays` from `@suaveplan/bytes`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`concatBytes` is a direct `const` alias for `concatUint8Arrays` from `@suaveplan/bytes`. The underlying implementation uses two passes: the first sums `part.length` to pre-allocate once (no reallocation), the second uses `Uint8Array#set` which hits the fast path in every modern engine. Delegating here removes identical logic that previously lived in both packages and ensures all `@suaveplan/crypto-*` consumers share the same implementation with a single point of maintenance.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
`concatBytes` preserves exact input order and is safe to call with zero, one, or many arrays. Passing a zero-length array returns a new zero-length `Uint8Array`. Callers relying on fixed output lengths for cryptographic framing should validate each input's length before calling; the function trusts its inputs and performs a single pre-sized allocation.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { concatBytes } from "./concat-bytes.js";
|
||||
|
||||
describe("concatBytes", () => {
|
||||
it("returns empty on zero inputs", { timeout: 5000 }, () => {
|
||||
expect(concatBytes()).toEqual(new Uint8Array());
|
||||
});
|
||||
|
||||
it("returns a copy (not a view) on single input", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const out = concatBytes(a);
|
||||
expect(out).toEqual(a);
|
||||
expect(out).not.toBe(a);
|
||||
});
|
||||
|
||||
it("concatenates multiple inputs", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1, 2]);
|
||||
const b = new Uint8Array([3]);
|
||||
const c = new Uint8Array([4, 5, 6]);
|
||||
expect(concatBytes(a, b, c)).toEqual(
|
||||
new Uint8Array([1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty inputs in the mix", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
concatBytes(
|
||||
new Uint8Array([1]),
|
||||
new Uint8Array(),
|
||||
new Uint8Array([2])
|
||||
)
|
||||
).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { concatUint8Arrays } from "@suaveplan/bytes";
|
||||
|
||||
/**
|
||||
* Concatenate any number of `Uint8Array` inputs into a single new buffer.
|
||||
* Returns a freshly-allocated `Uint8Array` — no aliasing with the inputs.
|
||||
* Delegates to @suaveplan/bytes.
|
||||
*/
|
||||
export const concatBytes = concatUint8Arrays;
|
||||
@@ -0,0 +1 @@
|
||||
export { concatBytes } from "./concat-bytes";
|
||||
@@ -0,0 +1,52 @@
|
||||
# i2osp — Integer-to-Octet-String Primitive
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encode a non-negative integer as a fixed-length big-endian byte string, per RFC 8017 §4.1 (PKCS #1 v2.2). Used by RSA-OAEP, RSA-PSS, ECDSA, and every hash-to-curve or MGF1 construction that has to pour a counter or length field into a digest input. Having a shared primitive means every consumer in the `@suaveplan/crypto-*` family encodes counters identically and rejects out-of-range inputs with the same stable error code — a concrete mitigation for the historical class of bugs where one module silently truncated a counter while another padded it.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts `number` or `bigint` input.
|
||||
- Big-endian output of exactly `xLen` bytes, zero-padded at the left.
|
||||
- Range-checks the output fits — never silently truncates.
|
||||
- Stable error codes: `CRYPTO.UTILS.INTEGER.OUT_OF_RANGE` (value negative or too large), `CRYPTO.UTILS.BYTES.INVALID_LENGTH` (`xLen` not a non-negative integer).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { i2osp } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
const counter = i2osp(0x01020304, 4); // Uint8Array([0x01, 0x02, 0x03, 0x04])
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { concatBytes, i2osp } from "@suaveplan/crypto-utils/bytes";
|
||||
import { sha256 } from "@suaveplan/crypto-hash/sha256";
|
||||
|
||||
// MGF1 (RFC 8017 §B.2.1)
|
||||
function mgf1(seed: Uint8Array, maskLen: number): Uint8Array {
|
||||
const T: Uint8Array[] = [];
|
||||
let accLen = 0;
|
||||
for (let c = 0; accLen < maskLen; c++) {
|
||||
const block = sha256(concatBytes(seed, i2osp(c, 4)));
|
||||
T.push(block);
|
||||
accLen += block.length;
|
||||
}
|
||||
return concatBytes(...T).slice(0, maskLen);
|
||||
}
|
||||
|
||||
const mask = mgf1(new Uint8Array(32), 48);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `i2osp(n: number | bigint, xLen: number): Uint8Array` — Returns a `Uint8Array` of length `xLen` whose big-endian interpretation equals `n`. Throws `CryptoUtilsError` with code `CRYPTO.UTILS.INTEGER.OUT_OF_RANGE` if `n` is negative or does not fit in `xLen` bytes. Throws `CRYPTO.UTILS.BYTES.INVALID_LENGTH` if `xLen` is negative or non-integer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All arithmetic is routed through `BigInt` so `number` values up to `Number.MAX_SAFE_INTEGER` and larger `bigint` values are handled uniformly. The inner loop writes right-to-left (`i = xLen - 1` down to `0`), shifting by 8 bits each iteration. A final check (`v !== 0n`) detects overflow without needing a separate bit-length calculation. The partner primitive `os2ip` lives alongside and reverses the mapping.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { i2osp } from "./i2osp.js";
|
||||
|
||||
describe("i2osp", () => {
|
||||
it("encodes 0 as zero-filled", { timeout: 5000 }, () => {
|
||||
expect(i2osp(0, 4)).toEqual(new Uint8Array([0, 0, 0, 0]));
|
||||
});
|
||||
|
||||
it("encodes small values big-endian", { timeout: 5000 }, () => {
|
||||
expect(i2osp(258, 2)).toEqual(new Uint8Array([0x01, 0x02]));
|
||||
expect(i2osp(258, 4)).toEqual(new Uint8Array([0, 0, 0x01, 0x02]));
|
||||
});
|
||||
|
||||
it("encodes BigInt values", { timeout: 5000 }, () => {
|
||||
expect(i2osp(0xdeadbeefn, 4)).toEqual(
|
||||
new Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
||||
);
|
||||
});
|
||||
|
||||
it("handles xLen = 0 for value 0", { timeout: 5000 }, () => {
|
||||
expect(i2osp(0, 0)).toEqual(new Uint8Array());
|
||||
});
|
||||
|
||||
it("rejects negative values", { timeout: 5000 }, () => {
|
||||
expect(() => i2osp(-1, 4)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("rejects values that do not fit", { timeout: 5000 }, () => {
|
||||
expect(() => i2osp(256, 1)).toThrow(CryptoUtilsError);
|
||||
expect(() => i2osp(1n << 64n, 8)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("rejects bad xLen", { timeout: 5000 }, () => {
|
||||
expect(() => i2osp(0, -1)).toThrow(CryptoUtilsError);
|
||||
expect(() => i2osp(0, 1.5)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
/**
|
||||
* Integer-to-Octet-String Primitive (RFC 8017 §4.1 / PKCS #1).
|
||||
*
|
||||
* Encodes a non-negative integer `n` as a big-endian byte string of length `xLen`.
|
||||
*
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.INTEGER.OUT_OF_RANGE) if `n` is
|
||||
* negative or does not fit in `xLen` bytes.
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.BYTES.INVALID_LENGTH) if `xLen` is
|
||||
* negative or not an integer.
|
||||
*/
|
||||
export function i2osp(n: number | bigint, xLen: number): Uint8Array {
|
||||
if (!Number.isInteger(xLen) || xLen < 0) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["BYTES.INVALID_LENGTH"],
|
||||
"error.crypto.utils.bytes.invalid-length",
|
||||
{ context: { xLen } }
|
||||
);
|
||||
}
|
||||
let v = typeof n === "bigint" ? n : BigInt(n);
|
||||
if (v < 0n) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["INTEGER.OUT_OF_RANGE"],
|
||||
"error.crypto.utils.integer.out-of-range",
|
||||
{ context: { reason: "negative" } }
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(xLen);
|
||||
for (let i = xLen - 1; i >= 0; i--) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
if (v !== 0n) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["INTEGER.OUT_OF_RANGE"],
|
||||
"error.crypto.utils.integer.out-of-range",
|
||||
{ context: { reason: "does-not-fit", xLen } }
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { i2osp } from "./i2osp";
|
||||
@@ -0,0 +1,4 @@
|
||||
export { concatBytes } from "./concat-bytes/concat-bytes.js";
|
||||
export { i2osp } from "./i2osp/i2osp.js";
|
||||
export { os2ip } from "./os2ip/os2ip.js";
|
||||
export { xor } from "./xor/xor.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { os2ip } from "./os2ip";
|
||||
@@ -0,0 +1,51 @@
|
||||
# os2ip — Octet-String-to-Integer Primitive
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The inverse of `i2osp`. Decode a big-endian byte string into a `bigint`, per RFC 8017 §4.2 (PKCS #1 v2.2). Returns `bigint` unconditionally so callers never lose precision, which matters for RSA moduli, ECDSA `r`/`s` pairs, and any Montgomery-ladder scalar.
|
||||
|
||||
## Features
|
||||
|
||||
- Always returns `bigint`; never lossy.
|
||||
- Big-endian decode.
|
||||
- Empty input → `0n` (matches RFC convention for a zero-length octet string representing 0).
|
||||
- Zero dependencies; pure TypeScript.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { os2ip } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
const n = os2ip(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); // 3_735_928_559n
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { i2osp, os2ip } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
// Check that a candidate scalar is strictly less than the group order.
|
||||
function scalarIsInRange(scalar: Uint8Array, order: bigint): boolean {
|
||||
const n = os2ip(scalar);
|
||||
return n > 0n && n < order;
|
||||
}
|
||||
|
||||
const order = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
||||
const candidate = i2osp(1234567890n, 32);
|
||||
console.log(scalarIsInRange(candidate, order));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `os2ip(bytes: Uint8Array): bigint` — Decodes `bytes` as a big-endian non-negative integer. Empty input yields `0n`. Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A single-pass fold: `n = (n << 8) | byte[i]`. The shift-OR form avoids a multiply by 256, which `BigInt` engines internally implement as a shift anyway, but the explicit form is easier to audit. For inputs over a few kilobytes the `BigInt` arithmetic dominates; callers that decode large inputs hot should consider streaming parsing at the protocol layer instead of materialising a `BigInt`.
|
||||
|
||||
### RFC 8017 Context
|
||||
|
||||
`os2ip` (Octet-String-to-Integer Primitive) is defined by PKCS#1 v2.2 / RFC 8017 §4.2 and converts a big-endian octet string to a non-negative `bigint`. Together with its inverse `i2osp` it forms the byte-level glue between cryptographic framing layers and integer-arithmetic primitives such as RSA modular exponentiation. Leading zero bytes are preserved at the integer level only insofar as they do not affect magnitude — the caller must track octet-string length separately.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { i2osp } from "../i2osp/i2osp.js";
|
||||
import { os2ip } from "./os2ip.js";
|
||||
|
||||
describe("os2ip", () => {
|
||||
it("returns 0n for empty input", { timeout: 5000 }, () => {
|
||||
expect(os2ip(new Uint8Array())).toBe(0n);
|
||||
});
|
||||
|
||||
it("decodes big-endian", { timeout: 5000 }, () => {
|
||||
expect(os2ip(new Uint8Array([0x01, 0x02]))).toBe(258n);
|
||||
expect(os2ip(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))).toBe(
|
||||
0xdeadbeefn
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores leading zeros", { timeout: 5000 }, () => {
|
||||
expect(os2ip(new Uint8Array([0, 0, 0, 0x42]))).toBe(0x42n);
|
||||
});
|
||||
|
||||
it("is the inverse of i2osp for random values", { timeout: 5000 }, () => {
|
||||
for (let n = 0; n < 40; n++) {
|
||||
const bytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[0] = 0; // keep the value non-negative and fit in 7 bytes
|
||||
const v = os2ip(bytes);
|
||||
expect(i2osp(v, 8)).toEqual(bytes);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Octet-String-to-Integer Primitive (RFC 8017 §4.2 / PKCS #1).
|
||||
*
|
||||
* Decodes a big-endian byte string into a `bigint`. Always returns a `bigint`
|
||||
* so callers never lose precision for octet strings longer than 6 bytes.
|
||||
*/
|
||||
export function os2ip(bytes: Uint8Array): bigint {
|
||||
let n = 0n;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
n = (n << 8n) | BigInt(bytes[i] as number);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { xor } from "./xor";
|
||||
@@ -0,0 +1,48 @@
|
||||
# xor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Byte-wise XOR of two equal-length `Uint8Array` buffers. XOR is the kernel of every stream cipher and every one-time-pad construction, and appears inside HMAC (ipad/opad), counter-mode encryption, and KDF mixing steps. A dedicated helper with a strict length check avoids two footguns that recur in crypto code: (1) silently truncating or extending one operand and (2) leaking a length-mismatch by differential timing in the caller's branch.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict length check — throws on mismatch rather than truncating or extending.
|
||||
- Stable error code `CRYPTO.UTILS.BYTES.LENGTH_MISMATCH` with both lengths in context.
|
||||
- Returns a freshly-allocated output (never mutates inputs).
|
||||
- Pure TypeScript, tight inner loop.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { xor } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
const stream = xor(keystream, plaintext); // ciphertext, if keystream is one-shot
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { xor } from "@suaveplan/crypto-utils/bytes";
|
||||
import { concatBytes } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
// HMAC inner/outer pad construction: `ipad = key ⊕ 0x36…, opad = key ⊕ 0x5c…`
|
||||
function hmacPads(paddedKey: Uint8Array): { ipad: Uint8Array; opad: Uint8Array } {
|
||||
const b = paddedKey.length;
|
||||
const i36 = new Uint8Array(b).fill(0x36);
|
||||
const o5c = new Uint8Array(b).fill(0x5c);
|
||||
return { ipad: xor(paddedKey, i36), opad: xor(paddedKey, o5c) };
|
||||
}
|
||||
|
||||
const { ipad, opad } = hmacPads(new Uint8Array(64));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `xor(a: Uint8Array, b: Uint8Array): Uint8Array` — returns a new `Uint8Array` whose elements are `a[i] ^ b[i]`. Throws `CryptoUtilsError` with code `CRYPTO.UTILS.BYTES.LENGTH_MISMATCH` and context `{ aLen, bLen }` when inputs differ in length.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The inner loop reads both operands by index and writes the output in one pass, which every modern engine turns into a single SIMD-friendly memory-store pattern. We do not expose an in-place `xorInto` variant because XOR on aliased buffers is a common source of correctness bugs in cipher implementations; callers that need it should write `out[i] = a[i] ^ b[i]` explicitly where the aliasing semantics are obvious from context. Error context carries both lengths so the caller can diagnose without logging the (possibly secret) inputs.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { xor } from "./xor.js";
|
||||
|
||||
describe("xor", () => {
|
||||
it("xors identical buffers to zero", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([0x55, 0xaa, 0xff]);
|
||||
expect(xor(a, a)).toEqual(new Uint8Array([0, 0, 0]));
|
||||
});
|
||||
|
||||
it("xors known vector", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([0x00, 0xff, 0x0f, 0xaa]);
|
||||
const b = new Uint8Array([0xff, 0xff, 0xf0, 0x55]);
|
||||
expect(xor(a, b)).toEqual(new Uint8Array([0xff, 0x00, 0xff, 0xff]));
|
||||
});
|
||||
|
||||
it("is its own inverse", { timeout: 5000 }, () => {
|
||||
const k = new Uint8Array(32);
|
||||
crypto.getRandomValues(k);
|
||||
const m = new Uint8Array(32);
|
||||
crypto.getRandomValues(m);
|
||||
expect(xor(xor(m, k), k)).toEqual(m);
|
||||
});
|
||||
|
||||
it("rejects mismatched lengths", { timeout: 5000 }, () => {
|
||||
expect(() => xor(new Uint8Array(2), new Uint8Array(3))).toThrow(
|
||||
CryptoUtilsError
|
||||
);
|
||||
});
|
||||
|
||||
it("handles empty buffers", { timeout: 5000 }, () => {
|
||||
expect(xor(new Uint8Array(), new Uint8Array())).toEqual(
|
||||
new Uint8Array()
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
/**
|
||||
* Length-matched XOR of two equal-length buffers.
|
||||
*
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.BYTES.LENGTH_MISMATCH) if the inputs
|
||||
* differ in length.
|
||||
*/
|
||||
export function xor(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
if (a.length !== b.length) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["BYTES.LENGTH_MISMATCH"],
|
||||
"error.crypto.utils.bytes.length-mismatch",
|
||||
{ context: { aLen: a.length, bLen: b.length } }
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(a.length);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
out[i] = (a[i] as number) ^ (b[i] as number);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# constantTimeEqual
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Byte-for-byte comparison of two `Uint8Array` buffers that takes time dependent only on the input length, not on where they first differ. Use it — and never the built-in `===`, `Array.prototype.every`, or `Buffer.equals` — for comparing authentication tags, MAC digests, password hashes, and any other value where an attacker might use wall-clock time to learn a secret byte-by-byte.
|
||||
|
||||
## Features
|
||||
|
||||
- No early return on mismatch within the byte loop.
|
||||
- Constant-time in buffer **contents**; length check is the only short-circuit (lengths of MACs/tags are public constants).
|
||||
- Zero allocations; pure loop over the inputs.
|
||||
- No dependencies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
|
||||
|
||||
const tagReceived = new Uint8Array(16);
|
||||
const tagExpected = new Uint8Array(16);
|
||||
if (!constantTimeEqual(tagReceived, tagExpected)) {
|
||||
throw new Error("tag mismatch");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
|
||||
|
||||
// Verify a computed MAC against a header-supplied expected MAC in one step,
|
||||
// without ever comparing via `===`, `for-of`, or `Buffer.equals`.
|
||||
function verifyMessageAuth(
|
||||
macImpl: (msg: Uint8Array) => Uint8Array,
|
||||
message: Uint8Array,
|
||||
expected: Uint8Array
|
||||
): boolean {
|
||||
const computed = macImpl(message);
|
||||
return constantTimeEqual(computed, expected);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean` — returns `true` iff both buffers have the same length **and** every byte matches. When lengths differ, returns `false` immediately (length is not secret).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The loop computes `diff |= a[i] ^ b[i]`, accumulating a nonzero value iff any pair of bytes differs; the final check `diff === 0` is also constant time because it inspects a single integer. JavaScript engines aggressively inline this pattern, so the generated machine code is effectively identical to the hand-written one found in `@noble/hashes` and `libsodium`'s `sodium_memcmp`. The length short-circuit exists because returning `false` on length mismatch does not leak secret information — MAC and tag lengths are public protocol constants — and because continuing the loop with a shorter buffer would require masking that adds complexity with no security benefit.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { constantTimeEqual } from "./constant-time-equal.js";
|
||||
|
||||
describe("constantTimeEqual", () => {
|
||||
it("returns true for identical buffers", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([1, 2, 3]);
|
||||
expect(constantTimeEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for empty buffers", { timeout: 5000 }, () => {
|
||||
expect(constantTimeEqual(new Uint8Array(), new Uint8Array())).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when contents differ at end", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
new Uint8Array([1, 2, 4])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when contents differ at start", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(
|
||||
new Uint8Array([0, 2, 3]),
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when lengths differ", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the full loop regardless of mismatch", { timeout: 5000 }, () => {
|
||||
// Property check: result only depends on whether any byte differs.
|
||||
const a = new Uint8Array(32);
|
||||
const b = new Uint8Array(32);
|
||||
a[0] = 1;
|
||||
b[0] = 2;
|
||||
a[31] = 3;
|
||||
b[31] = 4;
|
||||
expect(constantTimeEqual(a, b)).toBe(false);
|
||||
a[0] = 0;
|
||||
b[0] = 0;
|
||||
a[31] = 0;
|
||||
b[31] = 0;
|
||||
expect(constantTimeEqual(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Constant-time byte comparison.
|
||||
*
|
||||
* Returns `true` iff both buffers have the same length AND every byte is
|
||||
* identical. Execution time depends only on the buffer lengths, not on
|
||||
* their contents, preventing timing side-channels when comparing
|
||||
* authentication tags, MAC digests, or password hashes.
|
||||
*
|
||||
* When the lengths differ, the function returns `false` immediately — this
|
||||
* is safe because the lengths of MACs and tags are fixed protocol
|
||||
* constants that are not secret. The loop over contents never short-circuits.
|
||||
*/
|
||||
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= (a[i] as number) ^ (b[i] as number);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { constantTimeEqual } from "./constant-time-equal";
|
||||
@@ -0,0 +1 @@
|
||||
export { constantTimeEqual } from "./constant-time-equal/constant-time-equal.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared constants for the `@suaveplan/crypto-utils` package.
|
||||
* Currently empty; reserved for future use (e.g. well-known domain-separation tags).
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,60 @@
|
||||
# Base58 encoding (Bitcoin alphabet)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes byte buffers using the Bitcoin Base58 alphabet, which omits the four visually ambiguous characters `0`, `O`, `I`, and `l`. Base58 is the encoding of choice for address formats where a human will copy the string by eye (Bitcoin addresses, Solana public keys, some Ethereum vanity formats) and where the absence of punctuation lets the value travel through URLs, QR codes, and plain text without escaping. Leading zero bytes are preserved as the `'1'` prefix character, matching the behaviour of Bitcoin Base58Check address payloads.
|
||||
|
||||
## Features
|
||||
|
||||
- Bitcoin alphabet: `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`.
|
||||
- `BigInt`-based conversion — no floating point, no length limit beyond JS heap size.
|
||||
- Preserves leading-zero bytes as `'1'` prefix characters.
|
||||
- Stable error code `CRYPTO.UTILS.ENCODING.INVALID_INPUT` on decode failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromBase58, toBase58 } from "@suaveplan/crypto-utils/encoding";
|
||||
|
||||
const addr = toBase58(new Uint8Array([0x00, 0xeb, 0x15, 0x23])); // "1NS..."-style prefix
|
||||
const back = fromBase58(addr);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fromBase58, toBase58 } from "@suaveplan/crypto-utils/encoding";
|
||||
import { CryptoUtilsError } from "@suaveplan/crypto-utils/errors";
|
||||
|
||||
function verifyAddressPayload(address: string, expectedLeadingZeros: number): Uint8Array {
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = fromBase58(address);
|
||||
} catch (err) {
|
||||
if (err instanceof CryptoUtilsError) {
|
||||
throw new Error(`Not a Base58 address: ${address}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
if (zeros !== expectedLeadingZeros) {
|
||||
throw new Error(`Expected ${expectedLeadingZeros} leading zero bytes, got ${zeros}`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
verifyAddressPayload("1NS17iag9jJgTHD1VXjvLCEnZuQ3rJDE9L", 1);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toBase58(bytes: Uint8Array): string` — Encodes into the Bitcoin alphabet. Empty input produces the empty string. Leading zero bytes become leading `'1'` characters, one per zero byte.
|
||||
- `fromBase58(s: string): Uint8Array` — Decodes into bytes. Leading `'1'` characters become leading zero bytes. Throws `CryptoUtilsError(CRYPTO.UTILS.ENCODING.INVALID_INPUT)` on any out-of-alphabet character, with `context.input` truncated to the first 32 characters of the offending string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Conversion goes through `BigInt` so arbitrary-length buffers are supported without losing precision. The encoder counts leading zero bytes up front, then converts the remaining bytes to a `BigInt`, repeatedly divides by 58 to produce characters from least significant to most, and finally prepends one `'1'` per leading zero byte. The decoder is the reverse; it pre-counts `'1'` prefix characters, then accumulates `n = n * 58 + value` into a `BigInt`, and finally emits bytes by repeated `n >>= 8`. Base58 is intentionally slower than Base64 — its purpose is human-readability, not throughput. Use Base64/Base64Url for wire formats.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { fromBase58, toBase58 } from "./base58.js";
|
||||
|
||||
function ascii(s: string): Uint8Array {
|
||||
return new Uint8Array([...s].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
// From the Bitcoin Base58 reference test vectors (github.com/bitcoin/bitcoin)
|
||||
// and libbase58.
|
||||
const VECTORS: Array<{ hex: string; b58: string }> = [
|
||||
{ hex: "", b58: "" },
|
||||
{ hex: "61", b58: "2g" }, // 'a'
|
||||
{ hex: "626262", b58: "a3gV" }, // 'bbb'
|
||||
{ hex: "636363", b58: "aPEr" }, // 'ccc'
|
||||
{
|
||||
hex: "73696d706c792061206c6f6e6720737472696e67",
|
||||
b58: "2cFupjhnEsSn59qHXstmK2ffpLv2",
|
||||
},
|
||||
{
|
||||
hex: "00eb15231dfceb60925886b67d065299925915aeb172c06647",
|
||||
b58: "1NS17iag9jJgTHD1VXjvLCEnZuQ3rJDE9L",
|
||||
},
|
||||
{ hex: "516b6fcd0f", b58: "ABnLTmg" },
|
||||
{ hex: "bf4f89001e670274dd", b58: "3SEo3LWLoPntC" },
|
||||
{ hex: "572e4794", b58: "3EFU7m" },
|
||||
{ hex: "ecac89cad93923c02321", b58: "EJDM8drfXA6uyA" },
|
||||
{ hex: "10c8511e", b58: "Rt5zm" },
|
||||
{ hex: "00000000000000000000", b58: "1111111111" },
|
||||
];
|
||||
|
||||
function hexToBytes(h: string): Uint8Array {
|
||||
if (h.length === 0) return new Uint8Array();
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("base58", () => {
|
||||
it("bitcoin reference vectors round-trip", { timeout: 5000 }, () => {
|
||||
for (const v of VECTORS) {
|
||||
const bytes = hexToBytes(v.hex);
|
||||
expect(toBase58(bytes)).toBe(v.b58);
|
||||
expect(fromBase58(v.b58)).toEqual(bytes);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves leading zero bytes as '1' prefix", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([0, 0, 0, 1]);
|
||||
const encoded = toBase58(bytes);
|
||||
expect(encoded.startsWith("111")).toBe(true);
|
||||
expect(fromBase58(encoded)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("round-trips random bytes", { timeout: 5000 }, () => {
|
||||
for (let n = 0; n < 80; n += 5) {
|
||||
const bytes = new Uint8Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
expect(fromBase58(toBase58(bytes))).toEqual(bytes);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects out-of-alphabet characters", { timeout: 5000 }, () => {
|
||||
expect(() => fromBase58("0OIl")).toThrow(CryptoUtilsError);
|
||||
expect(() => fromBase58("!")).toThrow(CryptoUtilsError);
|
||||
expect(() => fromBase58("héllo")).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("handles encoding of ascii 'a'", { timeout: 5000 }, () => {
|
||||
expect(toBase58(ascii("a"))).toBe("2g");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
// Bitcoin alphabet (Base58Check): no 0, O, I, l to avoid visual ambiguity.
|
||||
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
const BASE = 58n;
|
||||
|
||||
const DECODE_TABLE = new Int8Array(128).fill(-1);
|
||||
for (let i = 0; i < ALPHABET.length; i++) {
|
||||
DECODE_TABLE[ALPHABET.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `bytes` with the Bitcoin Base58 alphabet. Leading `0x00` bytes are
|
||||
* preserved as leading `'1'` characters (one per zero byte), matching the
|
||||
* Bitcoin Base58Check convention for address payloads.
|
||||
*/
|
||||
export function toBase58(bytes: Uint8Array): string {
|
||||
if (bytes.length === 0) return "";
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
|
||||
let n = 0n;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
n = (n << 8n) | BigInt(bytes[i] as number);
|
||||
}
|
||||
|
||||
let out = "";
|
||||
while (n > 0n) {
|
||||
const r = Number(n % BASE);
|
||||
out = ALPHABET[r] + out;
|
||||
n = n / BASE;
|
||||
}
|
||||
return "1".repeat(zeros) + out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Base58 string into bytes.
|
||||
*
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.ENCODING.INVALID_INPUT) on non-alphabet
|
||||
* characters.
|
||||
*/
|
||||
export function fromBase58(s: string): Uint8Array {
|
||||
if (s.length === 0) return new Uint8Array();
|
||||
|
||||
let zeros = 0;
|
||||
while (zeros < s.length && s[zeros] === "1") zeros++;
|
||||
|
||||
let n = 0n;
|
||||
for (let i = zeros; i < s.length; i++) {
|
||||
const cc = s.charCodeAt(i);
|
||||
const v = cc < 128 ? DECODE_TABLE[cc] : -1;
|
||||
if (v === -1 || v === undefined) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["ENCODING.INVALID_INPUT"],
|
||||
"error.crypto.utils.encoding.invalid-input",
|
||||
{ context: { encoding: "base58", input: s.slice(0, 32) } }
|
||||
);
|
||||
}
|
||||
n = n * BASE + BigInt(v);
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
while (n > 0n) {
|
||||
bytes.unshift(Number(n & 0xffn));
|
||||
n >>= 8n;
|
||||
}
|
||||
const out = new Uint8Array(zeros + bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out[zeros + i] = bytes[i] as number;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fromBase58, toBase58 } from "./base58";
|
||||
@@ -0,0 +1,56 @@
|
||||
# Base64 encoding
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Standard Base64 (RFC 4648 §4) and URL-safe Base64 (§5) encoding and decoding for byte buffers. Standard Base64 is the default for JSON, JWT headers, and binary payloads in MIME. URL-safe Base64 substitutes `-` and `_` for `+` and `/` so the result can appear in URLs and file paths without percent-escaping. Standard Base64 (`toBase64` / `fromBase64`) is delegated entirely to `@suaveplan/bytes`; the URL-safe variants are implemented here because they use a crypto-specific `+→- / →_` substitution pattern not present in the bytes package.
|
||||
|
||||
## Features
|
||||
|
||||
- `toBase64` / `fromBase64`: RFC 4648 §4 with `=` padding — delegated to `@suaveplan/bytes`.
|
||||
- `toBase64Url` / `fromBase64Url`: RFC 4648 §5 without padding — implemented natively by post-processing the standard base64 output.
|
||||
- URL-safe decoder tolerates optional trailing `=` padding.
|
||||
- Throws `BytesError` (from `@suaveplan/bytes`) on malformed standard base64; same error type propagates through the URL-safe decoder.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromBase64, toBase64 } from "@suaveplan/crypto-utils/encoding";
|
||||
|
||||
const s = toBase64(new TextEncoder().encode("foobar")); // "Zm9vYmFy"
|
||||
const bytes = fromBase64(s);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fromBase64Url, toBase64Url } from "@suaveplan/crypto-utils/encoding";
|
||||
import { BytesError } from "@suaveplan/bytes";
|
||||
|
||||
function parseJwtSegment(segment: string): Uint8Array {
|
||||
try {
|
||||
return fromBase64Url(segment);
|
||||
} catch (err) {
|
||||
if (err instanceof BytesError) {
|
||||
throw new Error(`JWT segment not valid base64url: ${segment.slice(0, 8)}…`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const encoded = toBase64Url(new Uint8Array([0xfb, 0xff, 0xbf]));
|
||||
parseJwtSegment(encoded);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toBase64(bytes: Uint8Array): string` — RFC 4648 §4 with `=` padding. Never throws. Alias for `bytesToBase64` from `@suaveplan/bytes`.
|
||||
- `fromBase64(s: string): Uint8Array` — RFC 4648 §4. Throws `BytesError(BYTES.ENCODE.INVALID_BASE64)` on invalid characters or bad length.
|
||||
- `toBase64Url(bytes: Uint8Array): string` — RFC 4648 §5 without padding. Calls `toBase64` then substitutes `+→-`, `/→_`, strips `=`.
|
||||
- `fromBase64Url(s: string): Uint8Array` — RFC 4648 §5. Restores `+` and `/`, adds padding, then delegates to `fromBase64`. Tolerates optional trailing `=` padding. Throws `BytesError` on malformed input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Standard base64 (`toBase64` / `fromBase64`) are direct `const` aliases for `bytesToBase64` / `base64ToBytes` from `@suaveplan/bytes`, eliminating duplicate encoding logic. URL-safe base64 is implemented as a post-processing step: `toBase64Url` calls `toBase64` then performs three `String.replace` calls to swap characters and strip padding. `fromBase64Url` reverses the substitution, computes missing padding (aligning to a multiple of 4), then delegates to `fromBase64`. This approach ensures the URL-safe round-trip shares exactly the same underlying byte semantics as the standard variant. Error type for invalid input is `BytesError` (from `@suaveplan/bytes`) rather than the legacy `CryptoUtilsError`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BytesError } from "@suaveplan/bytes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { fromBase64, fromBase64Url, toBase64, toBase64Url } from "./base64.js";
|
||||
|
||||
// RFC 4648 §10 test vectors
|
||||
const RFC4648 = [
|
||||
{ bytes: "", b64: "" },
|
||||
{ bytes: "f", b64: "Zg==" },
|
||||
{ bytes: "fo", b64: "Zm8=" },
|
||||
{ bytes: "foo", b64: "Zm9v" },
|
||||
{ bytes: "foob", b64: "Zm9vYg==" },
|
||||
{ bytes: "fooba", b64: "Zm9vYmE=" },
|
||||
{ bytes: "foobar", b64: "Zm9vYmFy" },
|
||||
] as const;
|
||||
|
||||
function ascii(s: string): Uint8Array {
|
||||
return new Uint8Array([...s].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
describe("base64", () => {
|
||||
it("RFC 4648 §10 vectors round-trip", { timeout: 5000 }, () => {
|
||||
for (const v of RFC4648) {
|
||||
expect(toBase64(ascii(v.bytes))).toBe(v.b64);
|
||||
expect(fromBase64(v.b64)).toEqual(ascii(v.bytes));
|
||||
}
|
||||
});
|
||||
|
||||
it("base64url drops padding", { timeout: 5000 }, () => {
|
||||
expect(toBase64Url(ascii("fo"))).toBe("Zm8");
|
||||
expect(fromBase64Url("Zm8")).toEqual(ascii("fo"));
|
||||
// With explicit trailing padding — decoder tolerates it
|
||||
expect(fromBase64Url("Zm8=")).toEqual(ascii("fo"));
|
||||
});
|
||||
|
||||
it("base64url uses - and _", { timeout: 5000 }, () => {
|
||||
const bytes = new Uint8Array([0xfb, 0xff, 0xbf]);
|
||||
const std = toBase64(bytes);
|
||||
const url = toBase64Url(bytes);
|
||||
expect(std).toContain("+");
|
||||
expect(url).not.toContain("+");
|
||||
expect(url).not.toContain("/");
|
||||
expect(fromBase64Url(url)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("round-trips random bytes", { timeout: 5000 }, () => {
|
||||
for (let n = 0; n < 200; n += 7) {
|
||||
const bytes = new Uint8Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
expect(fromBase64(toBase64(bytes))).toEqual(bytes);
|
||||
expect(fromBase64Url(toBase64Url(bytes))).toEqual(bytes);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects single-char remainders", { timeout: 5000 }, () => {
|
||||
expect(() => fromBase64("A")).toThrow(BytesError);
|
||||
expect(() => fromBase64Url("A")).toThrow(BytesError);
|
||||
});
|
||||
|
||||
it("rejects invalid characters", { timeout: 5000 }, () => {
|
||||
expect(() => fromBase64("!!!!")).toThrow(BytesError);
|
||||
expect(() => fromBase64Url("@@@@")).toThrow(BytesError);
|
||||
// Non-ASCII
|
||||
expect(() => fromBase64("éééé")).toThrow(BytesError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { base64ToBytes, bytesToBase64 } from "@suaveplan/bytes";
|
||||
|
||||
/** Encode `bytes` as standard Base64 (RFC 4648 §4, `=` padding). Delegates to @suaveplan/bytes. */
|
||||
export const toBase64 = bytesToBase64;
|
||||
|
||||
/** Decode a standard Base64 string. Delegates to @suaveplan/bytes. */
|
||||
export const fromBase64 = base64ToBytes;
|
||||
|
||||
/** Encode `bytes` as URL-safe Base64 (RFC 4648 §5, no padding). */
|
||||
export function toBase64Url(bytes: Uint8Array): string {
|
||||
return toBase64(bytes)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
/** Decode a URL-safe Base64 string. Padding optional. */
|
||||
export function fromBase64Url(b64url: string): Uint8Array {
|
||||
const padded = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = padded.length % 4;
|
||||
return fromBase64(pad ? padded + "=".repeat(4 - pad) : padded);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { fromBase64, fromBase64Url, toBase64, toBase64Url } from "./base64";
|
||||
@@ -0,0 +1,55 @@
|
||||
# Hex encoding
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Convert between `Uint8Array` byte buffers and lowercase hexadecimal strings. Hex is the canonical human-readable representation of cryptographic material (digests, public keys, nonces) because every byte maps to exactly two characters with no ambiguity or padding. This module re-exports `bytesToHex` and `hexToBytes` from `@suaveplan/bytes` under the crypto-friendly names `toHex` / `fromHex`, ensuring every `@suaveplan/crypto-*` package produces byte-for-byte identical output and rejects malformed input with a stable error code.
|
||||
|
||||
## Features
|
||||
|
||||
- `toHex(bytes)` — lowercase, no `0x` prefix; delegates to `@suaveplan/bytes`.
|
||||
- `fromHex(hex)` — accepts both upper and lower case; rejects odd length or non-hex characters.
|
||||
- Throws `BytesError(BYTES.ENCODE.INVALID_HEX)` on malformed input (from `@suaveplan/bytes`).
|
||||
- Zero implementation code in this module — pure delegation ensures a single source of truth.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromHex, toHex } from "@suaveplan/crypto-utils/encoding";
|
||||
|
||||
const digest = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
||||
const s = toHex(digest); // "deadbeef"
|
||||
const back = fromHex(s); // Uint8Array([0xde, 0xad, 0xbe, 0xef])
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fromHex, toHex } from "@suaveplan/crypto-utils/encoding";
|
||||
import { BytesError } from "@suaveplan/bytes";
|
||||
|
||||
function parseDigest(input: string): Uint8Array {
|
||||
try {
|
||||
return fromHex(input.trim().replace(/^0x/, ""));
|
||||
} catch (err) {
|
||||
if (err instanceof BytesError) {
|
||||
throw new Error(`digest must be hex-encoded: ${input.slice(0, 32)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const digest = parseDigest("0xDEADBEEF");
|
||||
console.log(toHex(digest));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `toHex(bytes: Uint8Array): string` — Returns a lowercase hex string whose length is `2 * bytes.length`. Never throws. Alias for `bytesToHex` from `@suaveplan/bytes`.
|
||||
- `fromHex(hex: string): Uint8Array` — Parses upper or lower case. Throws `BytesError` with code `BYTES.ENCODE.INVALID_HEX` on odd-length or non-hex input. Alias for `hexToBytes` from `@suaveplan/bytes`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This module is a thin delegation layer: `toHex` and `fromHex` are direct `const` aliases for `bytesToHex` and `hexToBytes` from `@suaveplan/bytes`. No encoding logic lives here — this removes duplication between the two packages and ensures all `@suaveplan/crypto-*` consumers share exactly the same implementation. Errors thrown on invalid input originate from `@suaveplan/bytes` as `BytesError` instances; callers should catch `BytesError` rather than the old `CryptoUtilsError`. The public function names (`toHex` / `fromHex`) are preserved so no downstream caller requires import changes.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BytesError } from "@suaveplan/bytes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { fromHex, toHex } from "./hex.js";
|
||||
|
||||
describe("hex", () => {
|
||||
it("round-trips the empty string", { timeout: 5000 }, () => {
|
||||
expect(toHex(new Uint8Array())).toBe("");
|
||||
expect(fromHex("")).toEqual(new Uint8Array());
|
||||
});
|
||||
|
||||
it("encodes known vectors", { timeout: 5000 }, () => {
|
||||
// RFC 4648 test vectors
|
||||
expect(toHex(new Uint8Array([0x66]))).toBe("66"); // 'f'
|
||||
expect(toHex(new Uint8Array([0x66, 0x6f, 0x6f]))).toBe("666f6f"); // 'foo'
|
||||
expect(toHex(new Uint8Array([0x00, 0xff, 0x10]))).toBe("00ff10");
|
||||
});
|
||||
|
||||
it("decodes known vectors", { timeout: 5000 }, () => {
|
||||
expect(fromHex("666f6f")).toEqual(new Uint8Array([0x66, 0x6f, 0x6f]));
|
||||
expect(fromHex("FF10")).toEqual(new Uint8Array([0xff, 0x10]));
|
||||
});
|
||||
|
||||
it("round-trips random bytes", { timeout: 5000 }, () => {
|
||||
for (let n = 0; n < 200; n += 13) {
|
||||
const bytes = new Uint8Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
expect(fromHex(toHex(bytes))).toEqual(bytes);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects odd-length input", { timeout: 5000 }, () => {
|
||||
expect(() => fromHex("abc")).toThrow(BytesError);
|
||||
});
|
||||
|
||||
it("rejects non-hex chars", { timeout: 5000 }, () => {
|
||||
expect(() => fromHex("zz")).toThrow(BytesError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { bytesToHex, hexToBytes } from "@suaveplan/bytes";
|
||||
|
||||
/** Convert a Uint8Array to lowercase hex string. Delegates to @suaveplan/bytes. */
|
||||
export const toHex = bytesToHex;
|
||||
|
||||
/** Convert a hex string to Uint8Array. Delegates to @suaveplan/bytes. */
|
||||
export const fromHex = hexToBytes;
|
||||
@@ -0,0 +1 @@
|
||||
export { fromHex, toHex } from "./hex";
|
||||
@@ -0,0 +1,8 @@
|
||||
export { fromBase58, toBase58 } from "./base58/base58.js";
|
||||
export {
|
||||
fromBase64,
|
||||
fromBase64Url,
|
||||
toBase64,
|
||||
toBase64Url,
|
||||
} from "./base64/base64.js";
|
||||
export { fromHex, toHex } from "./hex/hex.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable, namespaced error-code registry for `@suaveplan/crypto-utils`. Every error thrown by this package carries a code from this registry, which lets callers type-narrow on `error.code` rather than relying on string matching against message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `defineCodes` call produces a frozen, read-only map of codes under the `CRYPTO.UTILS` namespace
|
||||
- Codes are plain string literals (`"CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED"`, etc.) — safe to log, serialize, and compare across process boundaries
|
||||
- `CryptoUtilsCode` union type keeps downstream `catch` blocks fully typed without any runtime cost
|
||||
- Covers the full error surface of the package: encoding validation, byte-length mismatches, integer-range checks, Merkle-tree errors, RNG limits, and the primitive-not-implemented sentinel
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CRYPTO_UTILS_CODES, type CryptoUtilsCode } from "@suaveplan/crypto-utils/errors/codes";
|
||||
|
||||
function handleError(code: CryptoUtilsCode): string {
|
||||
switch (code) {
|
||||
case CRYPTO_UTILS_CODES.ENCODING_INVALID_INPUT:
|
||||
return "The supplied string is not valid for the chosen encoding.";
|
||||
case CRYPTO_UTILS_CODES.PRIMITIVE_NOT_IMPLEMENTED:
|
||||
return "This cryptographic primitive has no native implementation yet.";
|
||||
default:
|
||||
return "Unknown crypto-utils error.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Type-narrowing in a catch block using the `CryptoUtilsCode` union:
|
||||
|
||||
```ts
|
||||
import { CRYPTO_UTILS_CODES, type CryptoUtilsCode } from "@suaveplan/crypto-utils/errors/codes";
|
||||
import { CryptoUtilsError } from "@suaveplan/crypto-utils/errors/crypto-utils-error";
|
||||
|
||||
async function safeHex(input: string): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const { hexToBytes } = await import("@suaveplan/crypto-utils/encoding/hex/hex");
|
||||
return hexToBytes(input);
|
||||
} catch (err) {
|
||||
if (err instanceof CryptoUtilsError) {
|
||||
const code: CryptoUtilsCode = err.code;
|
||||
if (code === CRYPTO_UTILS_CODES.ENCODING_INVALID_INPUT) {
|
||||
console.warn("Bad hex input, returning null:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CRYPTO_UTILS_CODES`
|
||||
|
||||
```ts
|
||||
const CRYPTO_UTILS_CODES: {
|
||||
readonly PRIMITIVE_NOT_IMPLEMENTED: "CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED";
|
||||
readonly ENCODING_INVALID_INPUT: "CRYPTO.UTILS.ENCODING.INVALID_INPUT";
|
||||
readonly BYTES_LENGTH_MISMATCH: "CRYPTO.UTILS.BYTES.LENGTH_MISMATCH";
|
||||
readonly BYTES_INVALID_LENGTH: "CRYPTO.UTILS.BYTES.INVALID_LENGTH";
|
||||
readonly INTEGER_OUT_OF_RANGE: "CRYPTO.UTILS.INTEGER.OUT_OF_RANGE";
|
||||
readonly MERKLE_EMPTY_LEAVES: "CRYPTO.UTILS.MERKLE.EMPTY_LEAVES";
|
||||
readonly MERKLE_INVALID_PROOF: "CRYPTO.UTILS.MERKLE.INVALID_PROOF";
|
||||
readonly RNG_INVALID_LENGTH: "CRYPTO.UTILS.RNG.INVALID_LENGTH";
|
||||
};
|
||||
```
|
||||
|
||||
Frozen registry produced by `defineCodes("CRYPTO.UTILS", [...])`. Dot-separated sub-namespaces (`ENCODING.INVALID_INPUT`, `BYTES.LENGTH_MISMATCH`, etc.) become underscore-keyed properties on the returned object.
|
||||
|
||||
**Returns** — A read-only record whose values are opaque string literals suitable for stable logging and wire serialization.
|
||||
|
||||
---
|
||||
|
||||
### `CryptoUtilsCode`
|
||||
|
||||
```ts
|
||||
type CryptoUtilsCode = (typeof CRYPTO_UTILS_CODES)[keyof typeof CRYPTO_UTILS_CODES];
|
||||
```
|
||||
|
||||
Union of every string literal in `CRYPTO_UTILS_CODES`. Use this as the `code` parameter type in `catch` blocks or error-handler signatures to stay fully typed without importing the entire error object.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` (from `@suaveplan/error`) is the single source of truth for code formatting: it prepends the namespace, freezes the resulting object, and guarantees that each value is a unique string. This file owns no logic — it is a pure declaration layer.
|
||||
|
||||
The dot-notation in the raw array entries (`"ENCODING.INVALID_INPUT"`) is a human-readable convention for grouping related codes within the namespace; the resulting object key uses the underscore-separated form (`ENCODING_INVALID_INPUT`). Consumers should always reference `CRYPTO_UTILS_CODES.*` constants rather than the raw string literals so that renames are caught at compile time.
|
||||
|
||||
Adding a new code requires only appending a string to the `as const` array in this file and optionally adding a new subclass to the `errors/` directory. No numeric IDs, no registry migrations.
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
export const CRYPTO_UTILS_CODES = defineCodes("CRYPTO.UTILS", [
|
||||
"PRIMITIVE_NOT_IMPLEMENTED",
|
||||
"ENCODING.INVALID_INPUT",
|
||||
"BYTES.LENGTH_MISMATCH",
|
||||
"BYTES.INVALID_LENGTH",
|
||||
"INTEGER.OUT_OF_RANGE",
|
||||
"MERKLE.EMPTY_LEAVES",
|
||||
"MERKLE.INVALID_PROOF",
|
||||
"RNG.INVALID_LENGTH",
|
||||
] as const);
|
||||
|
||||
export type CryptoUtilsCode =
|
||||
(typeof CRYPTO_UTILS_CODES)[keyof typeof CRYPTO_UTILS_CODES];
|
||||
@@ -0,0 +1 @@
|
||||
export { CRYPTO_UTILS_CODES, type CryptoUtilsCode } from "./codes.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# crypto-utils-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `CryptoUtilsError`, the package-level base error class for `@suaveplan/crypto-utils`. All errors thrown by encoding, byte-manipulation, Merkle-tree, random, and primitive-registry code in this package extend this class, giving callers a single catch target with a fully typed `code` discriminator.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `SuaveplanError<CryptoUtilsCode>` from `@suaveplan/error`, inheriting cause-chain traversal, Zod-validated wire format, redaction, and OTel span-event adapter
|
||||
- Constrains the generic `code` parameter to the `CryptoUtilsCode` union so TypeScript rejects out-of-range codes at compile time
|
||||
- Accepts the full `SuaveplanErrorOptions` bag (cause, context, redact, i18n key) forwarded unchanged to the base class
|
||||
- Acts as a `instanceof` pivot: `err instanceof CryptoUtilsError` is sufficient to confirm the error originated in this package without inspecting the code string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CryptoUtilsError } from "@suaveplan/crypto-utils/errors/crypto-utils-error";
|
||||
import { CRYPTO_UTILS_CODES } from "@suaveplan/crypto-utils/errors/codes";
|
||||
|
||||
function assertHexLength(s: string): void {
|
||||
if (s.length % 2 !== 0) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES.ENCODING_INVALID_INPUT,
|
||||
"error.crypto.utils.encoding.invalid-hex-length",
|
||||
{ context: { input: s.slice(0, 16) } }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Catching only `CryptoUtilsError` while re-throwing everything else, and extracting structured context for observability:
|
||||
|
||||
```ts
|
||||
import { CryptoUtilsError } from "@suaveplan/crypto-utils/errors/crypto-utils-error";
|
||||
import { CRYPTO_UTILS_CODES } from "@suaveplan/crypto-utils/errors/codes";
|
||||
import { hexToBytes } from "@suaveplan/crypto-utils/encoding/hex/hex";
|
||||
|
||||
async function parseHexSafe(raw: string): Promise<Uint8Array> {
|
||||
try {
|
||||
return hexToBytes(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof CryptoUtilsError) {
|
||||
// Structured context is always available on the base class
|
||||
const ctx = err.context ?? {};
|
||||
console.error("CryptoUtilsError", {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
context: ctx,
|
||||
});
|
||||
|
||||
if (err.code === CRYPTO_UTILS_CODES.ENCODING_INVALID_INPUT) {
|
||||
// Return an empty buffer on invalid encoding rather than crashing
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CryptoUtilsError`
|
||||
|
||||
```ts
|
||||
class CryptoUtilsError extends SuaveplanError<CryptoUtilsCode> {
|
||||
public constructor(
|
||||
code: CryptoUtilsCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|--------------|------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| `code` | `CryptoUtilsCode` | One of the stable string literals from `CRYPTO_UTILS_CODES`. |
|
||||
| `messageKey` | `string` | Dot-separated i18n key (e.g. `"error.crypto.utils.encoding.invalid-hex-length"`). |
|
||||
| `options` | `SuaveplanErrorOptions \| undefined` | Optional bag: `cause`, `context` (arbitrary record), `redact` flag, resolver override. |
|
||||
|
||||
**Throws** — Never throws itself; this is a constructor.
|
||||
|
||||
**Inherits** from `SuaveplanError<CryptoUtilsCode>`:
|
||||
|
||||
- `.code` — the `CryptoUtilsCode` literal
|
||||
- `.context` — arbitrary structured metadata attached at throw site
|
||||
- `.cause` — original error (for cause-chain traversal)
|
||||
- `.toJSON()` / `.toBinary()` — Zod-validated wire serialisation
|
||||
- `.toOtelEvent()` — OpenTelemetry span-event adapter
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor body consists entirely of a `super(...)` forwarding call. The `biome-ignore` comment suppresses the "useless constructor" lint rule because the public constructor is intentionally widening the visibility of the protected base constructor — without it, internal subclasses in this package could not call `new CryptoUtilsError(...)` from outside the class hierarchy.
|
||||
|
||||
`CryptoUtilsError` is not abstract: lower-level utilities that do not warrant a dedicated subclass can instantiate it directly. Higher-level subclasses (`PrimitiveNotImplemented`) extend it to add semantically richer properties (e.g. `primitiveId`).
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import type { CryptoUtilsCode } from "../codes/codes.js";
|
||||
|
||||
/**
|
||||
* Umbrella error class for `@suaveplan/crypto-utils`. All thrown errors in
|
||||
* this package extend `SuaveplanError` through this subclass so callers can
|
||||
* type-narrow by `error.code`.
|
||||
*/
|
||||
export class CryptoUtilsError extends SuaveplanError<CryptoUtilsCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens base ctor visibility
|
||||
public constructor(
|
||||
code: CryptoUtilsCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CryptoUtilsError } from "./crypto-utils-error.js";
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CRYPTO_UTILS_CODES, type CryptoUtilsCode } from "./codes/codes.js";
|
||||
export { CryptoUtilsError } from "./crypto-utils-error/crypto-utils-error.js";
|
||||
export { PrimitiveNotImplemented } from "./primitive-not-implemented/primitive-not-implemented.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { PrimitiveNotImplemented } from "./primitive-not-implemented.js";
|
||||
@@ -0,0 +1,101 @@
|
||||
# primitive-not-implemented
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `PrimitiveNotImplemented`, the error thrown by any native-implementation stub inside `impl/<id>.native.ts` files that have not yet been written. It signals that the caller requested a specific cryptographic primitive by ID but no concrete runtime implementation exists for it, as distinct from a configuration or validation error.
|
||||
|
||||
## Features
|
||||
|
||||
- Extends `CryptoUtilsError` with stable code `CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED`
|
||||
- Carries the primitive identifier both in the i18n message key and on the strongly-typed `primitiveId` property for programmatic inspection without string parsing
|
||||
- Merges caller-supplied `context` with `{ primitiveId }` so the id appears in structured logs and OTel span events regardless of i18n resolution
|
||||
- Accepts the full `SuaveplanErrorOptions` bag for cause-chaining, redaction, and context forwarding
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
Throwing from a native-implementation stub:
|
||||
|
||||
```ts
|
||||
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors/primitive-not-implemented";
|
||||
|
||||
// Inside impl/argon2id.native.ts
|
||||
export function hashArgon2idNative(): Uint8Array {
|
||||
throw new PrimitiveNotImplemented("argon2id");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Catching `PrimitiveNotImplemented` in a primitive selector to fall back to a noble implementation:
|
||||
|
||||
```ts
|
||||
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors/primitive-not-implemented";
|
||||
import { hashArgon2idNative } from "./impl/argon2id.native.js";
|
||||
import { hashArgon2idNoble } from "./impl/argon2id.noble.js";
|
||||
import type { Argon2idOptions } from "./argon2id.types.js";
|
||||
|
||||
export async function hashArgon2id(
|
||||
password: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
opts: Argon2idOptions
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return hashArgon2idNative(password, salt, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof PrimitiveNotImplemented && err.primitiveId === "argon2id") {
|
||||
// Native path not available; fall through to pure-JS noble implementation
|
||||
return hashArgon2idNoble(password, salt, opts);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `primitiveId` property enables the selector to distinguish "not implemented" (expected during development) from other errors (unexpected, must propagate).
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PrimitiveNotImplemented`
|
||||
|
||||
```ts
|
||||
class PrimitiveNotImplemented extends CryptoUtilsError {
|
||||
public readonly primitiveId: string;
|
||||
|
||||
public constructor(
|
||||
primitiveId: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---------------|----------------------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `primitiveId` | `string` | Identifier of the primitive that lacks a native implementation (e.g. `"argon2id"`, `"chacha20"`). |
|
||||
| `options` | `SuaveplanErrorOptions \| undefined` | Optional bag: `cause`, `context` (merged with `{ primitiveId }`), `redact`, resolver override. |
|
||||
|
||||
**Properties**
|
||||
|
||||
| Property | Type | Description |
|
||||
|---------------|----------|-----------------------------------------------------|
|
||||
| `primitiveId` | `string` | The primitive ID passed to the constructor. Read-only. |
|
||||
| `code` | `"CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED"` | Fixed stable code inherited via `CryptoUtilsError`. |
|
||||
|
||||
**Inherits** from `CryptoUtilsError` → `SuaveplanError<CryptoUtilsCode>`:
|
||||
|
||||
- `.context` — includes at minimum `{ primitiveId }`
|
||||
- `.cause` — for cause-chaining
|
||||
- `.toJSON()` / `.toBinary()` — wire serialisation
|
||||
- `.toOtelEvent()` — OpenTelemetry span-event adapter
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `primitiveId` is attached to both `error.primitiveId` and `error.context.primitiveId`. The duplication is intentional: the property provides a typed accessor without object-spread indexing, while the context entry ensures it flows through any OTel or structured-logging pipeline that records `error.context` generically.
|
||||
|
||||
The i18n message key `"error.crypto.utils.primitive-not-implemented"` is intentionally generic — the `primitiveId` in context gives translators and log consumers the specific detail. This keeps the number of translation keys bounded even as new primitives are added.
|
||||
|
||||
This error is expected during development when the noble implementation is available but the native path has not been written. In production builds all native stubs should either be implemented or the selector should never route to the native path, so encountering this error in a production log indicates a misconfigured build.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../crypto-utils-error/crypto-utils-error.js";
|
||||
import { PrimitiveNotImplemented } from "./primitive-not-implemented.js";
|
||||
|
||||
describe("PrimitiveNotImplemented", () => {
|
||||
it("carries the primitive id", { timeout: 5000 }, () => {
|
||||
const err = new PrimitiveNotImplemented("sha256");
|
||||
expect(err.primitiveId).toBe("sha256");
|
||||
expect(err.context?.primitiveId).toBe("sha256");
|
||||
});
|
||||
|
||||
it("uses the stable code CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new PrimitiveNotImplemented("ed25519");
|
||||
expect(err.code).toBe("CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED");
|
||||
});
|
||||
|
||||
it("is a CryptoUtilsError", { timeout: 5000 }, () => {
|
||||
const err = new PrimitiveNotImplemented("aes-256-gcm");
|
||||
expect(err).toBeInstanceOf(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("preserves additional context", { timeout: 5000 }, () => {
|
||||
const err = new PrimitiveNotImplemented("ed25519", {
|
||||
context: { reason: "phase-0" },
|
||||
});
|
||||
expect(err.context?.reason).toBe("phase-0");
|
||||
expect(err.context?.primitiveId).toBe("ed25519");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { CRYPTO_UTILS_CODES } from "../codes/codes.js";
|
||||
import { CryptoUtilsError } from "../crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
/**
|
||||
* Thrown by any `impl/<id>.native.ts` file whose native implementation has
|
||||
* not yet been written. Stable code: `CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED`.
|
||||
*
|
||||
* The primitive id is carried both on the message key (for i18n) and on
|
||||
* `error.context.primitiveId` (for programmatic inspection).
|
||||
*/
|
||||
export class PrimitiveNotImplemented extends CryptoUtilsError {
|
||||
public readonly primitiveId: string;
|
||||
|
||||
public constructor(primitiveId: string, options?: SuaveplanErrorOptions) {
|
||||
super(
|
||||
CRYPTO_UTILS_CODES.PRIMITIVE_NOT_IMPLEMENTED,
|
||||
"error.crypto.utils.primitive-not-implemented",
|
||||
{
|
||||
...options,
|
||||
context: {
|
||||
...(options?.context ?? {}),
|
||||
primitiveId,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.primitiveId = primitiveId;
|
||||
}
|
||||
}
|
||||
70
packages/security-stack/crypto/crypto-utils/src/index.ts
Normal file
70
packages/security-stack/crypto/crypto-utils/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Bytes
|
||||
export {
|
||||
concatBytes,
|
||||
i2osp,
|
||||
os2ip,
|
||||
xor,
|
||||
} from "./bytes/index.js";
|
||||
// Constant-time
|
||||
export { constantTimeEqual } from "./constant-time/index.js";
|
||||
// Encoding
|
||||
export {
|
||||
fromBase58,
|
||||
fromBase64,
|
||||
fromBase64Url,
|
||||
fromHex,
|
||||
toBase58,
|
||||
toBase64,
|
||||
toBase64Url,
|
||||
toHex,
|
||||
} from "./encoding/index.js";
|
||||
// Errors
|
||||
export {
|
||||
CRYPTO_UTILS_CODES,
|
||||
type CryptoUtilsCode,
|
||||
CryptoUtilsError,
|
||||
PrimitiveNotImplemented,
|
||||
} from "./errors/index.js";
|
||||
// Merkle
|
||||
export {
|
||||
type HashFn,
|
||||
type MerkleProofStep,
|
||||
merkleProof,
|
||||
merkleRoot,
|
||||
verifyMerkleProof,
|
||||
} from "./merkle/index.js";
|
||||
// Primitive id
|
||||
export {
|
||||
CIPHER_PRIMITIVE_IDS,
|
||||
type CipherPrimitiveId,
|
||||
CURVE_PRIMITIVE_IDS,
|
||||
type CurvePrimitiveId,
|
||||
HASH_PRIMITIVE_IDS,
|
||||
type HashPrimitiveId,
|
||||
isPrimitiveId,
|
||||
PRIMITIVE_IDS,
|
||||
type PrimitiveCategory,
|
||||
type PrimitiveId,
|
||||
primitiveCategory,
|
||||
} from "./primitive-id/index.js";
|
||||
// Random
|
||||
export {
|
||||
type ByteSource,
|
||||
defaultByteSource,
|
||||
type RandomBytesOptions,
|
||||
randomBytes,
|
||||
} from "./random/index.js";
|
||||
// Testing
|
||||
export {
|
||||
type DiffFuzzOptions,
|
||||
type DiffFuzzResult,
|
||||
diffFuzz,
|
||||
type Impl,
|
||||
type ImplReport,
|
||||
type RunVectorsOptions,
|
||||
runVectors,
|
||||
type TestVector,
|
||||
type VectorResult,
|
||||
} from "./testing/index.js";
|
||||
// Zeroize
|
||||
export { zeroize } from "./zeroize/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type MerkleProofStep,
|
||||
merkleProof,
|
||||
verifyMerkleProof,
|
||||
} from "./merkle-proof/merkle-proof.js";
|
||||
export { type HashFn, merkleRoot } from "./merkle-root/merkle-root.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type MerkleProofStep,
|
||||
merkleProof,
|
||||
verifyMerkleProof,
|
||||
} from "./merkle-proof";
|
||||
@@ -0,0 +1,70 @@
|
||||
# merkleProof
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Produce and verify a Merkle inclusion proof for a specific leaf index. A proof is an ordered list of sibling hashes that, together with the leaf, reconstructs the root. Given the root is already published (on-chain, in a receipt, in an SCT), a peer can verify that a particular leaf was part of the committed set in `O(log n)` hashes, without having to see the whole tree. Paired with `merkleRoot` from the same directory, these two primitives cover the build-side and the verify-side of every Merkle commitment scheme in the stack.
|
||||
|
||||
## Features
|
||||
|
||||
- Proofs carry `{ sibling, side }` tuples so verification is direction-aware.
|
||||
- Same odd-count duplication rule as `merkleRoot` (Bitcoin convention).
|
||||
- `verifyMerkleProof` uses `constantTimeEqual` for the final root comparison.
|
||||
- Throws `CryptoUtilsError(CRYPTO.UTILS.MERKLE.INVALID_PROOF)` on out-of-range index.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sha256 } from "@suaveplan/crypto-hash/sha256";
|
||||
import { merkleProof, merkleRoot, verifyMerkleProof } from "@suaveplan/crypto-utils/merkle";
|
||||
|
||||
const leaves = [new Uint8Array([1]), new Uint8Array([2]), new Uint8Array([3]), new Uint8Array([4])];
|
||||
const root = merkleRoot(leaves, sha256);
|
||||
const proof = merkleProof(leaves, 2, sha256); // proof for leaves[2]
|
||||
console.log(verifyMerkleProof(leaves[2], proof, root, sha256)); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sha256 } from "@suaveplan/crypto-hash/sha256";
|
||||
import { merkleProof, verifyMerkleProof } from "@suaveplan/crypto-utils/merkle";
|
||||
|
||||
// Serialise a proof as hex for wire transport: every step is "L:<hex>" or "R:<hex>".
|
||||
function serialiseProof(proof: ReturnType<typeof merkleProof>): string {
|
||||
return proof
|
||||
.map((s) => `${s.side === "left" ? "L" : "R"}:${[...s.sibling].map((b) => b.toString(16).padStart(2, "0")).join("")}`)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function parseProof(s: string): ReturnType<typeof merkleProof> {
|
||||
return s.split(",").map((tok) => {
|
||||
const [side, hex] = tok.split(":");
|
||||
const sibling = new Uint8Array((hex ?? "").length / 2);
|
||||
for (let i = 0; i < sibling.length; i++) sibling[i] = parseInt((hex ?? "").slice(i * 2, i * 2 + 2), 16);
|
||||
return { sibling, side: side === "L" ? ("left" as const) : ("right" as const) };
|
||||
});
|
||||
}
|
||||
|
||||
declare const leaves: Uint8Array[];
|
||||
declare const root: Uint8Array;
|
||||
const wire = serialiseProof(merkleProof(leaves, 1, sha256));
|
||||
verifyMerkleProof(leaves[1], parseProof(wire), root, sha256);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `merkleProof(leaves: readonly Uint8Array[], index: number, hash: HashFn): MerkleProofStep[]`
|
||||
- Throws `CryptoUtilsError(CRYPTO.UTILS.MERKLE.EMPTY_LEAVES)` on empty leaves.
|
||||
- Throws `CryptoUtilsError(CRYPTO.UTILS.MERKLE.INVALID_PROOF)` on index out of range.
|
||||
- Returns empty array for single-leaf trees.
|
||||
- `verifyMerkleProof(leaf: Uint8Array, proof: readonly MerkleProofStep[], root: Uint8Array, hash: HashFn): boolean`
|
||||
- Returns `true` iff `proof` applied to `leaf` with `hash` reproduces `root`.
|
||||
- Uses `constantTimeEqual` for the final comparison.
|
||||
- `type MerkleProofStep = { sibling: Uint8Array; side: 'left' | 'right' }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The proof walker tracks `idx` at each level and halves it on the way up (`idx = Math.floor(idx / 2)`). When `idx` is the last element of an odd-count level, its sibling is itself (duplication rule), producing a `{ side: 'right', sibling: <copy of self> }` step; verifying recomputes `hash(self || self)` and the root matches. The `side` field is chosen from the leaf's perspective, not the sibling's — a leaf that is the _right_ child has a `left` sibling, hence `side === 'left'` means the sibling is prepended. `verifyMerkleProof` uses `constantTimeEqual` on the final root comparison so proof checks in low-level verifier code do not leak timing information about which specific byte differed.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { merkleRoot } from "../merkle-root/merkle-root.js";
|
||||
import { merkleProof, verifyMerkleProof } from "./merkle-proof.js";
|
||||
|
||||
function toyHash(input: Uint8Array): Uint8Array {
|
||||
let s = 0;
|
||||
for (let i = 0; i < input.length; i++) s = (s + (input[i] as number)) >>> 0;
|
||||
return new Uint8Array([
|
||||
s & 0xff,
|
||||
(s >> 8) & 0xff,
|
||||
(s >> 16) & 0xff,
|
||||
(s >> 24) & 0xff,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("merkleProof", () => {
|
||||
it("produces verifiable proofs for every index (power-of-two tree)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const leaves = [
|
||||
new Uint8Array([1]),
|
||||
new Uint8Array([2]),
|
||||
new Uint8Array([3]),
|
||||
new Uint8Array([4]),
|
||||
];
|
||||
const root = merkleRoot(leaves, toyHash);
|
||||
for (let i = 0; i < leaves.length; i++) {
|
||||
const proof = merkleProof(leaves, i, toyHash);
|
||||
expect(
|
||||
verifyMerkleProof(leaves[i] as Uint8Array, proof, root, toyHash)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles odd-count tree", { timeout: 5000 }, () => {
|
||||
const leaves = [
|
||||
new Uint8Array([1]),
|
||||
new Uint8Array([2]),
|
||||
new Uint8Array([3]),
|
||||
];
|
||||
const root = merkleRoot(leaves, toyHash);
|
||||
for (let i = 0; i < leaves.length; i++) {
|
||||
const proof = merkleProof(leaves, i, toyHash);
|
||||
expect(
|
||||
verifyMerkleProof(leaves[i] as Uint8Array, proof, root, toyHash)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("single-leaf proof is empty and verifies", { timeout: 5000 }, () => {
|
||||
const leaf = new Uint8Array([42]);
|
||||
const root = merkleRoot([leaf], toyHash);
|
||||
const proof = merkleProof([leaf], 0, toyHash);
|
||||
expect(proof).toHaveLength(0);
|
||||
expect(verifyMerkleProof(leaf, proof, root, toyHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong root", { timeout: 5000 }, () => {
|
||||
const leaves = [new Uint8Array([1]), new Uint8Array([2])];
|
||||
const root = merkleRoot(leaves, toyHash);
|
||||
const proof = merkleProof(leaves, 0, toyHash);
|
||||
const badRoot = new Uint8Array(root);
|
||||
badRoot[0] = (badRoot[0] as number) ^ 0xff;
|
||||
expect(
|
||||
verifyMerkleProof(leaves[0] as Uint8Array, proof, badRoot, toyHash)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects tampered leaf", { timeout: 5000 }, () => {
|
||||
const leaves = [new Uint8Array([1]), new Uint8Array([2])];
|
||||
const root = merkleRoot(leaves, toyHash);
|
||||
const proof = merkleProof(leaves, 0, toyHash);
|
||||
expect(
|
||||
verifyMerkleProof(new Uint8Array([99]), proof, root, toyHash)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty leaves", { timeout: 5000 }, () => {
|
||||
expect(() => merkleProof([], 0, toyHash)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("rejects out-of-range index", { timeout: 5000 }, () => {
|
||||
const leaves = [new Uint8Array([1])];
|
||||
expect(() => merkleProof(leaves, -1, toyHash)).toThrow(
|
||||
CryptoUtilsError
|
||||
);
|
||||
expect(() => merkleProof(leaves, 1, toyHash)).toThrow(CryptoUtilsError);
|
||||
expect(() => merkleProof(leaves, 1.5, toyHash)).toThrow(
|
||||
CryptoUtilsError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { concatBytes } from "../../bytes/concat-bytes/concat-bytes.js";
|
||||
import { constantTimeEqual } from "../../constant-time/constant-time-equal/constant-time-equal.js";
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import type { HashFn } from "../merkle-root/merkle-root.js";
|
||||
|
||||
/**
|
||||
* A `MerkleProofStep` records one sibling used while walking from a leaf
|
||||
* up to the root. `side` is the position of the sibling relative to the
|
||||
* accumulator: `'left'` means the sibling is prepended, `'right'` appended.
|
||||
*/
|
||||
export interface MerkleProofStep {
|
||||
readonly sibling: Uint8Array;
|
||||
readonly side: "left" | "right";
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce an inclusion proof for `leaves[index]`. Uses the same odd-count
|
||||
* duplication rule as `merkleRoot`.
|
||||
*/
|
||||
export function merkleProof(
|
||||
leaves: readonly Uint8Array[],
|
||||
index: number,
|
||||
hash: HashFn
|
||||
): MerkleProofStep[] {
|
||||
if (leaves.length === 0) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["MERKLE.EMPTY_LEAVES"],
|
||||
"error.crypto.utils.merkle.empty-leaves"
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(index) || index < 0 || index >= leaves.length) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["MERKLE.INVALID_PROOF"],
|
||||
"error.crypto.utils.merkle.invalid-proof",
|
||||
{
|
||||
context: {
|
||||
reason: "index-out-of-range",
|
||||
index,
|
||||
leafCount: leaves.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const steps: MerkleProofStep[] = [];
|
||||
let level: Uint8Array[] = leaves.map((l) => new Uint8Array(l));
|
||||
let idx = index;
|
||||
|
||||
while (level.length > 1) {
|
||||
const isRight = idx % 2 === 1;
|
||||
const siblingIdx = isRight ? idx - 1 : idx + 1;
|
||||
// Handle odd-level duplication: sibling of an unpaired last-node is itself.
|
||||
const sibling = (level[siblingIdx] ?? level[idx]) as Uint8Array;
|
||||
steps.push({
|
||||
sibling: new Uint8Array(sibling),
|
||||
side: isRight ? "left" : "right",
|
||||
});
|
||||
|
||||
const next: Uint8Array[] = [];
|
||||
for (let i = 0; i < level.length; i += 2) {
|
||||
const left = level[i] as Uint8Array;
|
||||
const right = (level[i + 1] ?? level[i]) as Uint8Array;
|
||||
next.push(hash(concatBytes(left, right)));
|
||||
}
|
||||
level = next;
|
||||
idx = Math.floor(idx / 2);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a proof produced by `merkleProof` against a known root.
|
||||
*/
|
||||
export function verifyMerkleProof(
|
||||
leaf: Uint8Array,
|
||||
proof: readonly MerkleProofStep[],
|
||||
root: Uint8Array,
|
||||
hash: HashFn
|
||||
): boolean {
|
||||
let acc: Uint8Array = new Uint8Array(leaf);
|
||||
for (const step of proof) {
|
||||
acc =
|
||||
step.side === "left"
|
||||
? hash(concatBytes(step.sibling, acc))
|
||||
: hash(concatBytes(acc, step.sibling));
|
||||
}
|
||||
return constantTimeEqual(acc, root);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type HashFn, merkleRoot } from "./merkle-root";
|
||||
@@ -0,0 +1,59 @@
|
||||
# merkleRoot
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Compute the Merkle tree root over a list of leaf byte buffers using a caller-supplied hash function. Merkle trees are the canonical structure for compact commitment to a set of values (transaction inclusion in Bitcoin blocks, file manifests in IPFS, SCT inclusion in RFC 6962 Certificate Transparency, OTel log-inclusion proofs). Factoring the tree construction out into this package means downstream consumers never re-derive the "duplicate the last node at odd-count levels" corner case where Bitcoin has historically had CVEs.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over any hash function `(Uint8Array) => Uint8Array`.
|
||||
- Bitcoin convention on odd-count levels: the last element is duplicated.
|
||||
- RFC 6962 internal-node construction: `hash(left || right)` with no prefix byte (callers that need domain separation should pre-prefix their leaves or wrap the hash).
|
||||
- Non-mutating: leaves are copied internally.
|
||||
- Throws `CryptoUtilsError(CRYPTO.UTILS.MERKLE.EMPTY_LEAVES)` on empty input.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { sha256 } from "@suaveplan/crypto-hash/sha256";
|
||||
import { merkleRoot } from "@suaveplan/crypto-utils/merkle";
|
||||
|
||||
const leaves = [new Uint8Array([1]), new Uint8Array([2]), new Uint8Array([3])];
|
||||
const root = merkleRoot(leaves, sha256);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { sha256 } from "@suaveplan/crypto-hash/sha256";
|
||||
import { concatBytes } from "@suaveplan/crypto-utils/bytes";
|
||||
import { merkleRoot } from "@suaveplan/crypto-utils/merkle";
|
||||
|
||||
// RFC 6962-style CT leaves: prefix 0x00 for leaf, 0x01 for internal — wrap the hash.
|
||||
const TAG_LEAF = new Uint8Array([0x00]);
|
||||
const TAG_NODE = new Uint8Array([0x01]);
|
||||
|
||||
function rfc6962Leaves(leaves: readonly Uint8Array[]): readonly Uint8Array[] {
|
||||
return leaves.map((l) => sha256(concatBytes(TAG_LEAF, l)));
|
||||
}
|
||||
function taggedHash(input: Uint8Array): Uint8Array {
|
||||
return sha256(concatBytes(TAG_NODE, input));
|
||||
}
|
||||
|
||||
const tlsExtensionRoot = merkleRoot(rfc6962Leaves(leaves), taggedHash);
|
||||
declare const leaves: Uint8Array[];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `merkleRoot(leaves: readonly Uint8Array[], hash: (input: Uint8Array) => Uint8Array): Uint8Array`
|
||||
- Throws `CryptoUtilsError(CRYPTO.UTILS.MERKLE.EMPTY_LEAVES)` on an empty leaf list.
|
||||
- Returns the root bytes as produced by `hash`. For single-leaf trees the leaf itself is returned unchanged.
|
||||
- `type HashFn = (input: Uint8Array) => Uint8Array`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The algorithm iterates level by level, pairing neighbours and applying `hash(concat(left, right))`. When a level has an odd number of entries, the last entry is duplicated (`level[i + 1] ?? level[i]`), which is the Bitcoin convention; RFC 6962 specifies a different handling (propagating the odd node up the tree), so CT implementations should wrap their leaves and pass tagged hashes rather than rely on this primitive's default. A partner primitive `merkleProof` lives alongside and produces an inclusion proof for a specific leaf index using the same layout.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { merkleRoot } from "./merkle-root.js";
|
||||
|
||||
// Simple deterministic hash: first 4 bytes are sum of all input bytes mod 2^32 (LE).
|
||||
function toyHash(input: Uint8Array): Uint8Array {
|
||||
let s = 0;
|
||||
for (let i = 0; i < input.length; i++) s = (s + (input[i] as number)) >>> 0;
|
||||
return new Uint8Array([
|
||||
s & 0xff,
|
||||
(s >> 8) & 0xff,
|
||||
(s >> 16) & 0xff,
|
||||
(s >> 24) & 0xff,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("merkleRoot", () => {
|
||||
it("single leaf: root equals leaf (no re-hash per RFC 6962)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const leaf = new Uint8Array([1, 2, 3]);
|
||||
expect(merkleRoot([leaf], toyHash)).toEqual(leaf);
|
||||
});
|
||||
|
||||
it("two leaves: root = hash(a || b)", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1]);
|
||||
const b = new Uint8Array([2]);
|
||||
expect(merkleRoot([a, b], toyHash)).toEqual(
|
||||
toyHash(new Uint8Array([1, 2]))
|
||||
);
|
||||
});
|
||||
|
||||
it("three leaves: duplicates last at odd levels", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1]);
|
||||
const b = new Uint8Array([2]);
|
||||
const c = new Uint8Array([3]);
|
||||
const abRoot = toyHash(new Uint8Array([1, 2]));
|
||||
const ccRoot = toyHash(new Uint8Array([3, 3]));
|
||||
const expected = toyHash(new Uint8Array([...abRoot, ...ccRoot]));
|
||||
expect(merkleRoot([a, b, c], toyHash)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("rejects empty leaves", { timeout: 5000 }, () => {
|
||||
expect(() => merkleRoot([], toyHash)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("does not mutate input leaves", { timeout: 5000 }, () => {
|
||||
const leaves = [new Uint8Array([1]), new Uint8Array([2])];
|
||||
const snapshot = leaves.map((l) => new Uint8Array(l));
|
||||
merkleRoot(leaves, toyHash);
|
||||
for (let i = 0; i < leaves.length; i++) {
|
||||
expect(leaves[i]).toEqual(snapshot[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { concatBytes } from "../../bytes/concat-bytes/concat-bytes.js";
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
export type HashFn = (input: Uint8Array) => Uint8Array;
|
||||
|
||||
/**
|
||||
* Build a Merkle tree and return the root.
|
||||
*
|
||||
* Hashing strategy matches RFC 6962: internal nodes are
|
||||
* `hash(left || right)` — no domain-separating prefix. Odd-count levels
|
||||
* duplicate the final leaf/node (Bitcoin convention).
|
||||
*
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.MERKLE.EMPTY_LEAVES) if `leaves` is empty.
|
||||
*/
|
||||
export function merkleRoot(
|
||||
leaves: readonly Uint8Array[],
|
||||
hash: HashFn
|
||||
): Uint8Array {
|
||||
if (leaves.length === 0) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["MERKLE.EMPTY_LEAVES"],
|
||||
"error.crypto.utils.merkle.empty-leaves"
|
||||
);
|
||||
}
|
||||
let level: Uint8Array[] = leaves.map((l) => new Uint8Array(l));
|
||||
while (level.length > 1) {
|
||||
const next: Uint8Array[] = [];
|
||||
for (let i = 0; i < level.length; i += 2) {
|
||||
const left = level[i] as Uint8Array;
|
||||
const right = (level[i + 1] ?? level[i]) as Uint8Array;
|
||||
next.push(hash(concatBytes(left, right)));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
return level[0] as Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Spec-conformance test for `openspec/specs/crypto-utils/spec.md` —
|
||||
* "No vendored code" requirement. Pins the structural invariant that
|
||||
* `src/vendored/` does not exist anywhere in this package.
|
||||
*/
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) {
|
||||
yield p;
|
||||
yield* walk(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("crypto-utils — no vendored code", () => {
|
||||
it("src/ contains no vendored directory", { timeout: 5000 }, () => {
|
||||
const srcDir = HERE; // already inside src/
|
||||
expect(existsSync(srcDir)).toBe(true);
|
||||
const offenders: string[] = [];
|
||||
for (const dir of walk(srcDir)) {
|
||||
if (dir.endsWith("/vendored") || dir.endsWith("\\vendored")) {
|
||||
offenders.push(dir);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
CipherPrimitiveId,
|
||||
CurvePrimitiveId,
|
||||
HashPrimitiveId,
|
||||
PrimitiveCategory,
|
||||
PrimitiveId,
|
||||
} from "./primitive-id.js";
|
||||
export {
|
||||
CIPHER_PRIMITIVE_IDS,
|
||||
CURVE_PRIMITIVE_IDS,
|
||||
HASH_PRIMITIVE_IDS,
|
||||
isPrimitiveId,
|
||||
PRIMITIVE_IDS,
|
||||
primitiveCategory,
|
||||
} from "./primitive-id.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# primitive-id
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
A single, stable registry of every cryptographic primitive id across the `@suaveplan/crypto-*` family. Codegen scripts, CI checks, the replaceability selector layer, and the `PrimitiveNotImplemented` error all read from this file so that a newly-added primitive lights up everywhere consistently, and a renamed or removed primitive triggers a type error in every consumer. Think of it as the `Rosetta Stone` of the crypto family.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-category `as const` tuples (`HASH_PRIMITIVE_IDS`, `CURVE_PRIMITIVE_IDS`, `CIPHER_PRIMITIVE_IDS`) and a combined `PRIMITIVE_IDS` tuple.
|
||||
- Literal-typed union types (`HashPrimitiveId`, `CurvePrimitiveId`, `CipherPrimitiveId`, `PrimitiveId`).
|
||||
- `primitiveCategory(id)` classifier (`'hash' | 'curve' | 'cipher' | undefined`).
|
||||
- `isPrimitiveId(id)` type guard.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { isPrimitiveId, PRIMITIVE_IDS } from "@suaveplan/crypto-utils/primitive-id";
|
||||
|
||||
for (const id of PRIMITIVE_IDS) {
|
||||
console.log("registered:", id);
|
||||
}
|
||||
|
||||
console.log(isPrimitiveId("sha256")); // true
|
||||
console.log(isPrimitiveId("md5")); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type PrimitiveId,
|
||||
primitiveCategory,
|
||||
} from "@suaveplan/crypto-utils/primitive-id";
|
||||
|
||||
function pathFor(id: PrimitiveId): string {
|
||||
const cat = primitiveCategory(id);
|
||||
switch (cat) {
|
||||
case "hash":
|
||||
return `@suaveplan/crypto-hash/src/primitives/${id}`;
|
||||
case "curve":
|
||||
return `@suaveplan/crypto-curve/src/primitives/${id}`;
|
||||
case "cipher":
|
||||
return `@suaveplan/crypto-cipher/src/primitives/${id}`;
|
||||
default:
|
||||
throw new Error(`unclassified id: ${id satisfies never}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(pathFor("ed25519"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HASH_PRIMITIVE_IDS: readonly ["sha256","sha384","sha512","hmac","hkdf","pbkdf2","argon2id"]`
|
||||
- `CURVE_PRIMITIVE_IDS: readonly ["secp256k1","ed25519","x25519","p256"]`
|
||||
- `CIPHER_PRIMITIVE_IDS: readonly ["aes-256-gcm","chacha20-poly1305","xchacha20-poly1305"]`
|
||||
- `PRIMITIVE_IDS: readonly [...HASH, ...CURVE, ...CIPHER]`
|
||||
- `type HashPrimitiveId`, `type CurvePrimitiveId`, `type CipherPrimitiveId`, `type PrimitiveId`
|
||||
- `primitiveCategory(id: string): 'hash' | 'curve' | 'cipher' | undefined`
|
||||
- `isPrimitiveId(id: string): id is PrimitiveId`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tuples are declared `as const` so they are both values (iterable at runtime, consumed by codegen) and literal types (`(typeof HASH_PRIMITIVE_IDS)[number]`). The classifier uses `Array#includes` cast through `readonly string[]` to avoid TypeScript's narrowing behaviour rejecting unknown string inputs — a narrower API (overloaded on `PrimitiveId`) was tried and rejected because the callers genuinely take arbitrary strings (from config files) and need the `undefined` case. Adding a new primitive here triggers a build-break everywhere the registry is exhaustively cased, which is exactly what the replaceability architecture wants.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
CIPHER_PRIMITIVE_IDS,
|
||||
CURVE_PRIMITIVE_IDS,
|
||||
HASH_PRIMITIVE_IDS,
|
||||
isPrimitiveId,
|
||||
PRIMITIVE_IDS,
|
||||
primitiveCategory,
|
||||
} from "./primitive-id.js";
|
||||
|
||||
describe("primitive-id", () => {
|
||||
it("covers all three packages", { timeout: 5000 }, () => {
|
||||
expect(HASH_PRIMITIVE_IDS.length).toBe(7);
|
||||
expect(CURVE_PRIMITIVE_IDS.length).toBe(4);
|
||||
expect(CIPHER_PRIMITIVE_IDS.length).toBe(3);
|
||||
expect(PRIMITIVE_IDS.length).toBe(14);
|
||||
});
|
||||
|
||||
it("classifies every primitive correctly", { timeout: 5000 }, () => {
|
||||
for (const id of HASH_PRIMITIVE_IDS) {
|
||||
expect(primitiveCategory(id)).toBe("hash");
|
||||
}
|
||||
for (const id of CURVE_PRIMITIVE_IDS) {
|
||||
expect(primitiveCategory(id)).toBe("curve");
|
||||
}
|
||||
for (const id of CIPHER_PRIMITIVE_IDS) {
|
||||
expect(primitiveCategory(id)).toBe("cipher");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ids", { timeout: 5000 }, () => {
|
||||
expect(primitiveCategory("md5")).toBeUndefined();
|
||||
expect(primitiveCategory("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isPrimitiveId narrows correctly", { timeout: 5000 }, () => {
|
||||
expect(isPrimitiveId("sha256")).toBe(true);
|
||||
expect(isPrimitiveId("ed25519")).toBe(true);
|
||||
expect(isPrimitiveId("aes-256-gcm")).toBe(true);
|
||||
expect(isPrimitiveId("md5")).toBe(false);
|
||||
expect(isPrimitiveId("")).toBe(false);
|
||||
});
|
||||
|
||||
it("the union has no duplicates", { timeout: 5000 }, () => {
|
||||
const set = new Set<string>(PRIMITIVE_IDS);
|
||||
expect(set.size).toBe(PRIMITIVE_IDS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Registry of every primitive id known to `@suaveplan/crypto-*`. Ordered by
|
||||
* package (crypto-hash → crypto-curve → crypto-cipher) and stable for wire
|
||||
* serialisation: adding a new id is an ADDED migration; renaming or
|
||||
* removing is a breaking change.
|
||||
*/
|
||||
export const HASH_PRIMITIVE_IDS = [
|
||||
"sha256",
|
||||
"sha384",
|
||||
"sha512",
|
||||
"hmac",
|
||||
"hkdf",
|
||||
"pbkdf2",
|
||||
"argon2id",
|
||||
] as const;
|
||||
|
||||
export const CURVE_PRIMITIVE_IDS = [
|
||||
"secp256k1",
|
||||
"ed25519",
|
||||
"x25519",
|
||||
"p256",
|
||||
] as const;
|
||||
|
||||
export const CIPHER_PRIMITIVE_IDS = [
|
||||
"aes-256-gcm",
|
||||
"chacha20-poly1305",
|
||||
"xchacha20-poly1305",
|
||||
] as const;
|
||||
|
||||
export const PRIMITIVE_IDS = [
|
||||
...HASH_PRIMITIVE_IDS,
|
||||
...CURVE_PRIMITIVE_IDS,
|
||||
...CIPHER_PRIMITIVE_IDS,
|
||||
] as const;
|
||||
|
||||
export type HashPrimitiveId = (typeof HASH_PRIMITIVE_IDS)[number];
|
||||
export type CurvePrimitiveId = (typeof CURVE_PRIMITIVE_IDS)[number];
|
||||
export type CipherPrimitiveId = (typeof CIPHER_PRIMITIVE_IDS)[number];
|
||||
export type PrimitiveId = (typeof PRIMITIVE_IDS)[number];
|
||||
|
||||
export type PrimitiveCategory = "hash" | "curve" | "cipher";
|
||||
|
||||
/**
|
||||
* Classify a primitive id as hash / curve / cipher.
|
||||
* Returns `undefined` if the id is not recognised.
|
||||
*/
|
||||
export function primitiveCategory(id: string): PrimitiveCategory | undefined {
|
||||
if ((HASH_PRIMITIVE_IDS as readonly string[]).includes(id)) return "hash";
|
||||
if ((CURVE_PRIMITIVE_IDS as readonly string[]).includes(id)) return "curve";
|
||||
if ((CIPHER_PRIMITIVE_IDS as readonly string[]).includes(id))
|
||||
return "cipher";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Type guard: is `id` a registered primitive? */
|
||||
export function isPrimitiveId(id: string): id is PrimitiveId {
|
||||
return (PRIMITIVE_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ByteSource,
|
||||
defaultByteSource,
|
||||
type RandomBytesOptions,
|
||||
randomBytes,
|
||||
} from "./random-bytes/random-bytes.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ByteSource,
|
||||
defaultByteSource,
|
||||
type RandomBytesOptions,
|
||||
randomBytes,
|
||||
} from "./random-bytes";
|
||||
@@ -0,0 +1,57 @@
|
||||
# randomBytes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Produce cryptographically-secure random bytes. Every `@suaveplan/crypto-*` package routes entropy through this single helper so there is exactly one callsite of `globalThis.crypto.getRandomValues` to audit, and callers that need deterministic entropy in tests (or a hardware-RNG adapter in production) can swap it with dependency injection rather than monkey-patching `globalThis`.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps `globalThis.crypto.getRandomValues` (Web Crypto) — works in Node 20+, Bun, browsers, Deno, Cloudflare Workers.
|
||||
- Dependency-injectable `ByteSource` for tests and hardware RNG adapters.
|
||||
- Explicit `n` range check; throws stable `CRYPTO.UTILS.RNG.INVALID_LENGTH` on bad input.
|
||||
- Returns a fresh `Uint8Array` every call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { randomBytes } from "@suaveplan/crypto-utils/random";
|
||||
|
||||
const nonce = randomBytes(12);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { randomBytes, type ByteSource } from "@suaveplan/crypto-utils/random";
|
||||
|
||||
// Deterministic source for tests — feeds a known sequence.
|
||||
function fixedSequence(bytes: Uint8Array): ByteSource {
|
||||
let pos = 0;
|
||||
return (n) => {
|
||||
const out = bytes.slice(pos, pos + n);
|
||||
pos += n;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
function generateKey(opts?: { byteSource?: ByteSource }): Uint8Array {
|
||||
return randomBytes(32, opts);
|
||||
}
|
||||
|
||||
const testKey = generateKey({
|
||||
byteSource: fixedSequence(new Uint8Array(32).fill(0xaa)),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `randomBytes(n: number, options?: { byteSource?: ByteSource }): Uint8Array` — returns `n` random bytes. Throws `CryptoUtilsError(CRYPTO.UTILS.RNG.INVALID_LENGTH)` if `n` is not a non-negative integer.
|
||||
- `defaultByteSource: ByteSource` — the `getRandomValues`-backed source. Exported for adapters that want to delegate to it.
|
||||
- `type ByteSource = (n: number) => Uint8Array` — the injection surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`globalThis.crypto` is a WHATWG Web Crypto `Crypto` instance on every supported runtime. In Node we rely on the polyfill injected at test-setup time (`node:crypto` `webcrypto`). The test-only `byteSource` option is deliberately on the call, not a module-level setter, so there is never a case where a test leaks a deterministic source into production code. The length check rejects `NaN`, non-integers, and negatives — all would previously have been forwarded to `getRandomValues`, which throws a less-precise `RangeError`.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
import { defaultByteSource, randomBytes } from "./random-bytes.js";
|
||||
|
||||
describe("randomBytes", () => {
|
||||
it("returns requested length", { timeout: 5000 }, () => {
|
||||
for (const n of [0, 1, 16, 32, 4096]) {
|
||||
expect(randomBytes(n).length).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces different output on consecutive calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = randomBytes(32);
|
||||
const b = randomBytes(32);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it("accepts a deterministic byteSource", { timeout: 5000 }, () => {
|
||||
let counter = 0;
|
||||
const stub = (n: number): Uint8Array => {
|
||||
const out = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = counter++;
|
||||
return out;
|
||||
};
|
||||
expect(randomBytes(4, { byteSource: stub })).toEqual(
|
||||
new Uint8Array([0, 1, 2, 3])
|
||||
);
|
||||
expect(randomBytes(2, { byteSource: stub })).toEqual(
|
||||
new Uint8Array([4, 5])
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-integer and negative lengths", { timeout: 5000 }, () => {
|
||||
expect(() => randomBytes(-1)).toThrow(CryptoUtilsError);
|
||||
expect(() => randomBytes(1.5)).toThrow(CryptoUtilsError);
|
||||
expect(() => randomBytes(Number.NaN)).toThrow(CryptoUtilsError);
|
||||
});
|
||||
|
||||
it("defaultByteSource returns the right length", { timeout: 5000 }, () => {
|
||||
expect(defaultByteSource(16).length).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CRYPTO_UTILS_CODES } from "../../errors/codes/codes.js";
|
||||
import { CryptoUtilsError } from "../../errors/crypto-utils-error/crypto-utils-error.js";
|
||||
|
||||
/**
|
||||
* A function that produces `n` cryptographically-random bytes.
|
||||
*/
|
||||
export type ByteSource = (n: number) => Uint8Array;
|
||||
|
||||
/**
|
||||
* The single callsite for `crypto.getRandomValues` in this package. Every
|
||||
* consumer that needs entropy MUST call `randomBytes` or accept a
|
||||
* `ByteSource` — never call `crypto.getRandomValues` directly.
|
||||
*/
|
||||
export const defaultByteSource: ByteSource = (n) => {
|
||||
const out = new Uint8Array(n);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
};
|
||||
|
||||
export interface RandomBytesOptions {
|
||||
readonly byteSource?: ByteSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return `n` cryptographically-random bytes.
|
||||
*
|
||||
* The default source is `globalThis.crypto.getRandomValues`. Pass
|
||||
* `{ byteSource }` to inject a deterministic or alternative source
|
||||
* (useful for tests and hardware RNG adapters).
|
||||
*
|
||||
* @throws CryptoUtilsError(CRYPTO.UTILS.RNG.INVALID_LENGTH) if `n` is not a
|
||||
* non-negative integer.
|
||||
*/
|
||||
export function randomBytes(
|
||||
n: number,
|
||||
options: RandomBytesOptions = {}
|
||||
): Uint8Array {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new CryptoUtilsError(
|
||||
CRYPTO_UTILS_CODES["RNG.INVALID_LENGTH"],
|
||||
"error.crypto.utils.rng.invalid-length",
|
||||
{ context: { requested: n } }
|
||||
);
|
||||
}
|
||||
const source = options.byteSource ?? defaultByteSource;
|
||||
return source(n);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# diffFuzz — differential-fuzz harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Run N iterations of random input against two implementations of the same primitive and report the first diverging reproducer, plus the total number of mismatches. Designed specifically for the "noble vs native" gating in `crypto-primitive-replaceability` Phase 1+: before a native implementation is allowed to flip to `'native'` in `crypto.config.ts`, it must run through `diffFuzz` against the reference `noble` impl without a single mismatch across millions of random inputs.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic `byteSource` injection for reproducible fuzz campaigns.
|
||||
- Generic over input and output types — works for hashes, ciphers, MACs, curve ops.
|
||||
- Captures first reproducer with the exact input, both outputs, and iteration index for CI log forensics.
|
||||
- Continues running after the first mismatch so the `mismatches` count approximates the rate, not just the existence of a bug.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { diffFuzz } from "@suaveplan/crypto-utils/testing";
|
||||
|
||||
const result = diffFuzz({
|
||||
iterations: 1000,
|
||||
sample: (src) => src(32),
|
||||
implA: (buf: Uint8Array) => buf.reduce((a, b) => a + b, 0),
|
||||
implB: (buf: Uint8Array) => buf.reduce((a, b) => a + b, 0),
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
console.log(result.mismatches); // 0
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { diffFuzz } from "@suaveplan/crypto-utils/testing";
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
|
||||
|
||||
// Differential fuzz SHA-256 noble vs native
|
||||
declare function sha256Noble(msg: Uint8Array): Uint8Array;
|
||||
declare function sha256Native(msg: Uint8Array): Uint8Array;
|
||||
|
||||
const result = diffFuzz({
|
||||
iterations: 100_000,
|
||||
sample: (src, iter) => src(iter % 4096), // variable-length messages
|
||||
implA: sha256Noble,
|
||||
implB: sha256Native,
|
||||
equals: constantTimeEqual,
|
||||
});
|
||||
|
||||
if (result.firstMismatch !== undefined) {
|
||||
const { iter, input, outputA, outputB } = result.firstMismatch;
|
||||
throw new Error(`SHA-256 diff at iter=${iter} len=${input.length} noble=${outputA.length}B native=${outputB.length}B`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `diffFuzz<V, E>(opts: DiffFuzzOptions<V, E>): DiffFuzzResult<V, E>`
|
||||
- `opts.iterations: number` — how many trials to run.
|
||||
- `opts.sample: (byteSource, iter) => V` — builds a fresh input each iteration. Receives the iteration index so callers can vary input shape by iteration.
|
||||
- `opts.implA`, `opts.implB: (input: V) => E`
|
||||
- `opts.equals: (a: E, b: E) => boolean`
|
||||
- `opts.byteSource?: ByteSource` — optional deterministic source; defaults to `defaultByteSource`.
|
||||
- Returns `{ iterations, mismatches, firstMismatch? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The loop is intentionally unrolled to a single pair of calls per iteration — no async, no microtasks, no chunking. For 100 000 iterations of SHA-256 this completes in a few hundred milliseconds in Node 20+. Callers that need structured output (e.g. `DifferentialFuzzCorpus` for CI regression pinning) should compose on top of this primitive rather than complicate its API. The `byteSource` injection is how reproducibility is achieved: when a CI job reports a fail, the caller can replay the run with a fixed source seeded from the failing iteration's context.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { diffFuzz } from "./diff-fuzz.js";
|
||||
|
||||
describe("diffFuzz", () => {
|
||||
it("reports zero mismatches when impls agree", { timeout: 5000 }, () => {
|
||||
const result = diffFuzz({
|
||||
iterations: 50,
|
||||
sample: (src) => src(4),
|
||||
implA: (buf: Uint8Array) => buf[0] ?? 0,
|
||||
implB: (buf: Uint8Array) => buf[0] ?? 0,
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(result.mismatches).toBe(0);
|
||||
expect(result.firstMismatch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures first reproducer", { timeout: 5000 }, () => {
|
||||
let count = 0;
|
||||
const result = diffFuzz({
|
||||
iterations: 10,
|
||||
sample: () => ++count,
|
||||
implA: (n: number) => n,
|
||||
implB: (n: number) => (n === 3 ? 999 : n),
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(result.mismatches).toBe(1);
|
||||
expect(result.firstMismatch?.iter).toBe(2); // 3rd iter, 0-indexed
|
||||
expect(result.firstMismatch?.input).toBe(3);
|
||||
expect(result.firstMismatch?.outputA).toBe(3);
|
||||
expect(result.firstMismatch?.outputB).toBe(999);
|
||||
});
|
||||
|
||||
it("uses injected byteSource deterministically", { timeout: 5000 }, () => {
|
||||
let counter = 0;
|
||||
const result = diffFuzz({
|
||||
iterations: 3,
|
||||
sample: (src) => src(2),
|
||||
implA: (b: Uint8Array) => b[0],
|
||||
implB: (b: Uint8Array) => b[0],
|
||||
equals: (a, b) => a === b,
|
||||
byteSource: (n) => {
|
||||
const out = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = counter++;
|
||||
return out;
|
||||
},
|
||||
});
|
||||
expect(result.iterations).toBe(3);
|
||||
expect(result.mismatches).toBe(0);
|
||||
expect(counter).toBe(6);
|
||||
});
|
||||
|
||||
it("counts all mismatches even after first reproducer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let count = 0;
|
||||
const result = diffFuzz({
|
||||
iterations: 10,
|
||||
sample: () => ++count,
|
||||
implA: () => 1,
|
||||
implB: () => 2,
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(result.mismatches).toBe(10);
|
||||
expect(result.firstMismatch?.iter).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ByteSource } from "../../random/random-bytes/random-bytes.js";
|
||||
import { defaultByteSource } from "../../random/random-bytes/random-bytes.js";
|
||||
|
||||
export interface DiffFuzzOptions<V, E> {
|
||||
readonly iterations: number;
|
||||
/** Build a fresh input per iteration. */
|
||||
readonly sample: (byteSource: ByteSource, iter: number) => V;
|
||||
readonly implA: (input: V) => E;
|
||||
readonly implB: (input: V) => E;
|
||||
readonly equals: (a: E, b: E) => boolean;
|
||||
/** Optional deterministic byte source for reproducibility. */
|
||||
readonly byteSource?: ByteSource;
|
||||
}
|
||||
|
||||
export interface DiffFuzzResult<V, E> {
|
||||
readonly iterations: number;
|
||||
readonly mismatches: number;
|
||||
/** First reproducer (iteration index, input, outputs). `undefined` on full pass. */
|
||||
readonly firstMismatch:
|
||||
| {
|
||||
readonly iter: number;
|
||||
readonly input: V;
|
||||
readonly outputA: E;
|
||||
readonly outputB: E;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Differential-fuzz harness. Runs `iterations` trials drawing random input
|
||||
* via `sample(byteSource, iter)`, computes `implA(input)` and `implB(input)`,
|
||||
* and reports the first mismatch with a reproducer plus the total count.
|
||||
*/
|
||||
export function diffFuzz<V, E>(
|
||||
opts: DiffFuzzOptions<V, E>
|
||||
): DiffFuzzResult<V, E> {
|
||||
const src = opts.byteSource ?? defaultByteSource;
|
||||
let mismatches = 0;
|
||||
let firstMismatch: DiffFuzzResult<V, E>["firstMismatch"];
|
||||
|
||||
for (let i = 0; i < opts.iterations; i++) {
|
||||
const input = opts.sample(src, i);
|
||||
const a = opts.implA(input);
|
||||
const b = opts.implB(input);
|
||||
if (!opts.equals(a, b)) {
|
||||
mismatches++;
|
||||
if (firstMismatch === undefined) {
|
||||
firstMismatch = {
|
||||
iter: i,
|
||||
input,
|
||||
outputA: a,
|
||||
outputB: b,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
iterations: opts.iterations,
|
||||
mismatches,
|
||||
firstMismatch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type DiffFuzzOptions,
|
||||
type DiffFuzzResult,
|
||||
diffFuzz,
|
||||
} from "./diff-fuzz";
|
||||
@@ -0,0 +1,118 @@
|
||||
# dudect
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`dudect` is a minimal constant-time smoke check for secret-handling code.
|
||||
It runs a function many times against two input classes — a fixed / low-entropy
|
||||
class and a random / high-entropy class — and applies Welch's t-test to the
|
||||
per-call latencies. A large absolute t-statistic suggests the function's
|
||||
runtime depends on its input, i.e. a potential timing side channel.
|
||||
|
||||
The harness is a statistical signal, not cryptographic analysis. It catches
|
||||
the obvious class of leaks (secret-indexed table lookups, early returns on
|
||||
secret byte comparisons) on hardware where the noise floor is low enough. It
|
||||
does NOT prove constant-time behaviour in a formal sense; production primitives
|
||||
still require code review and, ideally, formally-verified implementations.
|
||||
|
||||
The `crypto:timing` turbo task wires this harness up to run nightly against
|
||||
any `impl/*.native.ts` in `crypto-hash`, `crypto-curve`, or `crypto-cipher`
|
||||
that isn't still a `PrimitiveNotImplemented` stub. In Phase 0 every native
|
||||
is a stub, so the nightly runs a no-op and exits clean — the wiring is live
|
||||
and fires the moment a real native lands.
|
||||
|
||||
## Features
|
||||
|
||||
- Welch's t-test on interleaved class-A / class-B latency samples
|
||||
- Configurable total measurement count, warmup, and t-threshold
|
||||
- Deterministic control-flow — no I/O, no network, no filesystem
|
||||
- Returns numeric result (mean, variance, t-statistic) plus boolean verdict
|
||||
- Reference implementation of the test from Reparaz et al., DATE 2017
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { dudect } from "@suaveplan/crypto-utils/testing/dudect/dudect";
|
||||
|
||||
const result = dudect<Uint8Array>({
|
||||
fn: (buf) => nativeSha256(buf),
|
||||
sampleFixed: () => new Uint8Array(64),
|
||||
sampleRandom: () => crypto.getRandomValues(new Uint8Array(64)),
|
||||
});
|
||||
|
||||
if (!result.constantTime) {
|
||||
throw new Error(`sha256 native leaked timing: t=${result.tStatistic}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { dudect } from "@suaveplan/crypto-utils/testing/dudect/dudect";
|
||||
import { hmac as nativeHmac } from "@suaveplan/crypto-hash/primitives/hmac/impl/hmac.native";
|
||||
|
||||
const key = new Uint8Array(32);
|
||||
|
||||
const result = dudect<Uint8Array>({
|
||||
fn: (msg) => nativeHmac("SHA-256", key, msg),
|
||||
sampleFixed: () => new Uint8Array(128), // all zeros
|
||||
sampleRandom: () => {
|
||||
const buf = new Uint8Array(128);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf;
|
||||
},
|
||||
measurements: 100_000,
|
||||
warmup: 5_000,
|
||||
tThreshold: 4.5,
|
||||
});
|
||||
|
||||
console.log({
|
||||
meanFixed: result.meanA,
|
||||
meanRandom: result.meanB,
|
||||
t: result.tStatistic,
|
||||
verdict: result.constantTime ? "PASS" : "LEAK",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `dudect<V>(opts: DudectOptions<V>): DudectResult`
|
||||
|
||||
Run the dudect t-test.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `opts.fn` — function under test. Must do real work; a short-circuiting
|
||||
`fn` produces meaningless statistics.
|
||||
- `opts.sampleFixed` — returns a class-A (fixed / low-entropy) input.
|
||||
- `opts.sampleRandom` — returns a class-B (random / high-entropy) input of
|
||||
the same shape as class A.
|
||||
- `opts.measurements` — total timed calls, split evenly between the two
|
||||
classes. Default `20_000`.
|
||||
- `opts.warmup` — pre-measurement iterations discarded. Default `1_000`.
|
||||
- `opts.tThreshold` — absolute-value threshold for the leak verdict.
|
||||
Default `4.5` (per Reparaz et al.).
|
||||
|
||||
**Returns** `DudectResult` with per-class counts, means, variances, the
|
||||
Welch t-statistic, the configured threshold, and a boolean `constantTime`
|
||||
verdict (`true` when `|t| < threshold`).
|
||||
|
||||
**Throws** never; degenerate inputs (zero variance in both classes) yield
|
||||
`tStatistic === 0` which registers as constant-time.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `performance.now()` provides sub-ms resolution under Bun and modern Node.
|
||||
Precision degrades in sandboxed environments; interpret tight verdicts
|
||||
under VM / container load with care.
|
||||
- Class A and class B measurements are interleaved one-for-one. That smooths
|
||||
out GC pauses and clock drift that would otherwise bias the comparison.
|
||||
- Welch's t-test is the right tool because class-A and class-B variances
|
||||
are rarely equal in JavaScript — we cannot rely on Student's t.
|
||||
- The harness is intentionally small (~130 lines). It is not a replacement
|
||||
for formal constant-time analysis or `valgrind --track-origins` on a
|
||||
native binary; it is a cheap continuous-integration signal.
|
||||
- Hardware variability matters. CI-runner results should be interpreted
|
||||
relative to a historical baseline, not as an absolute pass/fail.
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { dudect } from "./dudect.js";
|
||||
|
||||
describe("dudect", () => {
|
||||
it("reports constant-time when work is input-independent", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// A trivial function whose runtime doesn't depend on input — should
|
||||
// yield a t-statistic that fluctuates below the threshold.
|
||||
const result = dudect<number>({
|
||||
fn: (x) => x + 1,
|
||||
sampleFixed: () => 0,
|
||||
sampleRandom: () => Math.floor(Math.random() * 1_000_000),
|
||||
measurements: 2_000,
|
||||
warmup: 100,
|
||||
});
|
||||
expect(result.measurementsA).toBe(1_000);
|
||||
expect(result.measurementsB).toBe(1_000);
|
||||
expect(Number.isFinite(result.tStatistic)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a leak when runtime scales with input size", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const result = dudect<Uint8Array>({
|
||||
fn: (buf) => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < buf.length; i++) acc = (acc + buf[i]!) | 0;
|
||||
return acc;
|
||||
},
|
||||
sampleFixed: () => new Uint8Array(8),
|
||||
sampleRandom: () => new Uint8Array(64_000),
|
||||
measurements: 2_000,
|
||||
warmup: 100,
|
||||
tThreshold: 4.5,
|
||||
});
|
||||
// Big size delta → large mean delta → huge t.
|
||||
expect(Math.abs(result.tStatistic)).toBeGreaterThan(4.5);
|
||||
expect(result.constantTime).toBe(false);
|
||||
});
|
||||
|
||||
it("honours a custom threshold", { timeout: 5000 }, () => {
|
||||
const result = dudect<number>({
|
||||
fn: (x) => x * 2,
|
||||
sampleFixed: () => 0,
|
||||
sampleRandom: () => 1,
|
||||
measurements: 500,
|
||||
warmup: 50,
|
||||
tThreshold: 1_000_000,
|
||||
});
|
||||
expect(result.tThreshold).toBe(1_000_000);
|
||||
expect(result.constantTime).toBe(true);
|
||||
});
|
||||
|
||||
it("handles zero standard error (all measurements identical) gracefully", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Capture real performance.now so we can restore it.
|
||||
const realNow = performance.now.bind(performance);
|
||||
|
||||
// Stub performance.now to always return the same value, forcing
|
||||
// zero variance in both classes and thus se === 0 in welchT.
|
||||
const stub = () => 1000;
|
||||
Object.defineProperty(performance, "now", {
|
||||
value: stub,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = dudect<number>({
|
||||
fn: (x) => x + 1,
|
||||
sampleFixed: () => 0,
|
||||
sampleRandom: () => 1,
|
||||
measurements: 20,
|
||||
warmup: 2,
|
||||
});
|
||||
// With se === 0, welchT returns 0, so |tStatistic| < threshold.
|
||||
expect(result.tStatistic).toBe(0);
|
||||
expect(result.constantTime).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(performance, "now", {
|
||||
value: realNow,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default measurements (20_000) and warmup (1_000) when omitted", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
// Stub performance.now so the defaults-driven run completes instantly.
|
||||
const realNow = performance.now.bind(performance);
|
||||
const stub = () => 2000;
|
||||
Object.defineProperty(performance, "now", {
|
||||
value: stub,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Omit measurements and warmup → exercises the ?? 20_000 and ?? 1_000 branches.
|
||||
const result = dudect<number>({
|
||||
fn: (x) => x + 1,
|
||||
sampleFixed: () => 0,
|
||||
sampleRandom: () => 1,
|
||||
});
|
||||
expect(result.measurementsA).toBe(10_000);
|
||||
expect(result.measurementsB).toBe(10_000);
|
||||
// All timings identical → se === 0 → tStatistic === 0.
|
||||
expect(result.tStatistic).toBe(0);
|
||||
expect(result.tThreshold).toBe(4.5);
|
||||
} finally {
|
||||
Object.defineProperty(performance, "now", {
|
||||
value: realNow,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Minimal dudect-style constant-time check. Runs a secret-handling function
|
||||
* repeatedly against two input classes (high-entropy vs. low-entropy / fixed
|
||||
* vs. random) and applies Welch's t-test to the resulting per-call latency
|
||||
* distributions. A t-statistic whose absolute value exceeds `tThreshold`
|
||||
* suggests a data-dependent timing leak.
|
||||
*
|
||||
* This is a statistical signal, not cryptographic analysis. It catches the
|
||||
* obvious class of leaks (secret-indexed table lookups, early returns on
|
||||
* secret comparisons) on hardware where the noise floor is low enough. It
|
||||
* does NOT prove constant-time behaviour in a formal sense — production
|
||||
* primitives still require review and ideally formally-verified impls.
|
||||
*
|
||||
* Reference: Reparaz, Balasch, Verbauwhede, "Dude, is my code constant
|
||||
* time?" (DATE 2017).
|
||||
*/
|
||||
export interface DudectOptions<V> {
|
||||
/** Function under test; it is timed — must do real work, not short-circuit. */
|
||||
readonly fn: (input: V) => unknown;
|
||||
/** Produce a fixed / low-entropy input (class A). */
|
||||
readonly sampleFixed: () => V;
|
||||
/** Produce a random / high-entropy input (class B). Same shape as class A. */
|
||||
readonly sampleRandom: () => V;
|
||||
/** Total measurements; split evenly across the two classes. Default 20_000. */
|
||||
readonly measurements?: number;
|
||||
/** Warmup iterations discarded before measurement. Default 1_000. */
|
||||
readonly warmup?: number;
|
||||
/**
|
||||
* t-statistic absolute threshold above which the test flags a leak.
|
||||
* The dudect paper uses 4.5; we default to 4.5 as well.
|
||||
*/
|
||||
readonly tThreshold?: number;
|
||||
}
|
||||
|
||||
export interface DudectResult {
|
||||
readonly measurementsA: number;
|
||||
readonly measurementsB: number;
|
||||
readonly meanA: number;
|
||||
readonly meanB: number;
|
||||
readonly varianceA: number;
|
||||
readonly varianceB: number;
|
||||
/** Welch's t-statistic; sign is arbitrary, magnitude is what matters. */
|
||||
readonly tStatistic: number;
|
||||
readonly tThreshold: number;
|
||||
/** `true` if `|tStatistic|` is below the threshold (no leak detected). */
|
||||
readonly constantTime: boolean;
|
||||
}
|
||||
|
||||
function mean(xs: readonly number[]): number {
|
||||
let s = 0;
|
||||
for (const x of xs) s += x;
|
||||
return s / xs.length;
|
||||
}
|
||||
|
||||
function variance(xs: readonly number[], mu: number): number {
|
||||
let s = 0;
|
||||
for (const x of xs) {
|
||||
const d = x - mu;
|
||||
s += d * d;
|
||||
}
|
||||
return s / (xs.length - 1);
|
||||
}
|
||||
|
||||
function welchT(
|
||||
meanA: number,
|
||||
meanB: number,
|
||||
varA: number,
|
||||
varB: number,
|
||||
nA: number,
|
||||
nB: number
|
||||
): number {
|
||||
const se = Math.sqrt(varA / nA + varB / nB);
|
||||
if (se === 0) return 0;
|
||||
return (meanA - meanB) / se;
|
||||
}
|
||||
|
||||
function timedCall(fn: (input: unknown) => unknown, input: unknown): number {
|
||||
const start = performance.now();
|
||||
fn(input);
|
||||
const end = performance.now();
|
||||
return end - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the dudect t-test. Alternates class-A and class-B measurements
|
||||
* to smooth over clock drift and load spikes, warms up, then reports.
|
||||
*/
|
||||
export function dudect<V>(opts: DudectOptions<V>): DudectResult {
|
||||
const total = opts.measurements ?? 20_000;
|
||||
const warmup = opts.warmup ?? 1_000;
|
||||
const tThreshold = opts.tThreshold ?? 4.5;
|
||||
const perClass = Math.floor(total / 2);
|
||||
|
||||
// Warmup — discarded.
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
opts.fn((i & 1) === 0 ? opts.sampleFixed() : opts.sampleRandom());
|
||||
}
|
||||
|
||||
const timesA: number[] = [];
|
||||
const timesB: number[] = [];
|
||||
for (let i = 0; i < perClass; i++) {
|
||||
// Interleave to even out external noise.
|
||||
timesA.push(
|
||||
timedCall(opts.fn as (x: unknown) => unknown, opts.sampleFixed())
|
||||
);
|
||||
timesB.push(
|
||||
timedCall(opts.fn as (x: unknown) => unknown, opts.sampleRandom())
|
||||
);
|
||||
}
|
||||
|
||||
const meanA = mean(timesA);
|
||||
const meanB = mean(timesB);
|
||||
const varA = variance(timesA, meanA);
|
||||
const varB = variance(timesB, meanB);
|
||||
const tStatistic = welchT(
|
||||
meanA,
|
||||
meanB,
|
||||
varA,
|
||||
varB,
|
||||
timesA.length,
|
||||
timesB.length
|
||||
);
|
||||
|
||||
return {
|
||||
measurementsA: timesA.length,
|
||||
measurementsB: timesB.length,
|
||||
meanA,
|
||||
meanB,
|
||||
varianceA: varA,
|
||||
varianceB: varB,
|
||||
tStatistic,
|
||||
tThreshold,
|
||||
constantTime: Math.abs(tStatistic) < tThreshold,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type DudectOptions, type DudectResult, dudect } from "./dudect";
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
type DiffFuzzOptions,
|
||||
type DiffFuzzResult,
|
||||
diffFuzz,
|
||||
} from "./diff-fuzz/diff-fuzz.js";
|
||||
export {
|
||||
type DudectOptions,
|
||||
type DudectResult,
|
||||
dudect,
|
||||
} from "./dudect/dudect.js";
|
||||
export {
|
||||
type Impl,
|
||||
type ImplReport,
|
||||
type RunVectorsOptions,
|
||||
runVectors,
|
||||
type TestVector,
|
||||
type VectorResult,
|
||||
} from "./run-vectors/run-vectors.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type Impl,
|
||||
type ImplReport,
|
||||
type RunVectorsOptions,
|
||||
runVectors,
|
||||
type TestVector,
|
||||
type VectorResult,
|
||||
} from "./run-vectors";
|
||||
@@ -0,0 +1,70 @@
|
||||
# runVectors — KAT harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Generic Known-Answer-Test runner. Given a list of `TestVector`s (input + expected output), a list of `Impl`s (functions that map input → output), and an equality predicate, it executes the cross product and returns structured pass / fail / pending counts per implementation. Every `impl/<id>.noble.ts` and `impl/<id>.native.ts` pair across the `@suaveplan/crypto-*` family drives its own test suite through this harness so the pass/pending/fail matrix is reported uniformly.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over input and output types.
|
||||
- Catches `PrimitiveNotImplemented` and marks those vectors "pending" so unimplemented native tiers don't colour red.
|
||||
- Per-impl summary: `pass`, `fail`, `pending` counts.
|
||||
- Preserves vector order in the per-impl result list so failing vectors can be located by name.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runVectors } from "@suaveplan/crypto-utils/testing";
|
||||
|
||||
const report = runVectors({
|
||||
vectors: [{ name: "v1", input: 2, expected: 4 }],
|
||||
impls: [{ name: "square", run: (n: number) => n * n }],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
console.log(report[0].summary); // { pass: 1, fail: 0, pending: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { runVectors } from "@suaveplan/crypto-utils/testing";
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
|
||||
import { fromHex } from "@suaveplan/crypto-utils/encoding";
|
||||
import { PrimitiveNotImplemented } from "@suaveplan/crypto-utils/errors";
|
||||
|
||||
type V = { msg: Uint8Array };
|
||||
type E = Uint8Array;
|
||||
|
||||
const vectors = [
|
||||
{ name: "empty", input: { msg: new Uint8Array() }, expected: fromHex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") },
|
||||
];
|
||||
|
||||
const report = runVectors<V, E>({
|
||||
vectors,
|
||||
impls: [
|
||||
{ name: "noble", run: ({ msg }) => sha256Noble(msg) },
|
||||
{ name: "native", run: () => { throw new PrimitiveNotImplemented("sha256"); } },
|
||||
],
|
||||
equals: constantTimeEqual,
|
||||
});
|
||||
|
||||
declare function sha256Noble(m: Uint8Array): Uint8Array;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runVectors<V, E>(opts: RunVectorsOptions<V, E>): readonly ImplReport[]`
|
||||
- `opts.vectors: readonly TestVector<V, E>[]`
|
||||
- `opts.impls: readonly Impl<V, E>[]`
|
||||
- `opts.equals: (a: E, b: E) => boolean` — for byte outputs, pass `constantTimeEqual`; for structural outputs, pass a deep-equal.
|
||||
- `type TestVector<V, E> = { name: string; input: V; expected: E }`
|
||||
- `type Impl<V, E> = { name: string; run: (input: V) => E }`
|
||||
- `type VectorResult = { name: string; status: 'pass' | 'fail' | 'pending'; reason?: string }`
|
||||
- `type ImplReport = { impl: string; results: readonly VectorResult[]; summary: { pass; fail; pending } }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness detects `PrimitiveNotImplemented` by checking `err.constructor.name === 'PrimitiveNotImplemented'` rather than `instanceof`. This decouples the harness from the error class's module identity (which would fail across bundler boundaries and `vite-node` hot reloads) and matches the convention used by `@noble/hashes`' own test helpers. Any other exception is captured as `fail` with its `message` as the `reason`. The harness is intentionally synchronous — KAT vectors are always fast — which keeps Vitest reporting tight.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PrimitiveNotImplemented } from "../../errors/primitive-not-implemented/primitive-not-implemented.js";
|
||||
import { runVectors } from "./run-vectors.js";
|
||||
|
||||
describe("runVectors", () => {
|
||||
it("reports pass for a matching impl", { timeout: 5000 }, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [
|
||||
{ name: "v1", input: 2, expected: 4 },
|
||||
{ name: "v2", input: 3, expected: 9 },
|
||||
],
|
||||
impls: [{ name: "sq", run: (n: number) => n * n }],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports[0]?.summary).toEqual({ pass: 2, fail: 0, pending: 0 });
|
||||
});
|
||||
|
||||
it("reports fail with reason on mismatch", { timeout: 5000 }, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [{ name: "v1", input: 2, expected: 5 }],
|
||||
impls: [{ name: "sq", run: (n: number) => n * n }],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports[0]?.summary.fail).toBe(1);
|
||||
expect(reports[0]?.results[0]?.status).toBe("fail");
|
||||
expect(reports[0]?.results[0]?.reason).toBe("output mismatch");
|
||||
});
|
||||
|
||||
it("marks pending when impl throws PrimitiveNotImplemented", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [{ name: "v1", input: 1, expected: 1 }],
|
||||
impls: [
|
||||
{
|
||||
name: "native",
|
||||
run: () => {
|
||||
throw new PrimitiveNotImplemented("sha256");
|
||||
},
|
||||
},
|
||||
],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports[0]?.summary.pending).toBe(1);
|
||||
expect(reports[0]?.results[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("reports fail with error message on other throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [{ name: "v1", input: 1, expected: 1 }],
|
||||
impls: [
|
||||
{
|
||||
name: "broken",
|
||||
run: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
},
|
||||
],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports[0]?.summary.fail).toBe(1);
|
||||
expect(reports[0]?.results[0]?.reason).toBe("kaboom");
|
||||
});
|
||||
|
||||
it("reports fail with stringified reason when non-Error is thrown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [{ name: "v1", input: 1, expected: 1 }],
|
||||
impls: [
|
||||
{
|
||||
name: "broken",
|
||||
run: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw "raw string error";
|
||||
},
|
||||
},
|
||||
],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports[0]?.summary.fail).toBe(1);
|
||||
expect(reports[0]?.results[0]?.reason).toBe("raw string error");
|
||||
});
|
||||
|
||||
it("runs across multiple impls independently", { timeout: 5000 }, () => {
|
||||
const reports = runVectors({
|
||||
vectors: [{ name: "v1", input: 3, expected: 9 }],
|
||||
impls: [
|
||||
{ name: "ok", run: (n: number) => n * n },
|
||||
{ name: "bad", run: (n: number) => n + 1 },
|
||||
],
|
||||
equals: (a, b) => a === b,
|
||||
});
|
||||
expect(reports).toHaveLength(2);
|
||||
expect(reports[0]?.summary.pass).toBe(1);
|
||||
expect(reports[1]?.summary.fail).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Generic KAT (Known-Answer-Test) harness. Runs a vector list against N
|
||||
* implementations and reports per-impl pass / fail / pending.
|
||||
*
|
||||
* A `TestVector` is opaque to this harness; each `Impl.run` maps a vector
|
||||
* to an observed output. The harness compares the observed output to the
|
||||
* vector's `expected` field using a user-supplied `equals`. Implementations
|
||||
* may throw `PrimitiveNotImplemented` to mark the whole set "pending" for
|
||||
* that impl.
|
||||
*/
|
||||
export interface TestVector<V, E> {
|
||||
readonly name: string;
|
||||
readonly input: V;
|
||||
readonly expected: E;
|
||||
}
|
||||
|
||||
export interface Impl<V, E> {
|
||||
readonly name: string;
|
||||
/** Throws `PrimitiveNotImplemented` to mark all vectors pending. */
|
||||
readonly run: (input: V) => E;
|
||||
}
|
||||
|
||||
export interface VectorResult {
|
||||
readonly name: string;
|
||||
readonly status: "pass" | "fail" | "pending";
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface ImplReport {
|
||||
readonly impl: string;
|
||||
readonly results: readonly VectorResult[];
|
||||
readonly summary: {
|
||||
readonly pass: number;
|
||||
readonly fail: number;
|
||||
readonly pending: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunVectorsOptions<V, E> {
|
||||
readonly vectors: readonly TestVector<V, E>[];
|
||||
readonly impls: readonly Impl<V, E>[];
|
||||
readonly equals: (a: E, b: E) => boolean;
|
||||
}
|
||||
|
||||
export function runVectors<V, E>(
|
||||
opts: RunVectorsOptions<V, E>
|
||||
): readonly ImplReport[] {
|
||||
const reports: ImplReport[] = [];
|
||||
for (const impl of opts.impls) {
|
||||
const results: VectorResult[] = [];
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
let pending = 0;
|
||||
for (const v of opts.vectors) {
|
||||
try {
|
||||
const got = impl.run(v.input);
|
||||
if (opts.equals(got, v.expected)) {
|
||||
results.push({ name: v.name, status: "pass" });
|
||||
pass++;
|
||||
} else {
|
||||
results.push({
|
||||
name: v.name,
|
||||
status: "fail",
|
||||
reason: "output mismatch",
|
||||
});
|
||||
fail++;
|
||||
}
|
||||
} catch (err) {
|
||||
const name = (err as { constructor?: { name?: string } })
|
||||
?.constructor?.name;
|
||||
if (name === "PrimitiveNotImplemented") {
|
||||
results.push({ name: v.name, status: "pending" });
|
||||
pending++;
|
||||
} else {
|
||||
results.push({
|
||||
name: v.name,
|
||||
status: "fail",
|
||||
reason:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
}
|
||||
reports.push({
|
||||
impl: impl.name,
|
||||
results,
|
||||
summary: { pass, fail, pending },
|
||||
});
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { zeroize } from "./zeroize.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# zeroize
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Best-effort overwrite of a `Uint8Array` with zero bytes. Secret material — private keys, AEAD keys, plaintext buffers, intermediate KDF state — should be zeroized as soon as it is no longer needed so that its remnants become unreadable if the process is later snapshotted, dumped, or forked into an attacker-controlled context.
|
||||
|
||||
JavaScript does not let us pin memory or guarantee the garbage collector has not already copied the underlying `ArrayBuffer` into a new region. `zeroize` is therefore a mitigation, not a guarantee. Use it in combination with narrow scoping (allocate the buffer inside a function, zeroize, return nothing sensitive) to minimise the residency window.
|
||||
|
||||
## Features
|
||||
|
||||
- In-place; no allocation.
|
||||
- Backed by `Uint8Array#fill(0)`, which is inlined to a tight memset on every modern engine.
|
||||
- Zero dependencies.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { zeroize } from "@suaveplan/crypto-utils/zeroize";
|
||||
|
||||
const key = new Uint8Array(32);
|
||||
// …use the key…
|
||||
zeroize(key);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { zeroize } from "@suaveplan/crypto-utils/zeroize";
|
||||
import { concatBytes } from "@suaveplan/crypto-utils/bytes";
|
||||
|
||||
function withScopedSecret<T>(
|
||||
secretBuilder: () => Uint8Array,
|
||||
fn: (secret: Uint8Array) => T
|
||||
): T {
|
||||
const secret = secretBuilder();
|
||||
try {
|
||||
return fn(secret);
|
||||
} finally {
|
||||
zeroize(secret);
|
||||
}
|
||||
}
|
||||
|
||||
withScopedSecret(
|
||||
() => concatBytes(new Uint8Array(16).fill(0xaa), new Uint8Array(16).fill(0xbb)),
|
||||
(key) => {
|
||||
// key is live inside this block only
|
||||
return key.length;
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `zeroize(buf: Uint8Array): void` — fills `buf` with `0` in place. Returns nothing. Never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Uint8Array#fill(0)` is the minimum the runtime can do without dropping into `node:buffer` or platform-specific APIs. Some environments (OpenSSL-linked `node:crypto`) expose `sodium_memzero`-equivalents that are marked volatile to prevent compiler dead-store elimination; we do not rely on those here because they are not available in browsers or edge runtimes, and cross-runtime consistency is more valuable than the marginal hardening on Node. Engines do not currently eliminate `Uint8Array#fill` as dead, but a future JIT optimisation could; revisit this helper if `dudect`-style analysis reports leakage.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { zeroize } from "./zeroize.js";
|
||||
|
||||
describe("zeroize", () => {
|
||||
it("overwrites a populated buffer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
zeroize(buf);
|
||||
expect(Array.from(buf)).toEqual([0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("is a no-op on empty buffer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array();
|
||||
zeroize(buf);
|
||||
expect(buf.length).toBe(0);
|
||||
});
|
||||
|
||||
it("mutates in place (same reference)", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([1, 2, 3]);
|
||||
const ref = buf;
|
||||
zeroize(buf);
|
||||
expect(ref.buffer).toBe(buf.buffer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Overwrite `buf` in-place with zero bytes.
|
||||
*
|
||||
* JavaScript cannot guarantee the garbage collector has not already relocated
|
||||
* or copied the underlying memory, so this is a best-effort operation. Its
|
||||
* purpose is to minimise the time window during which sensitive bytes
|
||||
* (secret keys, plaintext, derived material) remain readable on the heap
|
||||
* after they are no longer needed. Combine with aggressive buffer
|
||||
* scoping — allocate inside a function, zeroize, return nothing sensitive.
|
||||
*/
|
||||
export function zeroize(buf: Uint8Array): void {
|
||||
buf.fill(0);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
51
packages/security-stack/crypto/crypto-utils/vite.config.ts
Normal file
51
packages/security-stack/crypto/crypto-utils/vite.config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/crypto-utils",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"encoding/index": "src/encoding/index.ts",
|
||||
"bytes/index": "src/bytes/index.ts",
|
||||
"constant-time/index": "src/constant-time/index.ts",
|
||||
"random/index": "src/random/index.ts",
|
||||
"zeroize/index": "src/zeroize/index.ts",
|
||||
"merkle/index": "src/merkle/index.ts",
|
||||
"testing/index": "src/testing/index.ts",
|
||||
"primitive-id/index": "src/primitive-id/index.ts",
|
||||
"errors/index": "src/errors/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/bytes",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
36
packages/security-stack/crypto/crypto-utils/vitest.config.ts
Normal file
36
packages/security-stack/crypto/crypto-utils/vitest.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/constants/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user