diff --git a/src/DESIGN.md b/src/DESIGN.md index dc74c18..ea87294 100644 --- a/src/DESIGN.md +++ b/src/DESIGN.md @@ -2,6 +2,9 @@ - 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/errors/` defines standard error codes and priority ordering. +- `src/crypto/` wraps SHA-256 and PBKDF2-HMAC-SHA512 using standard libraries. +- `src/parser/` implements strict mnemonic parsing contracts. - `src/types/` defines shared DTOs such as `ValidationResult`. - `src/index.ts` re-exports the public surface for these foundational modules. diff --git a/src/bits/DESIGN.md b/src/bits/DESIGN.md new file mode 100644 index 0000000..d6671b9 --- /dev/null +++ b/src/bits/DESIGN.md @@ -0,0 +1,5 @@ +# 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/bits/bitOps.ts b/src/bits/bitOps.ts new file mode 100644 index 0000000..2b501e4 --- /dev/null +++ b/src/bits/bitOps.ts @@ -0,0 +1,71 @@ +export const bytesToBits = (bytes: Uint8Array): number[] => { + const bits: number[] = []; + for (const byte of bytes) { + for (let bit = 7; bit >= 0; bit -= 1) { + bits.push((byte >> bit) & 1); + } + } + return bits; +}; + +const assertBit = (bit: number): void => { + if (bit !== 0 && bit !== 1) { + throw new Error(`Invalid bit value: ${bit}`); + } +}; + +export const bitsToBytes = (bits: number[]): Uint8Array => { + if (bits.length % 8 !== 0) { + throw new Error("Bit length must be a multiple of 8"); + } + const bytes = new Uint8Array(bits.length / 8); + for (let i = 0; i < bytes.length; i += 1) { + let value = 0; + for (let j = 0; j < 8; j += 1) { + const bit = bits[i * 8 + j]; + assertBit(bit); + value = (value << 1) | bit; + } + bytes[i] = value; + } + return bytes; +}; + +const assertChunkSize = (size: number): void => { + if (!Number.isInteger(size) || size <= 0) { + throw new Error(`Invalid chunk size: ${size}`); + } +}; + +export const bitsToIntegers = (bits: number[], size: number): number[] => { + assertChunkSize(size); + if (bits.length % size !== 0) { + throw new Error(`Bit length must be a multiple of ${size}`); + } + const values: number[] = []; + for (let offset = 0; offset < bits.length; offset += size) { + let value = 0; + for (let i = 0; i < size; i += 1) { + const bit = bits[offset + i]; + assertBit(bit); + value = (value << 1) | bit; + } + values.push(value); + } + return values; +}; + +export const integersToBits = (values: number[], size: number): number[] => { + assertChunkSize(size); + const limit = 1 << size; + const bits: number[] = []; + for (const value of values) { + if (!Number.isInteger(value) || value < 0 || value >= limit) { + throw new Error(`Value out of range for ${size} bits: ${value}`); + } + for (let bit = size - 1; bit >= 0; bit -= 1) { + bits.push((value >> bit) & 1); + } + } + return bits; +}; diff --git a/src/constants/bip39.js b/src/constants/bip39.js deleted file mode 100644 index 1e1da60..0000000 --- a/src/constants/bip39.js +++ /dev/null @@ -1,30 +0,0 @@ -export const ENTROPY_BYTES = [16, 20, 24, 28, 32]; -export const ENTROPY_BITS = [128, 160, 192, 224, 256]; -export const WORD_COUNTS = [12, 15, 18, 21, 24]; -export const WORDLIST_SIZE = 2048; -export const SEED_BYTES = 64; -export const PBKDF2_ITERATIONS = 2048; -export const ENTROPY_BITS_BY_WORD_COUNT = new Map([ - [12, 128], - [15, 160], - [18, 192], - [21, 224], - [24, 256], -]); -export const WORD_COUNT_BY_ENTROPY_BITS = new Map([ - [128, 12], - [160, 15], - [192, 18], - [224, 21], - [256, 24], -]); -export const entropyBitsFromBytes = (bytes) => bytes * 8; -export const checksumBitsForEntropyBits = (entropyBits) => entropyBits / 32; -export const wordCountForEntropyBits = (entropyBits) => WORD_COUNT_BY_ENTROPY_BITS.get(entropyBits) ?? - (() => { - throw new Error(`Unsupported entropy bits: ${entropyBits}`); - })(); -export const entropyBitsForWordCount = (wordCount) => ENTROPY_BITS_BY_WORD_COUNT.get(wordCount) ?? - (() => { - throw new Error(`Unsupported word count: ${wordCount}`); - })(); diff --git a/src/crypto/DESIGN.md b/src/crypto/DESIGN.md new file mode 100644 index 0000000..0e35bde --- /dev/null +++ b/src/crypto/DESIGN.md @@ -0,0 +1,5 @@ +# 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/crypto/crypto.ts b/src/crypto/crypto.ts new file mode 100644 index 0000000..07c153e --- /dev/null +++ b/src/crypto/crypto.ts @@ -0,0 +1,33 @@ +import { createHash, pbkdf2Sync } from "node:crypto"; + +import { PBKDF2_ITERATIONS, SEED_BYTES } from "../constants/bip39.js"; +import { ErrorCode } from "../errors/errorCodes.js"; + +export class Pbkdf2FailureError extends Error { + code = ErrorCode.ERR_PBKDF2_FAILURE; + + constructor(message = "PBKDF2 failure") { + super(message); + this.name = "Pbkdf2FailureError"; + } +} + +export const sha256 = (data: Uint8Array): Uint8Array => + new Uint8Array(createHash("sha256").update(data).digest()); + +export const pbkdf2HmacSha512 = ( + password: Uint8Array, + salt: Uint8Array, + iterations = PBKDF2_ITERATIONS, + keyLen = SEED_BYTES, +): Uint8Array => { + try { + return new Uint8Array( + pbkdf2Sync(password, salt, iterations, keyLen, "sha512"), + ); + } catch (error) { + throw new Pbkdf2FailureError( + error instanceof Error ? error.message : "PBKDF2 failure", + ); + } +}; diff --git a/src/errors/errorCodes.js b/src/errors/errorCodes.js deleted file mode 100644 index a837dac..0000000 --- a/src/errors/errorCodes.js +++ /dev/null @@ -1,19 +0,0 @@ -export var ErrorCode; -(function (ErrorCode) { - ErrorCode["ERR_ENTROPY_LENGTH"] = "ERR_ENTROPY_LENGTH"; - ErrorCode["ERR_INVALID_MNEMONIC_FORMAT"] = "ERR_INVALID_MNEMONIC_FORMAT"; - ErrorCode["ERR_INVALID_WORD_COUNT"] = "ERR_INVALID_WORD_COUNT"; - ErrorCode["ERR_WORD_NOT_IN_LIST"] = "ERR_WORD_NOT_IN_LIST"; - ErrorCode["ERR_CHECKSUM_MISMATCH"] = "ERR_CHECKSUM_MISMATCH"; - ErrorCode["ERR_PBKDF2_FAILURE"] = "ERR_PBKDF2_FAILURE"; -})(ErrorCode || (ErrorCode = {})); -export const MNEMONIC_VALIDATION_ERROR_PRIORITY = [ - ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - ErrorCode.ERR_INVALID_WORD_COUNT, - ErrorCode.ERR_WORD_NOT_IN_LIST, - ErrorCode.ERR_CHECKSUM_MISMATCH, -]; -export const MNEMONIC_TO_SEED_ERROR_PRIORITY = [ - ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, - ErrorCode.ERR_PBKDF2_FAILURE, -]; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1547b99..0000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./constants/bip39.js"; -export * from "./errors/errorCodes.js"; -export * from "./types/validationResult.js"; diff --git a/src/index.ts b/src/index.ts index 1547b99..dccc1ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ +export * from "./bits/bitOps.js"; export * from "./constants/bip39.js"; +export * from "./crypto/crypto.js"; export * from "./errors/errorCodes.js"; +export * from "./parser/strictMnemonic.js"; export * from "./types/validationResult.js"; diff --git a/src/parser/DESIGN.md b/src/parser/DESIGN.md new file mode 100644 index 0000000..bac680f --- /dev/null +++ b/src/parser/DESIGN.md @@ -0,0 +1,5 @@ +# 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/parser/strictMnemonic.ts b/src/parser/strictMnemonic.ts new file mode 100644 index 0000000..aa11174 --- /dev/null +++ b/src/parser/strictMnemonic.ts @@ -0,0 +1,92 @@ +import { ErrorCode } from "../errors/errorCodes.js"; + +export type StrictMnemonicParseSuccess = { + ok: true; + words: string[]; + normalized_mnemonic: string; +}; + +export type StrictMnemonicParseFailure = { + ok: false; + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT; +}; + +export type StrictMnemonicParseResult = + | StrictMnemonicParseSuccess + | StrictMnemonicParseFailure; + +const isNfkd = (text: string): boolean => text.normalize("NFKD") === text; + +const isLowercaseAsciiWord = (word: string): boolean => /^[a-z]+$/.test(word); + +const parseWordsFromString = (text: string): string[] | null => { + if (text.length === 0) { + return null; + } + if (!isNfkd(text)) { + return null; + } + const words = text.split(" "); + if (words.some((word) => word.length === 0)) { + return null; + } + if (words.join(" ") !== text) { + return null; + } + if (!words.every(isLowercaseAsciiWord)) { + return null; + } + return words; +}; + +const parseWordsFromList = (value: unknown[]): string[] | null => { + if (value.length === 0) { + return null; + } + const words: string[] = []; + for (const item of value) { + if (typeof item !== "string") { + return null; + } + if (item.length === 0) { + return null; + } + if (/\s/u.test(item)) { + return null; + } + if (!isLowercaseAsciiWord(item)) { + return null; + } + words.push(item); + } + const normalized = words.join(" "); + const stringWords = parseWordsFromString(normalized); + if (!stringWords) { + return null; + } + return words; +}; + +export const parseMnemonicWordsStrict = ( + input: string | string[], +): StrictMnemonicParseResult => { + const words = + typeof input === "string" + ? parseWordsFromString(input) + : Array.isArray(input) + ? parseWordsFromList(input) + : null; + + if (!words) { + return { + ok: false, + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + }; + } + + return { + ok: true, + words, + normalized_mnemonic: words.join(" "), + }; +}; diff --git a/src/types/validationResult.js b/src/types/validationResult.js deleted file mode 100644 index cb0ff5c..0000000 --- a/src/types/validationResult.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/test/bits.spec.ts b/test/bits.spec.ts new file mode 100644 index 0000000..fb26ffe --- /dev/null +++ b/test/bits.spec.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bitsToBytes, + bitsToIntegers, + bytesToBits, + integersToBits, +} from "../src/bits/bitOps.ts"; + +test("bytesToBits reads MSB to LSB", () => { + const bits = bytesToBits(Uint8Array.from([0x80, 0x01])); + assert.deepEqual(bits.slice(0, 8), [1, 0, 0, 0, 0, 0, 0, 0]); + assert.deepEqual(bits.slice(8, 16), [0, 0, 0, 0, 0, 0, 0, 1]); +}); + +test("bitsToBytes restores original bytes", () => { + const original = Uint8Array.from([0x00, 0xff, 0x81]); + const bits = bytesToBits(original); + const restored = bitsToBytes(bits); + assert.deepEqual(Array.from(restored), Array.from(original)); +}); + +test("bitsToBytes rejects non-multiple of 8", () => { + assert.throws(() => bitsToBytes([1, 0, 1])); +}); + +test("bitsToIntegers splits into 11-bit values", () => { + const bits = [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // 1024 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, // 1 + ]; + assert.deepEqual(bitsToIntegers(bits, 11), [1024, 1]); +}); + +test("integersToBits encodes 11-bit values", () => { + const bits = integersToBits([0, 1, 2047], 11); + assert.equal(bits.length, 33); + assert.deepEqual(bits.slice(0, 11), new Array(11).fill(0)); + assert.deepEqual(bits.slice(11, 22), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + assert.deepEqual(bits.slice(22, 33), new Array(11).fill(1)); +}); + +test("integersToBits rejects out-of-range values", () => { + assert.throws(() => integersToBits([2048], 11)); +}); diff --git a/test/crypto.spec.ts b/test/crypto.spec.ts new file mode 100644 index 0000000..2800c6c --- /dev/null +++ b/test/crypto.spec.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + Pbkdf2FailureError, + pbkdf2HmacSha512, + sha256, +} from "../src/crypto/crypto.ts"; +import { ErrorCode } from "../src/errors/errorCodes.ts"; + +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + +test("sha256 matches known vector", () => { + const hash = sha256(new TextEncoder().encode("abc")); + assert.equal( + bytesToHex(hash), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); +}); + +test("pbkdf2HmacSha512 matches known vector", () => { + const derived = pbkdf2HmacSha512( + new TextEncoder().encode("password"), + new TextEncoder().encode("salt"), + ); + assert.equal( + bytesToHex(derived), + "91be23564f09fc855c82ce84a223ebe7d63d8b49d69372593a0d9ed39e143c83e1ab2f722a5ddb969feefc88403f7e2afe1afb8b2f0e6b20add0fb7b28368807", + ); +}); + +test("pbkdf2 failure surfaces error code", () => { + const error = new Pbkdf2FailureError(); + assert.equal(error.code, ErrorCode.ERR_PBKDF2_FAILURE); + assert.equal(error.name, "Pbkdf2FailureError"); +}); diff --git a/test/strict-mnemonic.spec.ts b/test/strict-mnemonic.spec.ts new file mode 100644 index 0000000..890aa1d --- /dev/null +++ b/test/strict-mnemonic.spec.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ErrorCode } from "../src/errors/errorCodes.ts"; +import { + parseMnemonicWordsStrict, + type StrictMnemonicParseResult, +} from "../src/parser/strictMnemonic.ts"; + +const validMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +const expectInvalid = (input: unknown): void => { + const result = parseMnemonicWordsStrict( + input as Parameters[0], + ); + assert.deepEqual(result, { + ok: false, + error_code: ErrorCode.ERR_INVALID_MNEMONIC_FORMAT, + } satisfies StrictMnemonicParseResult); +}; + +test("parseMnemonicWordsStrict accepts normalized string", () => { + const result = parseMnemonicWordsStrict(validMnemonic); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.normalized_mnemonic, validMnemonic); + assert.equal(result.words.length, 12); + assert.equal(result.words[0], "abandon"); + assert.equal(result.words[11], "about"); + } +}); + +test("parseMnemonicWordsStrict rejects string with extra spaces", () => { + expectInvalid(` ${validMnemonic}`); + expectInvalid(`${validMnemonic} `); + expectInvalid(validMnemonic.replace("abandon abandon", "abandon abandon")); +}); + +test("parseMnemonicWordsStrict rejects string with tabs or uppercase", () => { + expectInvalid(validMnemonic.replace("abandon", "abandon\tabandon")); + expectInvalid(validMnemonic.toUpperCase()); +}); + +test("parseMnemonicWordsStrict accepts word list", () => { + const words = validMnemonic.split(" "); + const result = parseMnemonicWordsStrict(words); + assert.equal(result.ok, true); + if (result.ok) { + assert.deepEqual(result.words, words); + assert.equal(result.normalized_mnemonic, validMnemonic); + } +}); + +test("parseMnemonicWordsStrict rejects invalid word list", () => { + expectInvalid(["abandon", "", "about"]); + expectInvalid(["abandon", "about about"]); + expectInvalid(["abandon", "About"]); + expectInvalid(["abandon", 123 as unknown as string]); +}); + +test("parseMnemonicWordsStrict rejects non-string input", () => { + expectInvalid(123); + expectInvalid({}); + expectInvalid(null); +}); diff --git a/tsconfig.json b/tsconfig.json index ae88da9..121c84f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", "types": ["node"], "strict": true, "esModuleInterop": true, diff --git a/tsconfig.test.json b/tsconfig.test.json index 0eae3cb..15fa963 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,9 +1,9 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"], - "allowImportingTsExtensions": true - }, - "include": ["test/**/*.ts"] + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + "allowImportingTsExtensions": true + }, + "include": ["test/**/*.ts"] }