Skip to content

Commit d9a412b

Browse files
authored
Merge pull request #4 from xt0x/feat/mnemonic-parse-and-validate
Implement mnemonic to entropy conversion and validation
2 parents b014638 + e8adff0 commit d9a412b

8 files changed

Lines changed: 365 additions & 40 deletions

src/bip39/DESIGN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
- Purpose: Core BIP39 conversion functions built on fixed assets and primitives.
44
- Scope: Deterministic conversions only; no UI or random entropy generation.
5+
- Includes: `entropyToMnemonic`, `mnemonicToEntropy`, and `validateMnemonic`.
56
- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only.

src/bip39/englishWordlist.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { readFileSync } from "node:fs";
2+
import { resolve } from "node:path";
3+
4+
import { WORDLIST_SIZE } from "../constants/bip39.js";
5+
6+
export type EnglishWordlist = {
7+
words: string[];
8+
wordToIndex: Map<string, number>;
9+
};
10+
11+
const ENGLISH_WORDLIST_PATH = "assets/english.txt";
12+
13+
let cachedEnglishWordlist: EnglishWordlist | null = null;
14+
15+
export const loadEnglishWordlist = (): EnglishWordlist => {
16+
if (cachedEnglishWordlist) {
17+
return cachedEnglishWordlist;
18+
}
19+
const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH);
20+
const text = readFileSync(filePath, "utf8");
21+
const lines = text
22+
.split("\n")
23+
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
24+
if (lines.length > 0 && lines[lines.length - 1] === "") {
25+
lines.pop();
26+
}
27+
if (lines.length !== WORDLIST_SIZE) {
28+
throw new Error(
29+
`Wordlist must contain ${WORDLIST_SIZE} words, got ${lines.length}`,
30+
);
31+
}
32+
const wordToIndex = new Map<string, number>();
33+
lines.forEach((word, index) => {
34+
if (word.length === 0) {
35+
throw new Error("Wordlist contains an empty word");
36+
}
37+
if (wordToIndex.has(word)) {
38+
throw new Error(`Duplicate word detected: ${word}`);
39+
}
40+
wordToIndex.set(word, index);
41+
});
42+
cachedEnglishWordlist = { words: lines, wordToIndex };
43+
return cachedEnglishWordlist;
44+
};

src/bip39/entropyToMnemonic.ts

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import { readFileSync } from "node:fs";
2-
import { resolve } from "node:path";
3-
41
import { bitsToIntegers, bytesToBits } from "../bits/bitOps.js";
52
import {
63
checksumBitsForEntropyBits,
74
ENTROPY_BYTES,
8-
WORDLIST_SIZE,
95
} from "../constants/bip39.js";
106
import { sha256 } from "../crypto/crypto.js";
117
import { ErrorCode } from "../errors/errorCodes.js";
12-
13-
const ENGLISH_WORDLIST_PATH = "assets/english.txt";
14-
15-
let cachedEnglishWords: string[] | null = null;
8+
import { loadEnglishWordlist } from "./englishWordlist.js";
169

1710
export class EntropyLengthError extends Error {
1811
code = ErrorCode.ERR_ENTROPY_LENGTH;
@@ -23,37 +16,6 @@ export class EntropyLengthError extends Error {
2316
}
2417
}
2518

26-
const loadEnglishWords = (): string[] => {
27-
if (cachedEnglishWords) {
28-
return cachedEnglishWords;
29-
}
30-
const filePath = resolve(process.cwd(), ENGLISH_WORDLIST_PATH);
31-
const text = readFileSync(filePath, "utf8");
32-
const lines = text
33-
.split("\n")
34-
.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
35-
if (lines.length > 0 && lines[lines.length - 1] === "") {
36-
lines.pop();
37-
}
38-
if (lines.length !== WORDLIST_SIZE) {
39-
throw new Error(
40-
`Wordlist must contain ${WORDLIST_SIZE} words, got ${lines.length}`,
41-
);
42-
}
43-
const seen = new Set<string>();
44-
for (const word of lines) {
45-
if (word.length === 0) {
46-
throw new Error("Wordlist contains an empty word");
47-
}
48-
if (seen.has(word)) {
49-
throw new Error(`Duplicate word detected: ${word}`);
50-
}
51-
seen.add(word);
52-
}
53-
cachedEnglishWords = lines;
54-
return lines;
55-
};
56-
5719
const isValidEntropyLength = (entropyBytes: number): boolean =>
5820
(ENTROPY_BYTES as readonly number[]).includes(entropyBytes);
5921

