diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..13f6214 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 BIP39 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..18b0d89 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,161 @@ +
+

BIP39

+

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

+ License + TypeScript + Node + pnpm +
+ + +## Project Overview + +This repository provides the core BIP39 workflows for the English wordlist profile: + +- Convert entropy to a mnemonic sentence +- Convert a mnemonic sentence back to entropy +- Derive a 64-byte seed from a mnemonic and passphrase +- Validate mnemonic structure, word membership, and checksum +- Use the same behavior from both a TypeScript API and a CLI + +It is backed by pinned specification assets in `assets/`, including the English wordlist and official test vectors. + +## Warnings + +- This project targets the English BIP39 profile only. +- The CLI and exported APIs distinguish between strict parsing and compatibility normalization; do not assume all inputs are auto-corrected. +- Generated mnemonics and derived seeds are sensitive secrets. Never commit them, log them, or share them in screenshots. +- This repository implements BIP39 behavior only. It does not implement BIP32, derivation paths, addresses, or wallet UX. + +## Quick Start + +### 1. Install dependencies + +```bash +pnpm install +``` + +### 2. Build the project + +```bash +pnpm build +``` + +### 3. Show CLI help + +```bash +node dist/cli/index.js --help +``` + +### 4. Try the CLI + +Generate a 12-word mnemonic: + +```bash +node dist/cli/index.js generate-mnemonic --words 12 +``` + +Convert entropy hex to a mnemonic: + +```bash +node dist/cli/index.js entropy-to-mnemonic 00000000000000000000000000000000 +``` + +Validate a mnemonic: + +```bash +node dist/cli/index.js validate "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +``` + +Derive a seed: + +```bash +node dist/cli/index.js mnemonic-to-seed "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" --passphrase TREZOR +``` + +### 5. Use the library API + +```ts +import { + entropyToMnemonic, + generateEntropy, + mnemonicToEntropy, + mnemonicToSeed, + validateMnemonic, +} from "./dist/index.js"; + +const entropy = generateEntropy(16); +const mnemonic = entropyToMnemonic(entropy); +const roundTripEntropy = mnemonicToEntropy(mnemonic); +const seed = mnemonicToSeed(mnemonic, "TREZOR"); +const validation = validateMnemonic(mnemonic); + +console.log({ + mnemonic, + roundTripEntropyLength: roundTripEntropy.length, + seedLength: seed.length, + validation, +}); +``` + +## Development Setup + +1. Ensure `Node.js >= 24` is installed. +2. Ensure `pnpm 10.30.0` or a compatible `pnpm` version is available. +3. Clone the repository. +4. Install dependencies with `pnpm install`. +5. Run `pnpm test` to execute the test suite. +6. Run `pnpm typecheck` to verify TypeScript types. +7. Run `pnpm lint` to check formatting and static issues. +8. Run `pnpm build` to emit JavaScript into `dist/`. + +## Output Location + +- Compiled JavaScript is emitted to `dist/`. +- The CLI entry point is emitted to `dist/cli/index.js`. +- Source files remain in `src/`. +- Tests live in `test/` and are not emitted by the build. + +## Configuration File Setup + +No runtime configuration file is required. + +The main repository-level configuration files are: + +- `package.json` for scripts and package metadata +- `tsconfig.json` and `tsconfig.build.json` for TypeScript compilation +- `biome.json` for linting and formatting + +## Environment Variables + +No environment variables are required for standard development, build, test, or CLI usage. + +## Directory 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 +├── test/ # Unit and integration tests +├── biome.json # Lint/format configuration +├── package.json # Scripts and package metadata +├── README.md # Project overview and usage +├── tsconfig.build.json # Build-time TypeScript configuration +└── tsconfig.json # Base TypeScript configuration +``` + +## License + +MIT - see [LICENSE](LICENSE). diff --git a/package.json b/package.json index 3450d11..d0e1fa2 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,20 @@ "version": "1.0.0", "description": "", "main": "index.js", + "bin": { + "bip39": "dist/cli/index.js" + }, "scripts": { "test": "node --test --import tsx \"test/**/*.test.ts\" \"test/**/*.spec.ts\" \"test/**/*.test.js\" \"test/**/*.spec.js\"", "lint": "biome check .", "typecheck": "tsc --noEmit", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json", "ci": "pnpm lint && pnpm typecheck && pnpm build", "format": "biome format --write ." }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "type": "module", "packageManager": "pnpm@10.30.0", "engines": { diff --git a/src/DESIGN.md b/src/DESIGN.md index 06ff8db..ae836f7 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -9,5 +9,7 @@ - `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/index.ts` re-exports the public surface for these foundational modules. diff --git a/src/cli/DESIGN.md b/src/cli/DESIGN.md new file mode 100644 index 0000000..df8992f --- /dev/null +++ b/src/cli/DESIGN.md @@ -0,0 +1,8 @@ +# cli design + +- 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. +- `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. diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..5491212 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,139 @@ +export type CliCommand = + | "validate" + | "entropy-to-mnemonic" + | "mnemonic-to-entropy" + | "mnemonic-to-seed" + | "generate-entropy" + | "generate-mnemonic" + | "generate-mnemonic-with-wordlist"; + +export type CliFlags = { + help: boolean; + strict: boolean; + passphrase: string | null; + bytes: number | null; + words: number | null; +}; + +export type ParsedArgs = { + command: CliCommand | null; + positionals: string[]; + flags: CliFlags; +}; + +export type ParseResult = + | { + ok: true; + value: ParsedArgs; + } + | { + ok: false; + error: string; + }; + +const COMMANDS = new Set([ + "validate", + "entropy-to-mnemonic", + "mnemonic-to-entropy", + "mnemonic-to-seed", + "generate-entropy", + "generate-mnemonic", + "generate-mnemonic-with-wordlist", +]); + +type ParseNumberResult = + | { ok: true; value: number } + | { ok: false; error: string }; + +const parseNumber = (value: string, label: string): ParseNumberResult => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return { ok: false, error: `Invalid ${label} value: ${value}` }; + } + return { ok: true, value: parsed }; +}; + +export const parseArgs = (argv: string[]): ParseResult => { + const flags: CliFlags = { + help: false, + strict: false, + passphrase: null, + bytes: null, + words: null, + }; + let command: CliCommand | null = null; + const positionals: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token.startsWith("--")) { + switch (token) { + case "--help": + flags.help = true; + break; + case "--strict": + flags.strict = true; + break; + case "--passphrase": { + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + return { ok: false, error: "Missing value for --passphrase" }; + } + flags.passphrase = next; + index += 1; + break; + } + case "--bytes": { + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + return { ok: false, error: "Missing value for --bytes" }; + } + const parsed = parseNumber(next, "bytes"); + if (!parsed.ok) { + return parsed; + } + flags.bytes = parsed.value; + index += 1; + break; + } + case "--words": { + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + return { ok: false, error: "Missing value for --words" }; + } + const parsed = parseNumber(next, "words"); + if (!parsed.ok) { + return parsed; + } + flags.words = parsed.value; + index += 1; + break; + } + default: + return { ok: false, error: `Unknown option: ${token}` }; + } + continue; + } + if (!command) { + if (!COMMANDS.has(token as CliCommand)) { + return { ok: false, error: `Unknown command: ${token}` }; + } + command = token as CliCommand; + continue; + } + positionals.push(token); + } + + if (!command && !flags.help) { + return { ok: false, error: "Missing command" }; + } + + return { + ok: true, + value: { + command, + positionals, + flags, + }, + }; +}; diff --git a/src/cli/commands/entropyToMnemonic.ts b/src/cli/commands/entropyToMnemonic.ts new file mode 100644 index 0000000..99551bd --- /dev/null +++ b/src/cli/commands/entropyToMnemonic.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..4539d74 --- /dev/null +++ b/src/cli/commands/generateEntropy.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..cab4409 --- /dev/null +++ b/src/cli/commands/generateMnemonic.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..a714a2d --- /dev/null +++ b/src/cli/commands/generateMnemonicWithWordlist.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..d0449fb --- /dev/null +++ b/src/cli/commands/mnemonicToEntropy.ts @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..9fe4a15 --- /dev/null +++ b/src/cli/commands/mnemonicToSeed.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..c710f88 --- /dev/null +++ b/src/cli/commands/validate.ts @@ -0,0 +1,31 @@ +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/hex.ts b/src/cli/hex.ts new file mode 100644 index 0000000..b58e5ad --- /dev/null +++ b/src/cli/hex.ts @@ -0,0 +1,22 @@ +const isHex = (value: string): boolean => /^[0-9a-fA-F]+$/u.test(value); + +export const hexToBytes = (hex: string): Uint8Array => { + const trimmed = hex.trim(); + if (trimmed.length === 0) { + throw new Error("Hex input is empty"); + } + if (trimmed.length % 2 !== 0) { + throw new Error("Hex input must have even length"); + } + if (!isHex(trimmed)) { + throw new Error("Hex input contains non-hex characters"); + } + const bytes = new Uint8Array(trimmed.length / 2); + for (let index = 0; index < trimmed.length; index += 2) { + bytes[index / 2] = Number.parseInt(trimmed.slice(index, index + 2), 16); + } + return bytes; +}; + +export const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..b2ca743 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { runCli } from "./runCli.js"; + +const readStdin = async (): Promise => { + if (process.stdin.isTTY) { + return null; + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +}; + +const exitCode = await runCli(process.argv.slice(2), { + readStdin, + writeStdout: (text) => { + process.stdout.write(text); + }, + writeStderr: (text) => { + process.stderr.write(text); + }, +}); + +process.exitCode = exitCode; diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts new file mode 100644 index 0000000..21b0b90 --- /dev/null +++ b/src/cli/runCli.ts @@ -0,0 +1,210 @@ +import { ENTROPY_BYTES, WORD_COUNTS } from "../constants/bip39.js"; +import type { ErrorCode } from "../errors/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 { bytesToHex, hexToBytes } from "./hex.js"; + +export type CliIO = { + readStdin: () => Promise; + writeStdout: (text: string) => void; + writeStderr: (text: string) => void; +}; + +type UsageError = { + message: string; +}; + +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 isErrorWithCode = (value: unknown): value is { code: ErrorCode } => + !!value && typeof value === "object" && "code" in value; + +const formatError = (code: ErrorCode): string => { + const message = ERROR_MESSAGES[code] ?? "Unknown error"; + return `error_code: ${code}\nmessage: ${message}\n`; +}; + +const isValidEntropyBytes = (bytes: number): boolean => + (ENTROPY_BYTES as readonly number[]).includes(bytes); + +const isValidWordCount = (count: number): boolean => + (WORD_COUNTS as readonly number[]).includes(count); + +const getInputFromArgsOrStdin = async ( + positionals: string[], + io: CliIO, + joiner: string, +): Promise => { + if (positionals.length > 0) { + return positionals.join(joiner); + } + return io.readStdin(); +}; + +const getSingleInputOrStdin = async ( + positionals: string[], + io: CliIO, +): Promise => { + if (positionals.length > 1) { + return { message: "Too many arguments" }; + } + const input = + positionals.length === 1 ? positionals[0] : await io.readStdin(); + if (input === null) { + return { message: "Missing input" }; + } + return input; +}; + +export const runCli = async (argv: string[], io: CliIO): Promise => { + const parsed = parseArgs(argv); + if (!parsed.ok) { + io.writeStderr(`${parsed.error}\n`); + io.writeStderr(usage); + return 2; + } + + const { command, flags, positionals } = parsed.value; + if (flags.help) { + io.writeStdout(usage); + return 0; + } + if (!command) { + io.writeStderr("Missing command\n"); + io.writeStderr(usage); + return 2; + } + + try { + switch (command) { + case "validate": { + const input = await getInputFromArgsOrStdin(positionals, io, " "); + if (input === null) { + io.writeStderr("Missing input\n"); + io.writeStderr(usage); + return 2; + } + const result = validateCommand(input, flags.strict); + if (!result.ok) { + io.writeStderr(formatError(result.errorCode)); + return 1; + } + io.writeStdout(`valid\nnormalized: ${result.normalized}\n`); + return 0; + } + case "entropy-to-mnemonic": { + const input = await getSingleInputOrStdin(positionals, io); + if (typeof input !== "string") { + io.writeStderr(`${input.message}\n`); + io.writeStderr(usage); + return 2; + } + let bytes: Uint8Array; + try { + bytes = hexToBytes(input); + } catch (error) { + io.writeStderr(`Invalid hex: ${(error as Error).message}\n`); + return 2; + } + const mnemonic = entropyToMnemonicCommand(bytes); + io.writeStdout(`${mnemonic}\n`); + return 0; + } + case "mnemonic-to-entropy": { + const input = await getInputFromArgsOrStdin(positionals, io, " "); + if (input === null) { + io.writeStderr("Missing input\n"); + io.writeStderr(usage); + return 2; + } + const entropy = mnemonicToEntropyCommand(input, flags.strict); + io.writeStdout(`${bytesToHex(entropy)}\n`); + return 0; + } + case "mnemonic-to-seed": { + const input = await getInputFromArgsOrStdin(positionals, io, " "); + if (input === null) { + io.writeStderr("Missing input\n"); + io.writeStderr(usage); + return 2; + } + const passphrase = flags.passphrase ?? ""; + const seed = mnemonicToSeedCommand(input, flags.strict, passphrase); + io.writeStdout(`${bytesToHex(seed)}\n`); + return 0; + } + case "generate-entropy": { + const bytes = flags.bytes ?? 16; + if (!isValidEntropyBytes(bytes)) { + io.writeStderr("Invalid --bytes value\n"); + io.writeStderr(usage); + return 2; + } + const entropy = generateEntropyCommand(bytes); + io.writeStdout(`${bytesToHex(entropy)}\n`); + return 0; + } + case "generate-mnemonic": { + const words = flags.words ?? 12; + if (!isValidWordCount(words)) { + io.writeStderr("Invalid --words value\n"); + io.writeStderr(usage); + return 2; + } + const mnemonic = generateMnemonicCommand(words); + io.writeStdout(`${mnemonic}\n`); + return 0; + } + case "generate-mnemonic-with-wordlist": { + const words = flags.words ?? 12; + if (!isValidWordCount(words)) { + io.writeStderr("Invalid --words value\n"); + io.writeStderr(usage); + return 2; + } + const result = generateMnemonicWithWordlistCommand(words); + io.writeStdout(`mnemonic: ${result.mnemonic}\n`); + io.writeStdout("wordlist:\n"); + for (const word of result.wordlist) { + io.writeStdout(`${word}\n`); + } + return 0; + } + default: + io.writeStderr("Unknown command\n"); + io.writeStderr(usage); + return 2; + } + } catch (error) { + if (isErrorWithCode(error)) { + io.writeStderr(formatError(error.code)); + return 1; + } + io.writeStderr(`Unexpected error: ${(error as Error).message}\n`); + return 3; + } +}; diff --git a/src/index.ts b/src/index.ts index 95b7030..f2a59f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ export * from "./constants/bip39.js"; export * from "./crypto/crypto.js"; export * from "./entropy/entropyGenerator.js"; export * from "./errors/errorCodes.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"; diff --git a/src/integration/DESIGN.md b/src/integration/DESIGN.md new file mode 100644 index 0000000..f06d337 --- /dev/null +++ b/src/integration/DESIGN.md @@ -0,0 +1,5 @@ +# 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. +- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only. diff --git a/src/integration/errorMessages.ts b/src/integration/errorMessages.ts new file mode 100644 index 0000000..8118d16 --- /dev/null +++ b/src/integration/errorMessages.ts @@ -0,0 +1,12 @@ +import { ErrorCode } from "../errors/errorCodes.js"; + +export const ERROR_MESSAGES: Record = { + [ErrorCode.ERR_ENTROPY_LENGTH]: + "Entropy length must be 16/20/24/28/32 bytes.", + [ErrorCode.ERR_INVALID_MNEMONIC_FORMAT]: "Mnemonic format is invalid.", + [ErrorCode.ERR_INVALID_WORD_COUNT]: + "Mnemonic word count must be 12/15/18/21/24.", + [ErrorCode.ERR_WORD_NOT_IN_LIST]: "Mnemonic contains an unknown word.", + [ErrorCode.ERR_CHECKSUM_MISMATCH]: "Mnemonic checksum does not match.", + [ErrorCode.ERR_PBKDF2_FAILURE]: "Failed to derive seed (PBKDF2 error).", +}; diff --git a/src/integration/externalIntegration.ts b/src/integration/externalIntegration.ts new file mode 100644 index 0000000..1f3ab18 --- /dev/null +++ b/src/integration/externalIntegration.ts @@ -0,0 +1,96 @@ +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"; + +export type SeedDerivationResult = + | { + ok: true; + seed: Uint8Array; + normalized_mnemonic: string; + } + | { + ok: false; + error_code: ErrorCode; + normalized_mnemonic: string | null; + }; + +export type Bip32Adapter = { + fromSeed: (seed: Uint8Array) => T; +}; + +export type Bip32DerivationResult = + | { + ok: true; + seed: Uint8Array; + root: T; + normalized_mnemonic: string; + } + | { + ok: false; + error_code: ErrorCode; + normalized_mnemonic: string | null; + }; + +const isErrorWithCode = (value: unknown): value is { code: ErrorCode } => + !!value && typeof value === "object" && "code" in value; + +export const validateMnemonicForUi = (input: string): ValidationResult => { + const normalized = normalizeMnemonicInput(input); + return validateMnemonic(normalized); +}; + +export const deriveSeedForUi = ( + input: string, + passphrase = "", +): SeedDerivationResult => { + const normalized = normalizeMnemonicInput(input); + const validation = validateMnemonic(normalized); + if (!validation.ok) { + return { + ok: false, + error_code: + validation.error_code ?? ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + normalized_mnemonic: validation.normalized_mnemonic, + }; + } + try { + const seed = mnemonicToSeed(normalized, passphrase); + return { + ok: true, + seed, + normalized_mnemonic: normalized, + }; + } catch (error) { + if (isErrorWithCode(error)) { + return { + ok: false, + error_code: error.code, + normalized_mnemonic: normalized, + }; + } + throw error; + } +}; + +export const deriveBip32RootFromMnemonic = ( + input: string, + passphrase: string, + adapter: Bip32Adapter, +): Bip32DerivationResult => { + const seedResult = deriveSeedForUi(input, passphrase); + if (!seedResult.ok) { + return seedResult; + } + return { + ok: true, + seed: seedResult.seed, + root: adapter.fromSeed(seedResult.seed), + normalized_mnemonic: seedResult.normalized_mnemonic, + }; +}; + +export const errorCodeToMessage = (code: ErrorCode): string => + ERROR_MESSAGES[code]; diff --git a/test/acceptance.spec.ts b/test/acceptance.spec.ts index 6d82d37..d68f305 100644 --- a/test/acceptance.spec.ts +++ b/test/acceptance.spec.ts @@ -65,7 +65,7 @@ test("roundtrip covers all allowed entropy lengths", () => { test("failure cases from appendix C are enforced", () => { assert.throws( () => entropyToMnemonic(new Uint8Array(15)), - (error) => + (error: unknown) => error instanceof EntropyLengthError && error.code === ErrorCode.ERR_ENTROPY_LENGTH, ); @@ -75,7 +75,7 @@ test("failure cases from appendix C are enforced", () => { mnemonicToEntropy( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", ), - (error) => + (error: unknown) => error instanceof InvalidWordCountError && error.code === ErrorCode.ERR_INVALID_WORD_COUNT, ); @@ -85,7 +85,7 @@ test("failure cases from appendix C are enforced", () => { mnemonicToEntropy( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", ), - (error) => + (error: unknown) => error instanceof ChecksumMismatchError && error.code === ErrorCode.ERR_CHECKSUM_MISMATCH, ); @@ -95,7 +95,7 @@ test("failure cases from appendix C are enforced", () => { mnemonicToEntropy( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon typo", ), - (error) => + (error: unknown) => error instanceof WordNotInListError && error.code === ErrorCode.ERR_WORD_NOT_IN_LIST, ); @@ -105,14 +105,14 @@ test("failure cases from appendix C are enforced", () => { mnemonicToEntropy( "abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", ), - (error) => + (error: unknown) => error instanceof InvalidMnemonicFormatError && error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, ); assert.throws( () => mnemonicToSeed(["abandon", "", "abandon"]), - (error) => + (error: unknown) => error instanceof InvalidMnemonicSeedFormatError && error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, ); diff --git a/test/cli/args.spec.ts b/test/cli/args.spec.ts new file mode 100644 index 0000000..f531dc7 --- /dev/null +++ b/test/cli/args.spec.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseArgs } from "../../src/cli/args.ts"; + +test("parseArgs parses command, positionals, and strict flag", () => { + const result = parseArgs(["validate", "abandon", "about", "--strict"]); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.command, "validate"); + assert.deepEqual(result.value.positionals, ["abandon", "about"]); + assert.equal(result.value.flags.strict, true); +}); + +test("parseArgs parses passphrase", () => { + const result = parseArgs([ + "mnemonic-to-seed", + "abandon", + "--passphrase", + "TREZOR", + ]); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.flags.passphrase, "TREZOR"); +}); + +test("parseArgs rejects unknown options", () => { + const result = parseArgs(["validate", "--nope"]); + assert.equal(result.ok, false); +}); + +test("parseArgs rejects missing option values", () => { + const result = parseArgs(["mnemonic-to-seed", "--passphrase"]); + assert.equal(result.ok, false); +}); + +test("parseArgs handles --help without command", () => { + const result = parseArgs(["--help"]); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.flags.help, true); + assert.equal(result.value.command, null); +}); + +test("parseArgs rejects missing command", () => { + const result = parseArgs([]); + assert.equal(result.ok, false); +}); + +test("parseArgs parses numeric options", () => { + const result = parseArgs([ + "generate-entropy", + "--bytes", + "24", + "--words", + "12", + ]); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.flags.bytes, 24); + assert.equal(result.value.flags.words, 12); +}); + +test("parseArgs accepts generate-mnemonic-with-wordlist command", () => { + const result = parseArgs([ + "generate-mnemonic-with-wordlist", + "--words", + "12", + ]); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.command, "generate-mnemonic-with-wordlist"); +}); diff --git a/test/cli/commands.spec.ts b/test/cli/commands.spec.ts new file mode 100644 index 0000000..5718926 --- /dev/null +++ b/test/cli/commands.spec.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +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"; + +const ENTROPY_HEX = "00000000000000000000000000000000"; +const MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +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); +}); + +test("mnemonicToSeedCommand matches vector", () => { + const seed = mnemonicToSeedCommand(MNEMONIC, false, "TREZOR"); + assert.equal(bytesToHex(seed), SEED_HEX); +}); + +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); +}); + +test("generateMnemonicCommand returns requested word count", () => { + const mnemonic = generateMnemonicCommand(12); + assert.equal(mnemonic.split(" ").length, 12); +}); + +test("generateMnemonicWithWordlistCommand returns mnemonic and wordlist", () => { + const result = generateMnemonicWithWordlistCommand(12); + assert.equal(result.mnemonic.split(" ").length, 12); + assert.equal(result.wordlist.length, 2048); + assert.equal(result.wordlist[0], "abandon"); + assert.equal(result.wordlist[result.wordlist.length - 1], "zoo"); +}); diff --git a/test/cli/hex.spec.ts b/test/cli/hex.spec.ts new file mode 100644 index 0000000..8b65a10 --- /dev/null +++ b/test/cli/hex.spec.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bytesToHex, hexToBytes } from "../../src/cli/hex.ts"; + +test("hexToBytes decodes valid hex", () => { + const bytes = hexToBytes("0001ff"); + assert.deepEqual(Array.from(bytes), [0, 1, 255]); +}); + +test("hexToBytes rejects odd-length hex", () => { + assert.throws(() => hexToBytes("0")); +}); + +test("hexToBytes rejects non-hex characters", () => { + assert.throws(() => hexToBytes("zz")); +}); + +test("hexToBytes rejects empty input", () => { + assert.throws(() => hexToBytes("")); +}); + +test("bytesToHex encodes bytes as lowercase hex", () => { + const hex = bytesToHex(Uint8Array.from([0, 15, 16, 255])); + assert.equal(hex, "000f10ff"); +}); diff --git a/test/cli/integration.spec.ts b/test/cli/integration.spec.ts new file mode 100644 index 0000000..c14c208 --- /dev/null +++ b/test/cli/integration.spec.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +test("cli validate runs via node with tsx", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "src/cli/index.ts", "validate", MNEMONIC], + { encoding: "utf8" }, + ); + + assert.equal(result.status, 0); + assert.match(result.stdout, /^valid\nnormalized: /u); +}); diff --git a/test/cli/run.spec.ts b/test/cli/run.spec.ts new file mode 100644 index 0000000..5785bfe --- /dev/null +++ b/test/cli/run.spec.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CliIO } from "../../src/cli/runCli.ts"; +import { runCli } from "../../src/cli/runCli.ts"; + +const ENTROPY_HEX = "00000000000000000000000000000000"; +const MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const SEED_HEX = + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" + + "1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"; + +const createIo = (stdin: string | null = null) => { + const stdout: string[] = []; + const stderr: string[] = []; + const io: CliIO = { + readStdin: async () => stdin, + writeStdout: (text) => { + stdout.push(text); + }, + writeStderr: (text) => { + stderr.push(text); + }, + }; + return { io, stdout, stderr }; +}; + +test("runCli validate succeeds with args", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli(["validate", MNEMONIC], io); + assert.equal(exitCode, 0); + const output = stdout.join(""); + assert.match(output, /^valid\nnormalized: /u); +}); + +test("runCli validate reads from stdin", async () => { + const { io, stdout } = createIo(MNEMONIC); + const exitCode = await runCli(["validate"], io); + assert.equal(exitCode, 0); + assert.match(stdout.join(""), /^valid\nnormalized: /u); +}); + +test("runCli entropy-to-mnemonic outputs mnemonic", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli(["entropy-to-mnemonic", ENTROPY_HEX], io); + assert.equal(exitCode, 0); + 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("runCli mnemonic-to-entropy outputs hex", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli(["mnemonic-to-entropy", MNEMONIC], io); + assert.equal(exitCode, 0); + assert.equal(stdout.join("").trim(), ENTROPY_HEX); +}); + +test("runCli mnemonic-to-seed outputs seed hex", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli( + ["mnemonic-to-seed", MNEMONIC, "--passphrase", "TREZOR"], + io, + ); + assert.equal(exitCode, 0); + assert.equal(stdout.join("").trim(), SEED_HEX); +}); + +test("runCli generate-entropy outputs hex of default length", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli(["generate-entropy"], io); + assert.equal(exitCode, 0); + const hex = stdout.join("").trim(); + assert.equal(hex.length, 32); + assert.match(hex, /^[0-9a-f]+$/u); +}); + +test("runCli generate-mnemonic outputs requested word count", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli(["generate-mnemonic", "--words", "12"], io); + assert.equal(exitCode, 0); + const words = stdout.join("").trim().split(" "); + assert.equal(words.length, 12); +}); + +test("runCli generate-mnemonic-with-wordlist outputs mnemonic and wordlist", async () => { + const { io, stdout } = createIo(); + const exitCode = await runCli( + ["generate-mnemonic-with-wordlist", "--words", "12"], + io, + ); + assert.equal(exitCode, 0); + const lines = stdout.join("").trimEnd().split("\n"); + assert.ok(lines.length >= 2050); + assert.match(lines[0], /^mnemonic: /u); + const mnemonic = lines[0].replace(/^mnemonic: /u, ""); + assert.equal(mnemonic.split(" ").length, 12); + assert.equal(lines[1], "wordlist:"); + const wordlist = lines.slice(2); + assert.equal(wordlist.length, 2048); + assert.equal(wordlist[0], "abandon"); + assert.equal(wordlist[wordlist.length - 1], "zoo"); +}); diff --git a/test/integration.spec.ts b/test/integration.spec.ts new file mode 100644 index 0000000..aa20302 --- /dev/null +++ b/test/integration.spec.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mnemonicToSeed } from "../src/bip39/mnemonicToSeed.ts"; +import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { + deriveBip32RootFromMnemonic, + deriveSeedForUi, + errorCodeToMessage, + validateMnemonicForUi, +} from "../src/integration/externalIntegration.ts"; + +const messyMnemonic = + " ABANDON abandon\tabandon\nABANDON abandon abandon abandon abandon abandon abandon abandon about "; +const normalizedMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +test("validateMnemonicForUi normalizes before validation", () => { + const result = validateMnemonicForUi(messyMnemonic); + assert.equal(result.ok, true); + assert.equal(result.normalized_mnemonic, normalizedMnemonic); +}); + +test("deriveSeedForUi returns seed for valid input", () => { + const expected = mnemonicToSeed(normalizedMnemonic, "TREZOR"); + const result = deriveSeedForUi(messyMnemonic, "TREZOR"); + assert.equal(result.ok, true); + if (result.ok) { + assert.deepEqual(Array.from(result.seed), Array.from(expected)); + assert.equal(result.normalized_mnemonic, normalizedMnemonic); + } +}); + +test("deriveSeedForUi returns error for invalid input", () => { + const result = deriveSeedForUi("abandon abandon", ""); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.error_code, ErrorCode.ERR_INVALID_WORD_COUNT); + } +}); + +test("deriveBip32RootFromMnemonic uses adapter", () => { + const result = deriveBip32RootFromMnemonic(messyMnemonic, "", { + fromSeed: (seed) => ({ length: seed.length, first: seed[0] }), + }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.root.length, 64); + } +}); + +test("errorCodeToMessage covers all error codes", () => { + for (const code of Object.values(ErrorCode)) { + const message = errorCodeToMessage(code); + assert.equal(typeof message, "string"); + assert.notEqual(message.length, 0); + } +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..8d5de7e --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 121c84f..53e7973 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,13 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "rootDir": "src", - "outDir": "dist", + "allowImportingTsExtensions": true, + "noEmit": true, "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index 15fa963..811685a 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -3,7 +3,8 @@ "compilerOptions": { "noEmit": true, "types": ["node"], - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "rootDir": "." }, - "include": ["test/**/*.ts"] + "include": ["test/**/*.ts", "src/**/*.ts"] }