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
3 changes: 3 additions & 0 deletions src/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions src/bits/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions src/bits/bitOps.ts
Original file line number Diff line number Diff line change
@@ -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;
};
30 changes: 0 additions & 30 deletions src/constants/bip39.js

This file was deleted.

5 changes: 5 additions & 0 deletions src/crypto/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions src/crypto/crypto.ts
Original file line number Diff line number Diff line change
@@ -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",
);
}
};
19 changes: 0 additions & 19 deletions src/errors/errorCodes.js

This file was deleted.

3 changes: 0 additions & 3 deletions src/index.js

This file was deleted.

3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions src/parser/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions src/parser/strictMnemonic.ts
Original file line number Diff line number Diff line change
@@ -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(" "),
};
};
1 change: 0 additions & 1 deletion src/types/validationResult.js

This file was deleted.

66 changes: 66 additions & 0 deletions test/bits.spec.ts
Original file line number Diff line number Diff line change
@@ -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));
});
39 changes: 39 additions & 0 deletions test/crypto.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading