Initial Commit
This commit is contained in:
163
packages/security-stack/security/password-generator/README.md
Normal file
163
packages/security-stack/security/password-generator/README.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# @suaveplan/password-generator - Cryptographically-strong password and passphrase generator — char-class, pronounceable, EFF Diceware, PIN, entropy estimator. Universal, zero browser/Node deps.
|
||||
|
||||
## Summary
|
||||
|
||||
Cryptographically-strong password and passphrase generator — char-class,
|
||||
pronounceable, EFF Diceware, and PIN modes with bias-free RNG, real entropy
|
||||
estimates, and injectable `Rng` for deterministic replay. Universal
|
||||
(zero browser / Node / React deps).
|
||||
|
||||
The package owns the surface end-to-end: bundled EFF Long (7776) and EFF
|
||||
Short 1 (1296) wordlists, Lemire-style rejection-sampling `randomInt` for
|
||||
unbiased integer draws, Fisher-Yates shuffle, char-class quotas, configurable
|
||||
passphrase decoration, an entropy estimator, and a `assertStrength` guard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [codes](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - pronounceable — `generatePronounceablePassword(opts…
|
||||
- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - pronounceable — `generatePronounceablePassword(opts…
|
||||
- [pronounceable](./src/password/pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [default-rng](./src/random/default-rng.md) — `defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.get…
|
||||
- [fisher-yates](./src/random/fisher-yates.md) — `shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `random…
|
||||
- [random-int](./src/random/random-int.md) — `randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2…
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [eff-long](./src/wordlists/eff-long.md) — Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this…
|
||||
- [eff-short](./src/wordlists/eff-short.md) — Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Sour…
|
||||
- [registry](./src/wordlists/registry.md) — The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets calle…
|
||||
<!-- end:auto-docs --> - [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [pronounceable](./src/password/pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-rng](./src/random/default-rng.md) — `defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.get…
|
||||
- [fisher-yates](./src/random/fisher-yates.md) — `shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `random…
|
||||
- [random-int](./src/random/random-int.md) — `randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2…
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [eff-long](./src/wordlists/eff-long.md) — Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this…
|
||||
- [eff-short](./src/wordlists/eff-short.md) — Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Sour…
|
||||
- [registry](./src/wordlists/registry.md) — The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets calle…
|
||||
- [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [pronounceable](./src/password/pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-rng](./src/random/default-rng.md) — `defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.get…
|
||||
- [fisher-yates](./src/random/fisher-yates.md) — `shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `random…
|
||||
- [random-int](./src/random/random-int.md) — `randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2…
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [eff-long](./src/wordlists/eff-long.md) — Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this…
|
||||
- [eff-short](./src/wordlists/eff-short.md) — Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Sour…
|
||||
- [registry](./src/wordlists/registry.md) — The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets calle…
|
||||
- [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_- pronounceable — `generatePronounceablePassword(opts…
|
||||
- [pronounceable](./src/password/pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-rng](./src/random/default-rng.md) — `defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.get…
|
||||
- [fisher-yates](./src/random/fisher-yates.md) — `shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `random…
|
||||
- [random-int](./src/random/random-int.md) — `randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2…
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [eff-long](./src/wordlists/eff-long.md) — Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this…
|
||||
- [eff-short](./src/wordlists/eff-short.md) — Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Sour…
|
||||
- [registry](./src/wordlists/registry.md) — The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets calle…
|
||||
- [charsets](./src/charsets/charsets.md) — <!-- begin:auto-docs --> _(no submodules)_- [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [entropy](./src/entropy/entropy.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [codes](./src/errors/codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_ - [passphrase](./src/passphrase/passphrase.md) — <!-- begin:auto-docs --> _(no submodules)_- [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_ - [password](./src/password/password.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pronounceable](./src/password/pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pin](./src/pin/pin.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-rng](./src/random/default-rng.md) — `defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.get…
|
||||
- [fisher-yates](./src/random/fisher-yates.md) — `shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `random…
|
||||
- [random-int](./src/random/random-int.md) — `randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2…
|
||||
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [eff-long](./src/wordlists/eff-long.md) — Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this…
|
||||
- [eff-short](./src/wordlists/eff-short.md) — Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Sour…
|
||||
- [registry](./src/wordlists/registry.md) — The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets calle…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/password-generator
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertStrength,
|
||||
estimateEntropy,
|
||||
generatePassphrase,
|
||||
generatePassword,
|
||||
generatePin,
|
||||
} from "@suaveplan/password-generator";
|
||||
|
||||
// Char-class password
|
||||
const pw = generatePassword({
|
||||
length: 16,
|
||||
requireLowercase: true,
|
||||
requireUppercase: true,
|
||||
requireDigits: true,
|
||||
requireSymbols: true,
|
||||
});
|
||||
|
||||
// 6-word EFF Long passphrase (≈ 77.5 bits)
|
||||
const phrase = generatePassphrase({});
|
||||
|
||||
// 6-digit PIN
|
||||
const pin = generatePin(6);
|
||||
|
||||
// Strength check
|
||||
assertStrength(pw, 80); // throws PWGEN.STRENGTH_INSUFFICIENT if < 80 bits
|
||||
const { bits, charsetSize } = estimateEntropy(pw);
|
||||
```
|
||||
|
||||
## Tree-Shaking
|
||||
|
||||
Each major surface ships at its own subpath so consumers pull only what they
|
||||
need:
|
||||
|
||||
```ts
|
||||
import { generatePassword } from "@suaveplan/password-generator/password";
|
||||
import { generatePassphrase } from "@suaveplan/password-generator/passphrase";
|
||||
import { generatePin } from "@suaveplan/password-generator/pin";
|
||||
import { EFF_LONG_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-long";
|
||||
import { EFF_SHORT_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-short";
|
||||
```
|
||||
|
||||
A consumer that imports only `./password` does not pull the 85 KB EFF Long
|
||||
list. A consumer that imports only `./pin` pulls neither wordlist.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`random/random-int`](./src/random/random-int.md) — bias-free `randomInt`
|
||||
via Lemire rejection sampling.
|
||||
- [`random/fisher-yates`](./src/random/fisher-yates.md) — Fisher-Yates shuffle
|
||||
built on the unbiased picker.
|
||||
- [`random/default-rng`](./src/random/default-rng.md) — default `Rng` wrapping
|
||||
`@suaveplan/crypto-utils.randomBytes`.
|
||||
- [`charsets/charsets`](./src/charsets/charsets.md) — `LOWERCASE` / `UPPERCASE`
|
||||
/ `DIGITS` / `SYMBOLS` / `AMBIGUOUS` / `LOOKALIKES` + `buildEffectiveCharset`.
|
||||
- [`password/password`](./src/password/password.md) — char-class generator with
|
||||
required-class quotas.
|
||||
- [`password/pronounceable`](./src/password/pronounceable.md) — CVC-pattern
|
||||
pronounceable generator.
|
||||
- [`passphrase/passphrase`](./src/passphrase/passphrase.md) — EFF Diceware
|
||||
passphrase generator.
|
||||
- [`pin/pin`](./src/pin/pin.md) — uniform digit-only PIN generator.
|
||||
- [`entropy/entropy`](./src/entropy/entropy.md) — entropy estimator and
|
||||
`assertStrength` guard.
|
||||
- [`wordlists/eff-long`](./src/wordlists/eff-long.md) — bundled EFF Long
|
||||
Wordlist (7776 entries).
|
||||
- [`wordlists/eff-short`](./src/wordlists/eff-short.md) — bundled EFF Short
|
||||
Wordlist 1 (1296 entries).
|
||||
- [`wordlists/registry`](./src/wordlists/registry.md) — plugin API for caller-
|
||||
supplied wordlists.
|
||||
- [`errors/codes`](./src/errors/codes.md) — `PWGEN_CODES` registry and
|
||||
`PwgenError` class.
|
||||
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod boundary schemas.
|
||||
- [`observability/metrics`](./src/observability/metrics.md) — OpenTelemetry
|
||||
histograms and counters.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "@suaveplan/password-generator",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Cryptographically-strong password and passphrase generator — char-class, pronounceable, EFF Diceware, PIN, entropy estimator. Universal, zero browser/Node deps.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./password": {
|
||||
"import": { "types": "./dist/password/index.d.ts", "default": "./dist/password/index.js" }
|
||||
},
|
||||
"./passphrase": {
|
||||
"import": { "types": "./dist/passphrase/index.d.ts", "default": "./dist/passphrase/index.js" }
|
||||
},
|
||||
"./pin": {
|
||||
"import": { "types": "./dist/pin/index.d.ts", "default": "./dist/pin/index.js" }
|
||||
},
|
||||
"./entropy": {
|
||||
"import": { "types": "./dist/entropy/index.d.ts", "default": "./dist/entropy/index.js" }
|
||||
},
|
||||
"./wordlists/eff-long": {
|
||||
"import": { "types": "./dist/wordlists/eff-long.d.ts", "default": "./dist/wordlists/eff-long.js" }
|
||||
},
|
||||
"./wordlists/eff-short": {
|
||||
"import": { "types": "./dist/wordlists/eff-short.d.ts", "default": "./dist/wordlists/eff-short.js" }
|
||||
},
|
||||
"./wordlists/registry": {
|
||||
"import": { "types": "./dist/wordlists/registry.d.ts", "default": "./dist/wordlists/registry.js" }
|
||||
},
|
||||
"./random": {
|
||||
"import": { "types": "./dist/random/index.d.ts", "default": "./dist/random/index.js" }
|
||||
},
|
||||
"./errors": {
|
||||
"import": { "types": "./dist/errors/index.d.ts", "default": "./dist/errors/index.js" }
|
||||
},
|
||||
"./schemas": {
|
||||
"import": { "types": "./dist/schemas/index.d.ts", "default": "./dist/schemas/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/crypto-utils": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"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,72 @@
|
||||
# `charsets`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the canonical character-class constants used by every generator in the package — `LOWERCASE`, `UPPERCASE`, `DIGITS`, `SYMBOLS`, plus the curated `AMBIGUOUS` and `LOOKALIKES` sets that callers subtract to avoid characters confusable in fixed-width fonts. Exposing each class as a frozen `ReadonlySet<string>` lets callers compose custom unions (`new Set([...LOWERCASE, ...DIGITS])`) without depending on any private representation.
|
||||
|
||||
The `buildEffectiveCharset(opts)` helper merges the enabled classes and subtracts `opts.exclude` to produce the deduplicated string array a generator can index into via `randomInt`. The four built-in classes are pairwise disjoint by construction, so insertion-order union is enough to deduplicate — adding a `Set`-tracked check would be dead code.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `ReadonlySet<string>` for each class — safe to share across modules.
|
||||
- `AMBIGUOUS` (`0Ol1I`) and `LOOKALIKES` curated by frequency of human misreads.
|
||||
- `SYMBOLS` drawn from the OWASP-recommended printable range; excludes shell-quoting hazards (backtick, backslash, whitespace).
|
||||
- `buildEffectiveCharset(opts)` accepts string / array / `Set` exclusions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AMBIGUOUS,
|
||||
buildEffectiveCharset,
|
||||
} from "@suaveplan/password-generator";
|
||||
|
||||
const charset = buildEffectiveCharset({ exclude: AMBIGUOUS });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
DIGITS,
|
||||
LOWERCASE,
|
||||
UPPERCASE,
|
||||
buildEffectiveCharset,
|
||||
} from "@suaveplan/password-generator";
|
||||
|
||||
// Hex charset only (digits + a-f) via two passes
|
||||
const hexLower = [
|
||||
...buildEffectiveCharset({ uppercase: false, symbols: false }),
|
||||
].filter((c) => DIGITS.has(c) || "abcdef".includes(c));
|
||||
|
||||
// "Vault-friendly" passwords: drop ambiguous + shell-hostile chars
|
||||
const safe = buildEffectiveCharset({ exclude: "0Ol1I`\\\"' " });
|
||||
const charset = safe.filter((c) => !UPPERCASE.has(c) || LOWERCASE.has(c.toLowerCase()));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Constants
|
||||
|
||||
- `LOWERCASE: ReadonlySet<string>` — `a–z` (26 entries).
|
||||
- `UPPERCASE: ReadonlySet<string>` — `A–Z` (26 entries).
|
||||
- `DIGITS: ReadonlySet<string>` — `0–9` (10 entries).
|
||||
- `SYMBOLS: ReadonlySet<string>` — `!@#$%^&*()-_=+[]{};:,.<>/?` (25 entries).
|
||||
- `AMBIGUOUS: ReadonlySet<string>` — `0Ol1I`.
|
||||
- `LOOKALIKES: ReadonlySet<string>` — `0OQ1lI5S2Z8B`.
|
||||
|
||||
### `buildEffectiveCharset(opts?: CharsetOptions): string[]`
|
||||
|
||||
Builds the deduplicated union of enabled classes minus `opts.exclude`. Returns a fresh array on every call; insertion order is lowercase → uppercase → digits → symbols.
|
||||
|
||||
- `lowercase` / `uppercase` / `digits` / `symbols` — each defaults to `true`.
|
||||
- `exclude` — `string` (per-character), `readonly string[]`, or `ReadonlySet<string>`; defaults to none.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every set is frozen at module load via `Object.freeze`, which only freezes the `Set` object itself (not the underlying internal storage). The intent is to surface accidental `.add` / `.delete` calls at call sites; consumers who need to add or remove characters must clone the set first.
|
||||
|
||||
`SYMBOLS` intentionally omits whitespace, the backslash, double-quote, and backtick. Those characters routinely break shell interpolations and CSV exports; including them would force every caller to filter them out before interpolating the generated password into a deployment script. Callers who want them can compose `new Set([...SYMBOLS, "\\\""])` themselves — the omission is conservative-by-default but trivially reversible.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AMBIGUOUS,
|
||||
buildEffectiveCharset,
|
||||
DIGITS,
|
||||
LOOKALIKES,
|
||||
LOWERCASE,
|
||||
SYMBOLS,
|
||||
UPPERCASE,
|
||||
} from "./charsets.js";
|
||||
|
||||
describe("character class constants", () => {
|
||||
it("LOWERCASE contains 26 entries", { timeout: 5000 }, () => {
|
||||
expect(LOWERCASE.size).toBe(26);
|
||||
expect(LOWERCASE.has("a")).toBe(true);
|
||||
expect(LOWERCASE.has("z")).toBe(true);
|
||||
expect(LOWERCASE.has("A")).toBe(false);
|
||||
});
|
||||
|
||||
it("UPPERCASE / DIGITS / SYMBOLS sizes match expectations", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(UPPERCASE.size).toBe(26);
|
||||
expect(DIGITS.size).toBe(10);
|
||||
expect(SYMBOLS.size).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("AMBIGUOUS contains the canonical 0/O/l/1/I set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const ch of "0Ol1I") expect(AMBIGUOUS.has(ch)).toBe(true);
|
||||
});
|
||||
|
||||
it("LOOKALIKES is a superset of AMBIGUOUS-style digits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(LOOKALIKES.has("0")).toBe(true);
|
||||
expect(LOOKALIKES.has("O")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEffectiveCharset", () => {
|
||||
it("defaults to the union of all four classes", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset();
|
||||
expect(cs.length).toBe(
|
||||
LOWERCASE.size + UPPERCASE.size + DIGITS.size + SYMBOLS.size
|
||||
);
|
||||
});
|
||||
|
||||
it("omits a class when disabled", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset({ symbols: false });
|
||||
for (const ch of SYMBOLS) expect(cs.includes(ch)).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a string-form exclusion", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset({ exclude: "abc" });
|
||||
expect(cs.includes("a")).toBe(false);
|
||||
expect(cs.includes("b")).toBe(false);
|
||||
expect(cs.includes("c")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies an array-form exclusion", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset({ exclude: ["x", "y"] });
|
||||
expect(cs.includes("x")).toBe(false);
|
||||
expect(cs.includes("y")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a Set-form exclusion", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset({ exclude: AMBIGUOUS });
|
||||
for (const ch of AMBIGUOUS) expect(cs.includes(ch)).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates the output", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset();
|
||||
expect(cs.length).toBe(new Set(cs).size);
|
||||
});
|
||||
|
||||
it("returns empty when every class is disabled", { timeout: 5000 }, () => {
|
||||
const cs = buildEffectiveCharset({
|
||||
lowercase: false,
|
||||
uppercase: false,
|
||||
digits: false,
|
||||
symbols: false,
|
||||
});
|
||||
expect(cs).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles undefined exclude", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
buildEffectiveCharset({ exclude: undefined }).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Character-class constants exposed as `ReadonlySet<string>` so callers
|
||||
* can author custom unions and intersections without pulling private
|
||||
* implementation details.
|
||||
*
|
||||
* Sets are frozen at module load time. The strings inside are
|
||||
* intentionally ASCII-only — Unicode case folding is out of scope here
|
||||
* (see the package proposal "Out of scope" list).
|
||||
*/
|
||||
|
||||
function freezeSet(chars: string): ReadonlySet<string> {
|
||||
return Object.freeze(new Set(chars.split(""))) as ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const LOWERCASE: ReadonlySet<string> = freezeSet(
|
||||
"abcdefghijklmnopqrstuvwxyz"
|
||||
);
|
||||
|
||||
export const UPPERCASE: ReadonlySet<string> = freezeSet(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
);
|
||||
|
||||
export const DIGITS: ReadonlySet<string> = freezeSet("0123456789");
|
||||
|
||||
/**
|
||||
* Symbol set drawn from the OWASP-recommended printable-ASCII range.
|
||||
* Excludes whitespace and the backtick / backslash pair to dodge shell
|
||||
* quoting hazards on call sites that interpolate generated passwords
|
||||
* into shell commands.
|
||||
*/
|
||||
export const SYMBOLS: ReadonlySet<string> = freezeSet(
|
||||
"!@#$%^&*()-_=+[]{};:,.<>/?"
|
||||
);
|
||||
|
||||
/** Characters frequently confused for one another in fixed-width fonts. */
|
||||
export const AMBIGUOUS: ReadonlySet<string> = freezeSet("0Ol1I");
|
||||
|
||||
/** Characters often misread as digits or other letters in handwriting. */
|
||||
export const LOOKALIKES: ReadonlySet<string> = freezeSet("0OQ1lI5S2Z8B");
|
||||
|
||||
export interface CharsetOptions {
|
||||
readonly lowercase?: boolean | undefined;
|
||||
readonly uppercase?: boolean | undefined;
|
||||
readonly digits?: boolean | undefined;
|
||||
readonly symbols?: boolean | undefined;
|
||||
readonly exclude?:
|
||||
| ReadonlySet<string>
|
||||
| readonly string[]
|
||||
| string
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function expandExclusions(
|
||||
exclude: CharsetOptions["exclude"]
|
||||
): ReadonlySet<string> {
|
||||
if (!exclude) return new Set();
|
||||
if (typeof exclude === "string") return new Set(exclude.split(""));
|
||||
if (Array.isArray(exclude)) return new Set(exclude as readonly string[]);
|
||||
return exclude as ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective charset for a generator call by unioning enabled
|
||||
* classes and subtracting `exclude`. Returns a deduplicated string array
|
||||
* (insertion order preserved) so callers can index into it via
|
||||
* `randomInt`.
|
||||
*/
|
||||
export function buildEffectiveCharset(opts: CharsetOptions = {}): string[] {
|
||||
const {
|
||||
lowercase = true,
|
||||
uppercase = true,
|
||||
digits = true,
|
||||
symbols = true,
|
||||
} = opts;
|
||||
const excluded = expandExclusions(opts.exclude);
|
||||
const out: string[] = [];
|
||||
// LOWERCASE / UPPERCASE / DIGITS / SYMBOLS are pairwise disjoint by
|
||||
// construction so insertion order alone deduplicates the union.
|
||||
const add = (set: ReadonlySet<string>): void => {
|
||||
for (const ch of set) {
|
||||
if (excluded.has(ch)) continue;
|
||||
out.push(ch);
|
||||
}
|
||||
};
|
||||
if (lowercase) add(LOWERCASE);
|
||||
if (uppercase) add(UPPERCASE);
|
||||
if (digits) add(DIGITS);
|
||||
if (symbols) add(SYMBOLS);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type { CharsetOptions } from "./charsets.js";
|
||||
export {
|
||||
AMBIGUOUS,
|
||||
buildEffectiveCharset,
|
||||
DIGITS,
|
||||
LOOKALIKES,
|
||||
LOWERCASE,
|
||||
SYMBOLS,
|
||||
UPPERCASE,
|
||||
} from "./charsets.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# `entropy`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`estimateEntropy(password, opts?)` returns the upper-bound entropy of a candidate password as `log2(charsetSize) * password.length`, where `charsetSize` is the cardinality of the union of every detected built-in class (lowercase / uppercase / digits / symbols) plus any caller-supplied `opts.extraClasses` whose characters appear in the password. `estimatePassphraseEntropy(words, wordlistSize)` returns `log2(wordlistSize) * words.length` for Diceware-style passphrases. `assertStrength(password, minBits, opts?)` consumes `estimateEntropy` and throws `PWGEN.STRENGTH_INSUFFICIENT` when the computed bits are below the floor.
|
||||
|
||||
The entropy formulas assume uniform sampling and ignore patterns — `"AAAAAAAA"` is reported as the same entropy as `"hG83mP9k"` if both contain the same charset classes. Pattern-aware scoring (zxcvbn-style) is intentionally out of scope and lives in the separate `@suaveplan/password-strength` package.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects class membership in a single pass over the password.
|
||||
- `extraClasses` opt-in lets callers report custom-charset passwords (emoji unions, hex-only PINs).
|
||||
- `assertStrength` throws on insufficient entropy with a structured error code.
|
||||
- Zod-validated `EntropyOptions`; mismatch → `PWGEN.INVALID_OPTIONS`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { estimateEntropy } from "@suaveplan/password-generator";
|
||||
|
||||
const { bits, charsetSize } = estimateEntropy("Abc123Xy");
|
||||
// charsetSize === 62, bits === log2(62) * 8 ≈ 47.6
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertStrength,
|
||||
estimateEntropy,
|
||||
estimatePassphraseEntropy,
|
||||
} from "@suaveplan/password-generator";
|
||||
|
||||
// Server-side guard before persisting a vault item
|
||||
function persistVaultItem(pw: string): void {
|
||||
assertStrength(pw, 80); // throws PWGEN.STRENGTH_INSUFFICIENT if < 80 bits
|
||||
// ... persist
|
||||
}
|
||||
|
||||
// Custom emoji-augmented class
|
||||
const e = estimateEntropy("emoji👍2025", { extraClasses: ["👍🎉🚀"] });
|
||||
|
||||
// Diceware reporting
|
||||
const words = ["correct", "horse", "battery", "staple", "alpha", "beta"];
|
||||
const passEntropy = estimatePassphraseEntropy(words, 7776); // ≈ 77.55 bits
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `estimateEntropy(password: string, opts?: EntropyOptions): EntropyEstimate`
|
||||
|
||||
Returns `{ bits, charsetSize }`. `bits` is `0` when the password is empty.
|
||||
|
||||
### `estimatePassphraseEntropy(words: readonly string[], wordlistSize: number): number`
|
||||
|
||||
Returns `log2(wordlistSize) * words.length`. Throws `PWGEN.INVALID_OPTIONS` for non-positive integer `wordlistSize`.
|
||||
|
||||
### `assertStrength(password: string, minBits: number, opts?: EntropyOptions): void`
|
||||
|
||||
Returns `void` on success. Throws `PwgenError(PWGEN.STRENGTH_INSUFFICIENT)` when the computed bits are strictly less than `minBits`.
|
||||
|
||||
### `EntropyOptions`
|
||||
|
||||
- `extraClasses?: readonly string[]` — additional character classes the password may draw from.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Class detection is a single forward pass over the password, tracking four boolean flags for the built-in classes plus a `Set<number>` of seen `extraClasses` indices. The `charsetSize` accumulator only adds a class's cardinality when the first matching character is seen, which keeps the cost `O(password.length * extraClasses.length)` worst-case.
|
||||
|
||||
The formula deliberately reports an _upper bound_. A consumer comparing this number to a cracking-time chart should treat it as a ceiling: real-world passwords contain dictionary patterns, keyboard walks, and date stamps that reduce the effective entropy substantially. Use `@suaveplan/password-strength` for adversarial-aware estimates. `assertStrength` is a deliberately blunt instrument — it answers "does this password meet the floor on the most generous estimator?" — and is intended as a server-side guardrail against drastically weak inputs, not a substitute for proper strength scoring.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
assertStrength,
|
||||
estimateEntropy,
|
||||
estimatePassphraseEntropy,
|
||||
} from "./entropy.js";
|
||||
|
||||
describe("estimateEntropy", () => {
|
||||
it("reports 26-char charset for lowercase-only password", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = estimateEntropy("abcdefgh");
|
||||
expect(r.charsetSize).toBe(26);
|
||||
expect(r.bits).toBeCloseTo(Math.log2(26) * 8, 6);
|
||||
});
|
||||
|
||||
it("reports 62-char charset for lowercase+uppercase+digits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = estimateEntropy("Abc123Xy");
|
||||
expect(r.charsetSize).toBe(62);
|
||||
expect(r.bits).toBeCloseTo(Math.log2(62) * 8, 6);
|
||||
});
|
||||
|
||||
it("adds symbol charset size when symbols present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = estimateEntropy("Abc!23Xy");
|
||||
expect(r.charsetSize).toBeGreaterThan(62);
|
||||
});
|
||||
|
||||
it("returns zero charset / zero bits for empty input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = estimateEntropy("");
|
||||
expect(r.charsetSize).toBe(0);
|
||||
expect(r.bits).toBe(0);
|
||||
});
|
||||
|
||||
it("detects extraClasses when characters match", { timeout: 5000 }, () => {
|
||||
const r = estimateEntropy("emoji👍ab", { extraClasses: ["👍"] });
|
||||
expect(r.charsetSize).toBeGreaterThanOrEqual(27);
|
||||
});
|
||||
|
||||
it("ignores extraClasses that do not appear", { timeout: 5000 }, () => {
|
||||
const r = estimateEntropy("abc", { extraClasses: ["XYZ"] });
|
||||
expect(r.charsetSize).toBe(26);
|
||||
});
|
||||
|
||||
it("rejects malformed opts with PWGEN.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
estimateEntropy("abc", { extraClasses: 42 as never })
|
||||
).toThrow(/PWGEN\.INVALID_OPTIONS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimatePassphraseEntropy", () => {
|
||||
it("6-word EFF Long passphrase reports ~77.5 bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = estimatePassphraseEntropy(
|
||||
["a", "b", "c", "d", "e", "f"],
|
||||
7776
|
||||
);
|
||||
expect(e).toBeCloseTo(Math.log2(7776) * 6, 6);
|
||||
});
|
||||
|
||||
it("5-word EFF Short passphrase reports ~51.7 bits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = estimatePassphraseEntropy(["a", "b", "c", "d", "e"], 1296);
|
||||
expect(e).toBeCloseTo(Math.log2(1296) * 5, 6);
|
||||
});
|
||||
|
||||
it("rejects non-positive wordlistSize", { timeout: 5000 }, () => {
|
||||
expect(() => estimatePassphraseEntropy(["a"], 0)).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
expect(() => estimatePassphraseEntropy(["a"], 1.5)).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertStrength", () => {
|
||||
it("assertStrength passes when entropy meets the floor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => assertStrength("Abc123Xy", 40)).not.toThrow();
|
||||
});
|
||||
|
||||
it("assertStrength throws when entropy is below the floor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => assertStrength("aaaaaaaa", 80)).toThrow(
|
||||
/PWGEN\.STRENGTH_INSUFFICIENT/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { DIGITS, LOWERCASE, SYMBOLS, UPPERCASE } from "../charsets/charsets.js";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { recordError } from "../observability/metrics.js";
|
||||
import { EntropyOptionsSchema } from "../schemas/schemas.js";
|
||||
|
||||
export interface EntropyOptions {
|
||||
/**
|
||||
* Extra character classes (each a string of unique characters) that
|
||||
* the password may draw from beyond the four built-in classes. Used
|
||||
* when the caller authored a custom union via
|
||||
* {@link buildEffectiveCharset}.
|
||||
*/
|
||||
readonly extraClasses?: readonly string[];
|
||||
}
|
||||
|
||||
export interface EntropyEstimate {
|
||||
readonly bits: number;
|
||||
readonly charsetSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the upper-bound entropy of `password` as
|
||||
* `log2(charsetSize) * length`.
|
||||
*
|
||||
* `charsetSize` is the cardinality of the union of every detected
|
||||
* built-in class (lowercase / uppercase / digits / symbols) plus any
|
||||
* `opts.extraClasses` whose characters appear in the password.
|
||||
*
|
||||
* This is intentionally a loose upper bound — pattern-aware scoring
|
||||
* lives in `@suaveplan/password-strength`.
|
||||
*/
|
||||
export function estimateEntropy(
|
||||
password: string,
|
||||
opts: EntropyOptions = {}
|
||||
): EntropyEstimate {
|
||||
const parsed = EntropyOptionsSchema.safeParse(opts);
|
||||
if (!parsed.success) {
|
||||
recordError("PWGEN.INVALID_OPTIONS");
|
||||
throw pwgenError("PWGEN.INVALID_OPTIONS", parsed.error.message, {
|
||||
cause: parsed.error,
|
||||
});
|
||||
}
|
||||
let charsetSize = 0;
|
||||
let hasLower = false;
|
||||
let hasUpper = false;
|
||||
let hasDigit = false;
|
||||
let hasSymbol = false;
|
||||
const seenExtra = new Set<number>();
|
||||
for (const ch of password) {
|
||||
if (!hasLower && LOWERCASE.has(ch)) {
|
||||
hasLower = true;
|
||||
charsetSize += LOWERCASE.size;
|
||||
} else if (!hasUpper && UPPERCASE.has(ch)) {
|
||||
hasUpper = true;
|
||||
charsetSize += UPPERCASE.size;
|
||||
} else if (!hasDigit && DIGITS.has(ch)) {
|
||||
hasDigit = true;
|
||||
charsetSize += DIGITS.size;
|
||||
} else if (!hasSymbol && SYMBOLS.has(ch)) {
|
||||
hasSymbol = true;
|
||||
charsetSize += SYMBOLS.size;
|
||||
}
|
||||
const extras = parsed.data.extraClasses;
|
||||
if (extras) {
|
||||
for (let i = 0; i < extras.length; i++) {
|
||||
if (seenExtra.has(i)) continue;
|
||||
const cls = extras[i] as string;
|
||||
if (cls.includes(ch)) {
|
||||
seenExtra.add(i);
|
||||
charsetSize += new Set(cls).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const bits =
|
||||
charsetSize === 0 ? 0 : Math.log2(charsetSize) * password.length;
|
||||
return { bits, charsetSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate passphrase entropy as `log2(wordlistSize) * words.length`.
|
||||
* The caller is trusted to pass a `words` array whose members were
|
||||
* drawn from a wordlist of size `wordlistSize`.
|
||||
*/
|
||||
export function estimatePassphraseEntropy(
|
||||
words: readonly string[],
|
||||
wordlistSize: number
|
||||
): number {
|
||||
if (!Number.isInteger(wordlistSize) || wordlistSize < 1) {
|
||||
throw pwgenError(
|
||||
"PWGEN.INVALID_OPTIONS",
|
||||
`wordlistSize must be a positive integer; received ${wordlistSize}`
|
||||
);
|
||||
}
|
||||
return Math.log2(wordlistSize) * words.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `PWGEN.STRENGTH_INSUFFICIENT` when `estimateEntropy(password).bits`
|
||||
* is strictly less than `minBits`. Returns void on success — callers prove
|
||||
* strength by absence of throw.
|
||||
*/
|
||||
export function assertStrength(
|
||||
password: string,
|
||||
minBits: number,
|
||||
opts?: EntropyOptions
|
||||
): void {
|
||||
const { bits } = estimateEntropy(password, opts);
|
||||
if (bits < minBits) {
|
||||
recordError("PWGEN.STRENGTH_INSUFFICIENT");
|
||||
throw pwgenError(
|
||||
"PWGEN.STRENGTH_INSUFFICIENT",
|
||||
`password entropy ${bits.toFixed(2)} bits is below the floor of ${minBits} bits`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
EntropyEstimate,
|
||||
EntropyOptions,
|
||||
} from "./entropy.js";
|
||||
export {
|
||||
assertStrength,
|
||||
estimateEntropy,
|
||||
estimatePassphraseEntropy,
|
||||
} from "./entropy.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# `errors/codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for `@suaveplan/password-generator` via `defineCodes("PWGEN", [...])` from `@suaveplan/error`. Every code names exactly one failure class so callers can branch on `error.code` rather than parsing message strings. The companion `PwgenError` class is the single concrete subtype thrown from this package, and `pwgenError(code, message, options)` is a convenience constructor that prefixes the message with the stable code for log scrapers.
|
||||
|
||||
The registry is intentionally minimal — six codes, no overlap, every code thrown from at least one production call site (verified by `codes.test.ts`). Adding a code without a thrown call site is banned per `AGENTS.md` §2c.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable codes: `INVALID_OPTIONS`, `OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `WORDLIST_NOT_REGISTERED`, `STRENGTH_INSUFFICIENT`, `RNG_FAILED`.
|
||||
- `PwgenError` extends `SuaveplanError<PwgenErrorCode>` for instanceof-friendly catching.
|
||||
- `pwgenError(code, message, { cause })` helper that auto-prefixes the message.
|
||||
- Frozen registry — direct property assignment throws in strict mode.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PwgenError, pwgenError } from "@suaveplan/password-generator/errors";
|
||||
|
||||
try {
|
||||
// ...
|
||||
} catch (e) {
|
||||
if (e instanceof PwgenError && e.code === "PWGEN.INVALID_OPTIONS") {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PWGEN_CODES,
|
||||
PwgenError,
|
||||
pwgenError,
|
||||
} from "@suaveplan/password-generator/errors";
|
||||
|
||||
// Catch and re-throw with structural context preserved
|
||||
try {
|
||||
throw pwgenError("PWGEN.RNG_FAILED", "downstream RNG bug", {
|
||||
cause: new Error("entropy source returned non-uint32"),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof PwgenError) {
|
||||
console.log(e.code, e.cause);
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate every code for an error-mapping table in i18n bundles
|
||||
for (const code of Object.values(PWGEN_CODES)) {
|
||||
console.log(code);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PWGEN_CODES`
|
||||
|
||||
Frozen record mapping each reason → fully-qualified code string. For example `PWGEN_CODES.RNG_FAILED === "PWGEN.RNG_FAILED"`.
|
||||
|
||||
### `PwgenErrorCode`
|
||||
|
||||
Literal-typed union of every registered code.
|
||||
|
||||
### `class PwgenError extends SuaveplanError<PwgenErrorCode>`
|
||||
|
||||
Construct with `(code, message, options?)`. Options may carry `cause: Error`.
|
||||
|
||||
### `pwgenError(code, message, options?): PwgenError`
|
||||
|
||||
Convenience constructor that prefixes the message with the code string. Non-`Error` `cause` values are silently dropped (per `SuaveplanError` contract).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry shape is dictated by `defineCodes` from `@suaveplan/error` — the return type is `{ readonly [K in Reasons[number]]:`PWGEN.${K}`}`, so TypeScript narrows `PWGEN_CODES.INVALID_OPTIONS` to the literal string `"PWGEN.INVALID_OPTIONS"`. This is what makes `if (e.code === "PWGEN.INVALID_OPTIONS")` typecheck without a `string` widening.
|
||||
|
||||
Three codes (`OPTIONS_INFEASIBLE`, `WORDLIST_TOO_SHORT`, `STRENGTH_INSUFFICIENT`) are sufficient to express every failure callers care about at first glance; the remaining three (`INVALID_OPTIONS`, `WORDLIST_NOT_REGISTERED`, `RNG_FAILED`) cover bug-shaped errors that should surface during development and never reach production. Catching `PwgenError` and branching on `code` lets call sites distinguish "user typed an invalid option" (recoverable, surface a form-validation message) from "RNG is broken" (panic, log, escalate).
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PWGEN_CODES, PwgenError, pwgenError } from "./codes.js";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist")
|
||||
continue;
|
||||
walk(p, out);
|
||||
} else if (
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".bench.ts")
|
||||
) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("PWGEN_CODES registry", () => {
|
||||
it("every registered code is thrown by at least one production call site", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sources = walk(SRC_ROOT)
|
||||
.filter((p) => !p.endsWith("/errors/codes.ts"))
|
||||
.map((p) => readFileSync(p, "utf8"))
|
||||
.join("\n");
|
||||
for (const code of Object.values(PWGEN_CODES)) {
|
||||
const short = code.replace(/^PWGEN\./, "");
|
||||
const pattern = new RegExp(`["']PWGEN\\.${short}["']`);
|
||||
expect(pattern.test(sources)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("PwgenError is a SuaveplanError subclass with the registered code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = pwgenError("PWGEN.INVALID_OPTIONS", "test");
|
||||
expect(e).toBeInstanceOf(PwgenError);
|
||||
expect(e.code).toBe("PWGEN.INVALID_OPTIONS");
|
||||
expect(e.message).toContain("PWGEN.INVALID_OPTIONS");
|
||||
});
|
||||
|
||||
it("pwgenError attaches Error cause when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const e = pwgenError("PWGEN.RNG_FAILED", "outer", { cause });
|
||||
expect(e.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("pwgenError ignores non-Error cause", { timeout: 5000 }, () => {
|
||||
const e = pwgenError("PWGEN.RNG_FAILED", "outer", { cause: "string" });
|
||||
expect(e.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("PwgenError ctor accepts default options", { timeout: 5000 }, () => {
|
||||
const e = new PwgenError("PWGEN.RNG_FAILED", "msg");
|
||||
expect(e.code).toBe("PWGEN.RNG_FAILED");
|
||||
});
|
||||
|
||||
it("pwgenError accepts default options", { timeout: 5000 }, () => {
|
||||
const e = pwgenError("PWGEN.RNG_FAILED", "msg");
|
||||
expect(e.code).toBe("PWGEN.RNG_FAILED");
|
||||
});
|
||||
|
||||
it("src tree exists and contains the expected modules", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(existsSync(join(SRC_ROOT, "random"))).toBe(true);
|
||||
expect(statSync(join(SRC_ROOT, "random")).isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable, registered error codes for `@suaveplan/password-generator`.
|
||||
*
|
||||
* Each code names a single failure class so callers can branch on the root
|
||||
* cause rather than parse message strings. Codes are namespaced `PWGEN.*`
|
||||
* and every entry is exercised by at least one production call site (no
|
||||
* orphan registry entries — see `openspec/AGENTS.md` §2c).
|
||||
*/
|
||||
export const PWGEN_CODES = defineCodes("PWGEN", [
|
||||
"INVALID_OPTIONS",
|
||||
"OPTIONS_INFEASIBLE",
|
||||
"WORDLIST_TOO_SHORT",
|
||||
"WORDLIST_NOT_REGISTERED",
|
||||
"STRENGTH_INSUFFICIENT",
|
||||
"RNG_FAILED",
|
||||
] as const);
|
||||
|
||||
/** Literal-typed union of every registered code. */
|
||||
export type PwgenErrorCode = (typeof PWGEN_CODES)[keyof typeof PWGEN_CODES];
|
||||
|
||||
/** Base class for every error thrown by `@suaveplan/password-generator`. */
|
||||
export class PwgenError extends SuaveplanError<PwgenErrorCode> {
|
||||
constructor(
|
||||
code: PwgenErrorCode,
|
||||
message: string,
|
||||
options: { cause?: Error } = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that constructs a `PwgenError` carrying a stable code and an
|
||||
* optional underlying cause. Centralised so call sites stay terse and the
|
||||
* code prefix appears in `.message` for log scrapers and toThrow matchers.
|
||||
*/
|
||||
export function pwgenError(
|
||||
code: PwgenErrorCode,
|
||||
message: string,
|
||||
options: { cause?: unknown } = {}
|
||||
): PwgenError {
|
||||
const opts: { cause?: Error } = {};
|
||||
if (options.cause instanceof Error) opts.cause = options.cause;
|
||||
return new PwgenError(code, `${code}: ${message}`, opts);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
PWGEN_CODES,
|
||||
PwgenError,
|
||||
type PwgenErrorCode,
|
||||
pwgenError,
|
||||
} from "./codes.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
describe("root barrel surface", () => {
|
||||
it("exports the documented public API", { timeout: 5000 }, () => {
|
||||
expect(typeof root.generatePassword).toBe("function");
|
||||
expect(typeof root.generatePassphrase).toBe("function");
|
||||
expect(typeof root.generatePronounceablePassword).toBe("function");
|
||||
expect(typeof root.generatePin).toBe("function");
|
||||
expect(typeof root.estimateEntropy).toBe("function");
|
||||
expect(typeof root.estimatePassphraseEntropy).toBe("function");
|
||||
expect(typeof root.assertStrength).toBe("function");
|
||||
expect(typeof root.randomInt).toBe("function");
|
||||
expect(typeof root.shuffleInPlace).toBe("function");
|
||||
expect(typeof root.defaultRng).toBe("function");
|
||||
expect(typeof root.registerWordlist).toBe("function");
|
||||
expect(typeof root.getWordlist).toBe("function");
|
||||
expect(typeof root.listWordlists).toBe("function");
|
||||
expect(root.EFF_LONG_WORDLIST.length).toBe(7776);
|
||||
expect(root.EFF_SHORT_WORDLIST.length).toBe(1296);
|
||||
expect(root.PWGEN_CODES.RNG_FAILED).toBe("PWGEN.RNG_FAILED");
|
||||
expect(typeof root.GeneratePasswordOptionsSchema).toBe("object");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Public root barrel for `@suaveplan/password-generator`. Explicit named
|
||||
// re-exports per CLAUDE.md §10.
|
||||
|
||||
export {
|
||||
AMBIGUOUS,
|
||||
buildEffectiveCharset,
|
||||
type CharsetOptions,
|
||||
DIGITS,
|
||||
LOOKALIKES,
|
||||
LOWERCASE,
|
||||
SYMBOLS,
|
||||
UPPERCASE,
|
||||
} from "./charsets/charsets.js";
|
||||
export {
|
||||
assertStrength,
|
||||
type EntropyEstimate,
|
||||
type EntropyOptions,
|
||||
estimateEntropy,
|
||||
estimatePassphraseEntropy,
|
||||
} from "./entropy/entropy.js";
|
||||
export {
|
||||
PWGEN_CODES,
|
||||
PwgenError,
|
||||
type PwgenErrorCode,
|
||||
pwgenError,
|
||||
} from "./errors/codes.js";
|
||||
export {
|
||||
entropyBits,
|
||||
errorsTotal,
|
||||
type GenerateMode,
|
||||
generateDurationMs,
|
||||
recordError,
|
||||
recordGeneration,
|
||||
} from "./observability/metrics.js";
|
||||
export { generatePassphrase } from "./passphrase/passphrase.js";
|
||||
export type {
|
||||
CapitalizationMode,
|
||||
GeneratePassphraseOptions,
|
||||
WordlistRef,
|
||||
} from "./passphrase/passphrase.types.js";
|
||||
export { generatePassword } from "./password/password.js";
|
||||
export type {
|
||||
GeneratePasswordOptions,
|
||||
PasswordMode,
|
||||
RequiredClasses,
|
||||
} from "./password/password.types.js";
|
||||
export {
|
||||
generatePronounceablePassword,
|
||||
PRONOUNCEABLE_CONSONANTS,
|
||||
PRONOUNCEABLE_VOWELS,
|
||||
} from "./password/pronounceable.js";
|
||||
export { generatePin } from "./pin/pin.js";
|
||||
export { defaultRng } from "./random/default-rng.js";
|
||||
export { shuffleInPlace } from "./random/fisher-yates.js";
|
||||
export { randomInt } from "./random/random-int.js";
|
||||
export {
|
||||
EntropyOptionsSchema,
|
||||
GeneratePassphraseOptionsSchema,
|
||||
GeneratePasswordOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export { EFF_LONG_WORDLIST } from "./wordlists/eff-long.js";
|
||||
export { EFF_SHORT_WORDLIST } from "./wordlists/eff-short.js";
|
||||
export {
|
||||
getWordlist,
|
||||
listWordlists,
|
||||
MIN_WORDLIST_SIZE,
|
||||
registerWordlist,
|
||||
} from "./wordlists/registry.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
_resetInstruments,
|
||||
entropyBits,
|
||||
errorsTotal,
|
||||
type GenerateMode,
|
||||
generateDurationMs,
|
||||
recordError,
|
||||
recordGeneration,
|
||||
} from "./metrics.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `observability/metrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Wires the package's OpenTelemetry instruments — `pwgen.generate_duration_ms{mode}` and `pwgen.entropy_bits{mode}` histograms, plus a `pwgen.errors_total{code}` counter — and exposes thin `recordGeneration` / `recordError` helpers every production call site uses. Instruments are obtained from `metrics.getMeter("@suaveplan/password-generator")` and lazy-initialised on first use so the active `MeterProvider` is always honoured (a global provider installed at startup overrides the package's no-op default).
|
||||
|
||||
The `mode` attribute is bounded to four documented values — `"random"`, `"pronounceable"`, `"passphrase"`, `"pin"` — so cardinality on every dashboard stays flat regardless of call-site volume. The `code` attribute on the counter mirrors the stable `PWGEN.*` registry, so a single counter view shows the full failure-mode mix.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy-init pattern with `_resetInstruments()` test hook.
|
||||
- Three instruments: two histograms (`generate_duration_ms`, `entropy_bits`) + one counter (`errors_total`).
|
||||
- Low-cardinality `mode` and `code` attributes.
|
||||
- `recordGeneration(mode, durationMs, bits)` rolls duration + entropy into one call.
|
||||
- `recordError(code)` accepts the raw `PWGEN.*` code string.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordError, recordGeneration } from "@suaveplan/password-generator";
|
||||
|
||||
const start = performance.now();
|
||||
const pw = "abc...";
|
||||
recordGeneration("random", performance.now() - start, 95.2);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { _resetInstruments } from "@suaveplan/password-generator";
|
||||
|
||||
// Install an in-memory provider for tests, then exercise the package.
|
||||
const calls: { name: string; value: number; attrs: Record<string, string> }[] = [];
|
||||
metrics.setGlobalMeterProvider({
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number, attrs: Record<string, string> = {}) =>
|
||||
calls.push({ name, value: v, attrs }),
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (v: number, attrs: Record<string, string> = {}) =>
|
||||
calls.push({ name, value: v, attrs }),
|
||||
}),
|
||||
}),
|
||||
} as never);
|
||||
_resetInstruments(); // Force lazy-init to use the fake provider next call
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordGeneration(mode: GenerateMode, durationMs: number, bits: number): void`
|
||||
|
||||
Records the per-call duration on `pwgen.generate_duration_ms` and the entropy on `pwgen.entropy_bits`, both tagged with `mode`.
|
||||
|
||||
### `recordError(code: string): void`
|
||||
|
||||
Increments `pwgen.errors_total` by 1 with `{ code }`.
|
||||
|
||||
### `_resetInstruments(): void`
|
||||
|
||||
Test-only hook that clears the lazy instrument cache. Call before installing a fake provider in tests.
|
||||
|
||||
### `generateDurationMs` / `entropyBits` / `errorsTotal`
|
||||
|
||||
Public facades on the underlying instruments — call `.record(value, { mode })` or `.add(value, { code })` for direct emission.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Lazy initialisation is critical: if instruments were created at module load, they would bind to whatever `MeterProvider` was global at that moment (almost always the no-op default), and a test installing a real provider afterwards would see no data. The `??`-style lazy pattern means every call to `getInstruments` walks the active provider's `getMeter` lookup until the first non-no-op call is made; from that point the cached references are reused for the lifetime of the process (or until `_resetInstruments()` clears them).
|
||||
|
||||
The four-value `mode` enum keeps per-mode cardinality bounded; adding a fifth mode would require a deliberate edit here and a refresh of dashboards. Resist the temptation to thread `password.length` or per-character `class` attributes into the histogram — those would explode cardinality and break the cost model OpenTelemetry backends assume.
|
||||
@@ -0,0 +1,149 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { generatePassphrase } from "../passphrase/passphrase.js";
|
||||
import { generatePassword } from "../password/password.js";
|
||||
import { generatePronounceablePassword } from "../password/pronounceable.js";
|
||||
import { generatePin } from "../pin/pin.js";
|
||||
import {
|
||||
_resetInstruments,
|
||||
entropyBits,
|
||||
errorsTotal,
|
||||
generateDurationMs,
|
||||
recordError,
|
||||
recordGeneration,
|
||||
} from "./metrics.js";
|
||||
|
||||
interface CounterCall {
|
||||
name: string;
|
||||
value: number;
|
||||
attrs: Record<string, string>;
|
||||
}
|
||||
interface HistogramCall {
|
||||
name: string;
|
||||
value: number;
|
||||
attrs: Record<string, string>;
|
||||
}
|
||||
|
||||
let counterCalls: CounterCall[];
|
||||
let histogramCalls: HistogramCall[];
|
||||
|
||||
function installFakeProvider(): void {
|
||||
counterCalls = [];
|
||||
histogramCalls = [];
|
||||
const provider = {
|
||||
getMeter: () => ({
|
||||
createCounter: (name: string) => ({
|
||||
add: (v: number, attrs: Record<string, string> = {}) => {
|
||||
counterCalls.push({ name, value: v, attrs });
|
||||
},
|
||||
}),
|
||||
createHistogram: (name: string) => ({
|
||||
record: (v: number, attrs: Record<string, string> = {}) => {
|
||||
histogramCalls.push({ name, value: v, attrs });
|
||||
},
|
||||
}),
|
||||
createUpDownCounter: () => ({ add: () => {} }),
|
||||
createObservableCounter: () => ({
|
||||
addCallback: () => {},
|
||||
removeCallback: () => {},
|
||||
}),
|
||||
createObservableGauge: () => ({
|
||||
addCallback: () => {},
|
||||
removeCallback: () => {},
|
||||
}),
|
||||
createObservableUpDownCounter: () => ({
|
||||
addCallback: () => {},
|
||||
removeCallback: () => {},
|
||||
}),
|
||||
createGauge: () => ({ record: () => {} }),
|
||||
}),
|
||||
};
|
||||
metrics.setGlobalMeterProvider(
|
||||
provider as unknown as Parameters<
|
||||
typeof metrics.setGlobalMeterProvider
|
||||
>[0]
|
||||
);
|
||||
}
|
||||
|
||||
describe("OpenTelemetry instrumentation", () => {
|
||||
beforeEach(() => {
|
||||
_resetInstruments();
|
||||
installFakeProvider();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
metrics.disable();
|
||||
_resetInstruments();
|
||||
});
|
||||
|
||||
it("emits pwgen.generate_duration_ms histogram for random mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
generatePassword({ length: 16 });
|
||||
const hit = histogramCalls.find(
|
||||
(h) =>
|
||||
h.name === "pwgen.generate_duration_ms" &&
|
||||
h.attrs.mode === "random"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits pwgen.entropy_bits histogram for passphrase mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
generatePassphrase({ length: 4 });
|
||||
const hit = histogramCalls.find(
|
||||
(h) =>
|
||||
h.name === "pwgen.entropy_bits" && h.attrs.mode === "passphrase"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits histogram for pronounceable mode", { timeout: 5000 }, () => {
|
||||
generatePronounceablePassword({ length: 9, mode: "pronounceable" });
|
||||
const hit = histogramCalls.find(
|
||||
(h) =>
|
||||
h.name === "pwgen.generate_duration_ms" &&
|
||||
h.attrs.mode === "pronounceable"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits histogram for pin mode", { timeout: 5000 }, () => {
|
||||
generatePin(6);
|
||||
const hit = histogramCalls.find(
|
||||
(h) =>
|
||||
h.name === "pwgen.generate_duration_ms" &&
|
||||
h.attrs.mode === "pin"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("increments pwgen.errors_total on PWGEN.OPTIONS_INFEASIBLE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => generatePassword({ length: 0 })).toThrow();
|
||||
const hit = counterCalls.find(
|
||||
(c) => c.name === "pwgen.errors_total" && c.attrs.code !== undefined
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("recordGeneration / recordError / instrument facades route through the active provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordGeneration("random", 1, 64);
|
||||
recordError("PWGEN.RNG_FAILED");
|
||||
generateDurationMs.record(2, { mode: "passphrase" });
|
||||
entropyBits.record(50, { mode: "passphrase" });
|
||||
errorsTotal.add(1, { code: "PWGEN.INVALID_OPTIONS" });
|
||||
expect(histogramCalls.length).toBeGreaterThan(0);
|
||||
expect(counterCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/** Low-cardinality `mode` enum for the histograms below. */
|
||||
export type GenerateMode = "random" | "pronounceable" | "passphrase" | "pin";
|
||||
|
||||
let _generateDurationMs: Histogram | undefined;
|
||||
let _entropyBits: Histogram | undefined;
|
||||
let _errorsTotal: Counter | undefined;
|
||||
|
||||
function getInstruments(): {
|
||||
generateDurationMs: Histogram;
|
||||
entropyBits: Histogram;
|
||||
errorsTotal: Counter;
|
||||
} {
|
||||
const meter = metrics.getMeter("@suaveplan/password-generator");
|
||||
_generateDurationMs ??= meter.createHistogram(
|
||||
"pwgen.generate_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of a single successful password / passphrase / PIN generation",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
_entropyBits ??= meter.createHistogram("pwgen.entropy_bits", {
|
||||
description: "Estimated entropy of the generated secret",
|
||||
unit: "bit",
|
||||
});
|
||||
_errorsTotal ??= meter.createCounter("pwgen.errors_total", {
|
||||
description:
|
||||
"Count of typed errors thrown from @suaveplan/password-generator",
|
||||
});
|
||||
return {
|
||||
generateDurationMs: _generateDurationMs,
|
||||
entropyBits: _entropyBits,
|
||||
errorsTotal: _errorsTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful generation. Threading every production path through
|
||||
* a single helper keeps the attribute key set ("mode") consistent and the
|
||||
* cardinality bounded.
|
||||
*/
|
||||
export function recordGeneration(
|
||||
mode: GenerateMode,
|
||||
durationMs: number,
|
||||
bits: number
|
||||
): void {
|
||||
const { generateDurationMs, entropyBits } = getInstruments();
|
||||
generateDurationMs.record(durationMs, { mode });
|
||||
entropyBits.record(bits, { mode });
|
||||
}
|
||||
|
||||
/** Record a typed error by stable code. */
|
||||
export function recordError(code: string): void {
|
||||
const { errorsTotal } = getInstruments();
|
||||
errorsTotal.add(1, { code });
|
||||
}
|
||||
|
||||
/** Reset the cached instruments (test-only). */
|
||||
export function _resetInstruments(): void {
|
||||
_generateDurationMs = undefined;
|
||||
_entropyBits = undefined;
|
||||
_errorsTotal = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-facing accessors so callers (and tests) can inspect the
|
||||
* underlying instruments. Lazy-evaluated so the active MeterProvider is
|
||||
* always honoured.
|
||||
*/
|
||||
export const generateDurationMs = {
|
||||
record(value: number, attrs: { mode: GenerateMode }): void {
|
||||
getInstruments().generateDurationMs.record(value, attrs);
|
||||
},
|
||||
};
|
||||
|
||||
export const entropyBits = {
|
||||
record(value: number, attrs: { mode: GenerateMode }): void {
|
||||
getInstruments().entropyBits.record(value, attrs);
|
||||
},
|
||||
};
|
||||
|
||||
export const errorsTotal = {
|
||||
add(value: number, attrs: { code: string }): void {
|
||||
getInstruments().errorsTotal.add(value, attrs);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { generatePassphrase } from "./passphrase.js";
|
||||
export type {
|
||||
CapitalizationMode,
|
||||
GeneratePassphraseOptions,
|
||||
WordlistRef,
|
||||
} from "./passphrase.types.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `passphrase`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`generatePassphrase(opts, rng?)` produces an EFF-style Diceware passphrase by drawing `length` words uniformly (via the bias-free `randomInt`) from the wordlist resolved via `opts.wordlist`. The default — 6 words from the EFF Long Wordlist joined with `-` — gives ≈ 77.55 bits of entropy, comfortably above the 75-bit floor recommended for symmetric-key-equivalent secrets. Diceware-style passphrases are dramatically more memorable than random char-class passwords at equivalent entropy, which is why the vault item editor in `@suaveplan/vault` defaults to this mode for the "Generate password" surface.
|
||||
|
||||
The function honours configurable separator, three capitalisation modes (`none` / `firstChar` / `randomWord`), and optional `addDigits` / `addSymbols` injections at random positions in the joined output. The decoration knobs exist so the same generator can produce strings that satisfy downstream "must contain a digit" composition rules without abandoning the Diceware backbone.
|
||||
|
||||
## Features
|
||||
|
||||
- Bundled EFF Long (7776) and EFF Short 1 (1296) wordlists via tree-shakeable subpath imports.
|
||||
- Caller-supplied `string[]` wordlists allowed past the 256-entry min-size guard.
|
||||
- Configurable separator (`default "-"`).
|
||||
- `capitalize: "none" | "firstChar" | "randomWord"`.
|
||||
- `addDigits` / `addSymbols` injection at randomised positions.
|
||||
- Deterministic under seeded `Rng`.
|
||||
- Zod-validated; mismatch → `PWGEN.INVALID_OPTIONS`.
|
||||
- Emits `pwgen.generate_duration_ms{mode=passphrase}` and `pwgen.entropy_bits{mode=passphrase}` histograms.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generatePassphrase } from "@suaveplan/password-generator";
|
||||
|
||||
const pp = generatePassphrase({}); // 6 words, EFF Long, joined with "-"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generatePassphrase } from "@suaveplan/password-generator";
|
||||
|
||||
// 5 short words capitalised at random, two trailing digits, one symbol
|
||||
const pp = generatePassphrase({
|
||||
length: 5,
|
||||
wordlist: "short",
|
||||
capitalize: "randomWord",
|
||||
addDigits: 2,
|
||||
addSymbols: 1,
|
||||
separator: " ",
|
||||
});
|
||||
|
||||
// Caller-supplied themed wordlist (must be ≥ 256 entries)
|
||||
const dnd = [/* ... 300 fantasy nouns ... */];
|
||||
const themed = generatePassphrase({ wordlist: dnd, length: 4 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generatePassphrase(opts?: GeneratePassphraseOptions, rng?: Rng): string`
|
||||
|
||||
- `opts.length` (default `6`): number of words to draw.
|
||||
- `opts.wordlist` (default `"long"`): `"long"` / `"short"` / registry name / inline `string[]`.
|
||||
- `opts.separator` (default `"-"`): string inserted between words before decoration.
|
||||
- `opts.capitalize` (default `"none"`): `"none"` / `"firstChar"` / `"randomWord"`.
|
||||
- `opts.addDigits` / `opts.addSymbols` (default `0`): count of characters injected at randomised positions.
|
||||
- `rng`: defaults to `defaultRng()`.
|
||||
|
||||
Throws `PwgenError(PWGEN.INVALID_OPTIONS)` on Zod mismatch, `PwgenError(PWGEN.WORDLIST_TOO_SHORT)` for inline arrays below 256 entries, and `PwgenError(PWGEN.WORDLIST_NOT_REGISTERED)` for unknown registry names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Wordlist resolution lazily routes through `getWordlist(name)` so the registry's min-size guard applies uniformly — there's no special-case for the bundled EFF lists. Inline `string[]` wordlists bypass the registry but go through the same 256-entry floor inline, so a caller passing a tiny array sees the same `PWGEN.WORDLIST_TOO_SHORT` they would see from `registerWordlist`.
|
||||
|
||||
Decoration is applied _after_ joining: `addDigits` / `addSymbols` inserts characters at uniformly-random positions in the already-joined string. This means a digit can land mid-word (e.g. `hor5se-rifle`) — that's intentional because inserting only at word boundaries would create a small-cardinality position distribution that's easier to fingerprint. Per-class injection uses bias-free `randomInt` against the `DIGITS` / `SYMBOLS` sets defined in the `charsets` module.
|
||||
|
||||
Entropy reporting uses `estimatePassphraseEntropy(words, wordlistSize)` against the _picked_ words (not the post-decoration string), so the reported figure reflects the Diceware contribution. Decoration adds a small amount of entropy on top of that, but the package does not currently roll that into the reported figure to keep the formula stable across runs.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import fc from "fast-check";
|
||||
import { EFF_LONG_WORDLIST } from "../wordlists/eff-long.js";
|
||||
import { EFF_SHORT_WORDLIST } from "../wordlists/eff-short.js";
|
||||
import { generatePassphrase } from "./passphrase.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePassphrase — spec conformance", () => {
|
||||
it("every sampled passphrase respects the membership invariant", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const longSet = new Set(EFF_LONG_WORDLIST);
|
||||
const shortSet = new Set(EFF_SHORT_WORDLIST);
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
ref: fc.constantFrom("long" as const, "short" as const),
|
||||
length: fc.integer({ min: 1, max: 12 }),
|
||||
}),
|
||||
({ ref, length }) => {
|
||||
// Use space separator so hyphenated entries
|
||||
// (`yo-yo`, `t-shirt`, …) round-trip through split.
|
||||
const pp = generatePassphrase({
|
||||
wordlist: ref,
|
||||
length,
|
||||
separator: " ",
|
||||
});
|
||||
const words = pp.split(" ");
|
||||
expect(words.length).toBe(length);
|
||||
const lookup = ref === "long" ? longSet : shortSet;
|
||||
for (const w of words) expect(lookup.has(w)).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("every output word is a member of the resolved wordlist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pp = generatePassphrase({
|
||||
length: 8,
|
||||
wordlist: "long",
|
||||
separator: " ",
|
||||
});
|
||||
for (const w of pp.split(" ")) {
|
||||
expect(EFF_LONG_WORDLIST.includes(w)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("generatePassphrase is deterministic under a seeded Rng", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = generatePassphrase(
|
||||
{ length: 6, capitalize: "firstChar" },
|
||||
seededRng(99)
|
||||
);
|
||||
const b = generatePassphrase(
|
||||
{ length: 6, capitalize: "firstChar" },
|
||||
seededRng(99)
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { DIGITS, SYMBOLS } from "../charsets/charsets.js";
|
||||
import { EFF_LONG_WORDLIST } from "../wordlists/eff-long.js";
|
||||
import { EFF_SHORT_WORDLIST } from "../wordlists/eff-short.js";
|
||||
import { generatePassphrase } from "./passphrase.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePassphrase", () => {
|
||||
it("defaults to EFF Long with length 6", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({});
|
||||
const words = pp.split("-");
|
||||
expect(words.length).toBe(6);
|
||||
for (const w of words) expect(EFF_LONG_WORDLIST.includes(w)).toBe(true);
|
||||
});
|
||||
|
||||
it("draws from EFF Short when wordlist is short", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({ wordlist: "short", length: 7 });
|
||||
const words = pp.split("-");
|
||||
expect(words.length).toBe(7);
|
||||
for (const w of words)
|
||||
expect(EFF_SHORT_WORDLIST.includes(w)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts inline wordlist arrays meeting the min-size guard", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inline = Array.from({ length: 256 }, (_, i) => `w${i}`);
|
||||
const pp = generatePassphrase({ wordlist: inline, length: 4 });
|
||||
const words = pp.split("-");
|
||||
expect(words.length).toBe(4);
|
||||
for (const w of words) expect(inline.includes(w)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects inline wordlist arrays below 256 entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
generatePassphrase({ wordlist: ["one", "two"], length: 2 })
|
||||
).toThrow(/PWGEN\.WORDLIST_TOO_SHORT/);
|
||||
});
|
||||
|
||||
it("capitalize firstChar applies to every word", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({ length: 4, capitalize: "firstChar" });
|
||||
const words = pp.split("-");
|
||||
for (const w of words) {
|
||||
expect(w.charAt(0)).toBe(w.charAt(0).toUpperCase());
|
||||
expect(/^[A-Z]/.test(w)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("capitalize randomWord applies to exactly one word", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pp = generatePassphrase(
|
||||
{ length: 4, capitalize: "randomWord" },
|
||||
seededRng(11)
|
||||
);
|
||||
const words = pp.split("-");
|
||||
const upperCount = words.filter((w) => /^[A-Z]/.test(w)).length;
|
||||
expect(upperCount).toBe(1);
|
||||
});
|
||||
|
||||
it("addDigits and addSymbols inject the requested counts", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Use a separator outside both DIGITS and SYMBOLS so the injected
|
||||
// counts are not confounded by the separator characters.
|
||||
const pp = generatePassphrase({
|
||||
length: 3,
|
||||
addDigits: 2,
|
||||
addSymbols: 1,
|
||||
separator: " ",
|
||||
});
|
||||
const digitCount = [...pp].filter((c) => DIGITS.has(c)).length;
|
||||
const symbolCount = [...pp].filter((c) => SYMBOLS.has(c)).length;
|
||||
expect(digitCount).toBe(2);
|
||||
expect(symbolCount).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a custom separator", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({ length: 4, separator: "." });
|
||||
expect(pp.split(".").length).toBe(4);
|
||||
});
|
||||
|
||||
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
|
||||
const a = generatePassphrase(
|
||||
{ length: 6, capitalize: "firstChar" },
|
||||
seededRng(42)
|
||||
);
|
||||
const b = generatePassphrase(
|
||||
{ length: 6, capitalize: "firstChar" },
|
||||
seededRng(42)
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rejects invalid input with PWGEN.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => generatePassphrase({ length: 0 })).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves wordlist via registry name", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({ wordlist: "eff-short", length: 4 });
|
||||
const words = pp.split("-");
|
||||
for (const w of words)
|
||||
expect(EFF_SHORT_WORDLIST.includes(w)).toBe(true);
|
||||
});
|
||||
|
||||
it("works with no opts argument", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase();
|
||||
expect(pp.split("-").length).toBe(6);
|
||||
});
|
||||
|
||||
it("applies capitalize none by default", { timeout: 5000 }, () => {
|
||||
const pp = generatePassphrase({ length: 4 });
|
||||
const words = pp.split("-");
|
||||
for (const w of words) expect(/^[a-z]/.test(w)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { DIGITS, SYMBOLS } from "../charsets/charsets.js";
|
||||
import { estimatePassphraseEntropy } from "../entropy/entropy.js";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { recordError, recordGeneration } from "../observability/metrics.js";
|
||||
import { defaultRng } from "../random/default-rng.js";
|
||||
import { randomInt } from "../random/random-int.js";
|
||||
import { GeneratePassphraseOptionsSchema } from "../schemas/schemas.js";
|
||||
import { getWordlist, MIN_WORDLIST_SIZE } from "../wordlists/registry.js";
|
||||
import type {
|
||||
GeneratePassphraseOptions,
|
||||
WordlistRef,
|
||||
} from "./passphrase.types.js";
|
||||
|
||||
const DEFAULT_LENGTH = 6;
|
||||
const DEFAULT_SEPARATOR = "-";
|
||||
|
||||
function resolveWordlist(ref: WordlistRef | undefined): readonly string[] {
|
||||
if (ref === undefined || ref === "long") return getWordlist("eff-long");
|
||||
if (ref === "short") return getWordlist("eff-short");
|
||||
if (typeof ref === "string") return getWordlist(ref);
|
||||
if (ref.length < MIN_WORDLIST_SIZE) {
|
||||
throw pwgenError(
|
||||
"PWGEN.WORDLIST_TOO_SHORT",
|
||||
`inline wordlist has ${ref.length} entries; minimum is ${MIN_WORDLIST_SIZE}`
|
||||
);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
function capitalizeFirst(word: string): string {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
function applyCapitalization(
|
||||
words: string[],
|
||||
mode: "none" | "firstChar" | "randomWord",
|
||||
rng: Rng
|
||||
): string[] {
|
||||
if (mode === "none") return words;
|
||||
if (mode === "firstChar") return words.map(capitalizeFirst);
|
||||
const pickIdx = randomInt(rng, words.length);
|
||||
return words.map((w, i) => (i === pickIdx ? capitalizeFirst(w) : w));
|
||||
}
|
||||
|
||||
function injectChars(
|
||||
base: string,
|
||||
count: number,
|
||||
source: ReadonlySet<string>,
|
||||
rng: Rng
|
||||
): string {
|
||||
if (count === 0) return base;
|
||||
const chars = [...source];
|
||||
let acc = base;
|
||||
for (let n = 0; n < count; n++) {
|
||||
const insertAt = randomInt(rng, acc.length + 1);
|
||||
const ch = chars[randomInt(rng, chars.length)] as string;
|
||||
acc = acc.slice(0, insertAt) + ch + acc.slice(insertAt);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an EFF-style Diceware passphrase. Default is 6 words drawn
|
||||
* uniformly from the EFF Long Wordlist (~77.5 bits of entropy) joined
|
||||
* with `-`.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.INVALID_OPTIONS) on schema mismatch.
|
||||
* @throws PwgenError(PWGEN.WORDLIST_TOO_SHORT) on inline wordlists with
|
||||
* < 256 entries.
|
||||
* @throws PwgenError(PWGEN.WORDLIST_NOT_REGISTERED) for unknown registry
|
||||
* names.
|
||||
*/
|
||||
export function generatePassphrase(
|
||||
opts: GeneratePassphraseOptions = {},
|
||||
rng?: Rng
|
||||
): string {
|
||||
const parsed = GeneratePassphraseOptionsSchema.safeParse(opts);
|
||||
if (!parsed.success) {
|
||||
recordError("PWGEN.INVALID_OPTIONS");
|
||||
throw pwgenError("PWGEN.INVALID_OPTIONS", parsed.error.message, {
|
||||
cause: parsed.error,
|
||||
});
|
||||
}
|
||||
const o = parsed.data;
|
||||
const length = o.length ?? DEFAULT_LENGTH;
|
||||
const separator = o.separator ?? DEFAULT_SEPARATOR;
|
||||
const capitalize = o.capitalize ?? "none";
|
||||
const addDigits = o.addDigits ?? 0;
|
||||
const addSymbols = o.addSymbols ?? 0;
|
||||
const effectiveRng = rng ?? defaultRng();
|
||||
const wordlist = resolveWordlist(o.wordlist);
|
||||
const start = performance.now();
|
||||
const picked: string[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
picked.push(
|
||||
wordlist[randomInt(effectiveRng, wordlist.length)] as string
|
||||
);
|
||||
}
|
||||
const cased = applyCapitalization(picked, capitalize, effectiveRng);
|
||||
let joined = cased.join(separator);
|
||||
joined = injectChars(joined, addDigits, DIGITS, effectiveRng);
|
||||
joined = injectChars(joined, addSymbols, SYMBOLS, effectiveRng);
|
||||
const bits = estimatePassphraseEntropy(picked, wordlist.length);
|
||||
recordGeneration("passphrase", performance.now() - start, bits);
|
||||
return joined;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type CapitalizationMode = "none" | "firstChar" | "randomWord";
|
||||
|
||||
/**
|
||||
* Wordlist reference: `"long"` resolves to the bundled EFF Long Wordlist,
|
||||
* `"short"` resolves to the bundled EFF Short Wordlist 1, any other
|
||||
* non-empty string resolves to a name in the registry, and a `string[]`
|
||||
* is passed through the registry's min-size guard inline.
|
||||
*/
|
||||
export type WordlistRef = "long" | "short" | string | readonly string[];
|
||||
|
||||
export interface GeneratePassphraseOptions {
|
||||
readonly length?: number;
|
||||
readonly wordlist?: WordlistRef;
|
||||
readonly separator?: string;
|
||||
readonly capitalize?: CapitalizationMode;
|
||||
readonly addDigits?: number;
|
||||
readonly addSymbols?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { generatePassword } from "./password.js";
|
||||
export type {
|
||||
GeneratePasswordOptions,
|
||||
PasswordMode,
|
||||
RequiredClasses,
|
||||
} from "./password.types.js";
|
||||
export {
|
||||
generatePronounceablePassword,
|
||||
PRONOUNCEABLE_CONSONANTS,
|
||||
PRONOUNCEABLE_VOWELS,
|
||||
} from "./pronounceable.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# `password`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - pronounceable — `generatePronounceablePassword(opts…
|
||||
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- pronounceable — `generatePronounceablePassword(opts…
|
||||
|
||||
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
|
||||
- pronounceable — `generatePronounceablePassword(opts…
|
||||
|
||||
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
|
||||
- pronounceable — `generatePronounceablePassword(opts…
|
||||
|
||||
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
- [pronounceable](./pronounceable.md) — `generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for should…
|
||||
|
||||
## Purpose
|
||||
|
||||
`generatePassword(opts, rng?)` produces a `length`-character password drawn uniformly from the union of enabled character classes minus any caller-supplied exclusions. Required-class quotas (`requireLowercase`, `requireUppercase`, `requireDigits`, `requireSymbols`) are satisfied without leaking position information: one randomly-chosen character from each required class is placed first, the remainder filled from the union, and the entire buffer Fisher-Yates-shuffled so the required-class characters are not biased to the head of the string. NIST SP 800-63B §5.1.1.2 deprecates required-class composition rules — they reduce effective entropy and trade resistance to cracking for a stronger memorability constraint — but downstream sites still mandate them, so the generator supports per-class quotas while documenting the cost.
|
||||
|
||||
## Features
|
||||
|
||||
- Bias-free draws via `randomInt` (Lemire 2018 rejection sampling).
|
||||
- Per-class enablement (`lowercase`/`uppercase`/`digits`/`symbols`) and per-class required quotas.
|
||||
- Arbitrary `exclude` set (string / `string[]` / `Set<string>`).
|
||||
- Deterministic under a seeded `Rng` for replay tests.
|
||||
- Zod-validated `opts`; mismatch → `PWGEN.INVALID_OPTIONS`.
|
||||
- Rejects infeasible options (`length < requiredCount` or empty effective charset) with `PWGEN.OPTIONS_INFEASIBLE`.
|
||||
- Emits `pwgen.generate_duration_ms{mode=random}` and `pwgen.entropy_bits{mode=random}` on success.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generatePassword } from "@suaveplan/password-generator";
|
||||
|
||||
const pw = generatePassword({ length: 16 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
AMBIGUOUS,
|
||||
generatePassword,
|
||||
} from "@suaveplan/password-generator";
|
||||
|
||||
// 32-char password that satisfies every class and avoids confusable chars
|
||||
const pw = generatePassword({
|
||||
length: 32,
|
||||
requireLowercase: true,
|
||||
requireUppercase: true,
|
||||
requireDigits: true,
|
||||
requireSymbols: true,
|
||||
exclude: AMBIGUOUS,
|
||||
});
|
||||
|
||||
// Deterministic replay under a seeded Rng
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
function seeded(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
const replayed = generatePassword({ length: 24 }, seeded(42));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generatePassword(opts: GeneratePasswordOptions, rng?: Rng): string`
|
||||
|
||||
- `opts.length`: positive integer total length.
|
||||
- `opts.lowercase` / `opts.uppercase` / `opts.digits` / `opts.symbols`: defaults to `true`; pass `false` to omit the class from the union.
|
||||
- `opts.requireLowercase` / `opts.requireUppercase` / `opts.requireDigits` / `opts.requireSymbols`: defaults to `false`; when `true`, at least one character from the class is guaranteed in the output.
|
||||
- `opts.exclude`: characters to subtract from the union after class merge.
|
||||
- `opts.mode`: ignored by `generatePassword`; see `pronounceable.ts` for the `pronounceable` mode.
|
||||
- `rng`: defaults to `defaultRng()`.
|
||||
|
||||
Throws `PwgenError(PWGEN.INVALID_OPTIONS)` on Zod mismatch, `PwgenError(PWGEN.OPTIONS_INFEASIBLE)` when `length < requiredCount` or when the effective charset is empty after exclusions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The required-class placement is a two-phase fill: phase 1 pushes one randomly-chosen character per enabled required class into the buffer; phase 2 fills the remainder from the full effective charset. The buffer is then shuffled in-place via `shuffleInPlace` so the required-class characters end up at uniformly-distributed positions across `[0, length)`. Without the shuffle, the leading `requiredCount` characters would be deterministic-by-position, which is observable to an attacker and weakens the resistance of the generated password.
|
||||
|
||||
Effective-charset construction goes through `buildEffectiveCharset`, which performs class-merge + exclusion arithmetic. If any required class becomes empty after exclusions (e.g. `requireDigits: true` with `exclude: "0123456789"`) the generator throws `PWGEN.OPTIONS_INFEASIBLE` instead of producing an output that silently violates the contract. Entropy is reported via `estimateEntropy` immediately before recording — this is an upper-bound `log2(charset) * length` estimate; pattern-aware scoring lives in `@suaveplan/password-strength`.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import fc from "fast-check";
|
||||
import { DIGITS, LOWERCASE, SYMBOLS, UPPERCASE } from "../charsets/charsets.js";
|
||||
import { generatePassword } from "./password.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePassword — spec conformance", () => {
|
||||
it("every sampled options bundle yields a quota-respecting password", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
length: fc.integer({ min: 4, max: 32 }),
|
||||
rl: fc.boolean(),
|
||||
ru: fc.boolean(),
|
||||
rd: fc.boolean(),
|
||||
rs: fc.boolean(),
|
||||
}),
|
||||
({ length, rl, ru, rd, rs }) => {
|
||||
const required =
|
||||
(rl ? 1 : 0) +
|
||||
(ru ? 1 : 0) +
|
||||
(rd ? 1 : 0) +
|
||||
(rs ? 1 : 0);
|
||||
if (length < required) return;
|
||||
const pw = generatePassword({
|
||||
length,
|
||||
requireLowercase: rl,
|
||||
requireUppercase: ru,
|
||||
requireDigits: rd,
|
||||
requireSymbols: rs,
|
||||
});
|
||||
if (rl)
|
||||
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(
|
||||
true
|
||||
);
|
||||
if (ru)
|
||||
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(
|
||||
true
|
||||
);
|
||||
if (rd)
|
||||
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
|
||||
if (rs)
|
||||
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("required-class character position is uniform after shuffle", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const trials = 2000;
|
||||
const length = 10;
|
||||
const positionCounts = new Array<number>(length).fill(0);
|
||||
for (let i = 0; i < trials; i++) {
|
||||
const pw = generatePassword({
|
||||
length,
|
||||
requireDigits: true,
|
||||
lowercase: true,
|
||||
uppercase: false,
|
||||
digits: false,
|
||||
symbols: false,
|
||||
});
|
||||
for (let j = 0; j < pw.length; j++) {
|
||||
if (DIGITS.has(pw[j] as string)) {
|
||||
positionCounts[j] = (positionCounts[j] as number) + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// With one required digit shuffled across `length` positions, the
|
||||
// first-digit position is uniform. Expected per bucket: trials/length.
|
||||
// Allow ±35% slack to keep this test stable on CI variance.
|
||||
const expected = trials / length;
|
||||
for (const count of positionCounts) {
|
||||
expect(count).toBeGreaterThan(expected * 0.65);
|
||||
expect(count).toBeLessThan(expected * 1.35);
|
||||
}
|
||||
});
|
||||
|
||||
it("every required class is present in the output", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
for (let trial = 0; trial < 100; trial++) {
|
||||
const pw = generatePassword({
|
||||
length: 16,
|
||||
requireLowercase: true,
|
||||
requireUppercase: true,
|
||||
requireDigits: true,
|
||||
requireSymbols: true,
|
||||
});
|
||||
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("generatePassword is deterministic under a seeded Rng", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = generatePassword(
|
||||
{ length: 32, requireDigits: true },
|
||||
seededRng(123)
|
||||
);
|
||||
const b = generatePassword(
|
||||
{ length: 32, requireDigits: true },
|
||||
seededRng(123)
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("default Rng draws from crypto-utils.randomBytes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const module = await import("@suaveplan/crypto-utils");
|
||||
expect(typeof module.randomBytes).toBe("function");
|
||||
// Sanity: the generated output is a 16-char string.
|
||||
const pw = generatePassword({ length: 16 });
|
||||
expect(pw.length).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
AMBIGUOUS,
|
||||
DIGITS,
|
||||
LOWERCASE,
|
||||
SYMBOLS,
|
||||
UPPERCASE,
|
||||
} from "../charsets/charsets.js";
|
||||
import { generatePassword } from "./password.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePassword", () => {
|
||||
it("generates a length-16 password from the default union", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePassword({ length: 16 });
|
||||
expect(pw.length).toBe(16);
|
||||
for (const ch of pw) {
|
||||
const inUnion =
|
||||
LOWERCASE.has(ch) ||
|
||||
UPPERCASE.has(ch) ||
|
||||
DIGITS.has(ch) ||
|
||||
SYMBOLS.has(ch);
|
||||
expect(inUnion).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects exclusion sets", { timeout: 5000 }, () => {
|
||||
const pw = generatePassword({ length: 32, exclude: AMBIGUOUS });
|
||||
for (const ch of pw) expect(AMBIGUOUS.has(ch)).toBe(false);
|
||||
});
|
||||
|
||||
it("satisfies all required-class quotas simultaneously", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let trial = 0; trial < 50; trial++) {
|
||||
const pw = generatePassword({
|
||||
length: 12,
|
||||
requireLowercase: true,
|
||||
requireUppercase: true,
|
||||
requireDigits: true,
|
||||
requireSymbols: true,
|
||||
});
|
||||
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => DIGITS.has(c))).toBe(true);
|
||||
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws PWGEN.OPTIONS_INFEASIBLE when length below required count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
generatePassword({
|
||||
length: 2,
|
||||
requireLowercase: true,
|
||||
requireUppercase: true,
|
||||
requireDigits: true,
|
||||
requireSymbols: true,
|
||||
})
|
||||
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("throws PWGEN.OPTIONS_INFEASIBLE when effective charset is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
generatePassword({
|
||||
length: 8,
|
||||
lowercase: false,
|
||||
uppercase: false,
|
||||
digits: false,
|
||||
symbols: false,
|
||||
})
|
||||
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("throws PWGEN.OPTIONS_INFEASIBLE when a required class is excluded out", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
generatePassword({
|
||||
length: 8,
|
||||
requireDigits: true,
|
||||
exclude: "0123456789",
|
||||
})
|
||||
).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("is deterministic under a seeded Rng", { timeout: 5000 }, () => {
|
||||
const a = generatePassword(
|
||||
{ length: 32, requireDigits: true },
|
||||
seededRng(7)
|
||||
);
|
||||
const b = generatePassword(
|
||||
{ length: 32, requireDigits: true },
|
||||
seededRng(7)
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("only requires lowercase when only lowercase requested", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePassword({ length: 8, requireLowercase: true });
|
||||
expect([...pw].some((c) => LOWERCASE.has(c))).toBe(true);
|
||||
});
|
||||
|
||||
it("only requires uppercase when only uppercase requested", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePassword({ length: 8, requireUppercase: true });
|
||||
expect([...pw].some((c) => UPPERCASE.has(c))).toBe(true);
|
||||
});
|
||||
|
||||
it("only requires symbols when only symbols requested", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePassword({ length: 8, requireSymbols: true });
|
||||
expect([...pw].some((c) => SYMBOLS.has(c))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid schema input with PWGEN.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => generatePassword({ length: -1 })).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts array-form exclude in generatePassword", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePassword({
|
||||
length: 16,
|
||||
requireDigits: true,
|
||||
exclude: ["a", "b", "c"],
|
||||
});
|
||||
for (const ch of pw) {
|
||||
expect(ch === "a" || ch === "b" || ch === "c").toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts Set-form exclude in generatePassword", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const setEx = new Set(["a", "b"]);
|
||||
const pw = generatePassword({ length: 16, exclude: setEx });
|
||||
for (const ch of pw) {
|
||||
expect(setEx.has(ch)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
buildEffectiveCharset,
|
||||
DIGITS,
|
||||
LOWERCASE,
|
||||
SYMBOLS,
|
||||
UPPERCASE,
|
||||
} from "../charsets/charsets.js";
|
||||
import { estimateEntropy } from "../entropy/entropy.js";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { recordError, recordGeneration } from "../observability/metrics.js";
|
||||
import { defaultRng } from "../random/default-rng.js";
|
||||
import { shuffleInPlace } from "../random/fisher-yates.js";
|
||||
import { randomInt } from "../random/random-int.js";
|
||||
import { GeneratePasswordOptionsSchema } from "../schemas/schemas.js";
|
||||
import type { GeneratePasswordOptions } from "./password.types.js";
|
||||
|
||||
function requiredCharFor(
|
||||
klass: "lower" | "upper" | "digit" | "symbol",
|
||||
exclude: ReadonlySet<string>,
|
||||
rng: Rng
|
||||
): string {
|
||||
const map: Record<typeof klass, ReadonlySet<string>> = {
|
||||
lower: LOWERCASE,
|
||||
upper: UPPERCASE,
|
||||
digit: DIGITS,
|
||||
symbol: SYMBOLS,
|
||||
};
|
||||
const filtered = [...map[klass]].filter((c) => !exclude.has(c));
|
||||
if (filtered.length === 0) {
|
||||
throw pwgenError(
|
||||
"PWGEN.OPTIONS_INFEASIBLE",
|
||||
`required class "${klass}" became empty after applying exclusions`
|
||||
);
|
||||
}
|
||||
return filtered[randomInt(rng, filtered.length)] as string;
|
||||
}
|
||||
|
||||
function expandExclusion(
|
||||
exclude: GeneratePasswordOptions["exclude"]
|
||||
): ReadonlySet<string> {
|
||||
if (!exclude) return new Set<string>();
|
||||
if (typeof exclude === "string") return new Set(exclude.split(""));
|
||||
if (Array.isArray(exclude)) return new Set(exclude as readonly string[]);
|
||||
return exclude as ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a `length`-character password drawn uniformly from the union of
|
||||
* enabled character classes minus any caller-supplied exclusions.
|
||||
*
|
||||
* Required-class quotas (`requireLowercase`/`requireUppercase`/
|
||||
* `requireDigits`/`requireSymbols`) are satisfied by placing one randomly-
|
||||
* chosen character from each required class first, filling the remainder
|
||||
* from the union, and Fisher-Yates-shuffling the buffer so the required-
|
||||
* class characters are not biased to the head of the string.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.INVALID_OPTIONS) on schema mismatch.
|
||||
* @throws PwgenError(PWGEN.OPTIONS_INFEASIBLE) if `length < requiredCount`
|
||||
* or if the effective charset is empty after exclusions.
|
||||
*/
|
||||
export function generatePassword(
|
||||
opts: GeneratePasswordOptions,
|
||||
rng?: Rng
|
||||
): string {
|
||||
const parsed = GeneratePasswordOptionsSchema.safeParse(opts);
|
||||
if (!parsed.success) {
|
||||
recordError("PWGEN.INVALID_OPTIONS");
|
||||
throw pwgenError("PWGEN.INVALID_OPTIONS", parsed.error.message, {
|
||||
cause: parsed.error,
|
||||
});
|
||||
}
|
||||
const o = parsed.data;
|
||||
const required: ("lower" | "upper" | "digit" | "symbol")[] = [];
|
||||
if (o.requireLowercase) required.push("lower");
|
||||
if (o.requireUppercase) required.push("upper");
|
||||
if (o.requireDigits) required.push("digit");
|
||||
if (o.requireSymbols) required.push("symbol");
|
||||
if (o.length < required.length) {
|
||||
recordError("PWGEN.OPTIONS_INFEASIBLE");
|
||||
throw pwgenError(
|
||||
"PWGEN.OPTIONS_INFEASIBLE",
|
||||
`length ${o.length} is below required-class count ${required.length}`
|
||||
);
|
||||
}
|
||||
const effectiveRng = rng ?? defaultRng();
|
||||
const charset = buildEffectiveCharset(o);
|
||||
if (charset.length === 0) {
|
||||
recordError("PWGEN.OPTIONS_INFEASIBLE");
|
||||
throw pwgenError(
|
||||
"PWGEN.OPTIONS_INFEASIBLE",
|
||||
"effective charset is empty — at least one class must be enabled and not fully excluded"
|
||||
);
|
||||
}
|
||||
const exclude = expandExclusion(o.exclude);
|
||||
const buffer: string[] = [];
|
||||
for (const klass of required) {
|
||||
buffer.push(requiredCharFor(klass, exclude, effectiveRng));
|
||||
}
|
||||
while (buffer.length < o.length) {
|
||||
buffer.push(charset[randomInt(effectiveRng, charset.length)] as string);
|
||||
}
|
||||
shuffleInPlace(buffer, effectiveRng);
|
||||
const out = buffer.join("");
|
||||
const start = performance.now();
|
||||
const { bits } = estimateEntropy(out);
|
||||
recordGeneration("random", performance.now() - start, bits);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CharsetOptions } from "../charsets/charsets.js";
|
||||
|
||||
/** Generation mode discriminator. */
|
||||
export type PasswordMode = "random" | "pronounceable";
|
||||
|
||||
/** Required-class quotas — caller asserts the result must contain ≥ 1 char from each enabled class. */
|
||||
export interface RequiredClasses {
|
||||
readonly requireLowercase?: boolean;
|
||||
readonly requireUppercase?: boolean;
|
||||
readonly requireDigits?: boolean;
|
||||
readonly requireSymbols?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options accepted by `generatePassword` and `generatePronounceablePassword`.
|
||||
* The character-class flags (`lowercase`/`uppercase`/`digits`/`symbols`) all
|
||||
* default to `true`; pass `false` to omit a class from the effective union.
|
||||
* `exclude` subtracts characters from the union after classes are merged.
|
||||
*/
|
||||
export interface GeneratePasswordOptions
|
||||
extends CharsetOptions,
|
||||
RequiredClasses {
|
||||
readonly length: number;
|
||||
readonly mode?: PasswordMode;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# `pronounceable`
|
||||
|
||||
## Purpose
|
||||
|
||||
`generatePronounceablePassword(opts, rng?)` emits CVC-pattern (consonant–vowel–consonant) syllables suitable for shoulder-surf-friendly UX contexts where the user must read the password aloud or type it from memory in a short window. The output alternates consonant-frequency-weighted picks from a 21-character consonant set and 5-character vowel set, drawn via the bias-free `randomInt` primitive. Pronounceable passwords trade entropy for memorability: per-character entropy is ≈ log2(21·5)/2 ≈ 4.7 bits versus ≈ 6.55 bits for a full 94-character symbol set, so callers SHOULD pair this mode with a longer `length` or reserve it strictly for UX contexts where the user re-types the password by hand.
|
||||
|
||||
The consonant ordering is derived from Norvig's 2013 English-corpus letter frequencies. Picks are uniform over the 21-character consonant set (not frequency-biased), but the choice of which consonants appear at all reflects common English digrams — `q` and `x` are present but produce visibly less "English-shaped" syllables than `t` and `n`.
|
||||
|
||||
## Features
|
||||
|
||||
- 3-character CVC syllables (`[bcdfghjklmnpqrstvwxyz][aeiou][bcdfghjklmnpqrstvwxyz]`).
|
||||
- Truncates the final syllable when `length % 3 !== 0`.
|
||||
- Deterministic under a seeded `Rng`.
|
||||
- Throws `PWGEN.INVALID_OPTIONS` for `length < 1` or non-integer `length`.
|
||||
- Emits `pwgen.generate_duration_ms{mode=pronounceable}` and `pwgen.entropy_bits{mode=pronounceable}` on success.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generatePronounceablePassword } from "@suaveplan/password-generator";
|
||||
|
||||
const pw = generatePronounceablePassword({ length: 12, mode: "pronounceable" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { generatePronounceablePassword } from "@suaveplan/password-generator";
|
||||
|
||||
function seeded(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Pair with longer length to recover entropy lost to the CVC constraint.
|
||||
// 18 chars ≈ 18 * 4.7 ≈ 85 bits.
|
||||
const pw = generatePronounceablePassword(
|
||||
{ length: 18, mode: "pronounceable" },
|
||||
seeded(1234),
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generatePronounceablePassword(opts: GeneratePasswordOptions & { mode: "pronounceable" }, rng?: Rng): string`
|
||||
|
||||
- `opts.length`: positive integer total length. The last syllable is truncated to fit.
|
||||
- `opts.mode`: must be `"pronounceable"`; type discriminator only.
|
||||
- `rng`: defaults to `defaultRng()`.
|
||||
|
||||
### Constants
|
||||
|
||||
- `PRONOUNCEABLE_CONSONANTS: string` — the 21-character consonant set ordered by English frequency.
|
||||
- `PRONOUNCEABLE_VOWELS: string` — `aeiou`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The generator never reaches Markov-state complexity — instead the CVC pattern is encoded directly in the position index. At output index `i`, `idx = i % 3` selects between the consonant set (`idx === 0` or `idx === 2`) and the vowel set (`idx === 1`). This sidesteps the digram-table lookups Markov-style generators perform and produces strictly uniform draws within each class.
|
||||
|
||||
Frequency-weighted picks (where common consonants like `t` and `n` would appear more often than rare ones like `q` and `x`) would make output more English-shaped at the cost of further entropy reduction. The trade-off was decided in favour of uniform per-class picks because the package's `assertStrength` measurement assumes the entropy formula `log2(charset) * length`, which only holds under uniform sampling. Switching to frequency-weighted picks would require carrying a separate empirical entropy estimate per locale.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import {
|
||||
generatePronounceablePassword,
|
||||
PRONOUNCEABLE_CONSONANTS,
|
||||
PRONOUNCEABLE_VOWELS,
|
||||
} from "./pronounceable.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePronounceablePassword", () => {
|
||||
it("emits CVC syllables of total length N", { timeout: 5000 }, () => {
|
||||
const pw = generatePronounceablePassword({
|
||||
length: 12,
|
||||
mode: "pronounceable",
|
||||
});
|
||||
expect(pw.length).toBe(12);
|
||||
for (let i = 0; i < pw.length; i++) {
|
||||
const ch = pw[i] as string;
|
||||
const idx = i % 3;
|
||||
if (idx === 1) {
|
||||
expect(PRONOUNCEABLE_VOWELS.includes(ch)).toBe(true);
|
||||
} else {
|
||||
expect(PRONOUNCEABLE_CONSONANTS.includes(ch)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("deterministic under seeded Rng", { timeout: 5000 }, () => {
|
||||
const a = generatePronounceablePassword(
|
||||
{ length: 12, mode: "pronounceable" },
|
||||
seededRng(5)
|
||||
);
|
||||
const b = generatePronounceablePassword(
|
||||
{ length: 12, mode: "pronounceable" },
|
||||
seededRng(5)
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("truncates last syllable for non-multiple-of-3 length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pw = generatePronounceablePassword({
|
||||
length: 7,
|
||||
mode: "pronounceable",
|
||||
});
|
||||
expect(pw.length).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects invalid length with PWGEN.INVALID_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
generatePronounceablePassword({ length: 0, mode: "pronounceable" })
|
||||
).toThrow(/PWGEN\.INVALID_OPTIONS/);
|
||||
expect(() =>
|
||||
generatePronounceablePassword({
|
||||
length: 1.5,
|
||||
mode: "pronounceable",
|
||||
})
|
||||
).toThrow(/PWGEN\.INVALID_OPTIONS/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { estimateEntropy } from "../entropy/entropy.js";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { recordError, recordGeneration } from "../observability/metrics.js";
|
||||
import { defaultRng } from "../random/default-rng.js";
|
||||
import { randomInt } from "../random/random-int.js";
|
||||
import type { GeneratePasswordOptions } from "./password.types.js";
|
||||
|
||||
/**
|
||||
* Consonants ordered by English letter frequency (Norvig 2013 corpus
|
||||
* analysis). Bias toward common consonants improves pronounceability
|
||||
* without harming uniform-letter entropy by more than ≈ 0.5 bits/char.
|
||||
*/
|
||||
const CONSONANTS = "tnsrhdlcmpfgwybvkxjqz";
|
||||
const VOWELS = "aeiou";
|
||||
|
||||
/**
|
||||
* Generate a CVC-pattern pronounceable password of `length` characters by
|
||||
* concatenating consonant–vowel–consonant syllables. The last syllable is
|
||||
* truncated to fit `length` exactly when `length % 3 !== 0`.
|
||||
*
|
||||
* Pronounceable output has materially lower entropy than the char-class
|
||||
* generator (per-char ≈ log2(21·5) ≈ 6.7 bits vs. log2(94) ≈ 6.55 for full
|
||||
* symbols, but the constraint on alternating C-V-C reduces the effective
|
||||
* search space). Callers SHOULD pair this mode with a longer `length` or
|
||||
* reserve it for shoulder-surf-friendly UX contexts.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.INVALID_OPTIONS) when `length` is not a positive integer.
|
||||
*/
|
||||
export function generatePronounceablePassword(
|
||||
opts: GeneratePasswordOptions & { mode: "pronounceable" },
|
||||
rng?: Rng
|
||||
): string {
|
||||
if (!Number.isInteger(opts.length) || opts.length < 1) {
|
||||
recordError("PWGEN.INVALID_OPTIONS");
|
||||
throw pwgenError(
|
||||
"PWGEN.INVALID_OPTIONS",
|
||||
`length must be a positive integer; received ${opts.length}`
|
||||
);
|
||||
}
|
||||
const effectiveRng = rng ?? defaultRng();
|
||||
const out: string[] = [];
|
||||
while (out.length < opts.length) {
|
||||
const idx = out.length % 3;
|
||||
const source = idx === 1 ? VOWELS : CONSONANTS;
|
||||
out.push(source[randomInt(effectiveRng, source.length)] as string);
|
||||
}
|
||||
const start = performance.now();
|
||||
const joined = out.join("");
|
||||
const { bits } = estimateEntropy(joined);
|
||||
recordGeneration("pronounceable", performance.now() - start, bits);
|
||||
return joined;
|
||||
}
|
||||
|
||||
/** Exposed for tests so the CVC pattern can be asserted deterministically. */
|
||||
export const PRONOUNCEABLE_CONSONANTS = CONSONANTS;
|
||||
export const PRONOUNCEABLE_VOWELS = VOWELS;
|
||||
@@ -0,0 +1 @@
|
||||
export { generatePin } from "./pin.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# `pin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`generatePin(length, rng?)` returns a `length`-character PIN drawn uniformly from `[0-9]` via the bias-free `randomInt(rng, 10)` primitive. PINs are intentionally a separate surface from `generatePassword` because the constraint set is narrower (only digits, no class quotas, no exclusion arithmetic) and call-site UX is different — banking apps, SIM lock screens, and confirmation flows want a tight `generatePin(6)` rather than a `generatePassword({ length: 6, lowercase: false, uppercase: false, symbols: false })` configuration cliff.
|
||||
|
||||
The per-digit entropy is `log2(10) ≈ 3.32` bits, so a 6-digit PIN carries ≈ 19.9 bits and a 12-digit PIN ≈ 39.9 bits. PINs SHOULD be paired with rate-limiting or lockout policies at the call site; this generator is responsible only for producing the digits, not for protecting the channel that submits them.
|
||||
|
||||
## Features
|
||||
|
||||
- Bias-free uniform draws over `[0, 10)`.
|
||||
- Throws `PWGEN.OPTIONS_INFEASIBLE` for `length < 1` or non-integer `length`.
|
||||
- Deterministic under a seeded `Rng`.
|
||||
- Emits `pwgen.generate_duration_ms{mode=pin}` and `pwgen.entropy_bits{mode=pin}` on success.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { generatePin } from "@suaveplan/password-generator";
|
||||
|
||||
const pin = generatePin(6); // "049213"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { generatePin } from "@suaveplan/password-generator";
|
||||
|
||||
function seeded(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Replay a known PIN for tests
|
||||
const replayed = generatePin(12, seeded(42));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generatePin(length: number, rng?: Rng): string`
|
||||
|
||||
- `length`: positive integer total digit count.
|
||||
- `rng`: defaults to `defaultRng()`.
|
||||
|
||||
Throws `PwgenError(PWGEN.OPTIONS_INFEASIBLE)` when `length < 1` or `length` is non-integer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function loops `length` times calling `randomInt(rng, 10).toString()` and concatenates the result. Building the output via `String#concat` rather than `Array.join` keeps the hot path allocation-light — for the typical 4-to-12-digit range the JIT keeps the buffer in registers without intermediate array creation.
|
||||
|
||||
Distribution conformance is tested by drawing 100 000 digits and asserting each of the 10 buckets falls within ±10% of the expected `total / 10`. The χ² statistical test owned by `randomInt` is what guarantees uniform per-bucket distribution; the PIN-specific test exists only to catch a regression where `generatePin` introduces its own bias on top of `randomInt`.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { generatePin } from "./pin.js";
|
||||
|
||||
describe("generatePin — distribution conformance", () => {
|
||||
it("distributes evenly across [0,9] over 100k digits", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const total = 100_000;
|
||||
const pin = generatePin(total);
|
||||
const counts = new Int32Array(10);
|
||||
for (const ch of pin) {
|
||||
counts[ch.charCodeAt(0) - 48] =
|
||||
(counts[ch.charCodeAt(0) - 48] as number) + 1;
|
||||
}
|
||||
const expected = total / 10;
|
||||
for (const c of counts) {
|
||||
expect(c).toBeGreaterThan(expected * 0.9);
|
||||
expect(c).toBeLessThan(expected * 1.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { generatePin } from "./pin.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePin", () => {
|
||||
it("generates a length-6 PIN", { timeout: 5000 }, () => {
|
||||
const pin = generatePin(6);
|
||||
expect(pin.length).toBe(6);
|
||||
expect(/^[0-9]{6}$/.test(pin)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects zero length", { timeout: 5000 }, () => {
|
||||
expect(() => generatePin(0)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("rejects negative length", { timeout: 5000 }, () => {
|
||||
expect(() => generatePin(-1)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("rejects non-integer length", { timeout: 5000 }, () => {
|
||||
expect(() => generatePin(2.5)).toThrow(/PWGEN\.OPTIONS_INFEASIBLE/);
|
||||
});
|
||||
|
||||
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
|
||||
const a = generatePin(8, seededRng(123));
|
||||
const b = generatePin(8, seededRng(123));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { recordError, recordGeneration } from "../observability/metrics.js";
|
||||
import { defaultRng } from "../random/default-rng.js";
|
||||
import { randomInt } from "../random/random-int.js";
|
||||
|
||||
const ENTROPY_BITS_PER_DIGIT = Math.log2(10);
|
||||
|
||||
/**
|
||||
* Generate a `length`-character PIN drawn uniformly from `[0-9]` via the
|
||||
* bias-free `randomInt(rng, 10)`.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.OPTIONS_INFEASIBLE) when `length < 1`.
|
||||
*/
|
||||
export function generatePin(length: number, rng?: Rng): string {
|
||||
if (!Number.isInteger(length) || length < 1) {
|
||||
recordError("PWGEN.OPTIONS_INFEASIBLE");
|
||||
throw pwgenError(
|
||||
"PWGEN.OPTIONS_INFEASIBLE",
|
||||
`PIN length must be a positive integer; received ${length}`
|
||||
);
|
||||
}
|
||||
const effectiveRng = rng ?? defaultRng();
|
||||
const start = performance.now();
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += randomInt(effectiveRng, 10).toString();
|
||||
}
|
||||
recordGeneration(
|
||||
"pin",
|
||||
performance.now() - start,
|
||||
ENTROPY_BITS_PER_DIGIT * length
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# `default-rng`
|
||||
|
||||
## Purpose
|
||||
|
||||
`defaultRng()` builds the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`, which delegates to `crypto.getRandomValues` and is therefore cryptographically strong on every supported runtime (Bun, Node ≥ 20, Deno, the browser). The function is invoked lazily by each generator when the caller does not supply their own `Rng`, which means consumers that always pass a deterministic seeded `Rng` (tests, replay logs) never reach `crypto.getRandomValues` at all and tree-shakers can drop this module.
|
||||
|
||||
Keeping the default in a separate file (rather than inline at every generator's call site) gives a single canonical place to swap entropy sources — for example to wire a hardware RNG adapter — without touching the generators themselves.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps `randomBytes(4)` to produce a uint32 per `nextUint32` call.
|
||||
- Optional `nextBytes(n)` exposed for the rare consumer that wants raw entropy.
|
||||
- Optional `nextFloat()` computed from `nextUint32() / 2^32`.
|
||||
- Zero module-scope state — every call to `defaultRng()` returns a fresh `Rng`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultRng } from "@suaveplan/password-generator/random";
|
||||
|
||||
const rng = defaultRng();
|
||||
const u = rng.nextUint32(); // ∈ [0, 2^32)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { randomInt, defaultRng } from "@suaveplan/password-generator/random";
|
||||
import { generatePassword } from "@suaveplan/password-generator/password";
|
||||
|
||||
// The two snippets below are equivalent — generatePassword falls back to
|
||||
// defaultRng() when no rng is supplied.
|
||||
generatePassword({ length: 16 });
|
||||
generatePassword({ length: 16 }, defaultRng());
|
||||
|
||||
// Explicit rng is preferable inside loops so the same instance is reused
|
||||
// across many calls without re-importing crypto each time.
|
||||
const rng = defaultRng();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
randomInt(rng, 7776);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultRng(): Rng`
|
||||
|
||||
Builds a fresh `Rng` whose `nextUint32` reads four bytes from `@suaveplan/crypto-utils.randomBytes` and assembles them into a big-endian uint32. The returned object also implements the optional `nextFloat` (uint32 normalised into `[0, 1)`) and `nextBytes` (direct passthrough to `randomBytes(n)`) members of the `Rng` contract.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bit layout for `nextUint32` is `(b0 << 24) | (b1 << 16) | (b2 << 8) | b3`, with the final `>>> 0` to coerce JavaScript's 32-bit signed arithmetic back into an unsigned integer. The same big-endian ordering is consistent across runtimes — `crypto.getRandomValues` makes no endianness promises, but since every byte is independent and uniform the assembly order does not affect the distribution.
|
||||
|
||||
`nextFloat` is intentionally implemented as `nextUint32() / 2^32` rather than `Math.random()` so the package contains _zero_ references to `Math.random` (rule 8 of the source-tree hygiene gate). The float resolution is 32-bit; consumers that need 53-bit precision should derive it from two `nextUint32` draws themselves.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultRng } from "./default-rng.js";
|
||||
|
||||
describe("defaultRng", () => {
|
||||
it("nextUint32 returns an integer in [0, 2^32)", { timeout: 5000 }, () => {
|
||||
const rng = defaultRng();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = rng.nextUint32();
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(0x1_0000_0000);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextFloat returns values in [0, 1)", { timeout: 5000 }, () => {
|
||||
const rng = defaultRng();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = rng.nextFloat();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("nextBytes returns an n-byte buffer", { timeout: 5000 }, () => {
|
||||
const rng = defaultRng();
|
||||
const bytes = rng.nextBytes?.(16);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes?.length).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { randomBytes } from "@suaveplan/crypto-utils";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Build the default `Rng` backed by `@suaveplan/crypto-utils.randomBytes`
|
||||
* (which delegates to `crypto.getRandomValues`). Constructed lazily so
|
||||
* importers that pass their own `Rng` never reach `crypto` at all and
|
||||
* tree-shakers can drop this module.
|
||||
*/
|
||||
export function defaultRng(): Rng {
|
||||
return {
|
||||
nextUint32(): number {
|
||||
const bytes = randomBytes(4);
|
||||
return (
|
||||
(((bytes[0] as number) << 24) |
|
||||
((bytes[1] as number) << 16) |
|
||||
((bytes[2] as number) << 8) |
|
||||
(bytes[3] as number)) >>>
|
||||
0
|
||||
);
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
nextBytes(n: number): Uint8Array {
|
||||
return randomBytes(n);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# `fisher-yates`
|
||||
|
||||
## Purpose
|
||||
|
||||
`shuffleInPlace(array, rng)` performs an in-place Fisher–Yates / Knuth shuffle parameterised over the bias-free `randomInt` primitive. The shuffle is the load-bearing step that prevents required-class characters from being biased to the head of `generatePassword`'s output buffer — without it, the first N characters of every generated password would always be the required-class quotas in declaration order, leaking information about the generator's option set to anyone who inspects the produced string.
|
||||
|
||||
The standard Knuth analysis guarantees that every permutation of `array` appears with equal probability, _provided_ the underlying integer draws are uniform. Pairing the algorithm with the bias-free `randomInt` rather than `Math.floor(Math.random() * n)` is what makes that guarantee hold for arbitrary `array.length`.
|
||||
|
||||
## Features
|
||||
|
||||
- Cryptographically-unbiased permutations via Lemire-style rejection sampling.
|
||||
- In-place mutation; returns the same array reference for fluent chaining.
|
||||
- No-op for arrays of length 0 or 1.
|
||||
- Pure given `rng`; deterministic under a seeded `Rng`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultRng, shuffleInPlace } from "@suaveplan/password-generator/random";
|
||||
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
shuffleInPlace(arr, defaultRng());
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { shuffleInPlace } from "@suaveplan/password-generator/random";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Reproducible card-deck shuffle for replay logs
|
||||
const deck = [...Array(52).keys()];
|
||||
shuffleInPlace(deck, seededRng(123));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `shuffleInPlace<T>(array: T[], rng: Rng): T[]`
|
||||
|
||||
- `array`: mutated in-place. The reference is returned so consumers can chain.
|
||||
- `rng`: any `Rng`; only `nextUint32` is used (via `randomInt`).
|
||||
- Returns: the same `array` reference.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation walks `i` from `array.length - 1` down to `1`. At each step it draws `j = randomInt(rng, i + 1)` and swaps positions `i` and `j`. Bias-free uniform draws are critical: a biased `randomInt` would skew certain permutations and the standard distributional argument would fail.
|
||||
|
||||
The shuffle complexity is `O(array.length)` with one `randomInt` call per element except the last. Memory complexity is `O(1)` beyond the input array. Returning the same array reference lets callers compose `shuffleInPlace(...).map(...)` without an explicit local variable; consumers who need an immutable shuffle should clone the input first.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { defaultRng } from "./default-rng.js";
|
||||
import { shuffleInPlace } from "./fisher-yates.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
// Mulberry32 — well-distributed for 32-bit seeds.
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shuffleInPlace", () => {
|
||||
it("preserves length", { timeout: 5000 }, () => {
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
shuffleInPlace(arr, defaultRng());
|
||||
expect(arr.length).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves multiset of elements", { timeout: 5000 }, () => {
|
||||
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const original = [...arr];
|
||||
shuffleInPlace(arr, defaultRng());
|
||||
expect([...arr].sort((a, b) => a - b)).toEqual(original);
|
||||
});
|
||||
|
||||
it("is deterministic under seeded Rng", { timeout: 5000 }, () => {
|
||||
const a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const b = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
shuffleInPlace(a, seededRng(42));
|
||||
shuffleInPlace(b, seededRng(42));
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("is no-op for arrays of length 0 or 1", { timeout: 5000 }, () => {
|
||||
expect(shuffleInPlace([], defaultRng())).toEqual([]);
|
||||
expect(shuffleInPlace(["x"], defaultRng())).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("returns the same array reference", { timeout: 5000 }, () => {
|
||||
const arr = [1, 2, 3];
|
||||
const result = shuffleInPlace(arr, defaultRng());
|
||||
expect(result).toBe(arr);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { randomInt } from "./random-int.js";
|
||||
|
||||
/**
|
||||
* Fisher–Yates in-place shuffle. Uses the bias-free
|
||||
* {@link randomInt} so the resulting permutation distribution is
|
||||
* uniform regardless of `array.length`.
|
||||
*
|
||||
* The shuffle iterates from the end of the array down to index 1; at each
|
||||
* step it swaps the current element with a uniformly-chosen element from
|
||||
* the prefix `[0, i]`. This guarantees every permutation appears with
|
||||
* equal probability (the standard Knuth analysis).
|
||||
*
|
||||
* Returns the same array reference for fluent chaining.
|
||||
*/
|
||||
export function shuffleInPlace<T>(array: T[], rng: Rng): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = randomInt(rng, i + 1);
|
||||
const tmp = array[i] as T;
|
||||
array[i] = array[j] as T;
|
||||
array[j] = tmp;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { defaultRng } from "./default-rng.js";
|
||||
export { shuffleInPlace } from "./fisher-yates.js";
|
||||
export { randomInt } from "./random-int.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# `random-int`
|
||||
|
||||
## Purpose
|
||||
|
||||
`randomInt(rng, max)` returns a uniformly-distributed integer in the half-open interval `[0, max)` for any `max ∈ [1, 2^32)` by drawing uint32 values from the supplied `Rng` and rejecting draws above the threshold `2^32 - (2^32 mod max)`. This is the load-bearing primitive every generator in the package relies on for unbiased sampling — replacing it with the naïve `Math.floor(rng() * max)` shortcut would silently bias output for non-power-of-two `max`, the very common case for 7776-entry word lists and 10-entry digit picks.
|
||||
|
||||
The implementation follows Daniel Lemire's 2018 _Fast Random Integer Generation in an Interval_ (https://arxiv.org/abs/1805.10941) §3 ("Nearly Divisionless"). Expected per-call draws are below 2 in the worst case; for the EFF Long list (`max = 7776`) the rejection probability is < 0.0000019 per draw, so the bounded retry budget (1024 attempts) acts purely as a guard against a pathologically broken `Rng`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strictly uniform output across `[0, max)`.
|
||||
- Rejection-sampling loop with a 1024-attempt safety bound.
|
||||
- Throws `PWGEN.RNG_FAILED` for invalid `max` or pathological RNGs.
|
||||
- Pure function; no I/O, no module-scope state.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultRng } from "@suaveplan/password-generator/random";
|
||||
import { randomInt } from "@suaveplan/password-generator/random";
|
||||
|
||||
const rng = defaultRng();
|
||||
const v = randomInt(rng, 7776); // ∈ [0, 7776)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { randomInt } from "@suaveplan/password-generator/random";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let s = seed >>> 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Deterministic replay for tests
|
||||
const rng = seededRng(42);
|
||||
const a = randomInt(rng, 256);
|
||||
const b = randomInt(rng, 256);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `randomInt(rng: Rng, max: number): number`
|
||||
|
||||
Returns a uniform integer in `[0, max)`.
|
||||
|
||||
- `rng`: any `Rng` from `@suaveplan/types`. Only `nextUint32` is used.
|
||||
- `max`: must satisfy `Number.isInteger(max) && 1 <= max < 2^32`.
|
||||
- Throws `PwgenError(PWGEN.RNG_FAILED)` when `max` is out of range or when the rejection loop exhausts its 1024-attempt budget.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The threshold `T = 2^32 - (2^32 mod max)` is the largest multiple of `max` below `2^32`. Any uint32 in `[0, T)` is uniformly distributed across the `T / max` complete copies of `[0, max)` it covers; values in `[T, 2^32)` are rejected and the next draw is taken. This makes the output strictly uniform at the cost of an expected `< 2` calls to `rng.nextUint32` for typical `max`.
|
||||
|
||||
The retry budget guards against an `Rng` that always returns values above the threshold — without the bound, a broken RNG would loop forever. 1024 attempts is far beyond any non-pathological RNG: even with `max = 7776` the probability of 1024 consecutive rejections is below `(7776 / 2^32)^1024`, indistinguishable from zero. Reaching the bound therefore proves the supplied `Rng` is broken and the function throws `PWGEN.RNG_FAILED` instead of hanging.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { defaultRng } from "./default-rng.js";
|
||||
import { randomInt } from "./random-int.js";
|
||||
|
||||
/**
|
||||
* Wilson-Hilferty transform: a chi-squared distribution with `df` degrees
|
||||
* of freedom can be approximated as a Normal((1 - 2/(9 df)), √(2/(9 df))³)
|
||||
* after the cube-root transform `(X/df)^(1/3)`. We then convert to
|
||||
* a standard normal Z-score and return the two-sided p-value from a
|
||||
* Hastings (1955) rational approximation. Avoids pulling a stats lib.
|
||||
*/
|
||||
function chiSquaredPValue(chi2: number, df: number): number {
|
||||
const cubeRoot = Math.cbrt(chi2 / df);
|
||||
const mean = 1 - 2 / (9 * df);
|
||||
const sd = Math.sqrt(2 / (9 * df));
|
||||
const z = (cubeRoot - mean) / sd;
|
||||
// One-sided survival of standard normal — chi-squared p-value is the
|
||||
// upper-tail probability, so a small `chi2` gives a large p.
|
||||
return survivalNormal(z);
|
||||
}
|
||||
|
||||
function survivalNormal(z: number): number {
|
||||
// Abramowitz & Stegun 26.2.17 — accurate to ~7.5e-8.
|
||||
const cdf = (x: number): number => {
|
||||
const sign = x < 0 ? -1 : 1;
|
||||
const ax = Math.abs(x) / Math.SQRT2;
|
||||
const t = 1 / (1 + 0.3275911 * ax);
|
||||
const a1 = 0.254829592;
|
||||
const a2 = -0.284496736;
|
||||
const a3 = 1.421413741;
|
||||
const a4 = -1.453152027;
|
||||
const a5 = 1.061405429;
|
||||
const erf =
|
||||
1 -
|
||||
((((a5 * t + a4) * t + a3) * t + a2) * t + a1) *
|
||||
t *
|
||||
Math.exp(-ax * ax);
|
||||
return 0.5 * (1 + sign * erf);
|
||||
};
|
||||
return 1 - cdf(z);
|
||||
}
|
||||
|
||||
describe("randomInt — statistical conformance", () => {
|
||||
it("1M-sample chi-squared p > 0.01 over n=7776 buckets", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const n = 7776;
|
||||
const samples = 1_000_000;
|
||||
const counts = new Int32Array(n);
|
||||
const rng = defaultRng();
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const v = randomInt(rng, n);
|
||||
counts[v] = (counts[v] as number) + 1;
|
||||
}
|
||||
const expected = samples / n;
|
||||
let chi2 = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const diff = (counts[i] as number) - expected;
|
||||
chi2 += (diff * diff) / expected;
|
||||
}
|
||||
const p = chiSquaredPValue(chi2, n - 1);
|
||||
expect(p).toBeGreaterThan(0.01);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { defaultRng } from "./default-rng.js";
|
||||
import { randomInt } from "./random-int.js";
|
||||
|
||||
function fixedRng(values: number[]): Rng {
|
||||
let i = 0;
|
||||
return {
|
||||
nextUint32(): number {
|
||||
const v = values[i++] ?? values[values.length - 1] ?? 0;
|
||||
return v >>> 0;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("randomInt", () => {
|
||||
it("returns value in [0, max)", { timeout: 5000 }, () => {
|
||||
const rng = defaultRng();
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const v = randomInt(rng, 7776);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(7776);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("triggers rejection branch above threshold", { timeout: 5000 }, () => {
|
||||
const max = 7776;
|
||||
const threshold = 0x1_0000_0000 - (0x1_0000_0000 % max);
|
||||
let calls = 0;
|
||||
const rng: Rng = {
|
||||
nextUint32(): number {
|
||||
calls++;
|
||||
return calls === 1 ? threshold + 5 : 42;
|
||||
},
|
||||
nextFloat(): number {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
const v = randomInt(rng, max);
|
||||
expect(calls).toBe(2);
|
||||
expect(v).toBe(42 % max);
|
||||
});
|
||||
|
||||
it("throws PWGEN.RNG_FAILED on pathological RNG", { timeout: 5000 }, () => {
|
||||
const max = 7776;
|
||||
const threshold = 0x1_0000_0000 - (0x1_0000_0000 % max);
|
||||
const rng = fixedRng([threshold + 1]);
|
||||
expect(() => randomInt(rng, max)).toThrow(/PWGEN\.RNG_FAILED/);
|
||||
});
|
||||
|
||||
it("throws PWGEN.RNG_FAILED for invalid max", { timeout: 5000 }, () => {
|
||||
const rng = defaultRng();
|
||||
expect(() => randomInt(rng, 0)).toThrow(/PWGEN\.RNG_FAILED/);
|
||||
expect(() => randomInt(rng, -1)).toThrow(/PWGEN\.RNG_FAILED/);
|
||||
expect(() => randomInt(rng, 1.5)).toThrow(/PWGEN\.RNG_FAILED/);
|
||||
expect(() => randomInt(rng, 0x1_0000_0000)).toThrow(
|
||||
/PWGEN\.RNG_FAILED/
|
||||
);
|
||||
});
|
||||
|
||||
it("reduces uint32 modulo max for in-range draw", { timeout: 5000 }, () => {
|
||||
const rng = fixedRng([100]);
|
||||
expect(randomInt(rng, 50)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
|
||||
/**
|
||||
* Bounded retry budget for the rejection loop. A correctly-implemented
|
||||
* `Rng.nextUint32` produces a uniformly-distributed uint32 every call; the
|
||||
* worst-case rejection probability is `(2^32 mod max) / 2^32 < max/2^32`,
|
||||
* which is < 0.0000019 for `max = 7776`. 1024 attempts give a worst-case
|
||||
* success probability of `1 - (max/2^32)^1024`, indistinguishable from 1.
|
||||
* Hitting this cap therefore proves the supplied `Rng` is broken.
|
||||
*/
|
||||
const REJECTION_RETRY_BUDGET = 1024;
|
||||
|
||||
/** Exclusive upper bound of the uint32 space. */
|
||||
const TWO_POW_32 = 0x1_0000_0000;
|
||||
|
||||
/**
|
||||
* Return a uniformly-distributed integer in the half-open interval
|
||||
* `[0, max)` using Lemire-style rejection sampling.
|
||||
*
|
||||
* Reference: Daniel Lemire, _Fast Random Integer Generation in an
|
||||
* Interval_, ACM Trans. Model. Comput. Simul. 2019,
|
||||
* https://arxiv.org/abs/1805.10941 §3 ("Nearly Divisionless").
|
||||
*
|
||||
* The naïve `Math.floor(rng() * max)` is biased for non-power-of-two
|
||||
* `max`. This implementation draws a uint32 and rejects values above the
|
||||
* threshold `2^32 - (2^32 mod max)`, re-drawing on rejection. The bias
|
||||
* disappears at the cost of an expected `< 2` draws per call.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.RNG_FAILED) if `rng.nextUint32` returns
|
||||
* non-uint32 values for {@link REJECTION_RETRY_BUDGET} consecutive
|
||||
* attempts.
|
||||
*/
|
||||
export function randomInt(rng: Rng, max: number): number {
|
||||
if (!Number.isInteger(max) || max < 1 || max >= TWO_POW_32) {
|
||||
throw pwgenError(
|
||||
"PWGEN.RNG_FAILED",
|
||||
`randomInt requires max in [1, 2^32); received ${max}`
|
||||
);
|
||||
}
|
||||
const threshold = TWO_POW_32 - (TWO_POW_32 % max);
|
||||
for (let attempt = 0; attempt < REJECTION_RETRY_BUDGET; attempt++) {
|
||||
const draw = rng.nextUint32() >>> 0;
|
||||
if (draw < threshold) return draw % max;
|
||||
}
|
||||
throw pwgenError(
|
||||
"PWGEN.RNG_FAILED",
|
||||
`Rng failed to produce an in-range uint32 after ${REJECTION_RETRY_BUDGET} attempts`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
EntropyOptionsSchema,
|
||||
GeneratePassphraseOptionsSchema,
|
||||
GeneratePasswordOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Owns the Zod boundary schemas that every public generator function uses to validate its `opts` argument. Schema mismatch throws `PwgenError(PWGEN.INVALID_OPTIONS)` with the original `ZodError` attached as `cause`, so callers can introspect the failing field path while keeping the typed error code stable for logging.
|
||||
|
||||
Strict-mode schemas reject unknown fields rather than silently dropping them — typos like `requrieDigits` (with a transposed `i`) surface immediately instead of being ignored at runtime. Three schemas are exported: `GeneratePasswordOptionsSchema`, `GeneratePassphraseOptionsSchema`, and `EntropyOptionsSchema`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-object schemas reject unknown fields.
|
||||
- Positive-integer guards on `length`-shaped fields.
|
||||
- Discriminated `wordlist` accepts `"long"`, `"short"`, an inline `string[]`, or any registry name.
|
||||
- All three schemas are invoked from at least one production call site (the conformance script verifies this).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { GeneratePasswordOptionsSchema } from "@suaveplan/password-generator/schemas";
|
||||
|
||||
const result = GeneratePasswordOptionsSchema.safeParse({ length: 16 });
|
||||
if (!result.success) throw result.error;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
EntropyOptionsSchema,
|
||||
GeneratePassphraseOptionsSchema,
|
||||
} from "@suaveplan/password-generator/schemas";
|
||||
|
||||
// Pre-validate user-supplied JSON before forwarding to the generator
|
||||
function fromJson(raw: unknown) {
|
||||
const parsed = GeneratePassphraseOptionsSchema.parse(raw);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Compose with downstream schemas
|
||||
import z from "zod";
|
||||
const apiBody = z.object({
|
||||
mode: z.literal("passphrase"),
|
||||
opts: GeneratePassphraseOptionsSchema,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GeneratePasswordOptionsSchema` — strict object mirroring `GeneratePasswordOptions`.
|
||||
- `GeneratePassphraseOptionsSchema` — strict object mirroring `GeneratePassphraseOptions`. `length` is optional in the schema (the generator applies the default `6`).
|
||||
- `EntropyOptionsSchema` — strict object mirroring `EntropyOptions`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas live in a single feature module to keep the conformance script's `src/schemas/` discovery simple — the script walks that directory and verifies every exported `*Schema` is invoked via `.parse(` or `.safeParse(` somewhere outside the directory. This pattern also matches the layout the `cbor`, `validation`, and `vault` packages already use, so reviewers don't need to context-switch.
|
||||
|
||||
Strict-object semantics are non-negotiable for boundary surfaces — Zod's default `passthrough` would let typos silently pass through to the generator and corrupt observability (a `requireDigit` typo with a missing `s` would not enable required-digit quotas but `recordError` would never fire). The trade-off — slightly more verbose error messages when adding new options forces a schema update in the same commit — is the entire point of the gate.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { estimateEntropy } from "../entropy/entropy.js";
|
||||
import { generatePassphrase } from "../passphrase/passphrase.js";
|
||||
import { generatePassword } from "../password/password.js";
|
||||
import {
|
||||
EntropyOptionsSchema,
|
||||
GeneratePassphraseOptionsSchema,
|
||||
GeneratePasswordOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("Zod boundary schemas", () => {
|
||||
it("GeneratePasswordOptionsSchema rejects negative length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => generatePassword({ length: -1 })).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
});
|
||||
|
||||
it("GeneratePasswordOptionsSchema accepts the documented option shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = GeneratePasswordOptionsSchema.safeParse({
|
||||
length: 16,
|
||||
lowercase: true,
|
||||
uppercase: false,
|
||||
digits: true,
|
||||
symbols: false,
|
||||
requireDigits: true,
|
||||
exclude: "abc",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("GeneratePassphraseOptionsSchema rejects zero length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => generatePassphrase({ length: 0 })).toThrow(
|
||||
/PWGEN\.INVALID_OPTIONS/
|
||||
);
|
||||
});
|
||||
|
||||
it("GeneratePassphraseOptionsSchema rejects unknown fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = GeneratePassphraseOptionsSchema.safeParse({
|
||||
length: 4,
|
||||
bogus: true,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("EntropyOptionsSchema rejects malformed extraClasses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
estimateEntropy("abc", { extraClasses: 42 as never })
|
||||
).toThrow(/PWGEN\.INVALID_OPTIONS/);
|
||||
});
|
||||
|
||||
it("EntropyOptionsSchema accepts a string[] extraClasses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = EntropyOptionsSchema.safeParse({ extraClasses: ["xyz"] });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Reusable count schema — `length` arguments must be positive integers. */
|
||||
const PositiveLength = z.number().int().positive();
|
||||
|
||||
/**
|
||||
* Zod schema mirroring `GeneratePasswordOptions`. The shape stays strict so
|
||||
* unknown fields propagate as `PWGEN.INVALID_OPTIONS` and callers see typos
|
||||
* at the boundary instead of silently-ignored options.
|
||||
*/
|
||||
export const GeneratePasswordOptionsSchema = z
|
||||
.object({
|
||||
length: PositiveLength,
|
||||
lowercase: z.boolean().optional(),
|
||||
uppercase: z.boolean().optional(),
|
||||
digits: z.boolean().optional(),
|
||||
symbols: z.boolean().optional(),
|
||||
requireLowercase: z.boolean().optional(),
|
||||
requireUppercase: z.boolean().optional(),
|
||||
requireDigits: z.boolean().optional(),
|
||||
requireSymbols: z.boolean().optional(),
|
||||
exclude: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.array(z.string()),
|
||||
z.instanceof(Set as unknown as { new (): Set<string> }),
|
||||
])
|
||||
.optional(),
|
||||
mode: z.enum(["random", "pronounceable"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CapitalizationMode = z.enum(["none", "firstChar", "randomWord"]);
|
||||
const WordlistRef = z.union([
|
||||
z.literal("long"),
|
||||
z.literal("short"),
|
||||
z.string().min(1),
|
||||
z.array(z.string()),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Zod schema mirroring `GeneratePassphraseOptions`. `length` defaults to 6
|
||||
* in the call site, not in the schema, so the schema rejects `0` rather
|
||||
* than silently coercing it into the default.
|
||||
*/
|
||||
export const GeneratePassphraseOptionsSchema = z
|
||||
.object({
|
||||
length: PositiveLength.optional(),
|
||||
wordlist: WordlistRef.optional(),
|
||||
separator: z.string().optional(),
|
||||
capitalize: CapitalizationMode.optional(),
|
||||
addDigits: z.number().int().nonnegative().optional(),
|
||||
addSymbols: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Zod schema for `EntropyOptions`. */
|
||||
export const EntropyOptionsSchema = z
|
||||
.object({
|
||||
extraClasses: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,48 @@
|
||||
# `eff-long` — EFF Long Wordlist
|
||||
|
||||
## Purpose
|
||||
|
||||
Bundles the EFF Long Wordlist as a frozen `readonly string[]` so callers who only want passphrase generation pull this 85 KB asset and nothing else. The list contains exactly 7776 entries (5-dice / 6⁵), giving ≈ 12.92 bits of entropy per word and 77.55 bits for a 6-word passphrase. Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt (Electronic Frontier Foundation, released under CC BY 3.0 US).
|
||||
|
||||
The wordlist is exposed via two surfaces: the registry name `"eff-long"` (via `getWordlist`) and the dedicated subpath import `@suaveplan/password-generator/wordlists/eff-long`. The subpath import lets a tree-shaker drop the array entirely from a bundle that only generates char-class passwords or PINs.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly 7776 ASCII entries.
|
||||
- Deduplicated and prefix-disambiguated within the first three characters (per EFF's selection criteria).
|
||||
- Each word is 3–9 characters and contains only `[a-z-]`.
|
||||
- Frozen array — safe to share across modules.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EFF_LONG_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-long";
|
||||
|
||||
console.log(EFF_LONG_WORDLIST[0]); // "abacus"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generatePassphrase } from "@suaveplan/password-generator";
|
||||
import { EFF_LONG_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-long";
|
||||
|
||||
// Verify a passphrase came from the EFF Long list
|
||||
const pp = generatePassphrase({});
|
||||
const longSet = new Set(EFF_LONG_WORDLIST);
|
||||
for (const w of pp.split("-")) {
|
||||
if (!longSet.has(w) && !w.includes("-")) throw new Error("not from EFF Long");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EFF_LONG_WORDLIST: readonly string[]`
|
||||
|
||||
Frozen array of 7776 lowercase ASCII strings. Mutation attempts throw at runtime in strict mode.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wordlist is embedded verbatim from the EFF source file. Re-generating it (for example to localise to a non-English language) requires verifying the prefix-disambiguation property: every pair of entries differs within the first three characters, which makes typed passphrases auto-completable without ambiguity in EFF's documented dice-rolling protocol. The package does not re-verify this property at runtime because the array is constant — it's the responsibility of any caller who re-bundles a translated list to maintain the property.
|
||||
|
||||
Bundle cost: roughly 85 KB minified UTF-8 (the array literal expansion). The `sideEffects: false` flag on the package plus the `preserveModules: true` Vite output ensure that an import of `@suaveplan/password-generator/password` does not transitively pull in this module — verified by the rollup tree-shaker against the build output.
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EFF_LONG_WORDLIST } from "./eff-long.js";
|
||||
|
||||
describe("EFF_LONG_WORDLIST", () => {
|
||||
it("contains exactly 7776 entries", { timeout: 5000 }, () => {
|
||||
expect(EFF_LONG_WORDLIST.length).toBe(7776);
|
||||
});
|
||||
|
||||
it("contains no duplicates", { timeout: 5000 }, () => {
|
||||
expect(new Set(EFF_LONG_WORDLIST).size).toBe(EFF_LONG_WORDLIST.length);
|
||||
});
|
||||
|
||||
it("contains only ASCII letters", { timeout: 30000 }, () => {
|
||||
for (const word of EFF_LONG_WORDLIST) {
|
||||
expect(/^[a-z-]+$/.test(word)).toBe(true);
|
||||
// ASCII-only — no characters above 0x7f
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
expect(word.charCodeAt(i)).toBeLessThan(128);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("starts with 'abacus' (sentinel)", { timeout: 5000 }, () => {
|
||||
expect(EFF_LONG_WORDLIST[0]).toBe("abacus");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# `eff-short` — EFF Short Wordlist 1
|
||||
|
||||
## Purpose
|
||||
|
||||
Bundles EFF's Short Wordlist #1 — 1296 entries (4-dice / 6⁴), ≈ 10.34 bits/word, every entry ≤ 5 ASCII characters. Source: https://www.eff.org/files/2016/09/08/eff_short_wordlist_1.txt (Electronic Frontier Foundation, released under CC BY 3.0 US). The short list is optimised for memorability and typing speed at the cost of fewer bits per word; an 8-word short-list passphrase (~82.7 bits) is comparable in entropy to a 6.4-word long-list passphrase but markedly faster to type.
|
||||
|
||||
Exposed via the registry name `"eff-short"` and the dedicated subpath `@suaveplan/password-generator/wordlists/eff-short` so a consumer who only wants the short list does not pull the ~85 KB long list.
|
||||
|
||||
## Features
|
||||
|
||||
- Exactly 1296 ASCII entries.
|
||||
- Every entry is ≤ 5 characters; words are easy to spell.
|
||||
- Deduplicated; only `[a-z-]` characters.
|
||||
- Frozen array.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EFF_SHORT_WORDLIST } from "@suaveplan/password-generator/wordlists/eff-short";
|
||||
|
||||
console.log(EFF_SHORT_WORDLIST.length); // 1296
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generatePassphrase } from "@suaveplan/password-generator";
|
||||
|
||||
// 8 short words for ≈ 82.7 bits — comparable to a 6-word EFF Long passphrase
|
||||
const pp = generatePassphrase({ wordlist: "short", length: 8 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EFF_SHORT_WORDLIST: readonly string[]`
|
||||
|
||||
Frozen array of 1296 lowercase ASCII strings.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The short list was constructed by EFF specifically for "shorter words that are easier to type". Per-word entropy is `log2(1296) ≈ 10.34` bits versus `log2(7776) ≈ 12.92` for the long list — to match a 6-word long-list passphrase's 77.55 bits, callers need 7.5 (round up to 8) short-list words. The trade-off favours short-list passphrases when the user must type the phrase on a constrained surface like a mobile virtual keyboard, where the per-word typing-time penalty dominates the per-word entropy gain.
|
||||
|
||||
EFF also publishes a Short Wordlist 2 with autocompleting prefixes; this package intentionally bundles only Wordlist 1 to keep the runtime surface minimal. Wordlist 2 is available as a caller-registered list via `registerWordlist`; future versions of this package may add a dedicated subpath if there is demand.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { EFF_SHORT_WORDLIST } from "./eff-short.js";
|
||||
|
||||
describe("EFF_SHORT_WORDLIST", () => {
|
||||
it("contains exactly 1296 entries", { timeout: 5000 }, () => {
|
||||
expect(EFF_SHORT_WORDLIST.length).toBe(1296);
|
||||
});
|
||||
|
||||
it("contains no duplicates", { timeout: 5000 }, () => {
|
||||
expect(new Set(EFF_SHORT_WORDLIST).size).toBe(
|
||||
EFF_SHORT_WORDLIST.length
|
||||
);
|
||||
});
|
||||
|
||||
it("contains only ASCII letters", { timeout: 5000 }, () => {
|
||||
for (const word of EFF_SHORT_WORDLIST) {
|
||||
expect(/^[a-z-]+$/.test(word)).toBe(true);
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
expect(word.charCodeAt(i)).toBeLessThan(128);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("starts with 'acid' (sentinel)", { timeout: 5000 }, () => {
|
||||
expect(EFF_SHORT_WORDLIST[0]).toBe("acid");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
export { EFF_LONG_WORDLIST } from "./eff-long.js";
|
||||
export { EFF_SHORT_WORDLIST } from "./eff-short.js";
|
||||
export {
|
||||
getWordlist,
|
||||
listWordlists,
|
||||
MIN_WORDLIST_SIZE,
|
||||
registerWordlist,
|
||||
} from "./registry.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# `wordlists/registry`
|
||||
|
||||
## Purpose
|
||||
|
||||
The wordlist registry exposes a small plugin API — `registerWordlist`, `getWordlist`, `listWordlists` — that lets callers add localised or themed wordlists to the same name-space the bundled EFF lists use. `generatePassphrase` resolves `opts.wordlist` through this registry, so any caller-registered list gets the same treatment as the built-in lists with zero changes to the passphrase generator. The registry enforces a 256-entry minimum (8 bits per word) so a 16-word passphrase built from any registered list crosses the 128-bit symmetric-key floor.
|
||||
|
||||
The registry is pre-populated with `"eff-long"` and `"eff-short"`. Calling `registerWordlist("eff-long", customList)` overwrites the bundled list — useful for caller-supplied localised variants of the EFF lists.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-populated with `"eff-long"` and `"eff-short"`.
|
||||
- 256-entry minimum enforced at registration.
|
||||
- Unknown names throw a typed `PWGEN.WORDLIST_NOT_REGISTERED` error.
|
||||
- Re-registration overwrites the previous entry (deliberate, for localisation).
|
||||
- `listWordlists()` for UI surfaces that need to render available options.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getWordlist,
|
||||
listWordlists,
|
||||
registerWordlist,
|
||||
} from "@suaveplan/password-generator/wordlists/registry";
|
||||
|
||||
const dnd = [/* ... 300 fantasy nouns ... */];
|
||||
registerWordlist("dnd-spells", dnd);
|
||||
console.log(listWordlists()); // ["eff-long", "eff-short", "dnd-spells"]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { generatePassphrase } from "@suaveplan/password-generator";
|
||||
import { registerWordlist } from "@suaveplan/password-generator/wordlists/registry";
|
||||
|
||||
// Localise the long list to Italian (must still be ≥ 256 entries; EFF
|
||||
// publishes localised lists at https://www.eff.org/dice).
|
||||
const italianLong = await loadItalianEffLong();
|
||||
registerWordlist("eff-long-it", italianLong);
|
||||
const pp = generatePassphrase({ wordlist: "eff-long-it", length: 6 });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `registerWordlist(name: string, words: readonly string[]): void`
|
||||
|
||||
Throws `PwgenError(PWGEN.WORDLIST_TOO_SHORT)` when `words.length < 256` (the `MIN_WORDLIST_SIZE` constant).
|
||||
|
||||
### `getWordlist(name: string): readonly string[]`
|
||||
|
||||
Throws `PwgenError(PWGEN.WORDLIST_NOT_REGISTERED)` for unknown names.
|
||||
|
||||
### `listWordlists(): string[]`
|
||||
|
||||
Returns every registered name, in insertion order.
|
||||
|
||||
### `MIN_WORDLIST_SIZE: 256`
|
||||
|
||||
Exported so callers can show "needs at least 256 entries" in upload UIs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is a single module-level `Map<string, readonly string[]>` keyed by name. There is intentionally no per-realm or per-call-site isolation — registration is global because the use case (localising the bundled lists, adding a themed list) is fundamentally a deployment-time decision, not per-request state. Callers that need stricter isolation should pass an inline `string[]` to `generatePassphrase` instead, which bypasses the registry entirely.
|
||||
|
||||
The 256-entry floor (`log2(256) = 8` bits/word) is set so that 10-word passphrases against the smallest acceptable list still reach 80 bits, which is above NIST SP 800-63B's symmetric-key-equivalent floor for moderate-strength contexts. EFF's Short Wordlist 1 (1296 entries) comfortably exceeds this — the floor is enforced primarily to protect against caller-supplied "wordlists" that are actually tiny themed lists too short to provide meaningful entropy.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getWordlist,
|
||||
listWordlists,
|
||||
MIN_WORDLIST_SIZE,
|
||||
registerWordlist,
|
||||
} from "./registry.js";
|
||||
|
||||
describe("wordlist registry", () => {
|
||||
it("pre-populates eff-long and eff-short", { timeout: 5000 }, () => {
|
||||
const names = listWordlists();
|
||||
expect(names.includes("eff-long")).toBe(true);
|
||||
expect(names.includes("eff-short")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers a wordlist and retrieves it", { timeout: 5000 }, () => {
|
||||
const words = Array.from({ length: 256 }, (_, i) => `w${i}`);
|
||||
registerWordlist("dnd-spells", words);
|
||||
expect(getWordlist("dnd-spells")).toEqual(words);
|
||||
});
|
||||
|
||||
it("rejects wordlist below 256 entries", { timeout: 5000 }, () => {
|
||||
expect(() => registerWordlist("tiny", ["one", "two"])).toThrow(
|
||||
/PWGEN\.WORDLIST_TOO_SHORT/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws PWGEN.WORDLIST_NOT_REGISTERED for unknown names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => getWordlist("never-registered")).toThrow(
|
||||
/PWGEN\.WORDLIST_NOT_REGISTERED/
|
||||
);
|
||||
});
|
||||
|
||||
it("MIN_WORDLIST_SIZE is 256", { timeout: 5000 }, () => {
|
||||
expect(MIN_WORDLIST_SIZE).toBe(256);
|
||||
});
|
||||
|
||||
it("listWordlists returns an array", { timeout: 5000 }, () => {
|
||||
expect(Array.isArray(listWordlists())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { pwgenError } from "../errors/codes.js";
|
||||
import { EFF_LONG_WORDLIST } from "./eff-long.js";
|
||||
import { EFF_SHORT_WORDLIST } from "./eff-short.js";
|
||||
|
||||
/**
|
||||
* Floor below which a registered wordlist is rejected. 256 entries
|
||||
* (8 bits per word) is the minimum that makes a 16-word passphrase
|
||||
* cross the 128-bit symmetric-key floor.
|
||||
*/
|
||||
export const MIN_WORDLIST_SIZE = 256;
|
||||
|
||||
const registry = new Map<string, readonly string[]>([
|
||||
["eff-long", EFF_LONG_WORDLIST],
|
||||
["eff-short", EFF_SHORT_WORDLIST],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Register a caller-supplied wordlist under `name`. Subsequent calls
|
||||
* with the same name overwrite. Re-registering `"eff-long"` or
|
||||
* `"eff-short"` is permitted — callers occasionally want to override
|
||||
* the bundled lists with a localised variant.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.WORDLIST_TOO_SHORT) if `words.length <
|
||||
* MIN_WORDLIST_SIZE` (256).
|
||||
*/
|
||||
export function registerWordlist(name: string, words: readonly string[]): void {
|
||||
if (words.length < MIN_WORDLIST_SIZE) {
|
||||
throw pwgenError(
|
||||
"PWGEN.WORDLIST_TOO_SHORT",
|
||||
`wordlist "${name}" has ${words.length} entries; minimum is ${MIN_WORDLIST_SIZE}`
|
||||
);
|
||||
}
|
||||
registry.set(name, words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the registered wordlist for `name`.
|
||||
*
|
||||
* @throws PwgenError(PWGEN.WORDLIST_NOT_REGISTERED) for unknown names.
|
||||
*/
|
||||
export function getWordlist(name: string): readonly string[] {
|
||||
const list = registry.get(name);
|
||||
if (!list) {
|
||||
throw pwgenError(
|
||||
"PWGEN.WORDLIST_NOT_REGISTERED",
|
||||
`no wordlist registered under name "${name}"`
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Return every registered wordlist name. */
|
||||
export function listWordlists(): string[] {
|
||||
return [...registry.keys()];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..", "src");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist")
|
||||
continue;
|
||||
walk(p, out);
|
||||
} else if (
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".bench.ts")
|
||||
) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("source-tree hygiene", () => {
|
||||
it("src tree contains zero Math.random references", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const files = walk(SRC_ROOT);
|
||||
const hits: string[] = [];
|
||||
for (const f of files) {
|
||||
const content = readFileSync(f, "utf8");
|
||||
if (/\bMath\.random\b/.test(content)) hits.push(f);
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("src tree contains zero biome-ignore directives", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const files = walk(SRC_ROOT);
|
||||
const hits: string[] = [];
|
||||
for (const f of files) {
|
||||
const content = readFileSync(f, "utf8");
|
||||
if (/biome-ignore/.test(content)) hits.push(f);
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/password-generator",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/crypto-utils",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user