@@ -70,7 +32,7 @@ export const entropyToMnemonic = (entropy: Uint8Array): string => {
7032
const checksum = bytesToBits(sha256(entropy)).slice(0, checksumBits);
7133
const combined = entropyBitArray.concat(checksum);
7234
const indices = bitsToIntegers(combined, 11);
73-
const words = loadEnglishWords();
35+
const { words } = loadEnglishWordlist();
7436
const mnemonicWords = indices.map((index) => {
7537
const word = words[index];
7638
if (word === undefined) {

src/bip39/mnemonicToEntropy.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js";
2+
import { WORD_COUNTS } from "../constants/bip39.js";
3+
import { sha256 } from "../crypto/crypto.js";
4+
import { ErrorCode } from "../errors/errorCodes.js";
5+
import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js";
6+
import { loadEnglishWordlist } from "./englishWordlist.js";
7+
8+
export class MnemonicToEntropyError extends Error {
9+
code: ErrorCode;
10+
11+
constructor(code: ErrorCode, message: string) {
12+
super(message);
13+
this.code = code;
14+
this.name = "MnemonicToEntropyError";
15+
}
16+
}
17+
18+
export class InvalidMnemonicFormatError extends MnemonicToEntropyError {
19+
constructor(message = "Invalid mnemonic format") {
20+
super(ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, message);
21+
this.name = "InvalidMnemonicFormatError";
22+
}
23+
}
24+
25+
export class InvalidWordCountError extends MnemonicToEntropyError {
26+
constructor(message = "Invalid word count") {
27+
super(ErrorCode.ERR_INVALID_WORD_COUNT, message);
28+
this.name = "InvalidWordCountError";
29+
}
30+
}
31+
32+
export class WordNotInListError extends MnemonicToEntropyError {
33+
constructor(message = "Word not in list") {
34+
super(ErrorCode.ERR_WORD_NOT_IN_LIST, message);
35+
this.name = "WordNotInListError";
36+
}
37+
}
38+
39+
export class ChecksumMismatchError extends MnemonicToEntropyError {
40+
constructor(message = "Checksum mismatch") {
41+
super(ErrorCode.ERR_CHECKSUM_MISMATCH, message);
42+
this.name = "ChecksumMismatchError";
43+
}
44+
}
45+
46+
const isValidWordCount = (count: number): boolean =>
47+
(WORD_COUNTS as readonly number[]).includes(count);
48+
49+
const checksumBitsForWordCount = (wordCount: number): number =>
50+
wordCount === 0 ? 0 : (wordCount * 11) / 33;
51+
52+
const arraysEqual = (a: number[], b: number[]): boolean =>
53+
a.length === b.length && a.every((value, index) => value === b[index]);
54+
55+
export const mnemonicToEntropy = (input: string | string[]): Uint8Array => {
56+
const parsed = parseMnemonicWordsStrict(input);
57+
if (!parsed.ok) {
58+
throw new InvalidMnemonicFormatError();
59+
}
60+
61+
const { words } = parsed;
62+
const wordCount = words.length;
63+
if (!isValidWordCount(wordCount)) {
64+
throw new InvalidWordCountError();
65+
}
66+
67+
const { wordToIndex } = loadEnglishWordlist();
68+
const indices = words.map((word) => {
69+
const index = wordToIndex.get(word);
70+
if (index === undefined) {
71+
throw new WordNotInListError(`Word not in list: ${word}`);
72+
}
73+
return index;
74+
});
75+
76+
const bits = integersToBits(indices, 11);
77+
const checksumBits = checksumBitsForWordCount(wordCount);
78+
const entropyBits = bits.length - checksumBits;
79+
const entropyBitArray = bits.slice(0, entropyBits);
80+
const checksumBitArray = bits.slice(entropyBits);
81+
const entropy = bitsToBytes(entropyBitArray);
82+
83+
const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits);
84+
if (!arraysEqual(checksumBitArray, expectedChecksum)) {
85+
throw new ChecksumMismatchError();
86+
}
87+
88+
return entropy;
89+
};

src/bip39/validateMnemonic.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { bitsToBytes, bytesToBits, integersToBits } from "../bits/bitOps.js";
2+
import { WORD_COUNTS } from "../constants/bip39.js";
3+
import { sha256 } from "../crypto/crypto.js";
4+
import { ErrorCode } from "../errors/errorCodes.js";
5+
import { parseMnemonicWordsStrict } from "../parser/strictMnemonic.js";
6+
import type { ValidationResult } from "../types/validationResult.js";
7+
import { loadEnglishWordlist } from "./englishWordlist.js";
8+
9+
const isValidWordCount = (count: number): boolean =>
10+
(WORD_COUNTS as readonly number[]).includes(count);
11+
12+
const checksumBitsForWordCount = (wordCount: number): number =>
13+
wordCount === 0 ? 0 : (wordCount * 11) / 33;
14+
15+
const arraysEqual = (a: number[], b: number[]): boolean =>
16+
a.length === b.length && a.every((value, index) => value === b[index]);
17+
18+
export const validateMnemonic = (
19+
input: string | string[],
20+
): ValidationResult => {
21+
const parsed = parseMnemonicWordsStrict(input);
22+
if (!parsed.ok) {
23+
return {
24+
ok: false,
25+
error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT,
26+
normalized_mnemonic: null,
27+
word_count: null,
28+
invalid_word: null,
29+
};
30+
}
31+
32+
const { words, normalized_mnemonic } = parsed;
33+
const wordCount = words.length;
34+
if (!isValidWordCount(wordCount)) {
35+
return {
36+
ok: false,
37+
error_code: ErrorCode.ERR_INVALID_WORD_COUNT,
38+
normalized_mnemonic,
39+
word_count: wordCount,
40+
invalid_word: null,
41+
};
42+
}
43+
44+
const { wordToIndex } = loadEnglishWordlist();
45+
const indices: number[] = [];
46+
for (const word of words) {
47+
const index = wordToIndex.get(word);
48+
if (index === undefined) {
49+
return {
50+
ok: false,
51+
error_code: ErrorCode.ERR_WORD_NOT_IN_LIST,
52+
normalized_mnemonic,
53+
word_count: wordCount,
54+
invalid_word: word,
55+
};
56+
}
57+
indices.push(index);
58+
}
59+
60+
const bits = integersToBits(indices, 11);
61+
const checksumBits = checksumBitsForWordCount(wordCount);
62+
const entropyBits = bits.length - checksumBits;
63+
const entropyBitArray = bits.slice(0, entropyBits);
64+
const checksumBitArray = bits.slice(entropyBits);
65+
const entropy = bitsToBytes(entropyBitArray);
66+
const expectedChecksum = bytesToBits(sha256(entropy)).slice(0, checksumBits);
67+
68+
if (!arraysEqual(checksumBitArray, expectedChecksum)) {
69+
return {
70+
ok: false,
71+
error_code: ErrorCode.ERR_CHECKSUM_MISMATCH,
72+
normalized_mnemonic,
73+
word_count: wordCount,
74+
invalid_word: null,
75+
};
76+
}
77+
78+
return {
79+
ok: true,
80+
error_code: null,
81+
normalized_mnemonic,
82+
word_count: wordCount,
83+
invalid_word: null,
84+
};
85+
};

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from "./bip39/entropyToMnemonic.js";
2+
export * from "./bip39/mnemonicToEntropy.js";
3+
export * from "./bip39/validateMnemonic.js";
24
export * from "./bits/bitOps.js";
35
export * from "./constants/bip39.js";
46
export * from "./crypto/crypto.js";

test/mnemonic-to-entropy.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import assert from "node:assert/strict";
2+
import { readFile } from "node:fs/promises";
3+
import { resolve } from "node:path";
4+
import test from "node:test";
5+
6+
import {
7+
ChecksumMismatchError,
8+
InvalidMnemonicFormatError,
9+
InvalidWordCountError,
10+
MnemonicToEntropyError,
11+
mnemonicToEntropy,
12+
WordNotInListError,
13+
} from "../src/bip39/mnemonicToEntropy.ts";
14+
import { ErrorCode } from "../src/errors/errorCodes.ts";
15+
16+
type Vector = [string, string, string, string];
17+
18+
const validMnemonic =
19+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
20+
21+
test("mnemonicToEntropy rejects invalid format", () => {
22+
assert.throws(
23+
() => mnemonicToEntropy(` ${validMnemonic}`),
24+
(error) =>
25+
error instanceof InvalidMnemonicFormatError &&
26+
error.code === ErrorCode.ERR_INVALID_MNEMONIC_FORMAT,
27+
);
28+
});
29+
30+
test("mnemonicToEntropy rejects invalid word count", () => {
31+
assert.throws(
32+
() => mnemonicToEntropy(validMnemonic.replace(" about", "")),
33+
(error) =>
34+
error instanceof InvalidWordCountError &&
35+
error.code === ErrorCode.ERR_INVALID_WORD_COUNT,
36+
);
37+
});
38+
39+
test("mnemonicToEntropy rejects word not in list", () => {
40+
assert.throws(
41+
() => mnemonicToEntropy(validMnemonic.replace("about", "typo")),
42+
(error) =>
43+
error instanceof WordNotInListError &&
44+
error.code === ErrorCode.ERR_WORD_NOT_IN_LIST,
45+
);
46+
});
47+
48+
test("mnemonicToEntropy rejects checksum mismatch", () => {
49+
const invalid =
50+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
51+
assert.throws(
52+
() => mnemonicToEntropy(invalid),
53+
(error) =>
54+
error instanceof ChecksumMismatchError &&
55+
error.code === ErrorCode.ERR_CHECKSUM_MISMATCH,
56+
);
57+
});
58+
59+
test("mnemonicToEntropy matches official vectors", async () => {
60+
const filePath = resolve(process.cwd(), "assets/vectors.json");
61+
const payload = JSON.parse(await readFile(filePath, "utf8")) as {
62+
english: Vector[];
63+
};
64+
65+
for (const [entropyHex, mnemonic] of payload.english) {
66+
const entropy = mnemonicToEntropy(mnemonic);
67+
const hex = Array.from(entropy)
68+
.map((byte) => byte.toString(16).padStart(2, "0"))
69+
.join("");
70+
assert.equal(hex, entropyHex);
71+
}
72+
});
73+
74+
test("mnemonicToEntropy error types share base class", () => {
75+
const error = new InvalidMnemonicFormatError();
76+
assert.ok(error instanceof MnemonicToEntropyError);
77+
});

0 commit comments

Comments
 (0)