Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!**/coverage", "!notes/grass-source"]
"includes": [
"**",
"!!**/dist",
"!!**/coverage",
"!assets",
"!notes/grass-source"
]
},
"formatter": {
"enabled": true
Expand Down
1 change: 1 addition & 0 deletions src/bip39/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

- 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`.
- Output: JavaScript is emitted to `dist/`; keep this directory TypeScript-only.
44 changes: 44 additions & 0 deletions src/bip39/englishWordlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { WORDLIST_SIZE } from "../constants/bip39.js";

export type EnglishWordlist = {
words: string[];
wordToIndex: Map<string, number>;
};

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<string, number>();
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;
};
42 changes: 2 additions & 40 deletions src/bip39/entropyToMnemonic.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { bitsToIntegers, bytesToBits } from "../bits/bitOps.js";
import {
checksumBitsForEntropyBits,
ENTROPY_BYTES,
WORDLIST_SIZE,
} from "../constants/bip39.js";
import { sha256 } from "../crypto/crypto.js";
import { ErrorCode } from "../errors/errorCodes.js";

const ENGLISH_WORDLIST_PATH = "assets/english.txt";

let cachedEnglishWords: string[] | null = null;
import { loadEnglishWordlist } from "./englishWordlist.js";

export class EntropyLengthError extends Error {
code = ErrorCode.ERR_ENTROPY_LENGTH;
Expand All @@ -23,37 +16,6 @@ export class EntropyLengthError extends Error {
}
}

const loadEnglishWords = (): string[] => {
if (cachedEnglishWords) {
return cachedEnglishWords;
}
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 seen = new Set<string>();
for (const word of lines) {
if (word.length === 0) {
throw new Error("Wordlist contains an empty word");
}
if (seen.has(word)) {
throw new Error(`Duplicate word detected: ${word}`);
}
seen.add(word);
}
cachedEnglishWords = lines;
return lines;
};

const isValidEntropyLength = (entropyBytes: number): boolean =>
(ENTROPY_BYTES as readonly number[]).includes(entropyBytes);

Expand All @@ -70,7 +32,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 = loadEnglishWords();
const { words } = loadEnglishWordlist();
const mnemonicWords = indices.map((index) => {
const word = words[index];
if (word === undefined) {
Expand Down
89 changes: 89 additions & 0 deletions src/bip39/mnemonicToEntropy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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;
};
49 changes: 49 additions & 0 deletions src/bip39/mnemonicToSeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { pbkdf2HmacSha512 } from "../crypto/crypto.js";
import { ErrorCode } from "../errors/errorCodes.js";

export class InvalidMnemonicSeedFormatError extends Error {
code = ErrorCode.ERR_INVALID_MNEMONIC_FORMAT;

constructor(message = "Invalid mnemonic format") {
super(message);
this.name = "InvalidMnemonicSeedFormatError";
}
}

const hasWhitespace = (value: string): boolean => /\s/u.test(value);

const normalizeNfkd = (value: string): string => value.normalize("NFKD");

const normalizeMnemonicInput = (input: string | string[]): string => {
if (typeof input === "string") {
return input;
}
if (!Array.isArray(input) || input.length === 0) {
throw new InvalidMnemonicSeedFormatError();
}
for (const item of input) {
if (typeof item !== "string") {
throw new InvalidMnemonicSeedFormatError();
}
if (item.length === 0 || hasWhitespace(item)) {
throw new InvalidMnemonicSeedFormatError();
}
}
return input.join(" ");
};

export const mnemonicToSeed = (
mnemonic: string | string[],
passphrase = "",
): Uint8Array => {
if (typeof passphrase !== "string") {
throw new InvalidMnemonicSeedFormatError();
}
const mnemonicText = normalizeMnemonicInput(mnemonic);
const normalizedMnemonic = normalizeNfkd(mnemonicText);
const normalizedPassphrase = normalizeNfkd(passphrase);
const encoder = new TextEncoder();
const password = encoder.encode(normalizedMnemonic);
const salt = encoder.encode(`mnemonic${normalizedPassphrase}`);
return pbkdf2HmacSha512(password, salt);
};
85 changes: 85 additions & 0 deletions src/bip39/validateMnemonic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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,
};
};
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export * from "./bip39/entropyToMnemonic.js";
export * from "./bip39/mnemonicToEntropy.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";
Expand Down
Loading