diff --git a/.vscode/settings.json b/.vscode/settings.json index a555cf2..4f9e6a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,8 @@ ], "[solidity]": { "editor.defaultFormatter": "JuanBlanco.solidity" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/README.md b/README.md index 244db76..c99bacb 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ -
-

BIP39

-

TypeScript library and CLI for generating, validating, and converting BIP39 English mnemonics

+# BIP39 + +**TypeScript library and CLI for generating, validating, and converting BIP39 English mnemonics** + License TypeScript Node pnpm -
- -## Project Overview +## Overview This repository provides the core BIP39 workflows for the English wordlist profile: @@ -77,11 +76,11 @@ node dist/cli/index.js mnemonic-to-seed "abandon abandon abandon abandon abandon ```ts import { - entropyToMnemonic, - generateEntropy, - mnemonicToEntropy, - mnemonicToSeed, - validateMnemonic, + entropyToMnemonic, + generateEntropy, + mnemonicToEntropy, + mnemonicToSeed, + validateMnemonic, } from "./dist/index.js"; const entropy = generateEntropy(16); @@ -91,10 +90,10 @@ const seed = mnemonicToSeed(mnemonic, "TREZOR"); const validation = validateMnemonic(mnemonic); console.log({ - mnemonic, - roundTripEntropyLength: roundTripEntropy.length, - seedLength: seed.length, - validation, + mnemonic, + roundTripEntropyLength: roundTripEntropy.length, + seedLength: seed.length, + validation, }); ``` @@ -134,31 +133,37 @@ The main repository-level configuration files are: - `tsconfig.json` for TypeScript compilation and build output settings - `biome.json` for linting and formatting -## Directory Structure +## Structure ```text . -├── assets/ # Pinned specification assets and test vectors -├── src/ # TypeScript source code -│ ├── bip39/ # Core entropy/mnemonic/seed workflows -│ ├── bits/ # Bit conversion helpers -│ ├── cli/ # Command-line interface -│ ├── constants/ # Fixed BIP39 constants and mappings -│ ├── crypto/ # SHA-256 and PBKDF2 wrappers -│ ├── entropy/ # Secure entropy generation -│ ├── errors/ # Standard error codes -│ ├── integration/ # Error messaging and integration adapters -│ ├── normalize/ # Compatibility input normalization -│ ├── parser/ # Strict mnemonic parsing rules -│ ├── types/ # Shared DTOs and result types -│ └── index.ts # Public export surface -├── tests/ # Unit and integration tests -├── biome.json # Lint/format configuration -├── package.json # Scripts and package metadata -├── README.md # Project overview and usage -└── tsconfig.json # TypeScript compilation configuration +├── assets/ +├── src/ +│ ├── bip39/ +│ ├── cli/ +│ ├── integration/ +│ └── index.ts +├── tests/ +├── biome.json +├── package.json +├── README.md +└── tsconfig.json ``` +| Path | Description | +| --- | --- | +| `assets/` | Pinned specification assets and test vectors | +| `src/` | TypeScript source code | +| `src/bip39/` | BIP39 library, primitives, entropy, and wordlists | +| `src/cli/` | Command-line interface | +| `src/integration/` | Shared input normalization and external adapters | +| `src/index.ts` | Public export surface | +| `tests/` | Unit and integration tests | +| `biome.json` | Lint/format configuration | +| `package.json` | Scripts and package metadata | +| `README.md` | Project overview and usage | +| `tsconfig.json` | TypeScript compilation configuration | + ## License MIT - see [LICENSE](LICENSE). diff --git a/src/DESIGN.md b/src/DESIGN.md index cf8c13d..2f4ebde 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -1,17 +1,10 @@ # src design - Purpose: TypeScript source for the BIP39 implementation. -- `src/constants/` defines fixed BIP39 constants and length/word-count relations. -- `src/bits/` implements bit and chunk conversions used in mnemonic encoding. -- `src/bip39/` contains core BIP39 conversion functions. -- `src/errors/` defines standard error codes and priority ordering. -- `src/crypto/` wraps SHA-256 and PBKDF2-HMAC-SHA512 using standard libraries. -- `src/normalize/` provides a compatibility input adapter (trim, NFKD, lowercase). -- `src/parser/` implements strict mnemonic parsing contracts. -- `src/entropy/` generates entropy via secure randomness with injectable providers for tests. -- `src/cli/` provides a command-line interface wrapping the core APIs. -- `src/integration/` wires core APIs to external systems (UI/BIP32) and error messaging. -- `src/types/` defines shared DTOs such as `ValidationResult`. -- `src/wordlist/` loads and validates the English wordlist with index mappings. -- `src/index.ts` re-exports the public surface for these foundational modules. +- `src/bip39/` groups the BIP39 library: conversions, validation and result types, strict parsing, constants, error codes, bit operations, cryptographic primitives, secure entropy generation, and English wordlists. These remain separate files with their existing responsibilities. +- `src/integration/` provides shared CLI/UI input normalization, external adapters (UI/BIP32), and error messaging. +- `src/cli/` provides argument handling, command adapters, and text output around the library APIs. +- Dependencies flow from CLI and integration adapters into the BIP39 library; the library does not depend on either adapter directory. +- `src/index.ts` preserves the public export surface independently of the internal file layout. +- Wordlist exports are explicit so the synchronous loader remains internal to core and CLI modules. - Build output is emitted to `dist/`; `src/` contains TypeScript sources only. diff --git a/src/bip39/DESIGN.md b/src/bip39/DESIGN.md index cdd1851..4b5527b 100644 --- a/src/bip39/DESIGN.md +++ b/src/bip39/DESIGN.md @@ -1,6 +1,19 @@ # bip39 design -- Purpose: Core BIP39 conversion functions built on fixed assets and primitives. -- Scope: Deterministic conversions only; no UI or random entropy generation. -- Includes: `entropyToMnemonic`, `mnemonicToEntropy`, `mnemonicToSeed`, and `validateMnemonic`. +- Purpose: Keep the BIP39 library and its supporting primitives together, with each responsibility in a separate module. +- Scope: Conversion, validation, strict parsing, wordlist loading, and secure entropy generation, including runtime file and standard-library crypto operations. UI/CLI input normalization and presentation remain in the adapter directories. +- `entropyToMnemonic.ts` handles entropy encoding; `mnemonicToSeed.ts` handles seed derivation separately from strict mnemonic validation. +- `mnemonic.ts` groups validation, entropy recovery, `ValidationResult`, and the existing recovery error classes around a private decoder. +- The decoder checks format, word count, word membership, and checksum in that order. Both public APIs propagate infrastructure errors unchanged. +- `validateMnemonic` returns exactly its five public fields; `mnemonicToEntropy` translates decoder failures into the existing exception classes. Decoded entropy stays internal to validation. +- `strictMnemonic.ts` enforces NFKD, spacing, and lowercase ASCII for the English profile and extracts word arrays; it does not apply compatibility input normalization. +- `constants.ts` defines fixed BIP39 constants and deterministic length/word-count relations without I/O or crypto dependencies. +- `bitOps.ts` provides pure conversions between bytes, bit arrays, and integer chunks in MSB-first order. +- `errorCodes.ts` defines error identifiers and priority ordering; user-facing messages belong to the integration layer. +- `crypto.ts` wraps standard SHA-256 and PBKDF2-HMAC-SHA512 primitives and maps PBKDF2 failures; it does not implement cryptographic algorithms itself. +- `entropyGenerator.ts` validates allowed byte lengths and delegates secure entropy generation to a randomness provider, with provider injection for tests. +- `wordlist.ts` owns the shared `Wordlist` type, line splitting, integrity checks, file-order index mappings, and both English wordlist loaders; it performs no input normalization or crypto. +- Public `loadEnglishWordlist` reads asynchronously; `loadEnglishWordlistSync` serves conversions and CLI generation and stays excluded from the root public exports. +- The loaders maintain separate caches because returned dictionaries are mutable; each caches only successful loads. +- Wordlist parsing preserves error precedence: the public parser checks empty lines first, while the synchronous loader checks word count before empty or duplicate words in file order. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/bits/bitOps.ts b/src/bip39/bitOps.ts similarity index 100% rename from src/bits/bitOps.ts rename to src/bip39/bitOps.ts diff --git a/src/constants/bip39.ts b/src/bip39/constants.ts similarity index 100% rename from src/constants/bip39.ts rename to src/bip39/constants.ts diff --git a/src/crypto/crypto.ts b/src/bip39/crypto.ts similarity index 85% rename from src/crypto/crypto.ts rename to src/bip39/crypto.ts index 07c153e..f397cc7 100644 --- a/src/crypto/crypto.ts +++ b/src/bip39/crypto.ts @@ -1,7 +1,7 @@ import { createHash, pbkdf2Sync } from "node:crypto"; -import { PBKDF2_ITERATIONS, SEED_BYTES } from "../constants/bip39.js"; -import { ErrorCode } from "../errors/errorCodes.js"; +import { PBKDF2_ITERATIONS, SEED_BYTES } from "./constants.js"; +import { ErrorCode } from "./errorCodes.js"; export class Pbkdf2FailureError extends Error { code = ErrorCode.ERR_PBKDF2_FAILURE; diff --git a/src/bip39/englishWordlist.ts b/src/bip39/englishWordlist.ts deleted file mode 100644 index d8cc778..0000000 --- a/src/bip39/englishWordlist.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -import { WORDLIST_SIZE } from "../constants/bip39.js"; - -export type EnglishWordlist = { - words: string[]; - wordToIndex: Map; -}; - -const ENGLISH_WORDLIST_PATH = "assets/english.txt"; - -let cachedEnglishWordlist: EnglishWordlist | null = null; - -export const loadEnglishWordlist = (): EnglishWordlist => { - if (cachedEnglishWordlist) { - return cachedEnglishWordlist; - } - const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); - const text = readFileSync(filePath, "utf8"); - const lines = text - .split("\n") - .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); - if (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop(); - } - if (lines.length !== WORDLIST_SIZE) { - throw new Error( - `Wordlist must contain ${WORDLIST_SIZE} words, got ${lines.length}`, - ); - } - const wordToIndex = new Map(); - lines.forEach((word, index) => { - if (word.length === 0) { - throw new Error("Wordlist contains an empty word"); - } - if (wordToIndex.has(word)) { - throw new Error(`Duplicate word detected: ${word}`); - } - wordToIndex.set(word, index); - }); - cachedEnglishWordlist = { words: lines, wordToIndex }; - return cachedEnglishWordlist; -}; diff --git a/src/entropy/entropyGenerator.ts b/src/bip39/entropyGenerator.ts similarity index 95% rename from src/entropy/entropyGenerator.ts rename to src/bip39/entropyGenerator.ts index e29abd3..e27fdb2 100644 --- a/src/entropy/entropyGenerator.ts +++ b/src/bip39/entropyGenerator.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; -import { ENTROPY_BYTES } from "../constants/bip39.js"; +import { ENTROPY_BYTES } from "./constants.js"; export class InvalidEntropyLengthError extends Error { constructor(message = "Invalid entropy length") { diff --git a/src/bip39/entropyToMnemonic.ts b/src/bip39/entropyToMnemonic.ts index 8f1845d..e5b59e6 100644 --- a/src/bip39/entropyToMnemonic.ts +++ b/src/bip39/entropyToMnemonic.ts @@ -1,11 +1,8 @@ -import { bitsToIntegers, bytesToBits } from "../bits/bitOps.js"; -import { - checksumBitsForEntropyBits, - ENTROPY_BYTES, -} from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; +import { bitsToIntegers, bytesToBits } from "./bitOps.js"; +import { checksumBitsForEntropyBits, ENTROPY_BYTES } from "./constants.js"; +import { sha256 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; +import { loadEnglishWordlistSync } from "./wordlist.js"; export class EntropyLengthError extends Error { code = ErrorCode.ERR_ENTROPY_LENGTH; @@ -32,7 +29,7 @@ export const entropyToMnemonic = (entropy: Uint8Array): string => { const checksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); const combined = entropyBitArray.concat(checksum); const indices = bitsToIntegers(combined, 11); - const { words } = loadEnglishWordlist(); + const { words } = loadEnglishWordlistSync(); const mnemonicWords = indices.map((index) => { const word = words[index]; if (word === undefined) { diff --git a/src/errors/errorCodes.ts b/src/bip39/errorCodes.ts similarity index 100% rename from src/errors/errorCodes.ts rename to src/bip39/errorCodes.ts diff --git a/src/bip39/mnemonic.ts b/src/bip39/mnemonic.ts new file mode 100644 index 0000000..55e2657 --- /dev/null +++ b/src/bip39/mnemonic.ts @@ -0,0 +1,176 @@ +import { bitsToBytes, bytesToBits, integersToBits } from "./bitOps.js"; +import { WORD_COUNTS } from "./constants.js"; +import { sha256 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; +import { parseMnemonicWordsStrict } from "./strictMnemonic.js"; +import { loadEnglishWordlistSync } from "./wordlist.js"; + +export type ValidationResult = { + ok: boolean; + error_code: ErrorCode | null; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; +}; + +export class MnemonicToEntropyError extends Error { + code: ErrorCode; + + constructor(code: ErrorCode, message: string) { + super(message); + this.code = code; + this.name = "MnemonicToEntropyError"; + } +} + +export class InvalidMnemonicFormatError extends MnemonicToEntropyError { + constructor(message = "Invalid mnemonic format") { + super(ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, message); + this.name = "InvalidMnemonicFormatError"; + } +} + +export class InvalidWordCountError extends MnemonicToEntropyError { + constructor(message = "Invalid word count") { + super(ErrorCode.ERR_INVALID_WORD_COUNT, message); + this.name = "InvalidWordCountError"; + } +} + +export class WordNotInListError extends MnemonicToEntropyError { + constructor(message = "Word not in list") { + super(ErrorCode.ERR_WORD_NOT_IN_LIST, message); + this.name = "WordNotInListError"; + } +} + +export class ChecksumMismatchError extends MnemonicToEntropyError { + constructor(message = "Checksum mismatch") { + super(ErrorCode.ERR_CHECKSUM_MISMATCH, message); + this.name = "ChecksumMismatchError"; + } +} + +type MnemonicDecodeResult = + | { + ok: true; + entropy: Uint8Array; + normalized_mnemonic: string; + word_count: number; + } + | { + ok: false; + error_code: + | ErrorCode.ERR_INVALID_MNEMONIC_FORMAT + | ErrorCode.ERR_INVALID_WORD_COUNT + | ErrorCode.ERR_WORD_NOT_IN_LIST + | ErrorCode.ERR_CHECKSUM_MISMATCH; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; + }; + +const isValidWordCount = (count: number): boolean => + (WORD_COUNTS as readonly number[]).includes(count); + +const checksumBitsForWordCount = (wordCount: number): number => + wordCount === 0 ? 0 : (wordCount * 11) / 33; + +const arraysEqual = (a: number[], b: number[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +const decodeMnemonic = (input: string | string[]): MnemonicDecodeResult => { + const parsed = parseMnemonicWordsStrict(input); + if (!parsed.ok) { + return { + ok: false, + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + normalized_mnemonic: null, + word_count: null, + invalid_word: null, + }; + } + + const { words, normalized_mnemonic } = parsed; + const wordCount = words.length; + if (!isValidWordCount(wordCount)) { + return { + ok: false, + error_code: ErrorCode.ERR_INVALID_WORD_COUNT, + normalized_mnemonic, + word_count: wordCount, + invalid_word: null, + }; + } + + const { wordToIndex } = loadEnglishWordlistSync(); + const indices: number[] = []; + for (const word of words) { + const index = wordToIndex.get(word); + if (index === undefined) { + return { + ok: false, + error_code: ErrorCode.ERR_WORD_NOT_IN_LIST, + normalized_mnemonic, + word_count: wordCount, + invalid_word: word, + }; + } + indices.push(index); + } + + const bits = integersToBits(indices, 11); + const checksumBits = checksumBitsForWordCount(wordCount); + const entropyBits = bits.length - checksumBits; + const entropyBitArray = bits.slice(0, entropyBits); + const checksumBitArray = bits.slice(entropyBits); + const entropy = bitsToBytes(entropyBitArray); + const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); + + if (!arraysEqual(checksumBitArray, expectedChecksum)) { + return { + ok: false, + error_code: ErrorCode.ERR_CHECKSUM_MISMATCH, + normalized_mnemonic, + word_count: wordCount, + invalid_word: null, + }; + } + + return { + ok: true, + entropy, + normalized_mnemonic, + word_count: wordCount, + }; +}; + +export const validateMnemonic = ( + input: string | string[], +): ValidationResult => { + const result = decodeMnemonic(input); + return { + ok: result.ok, + error_code: result.ok ? null : result.error_code, + normalized_mnemonic: result.normalized_mnemonic, + word_count: result.word_count, + invalid_word: result.ok ? null : result.invalid_word, + }; +}; + +export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { + const result = decodeMnemonic(input); + if (result.ok) { + return result.entropy; + } + switch (result.error_code) { + case ErrorCode.ERR_INVALID_MNEMONIC_FORMAT: + throw new InvalidMnemonicFormatError(); + case ErrorCode.ERR_INVALID_WORD_COUNT: + throw new InvalidWordCountError(); + case ErrorCode.ERR_WORD_NOT_IN_LIST: + throw new WordNotInListError(`Word not in list: ${result.invalid_word}`); + case ErrorCode.ERR_CHECKSUM_MISMATCH: + throw new ChecksumMismatchError(); + } +}; diff --git a/src/bip39/mnemonicToEntropy.ts b/src/bip39/mnemonicToEntropy.ts deleted file mode 100644 index 19fc240..0000000 --- a/src/bip39/mnemonicToEntropy.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js"; -import { WORD_COUNTS } from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; - -export class MnemonicToEntropyError extends Error { - code: ErrorCode; - - constructor(code: ErrorCode, message: string) { - super(message); - this.code = code; - this.name = "MnemonicToEntropyError"; - } -} - -export class InvalidMnemonicFormatError extends MnemonicToEntropyError { - constructor(message = "Invalid mnemonic format") { - super(ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, message); - this.name = "InvalidMnemonicFormatError"; - } -} - -export class InvalidWordCountError extends MnemonicToEntropyError { - constructor(message = "Invalid word count") { - super(ErrorCode.ERR_INVALID_WORD_COUNT, message); - this.name = "InvalidWordCountError"; - } -} - -export class WordNotInListError extends MnemonicToEntropyError { - constructor(message = "Word not in list") { - super(ErrorCode.ERR_WORD_NOT_IN_LIST, message); - this.name = "WordNotInListError"; - } -} - -export class ChecksumMismatchError extends MnemonicToEntropyError { - constructor(message = "Checksum mismatch") { - super(ErrorCode.ERR_CHECKSUM_MISMATCH, message); - this.name = "ChecksumMismatchError"; - } -} - -const isValidWordCount = (count: number): boolean => - (WORD_COUNTS as readonly number[]).includes(count); - -const checksumBitsForWordCount = (wordCount: number): number => - wordCount === 0 ? 0 : (wordCount * 11) / 33; - -const arraysEqual = (a: number[], b: number[]): boolean => - a.length === b.length && a.every((value, index) => value === b[index]); - -export const mnemonicToEntropy = (input: string | string[]): Uint8Array => { - const parsed = parseMnemonicWordsStrict(input); - if (!parsed.ok) { - throw new InvalidMnemonicFormatError(); - } - - const { words } = parsed; - const wordCount = words.length; - if (!isValidWordCount(wordCount)) { - throw new InvalidWordCountError(); - } - - const { wordToIndex } = loadEnglishWordlist(); - const indices = words.map((word) => { - const index = wordToIndex.get(word); - if (index === undefined) { - throw new WordNotInListError(`Word not in list: ${word}`); - } - return index; - }); - - const bits = integersToBits(indices, 11); - const checksumBits = checksumBitsForWordCount(wordCount); - const entropyBits = bits.length - checksumBits; - const entropyBitArray = bits.slice(0, entropyBits); - const checksumBitArray = bits.slice(entropyBits); - const entropy = bitsToBytes(entropyBitArray); - - const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); - if (!arraysEqual(checksumBitArray, expectedChecksum)) { - throw new ChecksumMismatchError(); - } - - return entropy; -}; diff --git a/src/bip39/mnemonicToSeed.ts b/src/bip39/mnemonicToSeed.ts index ba39530..f473569 100644 --- a/src/bip39/mnemonicToSeed.ts +++ b/src/bip39/mnemonicToSeed.ts @@ -1,5 +1,5 @@ -import { pbkdf2HmacSha512 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; +import { pbkdf2HmacSha512 } from "./crypto.js"; +import { ErrorCode } from "./errorCodes.js"; export class InvalidMnemonicSeedFormatError extends Error { code = ErrorCode.ERR_INVALID_MNEMONIC_FORMAT; diff --git a/src/parser/strictMnemonic.ts b/src/bip39/strictMnemonic.ts similarity index 97% rename from src/parser/strictMnemonic.ts rename to src/bip39/strictMnemonic.ts index aa11174..6a6b4c2 100644 --- a/src/parser/strictMnemonic.ts +++ b/src/bip39/strictMnemonic.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from "../errors/errorCodes.js"; +import { ErrorCode } from "./errorCodes.js"; export type StrictMnemonicParseSuccess = { ok: true; diff --git a/src/bip39/validateMnemonic.ts b/src/bip39/validateMnemonic.ts deleted file mode 100644 index 803672f..0000000 --- a/src/bip39/validateMnemonic.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js"; -import { WORD_COUNTS } from "../constants/bip39.js"; -import { sha256 } from "../crypto/crypto.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js"; -import type { ValidationResult } from "../types/validationResult.js"; -import { loadEnglishWordlist } from "./englishWordlist.js"; - -const isValidWordCount = (count: number): boolean => - (WORD_COUNTS as readonly number[]).includes(count); - -const checksumBitsForWordCount = (wordCount: number): number => - wordCount === 0 ? 0 : (wordCount * 11) / 33; - -const arraysEqual = (a: number[], b: number[]): boolean => - a.length === b.length && a.every((value, index) => value === b[index]); - -export const validateMnemonic = ( - input: string | string[], -): ValidationResult => { - const parsed = parseMnemonicWordsStrict(input); - if (!parsed.ok) { - return { - ok: false, - error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - normalized_mnemonic: null, - word_count: null, - invalid_word: null, - }; - } - - const { words, normalized_mnemonic } = parsed; - const wordCount = words.length; - if (!isValidWordCount(wordCount)) { - return { - ok: false, - error_code: ErrorCode.ERR_INVALID_WORD_COUNT, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; - } - - const { wordToIndex } = loadEnglishWordlist(); - const indices: number[] = []; - for (const word of words) { - const index = wordToIndex.get(word); - if (index === undefined) { - return { - ok: false, - error_code: ErrorCode.ERR_WORD_NOT_IN_LIST, - normalized_mnemonic, - word_count: wordCount, - invalid_word: word, - }; - } - indices.push(index); - } - - const bits = integersToBits(indices, 11); - const checksumBits = checksumBitsForWordCount(wordCount); - const entropyBits = bits.length - checksumBits; - const entropyBitArray = bits.slice(0, entropyBits); - const checksumBitArray = bits.slice(entropyBits); - const entropy = bitsToBytes(entropyBitArray); - const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits); - - if (!arraysEqual(checksumBitArray, expectedChecksum)) { - return { - ok: false, - error_code: ErrorCode.ERR_CHECKSUM_MISMATCH, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; - } - - return { - ok: true, - error_code: null, - normalized_mnemonic, - word_count: wordCount, - invalid_word: null, - }; -}; diff --git a/src/wordlist/wordlist.ts b/src/bip39/wordlist.ts similarity index 65% rename from src/wordlist/wordlist.ts rename to src/bip39/wordlist.ts index b0516cb..06885c6 100644 --- a/src/wordlist/wordlist.ts +++ b/src/bip39/wordlist.ts @@ -1,7 +1,8 @@ +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { WORDLIST_SIZE } from "../constants/bip39.js"; +import { WORDLIST_SIZE } from "./constants.js"; export type Wordlist = { words: string[]; @@ -10,7 +11,9 @@ export type Wordlist = { const ENGLISH_WORDLIST_PATH = "assets/english.txt"; -let cachedEnglishWordlist: Wordlist | null = null; +// Public callers can mutate their dictionary without changing core operations. +let cachedAsyncEnglishWordlist: Wordlist | null = null; +let cachedSyncEnglishWordlist: Wordlist | null = null; export const createWordlist = (words: string[]): Wordlist => { if (words.length !== WORDLIST_SIZE) { @@ -33,13 +36,18 @@ export const createWordlist = (words: string[]): Wordlist => { return { words: [...words], wordToIndex }; }; -export const parseWordlist = (text: string): Wordlist => { +const splitWordlistLines = (text: string): string[] => { const lines = text .split("\n") .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } + return lines; +}; + +export const parseWordlist = (text: string): Wordlist => { + const lines = splitWordlistLines(text); if (lines.some((line) => line.length === 0)) { throw new Error("Wordlist contains empty lines"); } @@ -47,13 +55,24 @@ export const parseWordlist = (text: string): Wordlist => { }; export const loadEnglishWordlist = async (): Promise => { - if (cachedEnglishWordlist) { - return cachedEnglishWordlist; + if (cachedAsyncEnglishWordlist) { + return cachedAsyncEnglishWordlist; } const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); const text = await readFile(filePath, "utf8"); - cachedEnglishWordlist = parseWordlist(text); - return cachedEnglishWordlist; + cachedAsyncEnglishWordlist = parseWordlist(text); + return cachedAsyncEnglishWordlist; +}; + +export const loadEnglishWordlistSync = (): Wordlist => { + if (cachedSyncEnglishWordlist) { + return cachedSyncEnglishWordlist; + } + const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH); + const text = readFileSync(filePath, "utf8"); + // The synchronous contract checks length before empty or duplicate words. + cachedSyncEnglishWordlist = createWordlist(splitWordlistLines(text)); + return cachedSyncEnglishWordlist; }; export const indexToWord = (wordlist: Wordlist, index: number): string => { diff --git a/src/bits/DESIGN.md b/src/bits/DESIGN.md deleted file mode 100644 index d6671b9..0000000 --- a/src/bits/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# bits design - -- Purpose: Convert between bytes, bit arrays, and fixed-size integer chunks. -- Scope: Pure bit manipulation utilities with deterministic ordering (MSB-first). -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md index df8992f..ee4be3b 100644 --- a/src/cli/DESIGN.md +++ b/src/cli/DESIGN.md @@ -2,7 +2,11 @@ - Purpose: Provide a human-friendly CLI wrapper for BIP39 core APIs. - `runCli` handles argument parsing, input resolution (args/stdin), and exit codes. -- `commands/` contains small adapters that invoke core use cases. +- `commands.ts` groups mnemonic generation, optional input normalization, and validation-result adapters with their result types. +- Validation and entropy recovery delegate to the shared `src/bip39/mnemonic.ts` module. +- `runCli` calls core entropy generation and entropy-to-mnemonic conversion directly; adapters are used where CLI-specific behavior is needed. +- Library functions and constants come from `src/bip39/`; compatibility normalization and error messages come from `src/integration/`. - `hex.ts` handles hex encoding/decoding for byte outputs. - The CLI defaults to normalized input, with `--strict` to disable normalization. - Added `generate-mnemonic-with-wordlist` to emit a generated mnemonic plus the full English wordlist. +- Wordlist output uses the synchronous loader in `src/bip39/wordlist.ts`, sharing the core dictionary while remaining independent of the public asynchronous loader's cache. diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..243b6ad --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,75 @@ +import { entropyBitsForWordCount, type WordCount } from "../bip39/constants.js"; +import { generateEntropy } from "../bip39/entropyGenerator.js"; +import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; +import { ErrorCode } from "../bip39/errorCodes.js"; +import { mnemonicToEntropy, validateMnemonic } from "../bip39/mnemonic.js"; +import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; +import { loadEnglishWordlistSync } from "../bip39/wordlist.js"; +import { normalizeMnemonicInput } from "../integration/normalizeMnemonicInput.js"; + +export const generateMnemonicCommand = (words: number): string => { + const entropyBits = entropyBitsForWordCount(words as WordCount); + const bytes = entropyBits / 8; + if (!Number.isInteger(bytes)) { + throw new Error("Invalid word count for entropy bytes"); + } + return entropyToMnemonic(generateEntropy(bytes)); +}; + +export type MnemonicWithWordlist = { + mnemonic: string; + wordlist: string[]; +}; + +export const generateMnemonicWithWordlistCommand = ( + words: number, +): MnemonicWithWordlist => { + const mnemonic = generateMnemonicCommand(words); + const { words: wordlist } = loadEnglishWordlistSync(); + return { mnemonic, wordlist }; +}; + +export const mnemonicToEntropyCommand = ( + input: string, + strict: boolean, +): Uint8Array => { + const normalized = strict ? input : normalizeMnemonicInput(input); + return mnemonicToEntropy(normalized); +}; + +export const mnemonicToSeedCommand = ( + input: string, + strict: boolean, + passphrase: string, +): Uint8Array => { + const normalized = strict ? input : normalizeMnemonicInput(input); + return mnemonicToSeed(normalized, passphrase); +}; + +export type ValidateCommandResult = + | { + ok: true; + normalized: string; + } + | { + ok: false; + errorCode: ErrorCode; + }; + +export const validateCommand = ( + input: string, + strict: boolean, +): ValidateCommandResult => { + const normalized = strict ? input : normalizeMnemonicInput(input); + const result = validateMnemonic(normalized); + if (!result.ok) { + return { + ok: false, + errorCode: result.error_code ?? ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + }; + } + return { + ok: true, + normalized: result.normalized_mnemonic ?? normalized, + }; +}; diff --git a/src/cli/commands/entropyToMnemonic.ts b/src/cli/commands/entropyToMnemonic.ts deleted file mode 100644 index 99551bd..0000000 --- a/src/cli/commands/entropyToMnemonic.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { entropyToMnemonic } from "../../bip39/entropyToMnemonic.js"; - -export const entropyToMnemonicCommand = (entropy: Uint8Array): string => - entropyToMnemonic(entropy); diff --git a/src/cli/commands/generateEntropy.ts b/src/cli/commands/generateEntropy.ts deleted file mode 100644 index 4539d74..0000000 --- a/src/cli/commands/generateEntropy.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { generateEntropy } from "../../entropy/entropyGenerator.js"; - -export const generateEntropyCommand = (bytes: number): Uint8Array => - generateEntropy(bytes); diff --git a/src/cli/commands/generateMnemonic.ts b/src/cli/commands/generateMnemonic.ts deleted file mode 100644 index cab4409..0000000 --- a/src/cli/commands/generateMnemonic.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { entropyToMnemonic } from "../../bip39/entropyToMnemonic.js"; -import type { WordCount } from "../../constants/bip39.js"; -import { entropyBitsForWordCount } from "../../constants/bip39.js"; -import { generateEntropy } from "../../entropy/entropyGenerator.js"; - -export const generateMnemonicCommand = (words: number): string => { - const entropyBits = entropyBitsForWordCount(words as WordCount); - const bytes = entropyBits / 8; - if (!Number.isInteger(bytes)) { - throw new Error("Invalid word count for entropy bytes"); - } - return entropyToMnemonic(generateEntropy(bytes)); -}; diff --git a/src/cli/commands/generateMnemonicWithWordlist.ts b/src/cli/commands/generateMnemonicWithWordlist.ts deleted file mode 100644 index a714a2d..0000000 --- a/src/cli/commands/generateMnemonicWithWordlist.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { loadEnglishWordlist } from "../../bip39/englishWordlist.js"; -import { generateMnemonicCommand } from "./generateMnemonic.js"; - -export type MnemonicWithWordlist = { - mnemonic: string; - wordlist: string[]; -}; - -export const generateMnemonicWithWordlistCommand = ( - words: number, -): MnemonicWithWordlist => { - const mnemonic = generateMnemonicCommand(words); - const { words: wordlist } = loadEnglishWordlist(); - return { mnemonic, wordlist }; -}; diff --git a/src/cli/commands/mnemonicToEntropy.ts b/src/cli/commands/mnemonicToEntropy.ts deleted file mode 100644 index d0449fb..0000000 --- a/src/cli/commands/mnemonicToEntropy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mnemonicToEntropy } from "../../bip39/mnemonicToEntropy.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export const mnemonicToEntropyCommand = ( - input: string, - strict: boolean, -): Uint8Array => { - const normalized = strict ? input : normalizeMnemonicInput(input); - return mnemonicToEntropy(normalized); -}; diff --git a/src/cli/commands/mnemonicToSeed.ts b/src/cli/commands/mnemonicToSeed.ts deleted file mode 100644 index 9fe4a15..0000000 --- a/src/cli/commands/mnemonicToSeed.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { mnemonicToSeed } from "../../bip39/mnemonicToSeed.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export const mnemonicToSeedCommand = ( - input: string, - strict: boolean, - passphrase: string, -): Uint8Array => { - const normalized = strict ? input : normalizeMnemonicInput(input); - return mnemonicToSeed(normalized, passphrase); -}; diff --git a/src/cli/commands/validate.ts b/src/cli/commands/validate.ts deleted file mode 100644 index c710f88..0000000 --- a/src/cli/commands/validate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { validateMnemonic } from "../../bip39/validateMnemonic.js"; -import { ErrorCode } from "../../errors/errorCodes.js"; -import { normalizeMnemonicInput } from "../../normalize/normalizeMnemonicInput.js"; - -export type ValidateCommandResult = - | { - ok: true; - normalized: string; - } - | { - ok: false; - errorCode: ErrorCode; - }; - -export const validateCommand = ( - input: string, - strict: boolean, -): ValidateCommandResult => { - const normalized = strict ? input : normalizeMnemonicInput(input); - const result = validateMnemonic(normalized); - if (!result.ok) { - return { - ok: false, - errorCode: result.error_code ?? ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - }; - } - return { - ok: true, - normalized: result.normalized_mnemonic ?? normalized, - }; -}; diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 21b0b90..a290e76 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -1,14 +1,16 @@ -import { ENTROPY_BYTES, WORD_COUNTS } from "../constants/bip39.js"; -import type { ErrorCode } from "../errors/errorCodes.js"; +import { ENTROPY_BYTES, WORD_COUNTS } from "../bip39/constants.js"; +import { generateEntropy } from "../bip39/entropyGenerator.js"; +import { entropyToMnemonic } from "../bip39/entropyToMnemonic.js"; +import type { ErrorCode } from "../bip39/errorCodes.js"; import { ERROR_MESSAGES } from "../integration/errorMessages.js"; import { parseArgs } from "./args.js"; -import { entropyToMnemonicCommand } from "./commands/entropyToMnemonic.js"; -import { generateEntropyCommand } from "./commands/generateEntropy.js"; -import { generateMnemonicCommand } from "./commands/generateMnemonic.js"; -import { generateMnemonicWithWordlistCommand } from "./commands/generateMnemonicWithWordlist.js"; -import { mnemonicToEntropyCommand } from "./commands/mnemonicToEntropy.js"; -import { mnemonicToSeedCommand } from "./commands/mnemonicToSeed.js"; -import { validateCommand } from "./commands/validate.js"; +import { + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, + mnemonicToEntropyCommand, + mnemonicToSeedCommand, + validateCommand, +} from "./commands.js"; import { bytesToHex, hexToBytes } from "./hex.js"; export type CliIO = { @@ -130,7 +132,7 @@ export const runCli = async (argv: string[], io: CliIO): Promise => { io.writeStderr(`Invalid hex: ${(error as Error).message}\n`); return 2; } - const mnemonic = entropyToMnemonicCommand(bytes); + const mnemonic = entropyToMnemonic(bytes); io.writeStdout(`${mnemonic}\n`); return 0; } @@ -164,7 +166,7 @@ export const runCli = async (argv: string[], io: CliIO): Promise => { io.writeStderr(usage); return 2; } - const entropy = generateEntropyCommand(bytes); + const entropy = generateEntropy(bytes); io.writeStdout(`${bytesToHex(entropy)}\n`); return 0; } diff --git a/src/constants/DESIGN.md b/src/constants/DESIGN.md deleted file mode 100644 index 86236f0..0000000 --- a/src/constants/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# constants design - -- Purpose: Define fixed BIP39 constants and deterministic length/word-count relations. -- Scope: Pure data and small helpers with no I/O or crypto dependencies. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/crypto/DESIGN.md b/src/crypto/DESIGN.md deleted file mode 100644 index 0e35bde..0000000 --- a/src/crypto/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# crypto design - -- Purpose: Provide thin wrappers around standard SHA-256 and PBKDF2-HMAC-SHA512 primitives. -- Scope: No custom crypto; only fixed-parameter calls and error mapping. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/entropy/DESIGN.md b/src/entropy/DESIGN.md deleted file mode 100644 index e386ec0..0000000 --- a/src/entropy/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# entropy design - -- Purpose: Provide secure entropy generation with injectable providers for testing. -- Scope: Validation of allowed byte lengths and delegation to a randomness provider. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/errors/DESIGN.md b/src/errors/DESIGN.md deleted file mode 100644 index 8a1edb2..0000000 --- a/src/errors/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# errors design - -- Purpose: Centralize standard BIP39 error codes and their priority ordering. -- Scope: Error identifiers only; no formatting or UI messaging. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/index.ts b/src/index.ts index fbd7337..18a588e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,20 @@ +export * from "./bip39/bitOps.js"; +export * from "./bip39/constants.js"; +export * from "./bip39/crypto.js"; +export * from "./bip39/entropyGenerator.js"; export * from "./bip39/entropyToMnemonic.js"; -export * from "./bip39/mnemonicToEntropy.js"; +export * from "./bip39/errorCodes.js"; +export * from "./bip39/mnemonic.js"; export * from "./bip39/mnemonicToSeed.js"; -export * from "./bip39/validateMnemonic.js"; -export * from "./bits/bitOps.js"; -export * from "./constants/bip39.js"; -export * from "./crypto/crypto.js"; -export * from "./entropy/entropyGenerator.js"; -export * from "./errors/errorCodes.js"; +export * from "./bip39/strictMnemonic.js"; +export { + createWordlist, + indexToWord, + loadEnglishWordlist, + parseWordlist, + type Wordlist, + wordToIndex, +} from "./bip39/wordlist.js"; export * from "./integration/errorMessages.js"; export * from "./integration/externalIntegration.js"; -export * from "./normalize/normalizeMnemonicInput.js"; -export * from "./parser/strictMnemonic.js"; -export * from "./types/validationResult.js"; -export * from "./wordlist/wordlist.js"; +export * from "./integration/normalizeMnemonicInput.js"; diff --git a/src/integration/DESIGN.md b/src/integration/DESIGN.md index f06d337..dc097e2 100644 --- a/src/integration/DESIGN.md +++ b/src/integration/DESIGN.md @@ -1,5 +1,9 @@ # integration design -- Purpose: Connect core BIP39 APIs to external layers like UI or BIP32. -- Scope: Normalize UI input, orchestrate validation/derivation, and map error codes to messages. +- Purpose: Connect the BIP39 library to external layers like CLI, UI, or BIP32. +- Scope: Provide compatibility input normalization, orchestrate UI validation/derivation, and map error codes to messages. +- `normalizeMnemonicInput.ts` is the shared CLI/UI adapter for trimming, whitespace normalization, NFKD normalization, and lowercasing for the English profile. It remains separate from the library's strict parser and seed derivation. +- `externalIntegration.ts` implements UI and downstream seed adapters; `errorMessages.ts` maps the library's error identifiers to presentation text. +- Validation and its `ValidationResult` type come from `src/bip39/mnemonic.ts`; seed derivation remains a separate core API. +- Integration adapters depend on `src/bip39/` and do not depend on the CLI. - Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/integration/errorMessages.ts b/src/integration/errorMessages.ts index 8118d16..d53bdb0 100644 --- a/src/integration/errorMessages.ts +++ b/src/integration/errorMessages.ts @@ -1,4 +1,4 @@ -import { ErrorCode } from "../errors/errorCodes.js"; +import { ErrorCode } from "../bip39/errorCodes.js"; export const ERROR_MESSAGES: Record = { [ErrorCode.ERR_ENTROPY_LENGTH]: diff --git a/src/integration/externalIntegration.ts b/src/integration/externalIntegration.ts index 1f3ab18..9dc8610 100644 --- a/src/integration/externalIntegration.ts +++ b/src/integration/externalIntegration.ts @@ -1,9 +1,8 @@ +import { ErrorCode } from "../bip39/errorCodes.js"; +import { type ValidationResult, validateMnemonic } from "../bip39/mnemonic.js"; import { mnemonicToSeed } from "../bip39/mnemonicToSeed.js"; -import { validateMnemonic } from "../bip39/validateMnemonic.js"; -import { ErrorCode } from "../errors/errorCodes.js"; -import { normalizeMnemonicInput } from "../normalize/normalizeMnemonicInput.js"; -import type { ValidationResult } from "../types/validationResult.js"; import { ERROR_MESSAGES } from "./errorMessages.js"; +import { normalizeMnemonicInput } from "./normalizeMnemonicInput.js"; export type SeedDerivationResult = | { diff --git a/src/normalize/normalizeMnemonicInput.ts b/src/integration/normalizeMnemonicInput.ts similarity index 100% rename from src/normalize/normalizeMnemonicInput.ts rename to src/integration/normalizeMnemonicInput.ts diff --git a/src/normalize/DESIGN.md b/src/normalize/DESIGN.md deleted file mode 100644 index 1a6d912..0000000 --- a/src/normalize/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# normalize design - -- Purpose: Provide a compatibility adapter for mnemonic input. -- Scope: Trim, whitespace normalization, NFKD normalization, and lowercase for English profile. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/parser/DESIGN.md b/src/parser/DESIGN.md deleted file mode 100644 index bac680f..0000000 --- a/src/parser/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# parser design - -- Purpose: Enforce strict mnemonic input contracts and extract word arrays. -- Scope: Validation of NFKD, spacing, and lowercase ASCII for the English profile. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/types/DESIGN.md b/src/types/DESIGN.md deleted file mode 100644 index 9e5d29e..0000000 --- a/src/types/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# types design - -- Purpose: Shared DTOs and types used by public APIs. -- Scope: Type declarations only; no runtime logic. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/types/validationResult.ts b/src/types/validationResult.ts deleted file mode 100644 index bab48ab..0000000 --- a/src/types/validationResult.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ErrorCode } from "../errors/errorCodes.js"; - -export type ValidationResult = { - ok: boolean; - error_code: ErrorCode | null; - normalized_mnemonic: string | null; - word_count: number | null; - invalid_word: string | null; -}; diff --git a/src/wordlist/DESIGN.md b/src/wordlist/DESIGN.md deleted file mode 100644 index a2d116c..0000000 --- a/src/wordlist/DESIGN.md +++ /dev/null @@ -1,5 +0,0 @@ -# wordlist design - -- Purpose: Load the English wordlist in file order and build index mappings. -- Scope: File parsing, integrity checks, and index lookups; no normalization or crypto. -- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/tests/acceptance.spec.ts b/tests/acceptance.spec.ts index 7646705..cb142f8 100644 --- a/tests/acceptance.spec.ts +++ b/tests/acceptance.spec.ts @@ -4,22 +4,19 @@ import { resolve } from "node:path"; import { test } from "vitest"; import { + ChecksumMismatchError, EntropyLengthError, + ErrorCode, entropyToMnemonic, -} from "../src/bip39/entropyToMnemonic.ts"; -import { - ChecksumMismatchError, InvalidMnemonicFormatError, + InvalidMnemonicSeedFormatError, InvalidWordCountError, + MnemonicToEntropyError, mnemonicToEntropy, - WordNotInListError, -} from "../src/bip39/mnemonicToEntropy.ts"; -import { - InvalidMnemonicSeedFormatError, mnemonicToSeed, -} from "../src/bip39/mnemonicToSeed.ts"; -import { validateMnemonic } from "../src/bip39/validateMnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; + validateMnemonic, + WordNotInListError, +} from "../src/index.ts"; const hexToBytes = (hex: string): Uint8Array => Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => @@ -62,6 +59,123 @@ test("roundtrip covers all allowed entropy lengths", () => { } }); +// Fixed zero-entropy cases for every ENT/CS/MS row in assets/bip-0039.mediawiki. +// The pinned official vectors do not include the 15- and 21-word lengths. +test.each([ + { bytes: 16, wordCount: 12, lastWord: "about" }, + { bytes: 20, wordCount: 15, lastWord: "address" }, + { bytes: 24, wordCount: 18, lastWord: "agent" }, + { bytes: 28, wordCount: 21, lastWord: "admit" }, + { bytes: 32, wordCount: 24, lastWord: "art" }, +])("public APIs support $wordCount words with string and array input", ({ + bytes, + wordCount, + lastWord, +}) => { + const entropy = new Uint8Array(bytes); + const words = [...Array(wordCount - 1).fill("abandon"), lastWord]; + const mnemonic = words.join(" "); + assert.equal(entropyToMnemonic(entropy), mnemonic); + for (const input of [mnemonic, words]) { + assert.deepEqual(mnemonicToEntropy(input), entropy); + assert.deepEqual(validateMnemonic(input), { + ok: true, + error_code: null, + normalized_mnemonic: mnemonic, + word_count: wordCount, + invalid_word: null, + }); + } + assert.equal(words.join(" "), mnemonic); + assert.deepEqual(entropy, new Uint8Array(bytes)); +}); + +const invalidMnemonicCases = [ + { + label: "format before word count and unknown words", + mnemonic: "TYPO abandon", + code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + ErrorType: InvalidMnemonicFormatError, + message: "Invalid mnemonic format", + normalized: null, + wordCount: null, + invalidWord: null, + }, + { + label: "word count before unknown words", + mnemonic: "typo abandon", + code: ErrorCode.ERR_INVALID_WORD_COUNT, + ErrorType: InvalidWordCountError, + message: "Invalid word count", + normalized: "typo abandon", + wordCount: 2, + invalidWord: null, + }, + { + label: "unknown word before checksum", + mnemonic: `${"abandon ".repeat(11)}typo`, + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + ErrorType: WordNotInListError, + message: "Word not in list: typo", + normalized: `${"abandon ".repeat(11)}typo`, + wordCount: 12, + invalidWord: "typo", + }, + { + label: "first unknown word when several are present", + mnemonic: `typo ${"abandon ".repeat(10)}unknown`, + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + ErrorType: WordNotInListError, + message: "Word not in list: typo", + normalized: `typo ${"abandon ".repeat(10)}unknown`, + wordCount: 12, + invalidWord: "typo", + }, + { + label: "checksum after valid format, word count and words", + mnemonic: Array(12).fill("abandon").join(" "), + code: ErrorCode.ERR_CHECKSUM_MISMATCH, + ErrorType: ChecksumMismatchError, + message: "Checksum mismatch", + normalized: Array(12).fill("abandon").join(" "), + wordCount: 12, + invalidWord: null, + }, +]; + +test.each( + invalidMnemonicCases, +)("public validation and decoding preserve $label", ({ + mnemonic, + code, + ErrorType, + message, + normalized, + wordCount, + invalidWord, +}) => { + for (const input of [mnemonic, mnemonic.split(" ")]) { + assert.deepEqual(validateMnemonic(input), { + ok: false, + error_code: code, + normalized_mnemonic: normalized, + word_count: wordCount, + invalid_word: invalidWord, + }); + assert.throws( + () => mnemonicToEntropy(input), + (error: unknown) => { + assert.ok(error instanceof ErrorType); + assert.ok(error instanceof MnemonicToEntropyError); + assert.equal(error.name, ErrorType.name); + assert.equal(error.code, code); + assert.equal(error.message, message); + return true; + }, + ); + } +}); + test("failure cases from appendix C are enforced", () => { assert.throws( () => entropyToMnemonic(new Uint8Array(15)), diff --git a/tests/bits.spec.ts b/tests/bits.spec.ts index 0faf9a9..ed8d178 100644 --- a/tests/bits.spec.ts +++ b/tests/bits.spec.ts @@ -6,7 +6,7 @@ import { bitsToIntegers, bytesToBits, integersToBits, -} from "../src/bits/bitOps.ts"; +} from "../src/bip39/bitOps.ts"; test("bytesToBits reads MSB to LSB", () => { const bits = bytesToBits(Uint8Array.from([0x80, 0x01])); diff --git a/tests/cli/commands.spec.ts b/tests/cli/commands.spec.ts index 503ae30..119d58f 100644 --- a/tests/cli/commands.spec.ts +++ b/tests/cli/commands.spec.ts @@ -1,13 +1,15 @@ import assert from "node:assert/strict"; +import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; - -import { entropyToMnemonicCommand } from "../../src/cli/commands/entropyToMnemonic.ts"; -import { generateEntropyCommand } from "../../src/cli/commands/generateEntropy.ts"; -import { generateMnemonicCommand } from "../../src/cli/commands/generateMnemonic.ts"; -import { generateMnemonicWithWordlistCommand } from "../../src/cli/commands/generateMnemonicWithWordlist.ts"; -import { mnemonicToEntropyCommand } from "../../src/cli/commands/mnemonicToEntropy.ts"; -import { mnemonicToSeedCommand } from "../../src/cli/commands/mnemonicToSeed.ts"; -import { validateCommand } from "../../src/cli/commands/validate.ts"; +import { ErrorCode } from "../../src/bip39/errorCodes.ts"; +import { InvalidMnemonicFormatError } from "../../src/bip39/mnemonic.ts"; +import { + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, + mnemonicToEntropyCommand, + mnemonicToSeedCommand, + validateCommand, +} from "../../src/cli/commands.ts"; const ENTROPY_HEX = "00000000000000000000000000000000"; const MNEMONIC = @@ -16,19 +18,9 @@ const SEED_HEX = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; -const hexToBytes = (hex: string): Uint8Array => - Uint8Array.from(hex.match(/.{2}/gu) ?? [], (pair) => - Number.parseInt(pair, 16), - ); - const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); -test("entropyToMnemonicCommand matches vector", () => { - const mnemonic = entropyToMnemonicCommand(hexToBytes(ENTROPY_HEX)); - assert.equal(mnemonic, MNEMONIC); -}); - test("mnemonicToEntropyCommand matches vector", () => { const entropy = mnemonicToEntropyCommand(MNEMONIC, false); assert.equal(bytesToHex(entropy), ENTROPY_HEX); @@ -40,26 +32,125 @@ test("mnemonicToSeedCommand matches vector", () => { }); test("validateCommand returns normalized mnemonic", () => { - const result = validateCommand(MNEMONIC, true); - assert.equal(result.ok, true); - if (!result.ok) return; - assert.equal(result.normalized, MNEMONIC); -}); - -test("generateEntropyCommand returns requested length", () => { - const entropy = generateEntropyCommand(16); - assert.equal(entropy.length, 16); + assert.deepEqual(validateCommand(MNEMONIC, true), { + ok: true, + normalized: MNEMONIC, + }); }); -test("generateMnemonicCommand returns requested word count", () => { - const mnemonic = generateMnemonicCommand(12); - assert.equal(mnemonic.split(" ").length, 12); +test.each([ + 12, 15, 18, 21, 24, +])("generateMnemonicCommand returns a valid %i-word mnemonic", (words) => { + const mnemonic = generateMnemonicCommand(words); + assert.equal(mnemonic.split(" ").length, words); + assert.deepEqual(validateCommand(mnemonic, true), { + ok: true, + normalized: mnemonic, + }); }); -test("generateMnemonicWithWordlistCommand returns mnemonic and wordlist", () => { - const result = generateMnemonicWithWordlistCommand(12); - assert.equal(result.mnemonic.split(" ").length, 12); +test.each([ + 12, 15, 18, 21, 24, +])("generateMnemonicWithWordlistCommand returns %i valid words and the English wordlist", (words) => { + const result = generateMnemonicWithWordlistCommand(words); + assert.equal(result.mnemonic.split(" ").length, words); + assert.deepEqual(validateCommand(result.mnemonic, true), { + ok: true, + normalized: result.mnemonic, + }); assert.equal(result.wordlist.length, 2048); + assert.equal(new Set(result.wordlist).size, 2048); assert.equal(result.wordlist[0], "abandon"); assert.equal(result.wordlist[result.wordlist.length - 1], "zoo"); + for (const word of result.mnemonic.split(" ")) { + assert.ok(result.wordlist.includes(word)); + } +}); + +test.each([ + ["uppercase", MNEMONIC.toUpperCase()], + ["whitespace", ` \t${MNEMONIC.replaceAll(" ", " \t")}\r\n`], + ["compatibility characters", MNEMONIC.replaceAll("a", "a")], +])("mnemonic commands normalize %s unless strict", (_label, input) => { + assert.deepEqual(validateCommand(input, false), { + ok: true, + normalized: MNEMONIC, + }); + assert.equal(bytesToHex(mnemonicToEntropyCommand(input, false)), ENTROPY_HEX); + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, false, "TREZOR")), + SEED_HEX, + ); + assert.deepEqual(validateCommand(input, true), { + ok: false, + errorCode: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + }); + assert.throws( + () => mnemonicToEntropyCommand(input, true), + InvalidMnemonicFormatError, + ); + + // BIP39 seed derivation applies NFKD even when CLI cleanup is disabled. + const strictSeed = pbkdf2Sync( + input.normalize("NFKD"), + "mnemonicTREZOR", + 2048, + 64, + "sha512", + ); + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, true, "TREZOR")), + strictSeed.toString("hex"), + ); +}); + +test.each([ + ["", ErrorCode.ERR_INVALID_MNEMONIC_FORMAT], + ["abandon", ErrorCode.ERR_INVALID_WORD_COUNT], + [MNEMONIC.replace("about", "unknownword"), ErrorCode.ERR_WORD_NOT_IN_LIST], + [MNEMONIC.replace("about", "abandon"), ErrorCode.ERR_CHECKSUM_MISMATCH], +])("validateCommand reports the error for %s", (input, errorCode) => { + assert.deepEqual(validateCommand(input, true), { ok: false, errorCode }); +}); + +test.each([ + false, + true, +])("mnemonicToSeedCommand preserves passphrase case and spaces with strict=%s", (strict) => { + const passphrase = " TréZoR \t"; + const expected = pbkdf2Sync( + MNEMONIC, + `mnemonic${passphrase.normalize("NFKD")}`, + 2048, + 64, + "sha512", + ); + assert.equal( + bytesToHex(mnemonicToSeedCommand(MNEMONIC, strict, passphrase)), + expected.toString("hex"), + ); +}); + +test.each([ + "", + "not a bip39 sentence", + MNEMONIC.replace("about", "abandon"), +])("mnemonicToSeedCommand derives a seed without mnemonic validation: %s", (input) => { + const expected = pbkdf2Sync(input, "mnemonic", 2048, 64, "sha512"); + for (const strict of [false, true]) { + assert.equal( + bytesToHex(mnemonicToSeedCommand(input, strict, "")), + expected.toString("hex"), + ); + } +}); + +test.each([ + generateMnemonicCommand, + generateMnemonicWithWordlistCommand, +])("%s preserves the unsupported-word-count exception", (generate) => { + assert.throws(() => generate(13), { + constructor: Error, + message: "Unsupported word count: 13", + }); }); diff --git a/tests/cli/integration.spec.ts b/tests/cli/integration.spec.ts index 7f80acf..d13ac32 100644 --- a/tests/cli/integration.spec.ts +++ b/tests/cli/integration.spec.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { pbkdf2Sync } from "node:crypto"; import { test } from "vitest"; const MNEMONIC = @@ -13,5 +14,86 @@ test("cli validate runs from the TypeScript entrypoint", () => { ); assert.equal(result.status, 0); - assert.match(result.stdout, /^valid\nnormalized: /u); + assert.equal(result.error, undefined); + assert.equal(result.stdout, `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(result.stderr, ""); +}); + +test.each([ + { + command: "validate", + output: `valid\nnormalized: ${MNEMONIC}\n`, + }, + { + command: "mnemonic-to-entropy", + output: "00000000000000000000000000000000\n", + }, +])("cli $command normalizes piped stdin by default", ({ command, output }) => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", command], + { encoding: "utf8", input: `\t${MNEMONIC.toUpperCase()}\r\n` }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, output); + assert.equal(result.stderr, ""); +}); + +test.each([ + "validate", + "mnemonic-to-entropy", +])("cli %s rejects the piped newline in strict mode", (command) => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", command, "--strict"], + { encoding: "utf8", input: `${MNEMONIC}\n` }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 1); + assert.equal(result.stdout, ""); + assert.equal( + result.stderr, + "error_code: ERR_INVALID_MNEMONIC_FORMAT\nmessage: Mnemonic format is invalid.\n", + ); +}); + +test.each([ + false, + true, +])("cli mnemonic-to-seed derives from piped stdin with strict=%s", (strict) => { + const input = `\t${MNEMONIC.toUpperCase()}\n`; + const args = ["--import", "tsx", "src/cli/index.ts", "mnemonic-to-seed"]; + if (strict) args.push("--strict"); + const result = spawnSync(process.execPath, args, { + encoding: "utf8", + input, + }); + const expectedSeed = pbkdf2Sync( + strict ? input : MNEMONIC, + "mnemonic", + 2048, + 64, + "sha512", + ).toString("hex"); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, `${expectedSeed}\n`); + assert.equal(result.stderr, ""); +}); + +test("cli entropy-to-mnemonic accepts a newline-terminated hex stream", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", "entropy-to-mnemonic"], + { encoding: "utf8", input: "00000000000000000000000000000000\n" }, + ); + + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(result.stdout, `${MNEMONIC}\n`); + assert.equal(result.stderr, ""); }); diff --git a/tests/cli/run.spec.ts b/tests/cli/run.spec.ts index 6b69252..f4863aa 100644 --- a/tests/cli/run.spec.ts +++ b/tests/cli/run.spec.ts @@ -10,6 +10,24 @@ const MNEMONIC = const SEED_HEX = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; +const USAGE = `Usage: bip39 [options] [input] + +Commands: + validate [MNEMONIC] [--strict] + entropy-to-mnemonic [HEX] + mnemonic-to-entropy [MNEMONIC] [--strict] + mnemonic-to-seed [MNEMONIC] [--strict] [--passphrase ] + generate-entropy [--bytes <16|20|24|28|32>] + generate-mnemonic [--words <12|15|18|21|24>] + generate-mnemonic-with-wordlist [--words <12|15|18|21|24>] + +Options: + --help Show help + --strict Disable input normalization + --passphrase Passphrase for mnemonic-to-seed + --bytes Entropy bytes for generate-entropy + --words Word count for generate-mnemonic +`; const createIo = (stdin: string | null = null) => { const stdout: string[] = []; @@ -27,18 +45,19 @@ const createIo = (stdin: string | null = null) => { }; test("runCli validate succeeds with args", async () => { - const { io, stdout } = createIo(); + const { io, stdout, stderr } = createIo(); const exitCode = await runCli(["validate", MNEMONIC], io); assert.equal(exitCode, 0); - const output = stdout.join(""); - assert.match(output, /^valid\nnormalized: /u); + assert.equal(stdout.join(""), `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(stderr.join(""), ""); }); test("runCli validate reads from stdin", async () => { - const { io, stdout } = createIo(MNEMONIC); + const { io, stdout, stderr } = createIo(`\t${MNEMONIC.toUpperCase()}\n`); const exitCode = await runCli(["validate"], io); assert.equal(exitCode, 0); - assert.match(stdout.join(""), /^valid\nnormalized: /u); + assert.equal(stdout.join(""), `valid\nnormalized: ${MNEMONIC}\n`); + assert.equal(stderr.join(""), ""); }); test("runCli entropy-to-mnemonic outputs mnemonic", async () => { @@ -48,11 +67,15 @@ test("runCli entropy-to-mnemonic outputs mnemonic", async () => { assert.equal(stdout.join("").trim(), MNEMONIC); }); -test("runCli entropy-to-mnemonic rejects invalid hex", async () => { - const { io, stderr } = createIo(); - const exitCode = await runCli(["entropy-to-mnemonic", "0"], io); - assert.equal(exitCode, 2); - assert.match(stderr.join(""), /Invalid hex/u); +test.each([ + ["0", "Hex input must have even length"], + ["zz", "Hex input contains non-hex characters"], + [" \n", "Hex input is empty"], +])("runCli rejects invalid hex %j with usage exit code", async (input, message) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(["entropy-to-mnemonic", input], io), 2); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `Invalid hex: ${message}\n`); }); test("runCli mnemonic-to-entropy outputs hex", async () => { @@ -72,6 +95,133 @@ test("runCli mnemonic-to-seed outputs seed hex", async () => { assert.equal(stdout.join("").trim(), SEED_HEX); }); +test.each([ + { + argv: ["validate", ...MNEMONIC.split(" ")], + output: `valid\nnormalized: ${MNEMONIC}\n`, + }, + { + argv: ["entropy-to-mnemonic", ENTROPY_HEX], + output: `${MNEMONIC}\n`, + }, + { + argv: ["mnemonic-to-entropy", ...MNEMONIC.split(" ")], + output: `${ENTROPY_HEX}\n`, + }, + { + argv: [ + "mnemonic-to-seed", + ...MNEMONIC.split(" "), + "--passphrase", + "TREZOR", + ], + output: `${SEED_HEX}\n`, + }, +])("runCli gives arguments precedence over stdin: $argv.0", async ({ + argv, + output, +}) => { + const { io, stdout, stderr } = createIo(); + io.readStdin = async () => { + assert.fail("stdin must not be read when an argument was supplied"); + }; + assert.equal(await runCli(argv, io), 0); + assert.equal(stdout.join(""), output); + assert.equal(stderr.join(""), ""); +}); + +test.each([ + { + argv: ["entropy-to-mnemonic", "00"], + code: "ERR_ENTROPY_LENGTH", + message: "Entropy length must be 16/20/24/28/32 bytes.", + }, + ...(["validate", "mnemonic-to-entropy"] as const).flatMap((command) => [ + { + argv: [command, `${MNEMONIC}\n`, "--strict"], + code: "ERR_INVALID_MNEMONIC_FORMAT", + message: "Mnemonic format is invalid.", + }, + { + argv: [command, "abandon"], + code: "ERR_INVALID_WORD_COUNT", + message: "Mnemonic word count must be 12/15/18/21/24.", + }, + { + argv: [command, MNEMONIC.replace("about", "unknownword")], + code: "ERR_WORD_NOT_IN_LIST", + message: "Mnemonic contains an unknown word.", + }, + { + argv: [command, MNEMONIC.replace("about", "abandon")], + code: "ERR_CHECKSUM_MISMATCH", + message: "Mnemonic checksum does not match.", + }, + ]), +])("runCli reports $code for $argv.0", async ({ argv, code, message }) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(argv, io), 1); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `error_code: ${code}\nmessage: ${message}\n`); +}); + +test.each([ + { argv: [], message: "Missing command" }, + { argv: ["unknown"], message: "Unknown command: unknown" }, + { argv: ["validate", "--nope"], message: "Unknown option: --nope" }, + { + argv: ["mnemonic-to-seed", "--passphrase"], + message: "Missing value for --passphrase", + }, + { + argv: ["generate-entropy", "--bytes", "17"], + message: "Invalid --bytes value", + }, + { + argv: ["generate-mnemonic", "--words", "13"], + message: "Invalid --words value", + }, + { + argv: ["generate-mnemonic-with-wordlist", "--words", "13"], + message: "Invalid --words value", + }, + { + argv: ["entropy-to-mnemonic", ENTROPY_HEX, ENTROPY_HEX], + message: "Too many arguments", + }, + ...[ + "validate", + "entropy-to-mnemonic", + "mnemonic-to-entropy", + "mnemonic-to-seed", + ].map((command) => ({ argv: [command], message: "Missing input" })), +])("runCli reports usage error $message for $argv.0", async ({ + argv, + message, +}) => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(argv, io), 2); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), `${message}\n${USAGE}`); +}); + +test("runCli prints help to stdout and succeeds", async () => { + const { io, stdout, stderr } = createIo(); + assert.equal(await runCli(["--help"], io), 0); + assert.equal(stdout.join(""), USAGE); + assert.equal(stderr.join(""), ""); +}); + +test("runCli reports unexpected I/O errors with exit code 3", async () => { + const { io, stdout, stderr } = createIo(); + io.readStdin = async () => { + throw new Error("stdin unavailable"); + }; + assert.equal(await runCli(["validate"], io), 3); + assert.equal(stdout.join(""), ""); + assert.equal(stderr.join(""), "Unexpected error: stdin unavailable\n"); +}); + test("runCli generate-entropy outputs hex of default length", async () => { const { io, stdout } = createIo(); const exitCode = await runCli(["generate-entropy"], io); @@ -81,6 +231,19 @@ test("runCli generate-entropy outputs hex of default length", async () => { assert.match(hex, /^[0-9a-f]+$/u); }); +test.each([ + 16, 20, 24, 28, 32, +])("runCli generate-entropy outputs %i bytes as hex", async (bytes) => { + const { io, stdout, stderr } = createIo(); + const exitCode = await runCli( + ["generate-entropy", "--bytes", String(bytes)], + io, + ); + assert.equal(exitCode, 0); + assert.equal(stderr.join(""), ""); + assert.match(stdout.join(""), new RegExp(`^[0-9a-f]{${bytes * 2}}\\n$`, "u")); +}); + test("runCli generate-mnemonic outputs requested word count", async () => { const { io, stdout } = createIo(); const exitCode = await runCli(["generate-mnemonic", "--words", "12"], io); diff --git a/tests/constants.spec.ts b/tests/constants.spec.ts index 52f408c..97b9d7e 100644 --- a/tests/constants.spec.ts +++ b/tests/constants.spec.ts @@ -12,7 +12,7 @@ import { WORD_COUNTS, WORDLIST_SIZE, wordCountForEntropyBits, -} from "../src/constants/bip39.ts"; +} from "../src/bip39/constants.ts"; test("BIP39 constants match spec", () => { assert.deepEqual(ENTROPY_BYTES, [16, 20, 24, 28, 32]); diff --git a/tests/crypto.spec.ts b/tests/crypto.spec.ts index e0d3ad3..9d12bea 100644 --- a/tests/crypto.spec.ts +++ b/tests/crypto.spec.ts @@ -5,8 +5,8 @@ import { Pbkdf2FailureError, pbkdf2HmacSha512, sha256, -} from "../src/crypto/crypto.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +} from "../src/bip39/crypto.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes) diff --git a/tests/entropy-generator.spec.ts b/tests/entropy-generator.spec.ts index 0e6c2f7..8496e01 100644 --- a/tests/entropy-generator.spec.ts +++ b/tests/entropy-generator.spec.ts @@ -6,7 +6,7 @@ import { type EntropyGenerator, generateEntropy, InvalidEntropyLengthError, -} from "../src/entropy/entropyGenerator.ts"; +} from "../src/bip39/entropyGenerator.ts"; const allowed = [16, 20, 24, 28, 32]; @@ -17,9 +17,14 @@ test("generateEntropy returns allowed lengths", () => { } }); -test("generateEntropy rejects invalid lengths", () => { - assert.throws(() => generateEntropy(15), InvalidEntropyLengthError); - assert.throws(() => generateEntropy(33), InvalidEntropyLengthError); +test.each([ + 15, 17, 33, +])("generateEntropy rejects %i bytes with its length error", (bytes) => { + assert.throws(() => generateEntropy(bytes), { + constructor: InvalidEntropyLengthError, + name: "InvalidEntropyLengthError", + message: "Entropy must be 16/20/24/28/32 bytes", + }); }); test("EntropyGenerator allows deterministic output in tests", () => { diff --git a/tests/entropy-to-mnemonic.spec.ts b/tests/entropy-to-mnemonic.spec.ts index 74199aa..67bb8f4 100644 --- a/tests/entropy-to-mnemonic.spec.ts +++ b/tests/entropy-to-mnemonic.spec.ts @@ -7,17 +7,19 @@ import { EntropyLengthError, entropyToMnemonic, } from "../src/bip39/entropyToMnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; type Vector = [string, string, string, string]; -test("entropyToMnemonic rejects invalid entropy length", () => { - assert.throws( - () => entropyToMnemonic(new Uint8Array(15)), - (error) => - error instanceof EntropyLengthError && - error.code === ErrorCode.ERR_ENTROPY_LENGTH, - ); +test.each([ + 15, 17, +])("entropyToMnemonic rejects %i bytes with its length error", (bytes) => { + assert.throws(() => entropyToMnemonic(new Uint8Array(bytes)), { + constructor: EntropyLengthError, + name: "EntropyLengthError", + code: ErrorCode.ERR_ENTROPY_LENGTH, + message: "Entropy must be 16/20/24/28/32 bytes", + }); }); test("entropyToMnemonic matches official vectors", async () => { diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts index 08e03de..19cda39 100644 --- a/tests/errors.spec.ts +++ b/tests/errors.spec.ts @@ -5,7 +5,7 @@ import { ErrorCode, MNEMONIC_TO_SEED_ERROR_PRIORITY, MNEMONIC_VALIDATION_ERROR_PRIORITY, -} from "../src/errors/errorCodes.ts"; +} from "../src/bip39/errorCodes.ts"; test("Error codes are fixed", () => { const codes = Object.values(ErrorCode); diff --git a/tests/integration.spec.ts b/tests/integration.spec.ts index 442065b..22247ac 100644 --- a/tests/integration.spec.ts +++ b/tests/integration.spec.ts @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { mnemonicToSeed } from "../src/bip39/mnemonicToSeed.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; import { deriveBip32RootFromMnemonic, deriveSeedForUi, diff --git a/tests/mnemonic-to-entropy.spec.ts b/tests/mnemonic-to-entropy.spec.ts index 0a7569f..4f6ceee 100644 --- a/tests/mnemonic-to-entropy.spec.ts +++ b/tests/mnemonic-to-entropy.spec.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { ChecksumMismatchError, InvalidMnemonicFormatError, @@ -10,8 +10,7 @@ import { MnemonicToEntropyError, mnemonicToEntropy, WordNotInListError, -} from "../src/bip39/mnemonicToEntropy.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +} from "../src/bip39/mnemonic.ts"; type Vector = [string, string, string, string]; @@ -75,3 +74,57 @@ test("mnemonicToEntropy error types share base class", () => { const error = new InvalidMnemonicFormatError(); assert.ok(error instanceof MnemonicToEntropyError); }); + +test.each([ + { + ErrorType: InvalidMnemonicFormatError, + name: "InvalidMnemonicFormatError", + code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + message: "Invalid mnemonic format", + }, + { + ErrorType: InvalidWordCountError, + name: "InvalidWordCountError", + code: ErrorCode.ERR_INVALID_WORD_COUNT, + message: "Invalid word count", + }, + { + ErrorType: WordNotInListError, + name: "WordNotInListError", + code: ErrorCode.ERR_WORD_NOT_IN_LIST, + message: "Word not in list", + }, + { + ErrorType: ChecksumMismatchError, + name: "ChecksumMismatchError", + code: ErrorCode.ERR_CHECKSUM_MISMATCH, + message: "Checksum mismatch", + }, +])("$name preserves its public identity and optional message", ({ + ErrorType, + name, + code, + message, +}) => { + for (const [error, expectedMessage] of [ + [new ErrorType(), message], + [new ErrorType("custom message"), "custom message"], + ] as const) { + assert.ok(error instanceof Error); + assert.ok(error instanceof MnemonicToEntropyError); + assert.equal(error.name, name); + assert.equal(error.code, code); + assert.equal(error.message, expectedMessage); + } +}); + +test("MnemonicToEntropyError preserves its supplied code and message", () => { + const error = new MnemonicToEntropyError( + ErrorCode.ERR_INVALID_WORD_COUNT, + "custom message", + ); + assert.ok(error instanceof Error); + assert.equal(error.name, "MnemonicToEntropyError"); + assert.equal(error.code, ErrorCode.ERR_INVALID_WORD_COUNT); + assert.equal(error.message, "custom message"); +}); diff --git a/tests/mnemonic-to-seed.spec.ts b/tests/mnemonic-to-seed.spec.ts index 771f168..a26bdfc 100644 --- a/tests/mnemonic-to-seed.spec.ts +++ b/tests/mnemonic-to-seed.spec.ts @@ -3,12 +3,11 @@ import { pbkdf2Sync } from "node:crypto"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { InvalidMnemonicSeedFormatError, mnemonicToSeed, } from "../src/bip39/mnemonicToSeed.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; const toHex = (bytes: Uint8Array): string => Array.from(bytes) @@ -56,6 +55,7 @@ test("mnemonicToSeed matches official vectors with TREZOR", async () => { for (const [, mnemonic, seed] of payload.english) { const derived = mnemonicToSeed(mnemonic, "TREZOR"); assert.equal(toHex(derived), seed); + assert.equal(toHex(mnemonicToSeed(mnemonic.split(" "), "TREZOR")), seed); } }); @@ -63,4 +63,80 @@ test("mnemonicToSeed matches pbkdf2 output with empty passphrase", () => { const expected = deriveWithNode(validMnemonic, ""); const derived = mnemonicToSeed(validMnemonic, ""); assert.equal(toHex(derived), expected); + assert.equal(toHex(mnemonicToSeed(validMnemonic)), expected); +}); + +// BIP39 "From mnemonic to seed" specifies NFKD for both password and salt. +test("mnemonicToSeed normalizes Unicode mnemonic and passphrase to NFKD", () => { + const mnemonic = "caf\u00e9 \u2460"; + const passphrase = "\u212b"; + const expected = deriveWithNode("cafe\u0301 1", "A\u030a"); + assert.equal(toHex(mnemonicToSeed(mnemonic, passphrase)), expected); + assert.equal( + toHex(mnemonicToSeed(mnemonic.split(" "), passphrase)), + expected, + ); +}); + +// Seed derivation is independent of wordlist membership and checksum validation. +test.each([ + { label: "nonstandard word count", mnemonic: "abandon about" }, + { label: "unknown word", mnemonic: validMnemonic.replace("about", "typo") }, + { + label: "invalid checksum", + mnemonic: validMnemonic.replace("about", "abandon"), + }, +])("mnemonicToSeed accepts $label", ({ mnemonic }) => { + assert.equal( + toHex(mnemonicToSeed(mnemonic, "TREZOR")), + deriveWithNode(mnemonic, "TREZOR"), + ); +}); + +test.each([ + { label: "uppercase", mnemonic: validMnemonic.toUpperCase() }, + { label: "outer spaces", mnemonic: ` ${validMnemonic} ` }, + { + label: "repeated spaces", + mnemonic: validMnemonic.replace(" ", " "), + }, +])("mnemonicToSeed preserves $label in string input", ({ mnemonic }) => { + const seed = toHex(mnemonicToSeed(mnemonic)); + assert.equal(seed, deriveWithNode(mnemonic, "")); + assert.notEqual(seed, deriveWithNode(validMnemonic, "")); +}); + +test.each([ + { label: "empty array", input: [] }, + { label: "null", input: null }, + { label: "object", input: {} }, + { label: "number", input: 123 }, + { label: "tab in array word", input: ["abandon\tabout"] }, +])("mnemonicToSeed rejects $label with its format error", ({ input }) => { + assert.throws( + () => mnemonicToSeed(input as unknown as string[]), + (error: unknown) => { + assert.ok(error instanceof InvalidMnemonicSeedFormatError); + assert.equal(error.name, "InvalidMnemonicSeedFormatError"); + assert.equal(error.code, ErrorCode.ERR_INVALID_MNEMONIC_FORMAT); + assert.equal(error.message, "Invalid mnemonic format"); + return true; + }, + ); +}); + +test.each([ + { label: "null", passphrase: null }, + { label: "number", passphrase: 123 }, + { label: "array", passphrase: ["TREZOR"] }, +])("mnemonicToSeed rejects a $label passphrase", ({ passphrase }) => { + assert.throws( + () => mnemonicToSeed(validMnemonic, passphrase as unknown as string), + (error: unknown) => { + assert.ok(error instanceof InvalidMnemonicSeedFormatError); + assert.equal(error.code, ErrorCode.ERR_INVALID_MNEMONIC_FORMAT); + assert.equal(error.message, "Invalid mnemonic format"); + return true; + }, + ); }); diff --git a/tests/normalize-mnemonic.spec.ts b/tests/normalize-mnemonic.spec.ts index 0ec8b8f..26ae1c5 100644 --- a/tests/normalize-mnemonic.spec.ts +++ b/tests/normalize-mnemonic.spec.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { normalizeMnemonicInput } from "../src/normalize/normalizeMnemonicInput.ts"; +import { normalizeMnemonicInput } from "../src/integration/normalizeMnemonicInput.ts"; test("normalizeMnemonicInput trims and collapses whitespace", () => { const input = " Abandon\tabandon\nABOUT "; diff --git a/tests/strict-mnemonic.spec.ts b/tests/strict-mnemonic.spec.ts index 15dda36..8555a04 100644 --- a/tests/strict-mnemonic.spec.ts +++ b/tests/strict-mnemonic.spec.ts @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; import { parseMnemonicWordsStrict, type StrictMnemonicParseResult, -} from "../src/parser/strictMnemonic.ts"; +} from "../src/bip39/strictMnemonic.ts"; const validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..ea3c626 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/tests/validate-mnemonic.spec.ts b/tests/validate-mnemonic.spec.ts index b873073..b8faec3 100644 --- a/tests/validate-mnemonic.spec.ts +++ b/tests/validate-mnemonic.spec.ts @@ -2,9 +2,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { test } from "vitest"; - -import { validateMnemonic } from "../src/bip39/validateMnemonic.ts"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { ErrorCode } from "../src/bip39/errorCodes.ts"; +import { validateMnemonic } from "../src/bip39/mnemonic.ts"; const validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; diff --git a/tests/validation-result.spec.ts b/tests/validation-result.spec.ts index bd88bbf..330d04b 100644 --- a/tests/validation-result.spec.ts +++ b/tests/validation-result.spec.ts @@ -1,19 +1,71 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { expectTypeOf, test } from "vitest"; -import { ErrorCode } from "../src/errors/errorCodes.ts"; -import type { ValidationResult } from "../src/types/validationResult.ts"; +import type { ValidationResult as MnemonicValidationResult } from "../src/bip39/mnemonic.ts"; +import { + ErrorCode, + entropyToMnemonic, + loadEnglishWordlist, + mnemonicToEntropy, + mnemonicToSeed, + type ValidationResult, + validateMnemonic, + type Wordlist, +} from "../src/index.ts"; test("ValidationResult shape is stable", () => { - const sample: ValidationResult = { + const result: ValidationResult = validateMnemonic("abandon about"); + assert.deepEqual(result, { ok: false, error_code: ErrorCode.ERR_INVALID_WORD_COUNT, - normalized_mnemonic: null, - word_count: 11, + normalized_mnemonic: "abandon about", + word_count: 2, invalid_word: null, - }; + }); +}); - assert.equal(sample.ok, false); - assert.equal(sample.error_code, "ERR_INVALID_WORD_COUNT"); - assert.equal(sample.word_count, 11); +// Vitest transpiles type assertions; the compiler test below checks them too. +test("public validation and conversion types remain compatible", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + ok: boolean; + error_code: ErrorCode | null; + normalized_mnemonic: string | null; + word_count: number | null; + invalid_word: string | null; + }>(); + expectTypeOf(validateMnemonic).toEqualTypeOf< + (input: string | string[]) => ValidationResult + >(); + expectTypeOf(entropyToMnemonic).toEqualTypeOf< + (entropy: Uint8Array) => string + >(); + expectTypeOf(mnemonicToEntropy).toEqualTypeOf< + (input: string | string[]) => Uint8Array + >(); + expectTypeOf(mnemonicToSeed).toEqualTypeOf< + (input: string | string[], passphrase?: string) => Uint8Array + >(); + expectTypeOf(loadEnglishWordlist).toEqualTypeOf<() => Promise>(); + expectTypeOf().toEqualTypeOf<{ + words: string[]; + wordToIndex: Map; + }>(); }); + +test("TypeScript checks the test suite and public API type contracts", () => { + const result = spawnSync( + process.execPath, + [ + createRequire(import.meta.url).resolve("typescript/bin/tsc"), + "-p", + fileURLToPath(new URL("./tsconfig.json", import.meta.url)), + ], + { encoding: "utf8", timeout: 20_000 }, + ); + assert.equal(result.error, undefined); + assert.equal(result.status, 0, `${result.stdout}${result.stderr}`); +}, 30_000); diff --git a/tests/wordlist-loaders.spec.ts b/tests/wordlist-loaders.spec.ts new file mode 100644 index 0000000..38709b5 --- /dev/null +++ b/tests/wordlist-loaders.spec.ts @@ -0,0 +1,320 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test, vi } from "vitest"; + +const fileReads = vi.hoisted(() => ({ + readFile: vi.fn(), + readFileSync: vi.fn(), +})); + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + readFile: fileReads.readFile, +})); + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + readFileSync: fileReads.readFileSync, +})); + +const { readFileSync } = + await vi.importActual("node:fs"); +const englishText = readFileSync( + new URL("../assets/english.txt", import.meta.url), + "utf8", +); +const words = Array.from({ length: 2048 }, (_, index) => `word${index}`); +const wordsWith = (replacements: Record): string[] => + words.map((word, index) => replacements[index] ?? word); +const mnemonic = `${"abandon ".repeat(11)}about`; + +beforeEach(() => { + vi.resetModules(); + fileReads.readFile.mockReset().mockResolvedValue(englishText); + fileReads.readFileSync.mockReset().mockReturnValue(englishText); +}); + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +// BIP39 uses an ordered, 2048-entry dictionary. These parsing and error +// contracts preserve the distinct synchronous and asynchronous entry points. +test.each([ + { name: "LF", separator: "\n", trailingNewline: false }, + { name: "LF with final newline", separator: "\n", trailingNewline: true }, + { name: "CRLF", separator: "\r\n", trailingNewline: false }, + { name: "CRLF with final newline", separator: "\r\n", trailingNewline: true }, +])("both English loaders accept $name and preserve indices", async (input) => { + const text = + words.join(input.separator) + + (input.trailingNewline ? input.separator : ""); + fileReads.readFile.mockResolvedValue(text); + fileReads.readFileSync.mockReturnValue(text); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + for (const list of [await loadAsync(), loadSync()]) { + assert.deepEqual(list.words, words); + assert.deepEqual( + [...list.wordToIndex], + words.map((word, i) => [word, i]), + ); + } +}); + +test.each([ + { + name: "an empty file", + lines: [], + asyncMessage: "Wordlist must contain 2048 words, got 0", + syncMessage: "Wordlist must contain 2048 words, got 0", + }, + { + name: "too few words", + lines: words.slice(1), + asyncMessage: "Wordlist must contain 2048 words, got 2047", + syncMessage: "Wordlist must contain 2048 words, got 2047", + }, + { + name: "too many words", + lines: [...words, "extra"], + asyncMessage: "Wordlist must contain 2048 words, got 2049", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "an empty word", + lines: wordsWith({ 100: "" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist contains an empty word", + }, + { + name: "a leading empty line with an incorrect count", + lines: ["", ...words], + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "two final newlines", + lines: [...words, "", ""], + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "duplicate words", + lines: wordsWith({ 1: "word0" }), + asyncMessage: "Duplicate word detected: word0", + syncMessage: "Duplicate word detected: word0", + }, + { + name: "a duplicate with an incorrect count", + lines: [...words, "word0"], + asyncMessage: "Wordlist must contain 2048 words, got 2049", + syncMessage: "Wordlist must contain 2048 words, got 2049", + }, + { + name: "a duplicate before an empty word", + lines: wordsWith({ 1: "word0", 100: "" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Duplicate word detected: word0", + }, + { + name: "an empty word before a duplicate", + lines: wordsWith({ 1: "", 100: "word0" }), + asyncMessage: "Wordlist contains empty lines", + syncMessage: "Wordlist contains an empty word", + }, +])("English loaders preserve error precedence for $name", async (input) => { + fileReads.readFile.mockResolvedValue(input.lines.join("\n")); + fileReads.readFileSync.mockReturnValue(input.lines.join("\n")); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + await assert.rejects(loadAsync, { + name: "Error", + message: input.asyncMessage, + }); + assert.throws(loadSync, { name: "Error", message: input.syncMessage }); +}); + +test("English loaders reuse a successfully loaded dictionary", async () => { + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + const asyncList = await loadAsync(); + const syncList = loadSync(); + fileReads.readFile.mockRejectedValue(new Error("Wordlist unavailable")); + fileReads.readFileSync.mockImplementation(() => { + throw new Error("Wordlist unavailable"); + }); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("English loaders retry after a failed file read", async () => { + const failure = new Error("Wordlist unavailable"); + fileReads.readFile.mockRejectedValueOnce(failure); + fileReads.readFileSync.mockImplementationOnce(() => { + throw failure; + }); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + await assert.rejects(loadAsync, failure); + assert.throws(loadSync, failure); + assert.equal((await loadAsync()).words[0], "abandon"); + assert.equal(loadSync().words[0], "abandon"); +}); + +test("English loaders retry after malformed wordlist contents", async () => { + fileReads.readFile.mockResolvedValueOnce("incomplete\n"); + fileReads.readFileSync.mockReturnValueOnce("incomplete\n"); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + const error = { + name: "Error", + message: "Wordlist must contain 2048 words, got 1", + }; + await assert.rejects(loadAsync, error); + assert.throws(loadSync, error); + assert.equal((await loadAsync()).words[2047], "zoo"); + assert.equal(loadSync().words[2047], "zoo"); +}); + +test.each([ + "async first", + "sync first", +])("English loader caches stay separate when initialized %s", async (order) => { + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + if (order === "sync first") loadSync(); + const asyncList = await loadAsync(); + const syncList = loadSync(); + assert.notStrictEqual(asyncList, syncList); + assert.notStrictEqual(asyncList.words, syncList.words); + assert.notStrictEqual(asyncList.wordToIndex, syncList.wordToIndex); + assert.deepEqual(asyncList, syncList); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("an asynchronous read can finish independently of a synchronous read", async () => { + let resolveRead!: (text: string) => void; + fileReads.readFile.mockReturnValueOnce( + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + const pending = loadAsync(); + assert.ok(pending instanceof Promise); + assert.equal(fileReads.readFileSync.mock.calls.length, 0); + const syncList = loadSync(); + assert.equal(syncList.words[0], "abandon"); + resolveRead(englishText); + const asyncList = await pending; + assert.notStrictEqual(asyncList, syncList); + assert.strictEqual(await loadAsync(), asyncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("a pending asynchronous read can fail and retry without affecting the synchronous cache", async () => { + let rejectRead!: (error: Error) => void; + fileReads.readFile.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectRead = reject; + }), + ); + const { loadEnglishWordlist: loadAsync, loadEnglishWordlistSync: loadSync } = + await import("../src/bip39/wordlist.ts"); + const pending = loadAsync(); + const failure = new Error("Asynchronous read failed"); + const rejection = assert.rejects(pending, failure); + const syncList = loadSync(); + rejectRead(failure); + await rejection; + assert.strictEqual(loadSync(), syncList); + const asyncList = await loadAsync(); + assert.equal(asyncList.words[0], "abandon"); + assert.notStrictEqual(asyncList, syncList); + assert.strictEqual(loadSync(), syncList); +}); + +test("mutating the public dictionary does not affect core BIP39 operations", async () => { + const { loadEnglishWordlist } = await import("../src/bip39/wordlist.ts"); + const { entropyToMnemonic } = await import( + "../src/bip39/entropyToMnemonic.ts" + ); + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" + ); + const list = await loadEnglishWordlist(); + const originalWords = [...list.words]; + const originalIndices = new Map(list.wordToIndex); + try { + list.words.fill("changed"); + list.wordToIndex.clear(); + assert.equal(entropyToMnemonic(new Uint8Array(16)), mnemonic); + assert.equal(validateMnemonic(mnemonic).ok, true); + assert.deepEqual(mnemonicToEntropy(mnemonic), new Uint8Array(16)); + } finally { + list.words.splice(0, list.words.length, ...originalWords); + for (const [word, index] of originalIndices) { + list.wordToIndex.set(word, index); + } + } +}); + +test.each([ + { + name: "invalid format", + input: "abandon unknown", + code: "ERR_INVALID_MNEMONIC_FORMAT", + errorName: "InvalidMnemonicFormatError", + message: "Invalid mnemonic format", + }, + { + name: "invalid word count", + input: "unknown", + code: "ERR_INVALID_WORD_COUNT", + errorName: "InvalidWordCountError", + message: "Invalid word count", + }, +])("$name is rejected without loading the wordlist", async (input) => { + fileReads.readFileSync.mockImplementation(() => { + throw new Error("Wordlist unavailable"); + }); + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" + ); + assert.equal(validateMnemonic(input.input).error_code, input.code); + assert.throws(() => mnemonicToEntropy(input.input), { + name: input.errorName, + code: input.code, + message: input.message, + }); + assert.equal(fileReads.readFile.mock.calls.length, 0); + assert.equal(fileReads.readFileSync.mock.calls.length, 0); +}); + +test("validation and decoding propagate wordlist read failures unchanged and retry", async () => { + const failure = new Error("Wordlist unavailable"); + fileReads.readFileSync.mockImplementation(() => { + throw failure; + }); + const { validateMnemonic, mnemonicToEntropy } = await import( + "../src/bip39/mnemonic.ts" + ); + for (const operation of [validateMnemonic, mnemonicToEntropy]) { + assert.throws( + () => operation(mnemonic), + (error: unknown) => error === failure, + ); + } + fileReads.readFileSync.mockReturnValue(englishText); + assert.deepEqual(validateMnemonic(mnemonic), { + ok: true, + error_code: null, + normalized_mnemonic: mnemonic, + word_count: 12, + invalid_word: null, + }); + assert.deepEqual(mnemonicToEntropy(mnemonic), new Uint8Array(16)); +}); diff --git a/tests/wordlist.spec.ts b/tests/wordlist.spec.ts index 3ae97bc..fb86cb2 100644 --- a/tests/wordlist.spec.ts +++ b/tests/wordlist.spec.ts @@ -7,15 +7,12 @@ import { loadEnglishWordlist, parseWordlist, wordToIndex, -} from "../src/wordlist/wordlist.ts"; +} from "../src/bip39/wordlist.ts"; const makeWords = (count: number): string[] => Array.from({ length: count }, (_, i) => `word${i}`); -const makeText = (words: string[], withTrailingNewline = false): string => { - const text = words.join("\n"); - return withTrailingNewline ? `${text}\n` : text; -}; +const makeText = (words: string[]): string => words.join("\n"); test("createWordlist accepts 2048 unique words", () => { const words = makeWords(2048); @@ -26,22 +23,82 @@ test("createWordlist accepts 2048 unique words", () => { assert.equal(list.words[2047], "word2047"); }); -test("createWordlist rejects incorrect length", () => { - const words = makeWords(2047); - assert.throws(() => createWordlist(words)); +test.each([ + 0, 2047, 2049, +])("createWordlist rejects %i words before checking their contents", (count) => { + const words = Array.from({ length: count }, () => ""); + assert.throws(() => createWordlist(words), { + name: "Error", + message: `Wordlist must contain 2048 words, got ${count}`, + }); }); test("createWordlist rejects duplicate words", () => { const words = makeWords(2048); words[2047] = "word0"; - assert.throws(() => createWordlist(words)); + assert.throws(() => createWordlist(words), { + name: "Error", + message: "Duplicate word detected: word0", + }); }); -test("parseWordlist parses text and preserves order", () => { +test("createWordlist rejects an empty word", () => { const words = makeWords(2048); - const list = parseWordlist(makeText(words, true)); - assert.equal(list.words[0], "word0"); - assert.equal(list.words[2047], "word2047"); + words[100] = ""; + assert.throws(() => createWordlist(words), { + name: "Error", + message: "Wordlist contains an empty word", + }); +}); + +test("createWordlist copies its input array", () => { + const words = makeWords(2048); + const list = createWordlist(words); + words[0] = "changed"; + words.pop(); + assert.equal(list.words.length, 2048); + assert.equal(indexToWord(list, 0), "word0"); + assert.equal(wordToIndex(list, "word0"), 0); +}); + +test.each([ + { name: "LF", separator: "\n", trailingNewline: false }, + { name: "LF with final newline", separator: "\n", trailingNewline: true }, + { name: "CRLF", separator: "\r\n", trailingNewline: false }, + { name: "CRLF with final newline", separator: "\r\n", trailingNewline: true }, +])("parseWordlist accepts $name and preserves every index", ({ + separator, + trailingNewline, +}) => { + const words = makeWords(2048); + const text = words.join(separator) + (trailingNewline ? separator : ""); + const list = parseWordlist(text); + assert.deepEqual(list.words, words); + assert.deepEqual( + [...list.wordToIndex], + words.map((word, i) => [word, i]), + ); +}); + +test.each([ + ["leading", `\n${makeText(makeWords(2048))}`], + ["internal", makeText(makeWords(2048)).replace("word100\n", "\n")], + ["extra trailing", `${makeText(makeWords(2048))}\n\n`], +])("parseWordlist rejects %s empty lines before size errors", (_, text) => { + assert.throws(() => parseWordlist(text), { + name: "Error", + message: "Wordlist contains empty lines", + }); +}); + +test("parseWordlist checks empty lines before duplicate words", () => { + const words = makeWords(2048); + words[1] = "word0"; + words[100] = ""; + assert.throws(() => parseWordlist(makeText(words)), { + name: "Error", + message: "Wordlist contains empty lines", + }); }); test("indexToWord and wordToIndex are inverse", () => { @@ -54,13 +111,40 @@ test("indexToWord and wordToIndex are inverse", () => { test("indexToWord throws on out-of-range", () => { const list = createWordlist(makeWords(2048)); - assert.throws(() => indexToWord(list, -1)); - assert.throws(() => indexToWord(list, 2048)); + for (const index of [-1, 2048]) { + assert.throws(() => indexToWord(list, index), { + name: "Error", + message: `Index out of range: ${index}`, + }); + } +}); + +test.each([ + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, +])("indexToWord rejects non-integer index %s", (index) => { + const list = createWordlist(makeWords(2048)); + assert.throws(() => indexToWord(list, index), { + name: "Error", + message: `Index must be an integer: ${index}`, + }); +}); + +test("indexToWord and wordToIndex observe changes to the returned dictionary", () => { + const list = createWordlist(makeWords(2048)); + list.words[0] = "changed"; + list.wordToIndex.set("changed", 0); + assert.equal(indexToWord(list, 0), "changed"); + assert.equal(wordToIndex(list, "changed"), 0); }); test("wordToIndex throws on unknown word", () => { const list = createWordlist(makeWords(2048)); - assert.throws(() => wordToIndex(list, "unknown")); + assert.throws(() => wordToIndex(list, "unknown"), { + name: "Error", + message: "Word not in list: unknown", + }); }); test("loadEnglishWordlist loads 2048 words with stable mapping", async () => { @@ -70,3 +154,13 @@ test("loadEnglishWordlist loads 2048 words with stable mapping", async () => { assert.equal(wordToIndex(list, list.words[0]), 0); assert.equal(wordToIndex(list, list.words[2047]), 2047); }); + +test("the public entry point preserves wordlist exports and keeps the synchronous loader internal", async () => { + const api = await import("../src/index.ts"); + assert.strictEqual(api.createWordlist, createWordlist); + assert.strictEqual(api.parseWordlist, parseWordlist); + assert.strictEqual(api.loadEnglishWordlist, loadEnglishWordlist); + assert.strictEqual(api.indexToWord, indexToWord); + assert.strictEqual(api.wordToIndex, wordToIndex); + assert.equal("loadEnglishWordlistSync" in api, false); +});