From 2471b7c967e7c5140cd57c5a7c4b0a6f9c29a578 Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:14:18 +0200 Subject: [PATCH] feat(hd): allow invalid checksums for puzzle wallets --- README.md | 22 +++++++- docs/1.guide/4.wallets.md | 6 ++- packages/pi/README.md | 6 +++ packages/pi/extensions/keys.ts | 20 +++++-- src/blockchain.ts | 18 +++++-- src/mcp.ts | 13 +++-- src/tool-operations.ts | 29 ++++++++-- src/types.ts | 4 ++ src/utils/bip39/index.ts | 37 +++++++++++++ src/utils/hd.ts | 31 ++++++----- test/eval-mcp.mjs | 27 ++++++++++ test/fixtures.ts | 9 ++++ test/mcp.test.ts | 37 +++++++++++++ test/pi-extension.test.ts | 56 ++++++++++++++++++- test/public-exports.test.ts | 24 ++++++++- test/utils/bip39.test.ts | 77 +++++++++++++++++++++++++- test/utils/hd.test.ts | 99 ++++++++++++++++++++++++++++++++++ tsconfig.type-tests.json | 1 + 18 files changed, 483 insertions(+), 33 deletions(-) create mode 100644 test/utils/hd.test.ts diff --git a/README.md b/README.md index 2eebd90..0496ba3 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,26 @@ sol.deriveHDWallet(mnemonic, "m/44'/501'/0'/0'", { passphrase: "TREZOR" }).addre secp256k1 chains walk BIP32 and ed25519 chains walk SLIP-10, which accepts hardened segments only. Bitcoin and Litecoin read the address type off the purpose level (44, 49, 84, 86) unless one is passed. Decred throws because its HD derivation differs from standard BIP32. Cardano throws, because CIP-1852 starts from the entropy rather than the BIP39 seed. +### Puzzle phrases with an invalid checksum + +A bad checksum does not always mean a wrong puzzle answer. The [claimed Bitcoin Movie Enigma solution](https://github.com/floflo777/open-crypto-puzzles/issues/24) has one. Repairing its last word derives a different wallet. + +`deriveHDWallet` rejects invalid checksums by default. Set `allowInvalidChecksum: true` explicitly to derive from the supplied words: + +```ts +const puzzleMnemonic = + "path mad alien apology escape spare miss goddess leopard crime visit clock start first blade guard close barrel term screen matrix toy ghost shine"; +const puzzleWallet = btc.deriveHDWallet(puzzleMnemonic, "m/84'/0'/0'/0/0", { + allowInvalidChecksum: true, +}); +console.log(puzzleWallet.address); +console.log(puzzleWallet.warnings); +``` + +This public, burned example produces `bc1q94ecsn0qk8lap2gefrycnms3ruepy889z969a6` and a checksum warning. The override still requires English BIP39 words and a count of 12, 15, 18, 21 or 24. It does not bypass path or chain restrictions. Whitespace collapsing and BIP39 NFKD normalization still apply. This is not arbitrary text hashing. + +MCP and Pi expose the same `allowInvalidChecksum` boolean on `keys_derive_hd_wallet`, defaulting to `false`. Warnings appear in tool text and Pi details. `keys_inspect_mnemonic` and the library's `inspectBIP39Mnemonic` export from `@agntn/keys/bip39` report `wordCountValid`, `wordlistValid` and `checksumValid` separately. A `null` checksum verdict means the shape or dictionary check failed. Entropy is returned by the inspection tool only for a fully valid mnemonic. The tool respects its `language` option. The library inspector defaults to English; pass a list from `loadBIP39Wordlist(language)` as its second argument to inspect another language. + ### Recover one missing BIP39 word ```ts @@ -186,7 +206,7 @@ const candidates = getMnemonicWordCandidates( ); ``` -The result only satisfies the BIP39 checksum. It does not prove that a candidate belongs to the wallet or puzzle target. +The result only satisfies the BIP39 checksum. It does not prove that a candidate belongs to the wallet or puzzle target. Use this filter only when canonical BIP39 generation is established. It excludes the actual last word from the Movie Enigma solution above. ### Map localized BIP39 words and indices diff --git a/docs/1.guide/4.wallets.md b/docs/1.guide/4.wallets.md index d19840c..4d4675d 100644 --- a/docs/1.guide/4.wallets.md +++ b/docs/1.guide/4.wallets.md @@ -134,7 +134,11 @@ bitcoinChain.deriveHDWallet(mnemonic, "m/84'/0'/0'/0/0").address; // bc1q... bitcoinChain.deriveHDWallet(mnemonic, "m/44'/0'/0'/0/0", { passphrase: "TREZOR" }, "p2sh"); ``` -The mnemonic must pass the English BIP39 checksum. secp256k1 chains derive with BIP32 and ed25519 chains with SLIP-10, so Solana and Aptos paths, and Sui on ed25519, have to be fully hardened. Bitcoin infers `legacy`, `p2sh`, `segwit`, or `taproot` from purpose 44, 49, 84, or 86 when no address type is given. Cardano throws: CIP-1852 starts from the entropy, not from the BIP39 seed. +By default, the mnemonic must pass the English BIP39 checksum. For public puzzles with an invalid checksum, pass `{ allowInvalidChecksum: true }` in the options. The result includes `warnings` when the checksum is invalid. Words are never repaired, because that would derive a different wallet. Valid phrases produce the same result with either setting. + +The override still requires English BIP39 words and a count of 12, 15, 18, 21 or 24. It preserves whitespace collapsing and NFKD normalization, not arbitrary raw text. `inspectBIP39Mnemonic` from `@agntn/keys/bip39` reports word count, dictionary membership and checksum separately without echoing the phrase. Checksum validity is `null` when the other checks prevent evaluating it. The MCP and Pi tools expose the same override and diagnostics. + +secp256k1 chains derive with BIP32 and ed25519 chains with SLIP-10, so Solana and Aptos paths, and Sui on ed25519, have to be fully hardened. Bitcoin infers `legacy`, `p2sh`, `segwit`, or `taproot` from purpose 44, 49, 84, or 86 when no address type is given. Cardano throws: CIP-1852 starts from the entropy, not from the BIP39 seed. ## Security Considerations diff --git a/packages/pi/README.md b/packages/pi/README.md index a964561..d47035c 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -26,6 +26,12 @@ Pi coding agent extension exposing the [`@agntn/keys`](../../README.md) library | `keys_verify_message` | Verify a signature against message + public key | | `keys_bip44_path` | Generate or parse a BIP44 derivation path | +## Puzzle checksum override + +`keys_derive_hd_wallet` rejects invalid checksums by default. For public puzzle candidates, set `allowInvalidChecksum: true` explicitly. The tool derives from the supplied words without repairing them and includes a warning in both text and details when the checksum is invalid. English dictionary membership and BIP39 word counts are still required. Whitespace collapsing, NFKD normalization and chain/path restrictions are unchanged. + +`keys_inspect_mnemonic` reports `wordCountValid`, `wordlistValid` and `checksumValid`. The checksum verdict is `null` when word count or dictionary membership prevents checking it. A bad checksum alone is not proof that a puzzle answer is wrong. `keys_recover_mnemonic_word` remains a checksum filter, so it is unsuitable when the target may use an invalid checksum. See the [Movie Enigma example](../../README.md#puzzle-phrases-with-an-invalid-checksum). + ## Repository status The extension stays in this repository. It is not registered or included in the npm package. Its handling of plaintext private keys must be redesigned before distribution. diff --git a/packages/pi/extensions/keys.ts b/packages/pi/extensions/keys.ts index a9b7fba..151b713 100644 --- a/packages/pi/extensions/keys.ts +++ b/packages/pi/extensions/keys.ts @@ -146,7 +146,8 @@ export default function keysExtension(pi: ExtensionAPI) { pi.registerTool({ name: "keys_derive_hd_wallet", label: "Derive HD Wallet", - description: "Derive a public key and address from a BIP39 mnemonic and derivation path", + description: + "Derive a public key and address from English BIP39 words and a path, optionally accepting an invalid checksum for public puzzles", promptSnippet: "Use to see which address a public puzzle mnemonic reaches on a given derivation path.", promptGuidelines: [ @@ -154,6 +155,9 @@ export default function keysExtension(pi: ExtensionAPI) { "Common paths: Bitcoin m/44'/0'/0'/0/0 (legacy), m/49'/0'/0'/0/0 (p2sh), m/84'/0'/0'/0/0 (segwit), m/86'/0'/0'/0/0 (taproot); Ethereum m/44'/60'/0'/0/0; Solana m/44'/501'/0'/0'; Aptos m/44'/637'/0'/0'/0'; Sui m/44'/784'/0'/0'/0'", "Bitcoin and Litecoin pick the address type from the path purpose unless addressType is set", "Optionally pass a BIP39 passphrase, a network, or an address type", + "For public puzzles, allowInvalidChecksum=true accepts a checksum failure with a warning, but still requires English BIP39 words and word counts", + "Never repair words just to satisfy the checksum. A bad checksum does not rule out a puzzle candidate", + "Whitespace is collapsed and BIP39 NFKD normalization still applies, not raw text hashing", "Decred HD derivation is not supported because it differs from standard BIP32", "Cardano is not supported because CIP-1852 derives from entropy, not from the BIP39 seed", "Use only public or disposable mnemonics because tool arguments are saved in the transcript", @@ -173,6 +177,12 @@ export default function keysExtension(pi: ExtensionAPI) { description: "Derivation path such as m/84'/0'/0'/0/0", }), passphrase: Type.Optional(Type.String({ description: "BIP39 passphrase. Default: empty" })), + allowInvalidChecksum: Type.Optional( + Type.Boolean({ + description: + "Accept an invalid checksum with a warning. English words and BIP39 word counts are still required. Default: false", + }), + ), addressType: ADDRESS_TYPE_PARAMETER, network: NETWORK_PARAMETER, }), @@ -187,6 +197,7 @@ export default function keysExtension(pi: ExtensionAPI) { params.passphrase, params.addressType, params.network, + params.allowInvalidChecksum, ); }, }); @@ -213,13 +224,16 @@ export default function keysExtension(pi: ExtensionAPI) { pi.registerTool({ name: "keys_inspect_mnemonic", label: "Inspect Mnemonic", - description: "Validate a BIP39 mnemonic and recover its entropy", + description: + "Inspect BIP39 word count, dictionary membership and checksum, with entropy only when valid", promptSnippet: "Use to check mnemonic candidates from public crypto puzzles.", promptGuidelines: [ "keys_inspect_mnemonic accepts an explicit BIP39 language; omission means english, not automatic detection", "Provide a BIP39 mnemonic", "Use only public or disposable candidates because tool arguments are saved in the transcript", - "Returns checksum validity, word count, and entropy for valid mnemonics", + "Returns wordCountValid, wordlistValid and checksumValid separately, with entropy only for valid mnemonics", + "checksumValid is null when word count or dictionary membership prevents checking it", + "A checksum failure is not proof that a puzzle candidate is wrong. keys_derive_hd_wallet accepts allowInvalidChecksum=true explicitly", ], parameters: Type.Object({ language: BIP39_LANGUAGE_PARAMETER, diff --git a/src/blockchain.ts b/src/blockchain.ts index c67e4a6..91670a2 100644 --- a/src/blockchain.ts +++ b/src/blockchain.ts @@ -1,7 +1,7 @@ import { webcrypto } from "node:crypto"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import { bytesToHex } from "@noble/hashes/utils.js"; -import { deriveKeyPrivateFromMnemonic } from "./utils/hd.ts"; +import { deriveMnemonicKey } from "./utils/hd.ts"; import type { AddressType, Blockchain, @@ -108,15 +108,25 @@ export abstract class AbstractBlockchain implements Blockchain { options?: HDWalletOptions, addressType?: AddressType, ): Wallet { - const { passphrase, ...keyOptions } = options ?? {}; - const keyPrivate = deriveKeyPrivateFromMnemonic( + const { passphrase, allowInvalidChecksum, ...keyOptions } = options ?? {}; + const { privateKey, checksumValid } = deriveMnemonicKey( mnemonic, path, this.resolveCurve(keyOptions), passphrase, + allowInvalidChecksum, ); - return this.deriveWallet(keyPrivate, keyOptions, addressType); + const wallet = this.deriveWallet(privateKey, keyOptions, addressType); + return checksumValid + ? wallet + : { + ...wallet, + warnings: [ + ...(wallet.warnings ?? []), + "BIP39 checksum is invalid. Derived from the supplied words without repairing the checksum.", + ], + }; } } diff --git a/src/mcp.ts b/src/mcp.ts index b11548b..23e2cec 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -157,7 +157,7 @@ const tools: readonly ToolDefinition[] = [ name: "keys_derive_hd_wallet", title: "Derive HD Wallet", description: - "Derive a public key and address from an English BIP39 mnemonic and an absolute derivation path. The mnemonic and optional passphrase enter the MCP transcript, so use only public or disposable material.", + "Derive a public key and address from English BIP39 words and a path. Use allowInvalidChecksum for public puzzle candidates that fail only the checksum. Words are not repaired. Inputs enter the MCP transcript, so use only public or disposable material.", inputSchema: Type.Object( { chain: chainArgument, @@ -171,6 +171,12 @@ const tools: readonly ToolDefinition[] = [ pattern: DERIVATION_PATH_SCHEMA_PATTERN, }), passphrase: Type.Optional(Type.String({ description: "BIP39 passphrase. Default: empty" })), + allowInvalidChecksum: Type.Optional( + Type.Boolean({ + description: + "Accept an invalid checksum with a warning. English words and BIP39 word counts are still required. Default: false", + }), + ), addressType: addressTypeArgument, network: networkArgument, }, @@ -185,6 +191,7 @@ const tools: readonly ToolDefinition[] = [ args["passphrase"], args["addressType"], args["network"], + args["allowInvalidChecksum"], ), }, { @@ -200,7 +207,7 @@ const tools: readonly ToolDefinition[] = [ name: "keys_inspect_mnemonic", title: "Inspect Mnemonic", description: - "Validate a BIP39 mnemonic and recover its entropy when valid. The phrase enters the MCP transcript, so use only public or disposable candidates.", + "Inspect BIP39 word count, dictionary membership and checksum separately. Recover entropy only when valid. A bad checksum does not rule out a puzzle candidate. The phrase enters the MCP transcript, so use only public or disposable candidates.", inputSchema: Type.Object( { language: BIP39_LANGUAGE_PARAMETER, @@ -287,7 +294,7 @@ const tools: readonly ToolDefinition[] = [ name: "keys_recover_mnemonic_word", title: "Recover Mnemonic Word", description: - "List English BIP39 words that make the checksum valid for one missing position. The partial phrase enters the MCP transcript, so use only public or disposable candidates.", + "List English BIP39 words that make the checksum valid for one missing position. Use this filter only when canonical BIP39 generation is established, not for puzzles that may have invalid checksums. Inputs enter the MCP transcript, so use only public or disposable candidates.", inputSchema: Type.Object( { mnemonic: Type.String({ diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 87eda17..e55b4fb 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -21,6 +21,7 @@ import { bip39, loadBIP39Wordlist, getMnemonicWordCandidates, + inspectBIP39Mnemonic, lookupBIP39Indices, lookupBIP39Words, } from "./utils/bip39/index.ts"; @@ -67,6 +68,7 @@ export interface DerivedWalletDetails { publicKey: string; address: string; path?: string; + warnings?: readonly string[]; } /** BIP39 inspection result without the supplied mnemonic. */ @@ -74,6 +76,9 @@ export interface MnemonicInspectionDetails { language: BIP39Language; valid: boolean; words: number; + wordCountValid: boolean; + wordlistValid: boolean; + checksumValid: boolean | null; entropy?: string; } @@ -415,6 +420,7 @@ export async function deriveWallet( * @param passphraseValue - Optional BIP39 passphrase. * @param addressTypeValue - Optional chain-specific address type. * @param networkValue - Optional network name. + * @param allowInvalidChecksumValue - Accept a checksum failure for a public puzzle, default false. * @returns {Promise>} Derived public wallet material. */ export async function deriveHdWallet( @@ -424,7 +430,12 @@ export async function deriveHdWallet( passphraseValue?: unknown, addressTypeValue?: unknown, networkValue?: unknown, + allowInvalidChecksumValue?: unknown, ): Promise> { + if (allowInvalidChecksumValue !== undefined && typeof allowInvalidChecksumValue !== "boolean") { + throw new TypeError("allowInvalidChecksum must be a boolean"); + } + const allowInvalidChecksum = allowInvalidChecksumValue ?? false; const path = requiredString(pathValue, "Derivation path"); if (!DERIVATION_PATH_PATTERN.test(path)) { throw new TypeError("Derivation path must look like m/84'/0'/0'/0/0"); @@ -436,13 +447,19 @@ export async function deriveHdWallet( ); const mnemonic = requiredString(mnemonicValue, "BIP39 mnemonic"); const passphrase = optionalString(passphraseValue, "BIP39 passphrase"); - const wallet = blockchain.deriveHDWallet(mnemonic, path, { passphrase }, addressType); + const wallet = blockchain.deriveHDWallet( + mnemonic, + path, + { passphrase, allowInvalidChecksum }, + addressType, + ); const details = { chain: blockchain.name, network: blockchain.network, path, publicKey: wallet.keys.public, address: wallet.address, + ...(wallet.warnings === undefined ? {} : { warnings: wallet.warnings }), }; return { content: content( @@ -451,6 +468,7 @@ export async function deriveHdWallet( `Path: ${path}`, `Public key: ${details.publicKey}`, `Address: ${details.address}`, + ...(details.warnings ?? []).map((warning) => `Warning: ${warning}`), ].join("\n"), ), details, @@ -494,18 +512,21 @@ export async function inspectMnemonic( const mnemonic = normalizedMnemonic(mnemonicValue); const language = parseBIP39Language(languageValue); const wordlist = await loadBIP39Wordlist(language); - const words = mnemonic.split(" ").length; - const valid = bip39.validateMnemonic(mnemonic, wordlist); + const inspection = inspectBIP39Mnemonic(mnemonic, wordlist); + const { valid, words, wordCountValid, wordlistValid, checksumValid } = inspection; const entropy = valid ? Buffer.from(bip39.mnemonicToEntropy(mnemonic, wordlist)).toString("hex") : undefined; - const details = { language, valid, words, ...(entropy === undefined ? {} : { entropy }) }; + const details = { language, ...inspection, ...(entropy === undefined ? {} : { entropy }) }; return { content: content( [ `Language: ${language}`, `Valid BIP39: ${valid ? "yes" : "no"}`, `Words: ${words}`, + `Word count valid: ${wordCountValid ? "yes" : "no"}`, + `Wordlist valid: ${wordlistValid ? "yes" : "no"}`, + `Checksum valid: ${checksumValid === null ? "not checked" : checksumValid ? "yes" : "no"}`, entropy === undefined ? undefined : `Entropy: ${entropy}`, ] .filter((line) => line !== undefined) diff --git a/src/types.ts b/src/types.ts index 2758833..9627a31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,8 @@ export interface Wallet extends Keys { * Blockchain address derived from the public key */ address: AddressFormat; + /** Present when HD derivation explicitly accepts an invalid mnemonic checksum. */ + warnings?: readonly string[]; } /** @@ -70,6 +72,8 @@ export interface KeyOptions { */ export interface HDWalletOptions extends KeyOptions { readonly passphrase?: string; + /** Accept an invalid checksum, but still require English BIP39 words and length. Default: false. */ + readonly allowInvalidChecksum?: boolean; } /** diff --git a/src/utils/bip39/index.ts b/src/utils/bip39/index.ts index a6e4d1b..b1c2356 100644 --- a/src/utils/bip39/index.ts +++ b/src/utils/bip39/index.ts @@ -102,6 +102,43 @@ export const entropyToMnemonic = (entropy: Uint8Array) => const MNEMONIC_WORD_COUNTS: readonly number[] = [12, 15, 18, 21, 24]; +/** BIP39 diagnostics without the input words or entropy. */ +export interface BIP39MnemonicInspection { + readonly valid: boolean; + readonly words: number; + readonly wordCountValid: boolean; + readonly wordlistValid: boolean; + /** Null when word count or dictionary membership prevents checking the checksum. */ + readonly checksumValid: boolean | null; +} + +/** + * Separates word count, dictionary membership and checksum after NFKD normalization. + * @param mnemonic - Candidate phrase with words separated by single spaces + * @param selectedWordlist - BIP39 word list, defaulting to English + * @returns {BIP39MnemonicInspection} Diagnostics without echoing the phrase + */ +export function inspectBIP39Mnemonic( + mnemonic: string, + selectedWordlist: readonly string[] = wordlist, +): BIP39MnemonicInspection { + const normalized = mnemonic.normalize("NFKD"); + const words = normalized === "" ? [] : normalized.split(" "); + const wordCountValid = MNEMONIC_WORD_COUNTS.includes(words.length); + const wordlistValid = words.length > 0 && words.every((word) => selectedWordlist.includes(word)); + const checksumValid = + wordCountValid && wordlistValid + ? bip39.validateMnemonic(normalized, [...selectedWordlist]) + : null; + return { + valid: checksumValid === true, + words: words.length, + wordCountValid, + wordlistValid, + checksumValid, + }; +} + /** * Lists English BIP39 words that make the checksum valid for a mnemonic with one placeholder. * @param mnemonic - Mnemonic template containing exactly one `?` diff --git a/src/utils/hd.ts b/src/utils/hd.ts index 895c8bd..c470a8f 100644 --- a/src/utils/hd.ts +++ b/src/utils/hd.ts @@ -1,6 +1,6 @@ import { bytesToHex } from "@noble/hashes/utils.js"; import { getMasterKeyFromSeed as getBIP32MasterKey } from "./bip32/index.ts"; -import { mnemonicToSeed, validateMnemonic } from "./bip39/index.ts"; +import { inspectBIP39Mnemonic, mnemonicToSeed } from "./bip39/index.ts"; import { getMasterKeyFromSeed as getSLIP10MasterKey } from "./slip10/index.ts"; import type { Curve } from "../types.ts"; @@ -15,31 +15,36 @@ export function normalizeMnemonic(mnemonic: string): string { /** * Walks an English BIP39 mnemonic down a path: BIP32 for secp256k1, SLIP-10 for ed25519. - * @param mnemonic - English BIP39 mnemonic with a valid checksum + * @param mnemonic - English BIP39 words, never repaired to satisfy a checksum * @param path - Derivation path such as `m/84'/0'/0'/0/0` * @param curve - Curve of the key the chain expects * @param passphrase - BIP39 passphrase, empty by default - * @returns {string} Private key at the path as hex + * @param allowInvalidChecksum - Accept only checksum failures when explicitly true + * @returns {{ privateKey: string; checksumValid: boolean }} Derived key and actual checksum verdict */ -export function deriveKeyPrivateFromMnemonic( +export function deriveMnemonicKey( mnemonic: string, path: string, curve: Curve, passphrase = "", -): string { + allowInvalidChecksum = false, +): { readonly privateKey: string; readonly checksumValid: boolean } { + if (typeof allowInvalidChecksum !== "boolean") { + throw new TypeError("allowInvalidChecksum must be a boolean"); + } const normalizedMnemonic = normalizeMnemonic(mnemonic); - if (!validateMnemonic(normalizedMnemonic)) { + const inspection = inspectBIP39Mnemonic(normalizedMnemonic); + if (inspection.checksumValid === null || (!inspection.valid && !allowInvalidChecksum)) { throw new Error("Invalid BIP39 mnemonic"); } const seed = mnemonicToSeed(normalizedMnemonic, passphrase); - if (curve === "ed25519") { - return bytesToHex(getSLIP10MasterKey(seed).derive(path).privateKey); - } - - const keyPrivate = getBIP32MasterKey(seed).derive(path).privateKey; - if (!keyPrivate) { + const privateKey = + curve === "ed25519" + ? getSLIP10MasterKey(seed).derive(path).privateKey + : getBIP32MasterKey(seed).derive(path).privateKey; + if (!privateKey) { throw new Error(`No private key at ${path}`); } - return bytesToHex(keyPrivate); + return { privateKey: bytesToHex(privateKey), checksumValid: inspection.valid }; } diff --git a/test/eval-mcp.mjs b/test/eval-mcp.mjs index 45cc7db..ed628ef 100644 --- a/test/eval-mcp.mjs +++ b/test/eval-mcp.mjs @@ -3,6 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import path from "node:path"; +import { invalidChecksumPuzzle } from "./fixtures.ts"; const server = path.resolve(import.meta.dirname, "../dist/cli.mjs"); const transport = new StdioClientTransport({ command: process.execPath, args: [server, "mcp"] }); @@ -97,6 +98,32 @@ try { const generatedMnemonic = /Mnemonic: ([a-z ]+)/.exec(generated)?.[1]; if (!generatedMnemonic) throw new Error("keys_generate_mnemonic returned no mnemonic"); await call("keys_inspect_mnemonic", { mnemonic: generatedMnemonic }, /Valid BIP39: yes/); + const puzzleArgs = { + chain: "bitcoin", + mnemonic: invalidChecksumPuzzle.mnemonic, + path: invalidChecksumPuzzle.path, + }; + const strictPuzzle = await client.callTool({ + name: "keys_derive_hd_wallet", + arguments: puzzleArgs, + }); + if (strictPuzzle.isError !== true || !text(strictPuzzle).includes("Invalid BIP39 mnemonic")) { + throw new Error("Invalid puzzle checksum must be rejected by default"); + } + const puzzleWallet = await call( + "keys_derive_hd_wallet", + { ...puzzleArgs, allowInvalidChecksum: true }, + /Warning: BIP39 checksum is invalid\./, + ); + if ( + !puzzleWallet.includes(invalidChecksumPuzzle.address) || + !puzzleWallet.includes(invalidChecksumPuzzle.publicKey) + ) { + throw new Error("Puzzle derivation did not reproduce the published wallet"); + } + if (puzzleWallet.includes(invalidChecksumPuzzle.mnemonic)) { + throw new Error("Puzzle derivation echoed the mnemonic"); + } await call("keys_inspect_mnemonic", { mnemonic }, /Valid BIP39: yes/); await call("keys_encode_bip39_entropy", { entropy: "00".repeat(16) }, /Words: 12/); for (const name of ["keys_generate_mnemonic", "keys_encode_bip39_entropy"]) { diff --git a/test/fixtures.ts b/test/fixtures.ts index 0e4c639..928ba7b 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -27,6 +27,15 @@ export const bip39TestVectors = { passphrase: "TREZOR", }; +/** Public, claimed Bitcoin Movie Enigma solution: floflo777/open-crypto-puzzles#24. */ +export const invalidChecksumPuzzle = { + mnemonic: + "path mad alien apology escape spare miss goddess leopard crime visit clock start first blade guard close barrel term screen matrix toy ghost shine", + path: "m/84'/0'/0'/0/0", + address: "bc1q94ecsn0qk8lap2gefrycnms3ruepy889z969a6", + publicKey: "022c17f7486b4107b42a243a62e4d0919af3e8ee858a272319bffb0536486b9405", +}; + // Bitcoin test vectors export const bitcoinTestVectors = { // Valid addresses for testing diff --git a/test/mcp.test.ts b/test/mcp.test.ts index e3bcafc..543b24f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -6,6 +6,7 @@ import { decredTestVectors, wifTestVectors, localizedMnemonicVectors, + invalidChecksumPuzzle, } from "./fixtures.ts"; import { createMcpServer } from "../src/mcp.ts"; @@ -67,6 +68,9 @@ describe("keys MCP server", () => { expect(inspected.isError).not.toBe(true); expect(text(inspected.content)).toContain(`Language: ${language}`); expect(text(inspected.content)).toContain(`Entropy: ${entropy}`); + expect(text(inspected.content)).toContain("Word count valid: yes"); + expect(text(inspected.content)).toContain("Wordlist valid: yes"); + expect(text(inspected.content)).toContain("Checksum valid: yes"); } const generated = await client.callTool({ name: "keys_generate_mnemonic", @@ -207,6 +211,39 @@ describe("keys MCP server", () => { expect(text(response.content)).not.toContain(mnemonic); }); + it("requires an explicit checksum override and reports the warning through MCP", async () => { + const client = await connectTestClient(); + const { mnemonic, path, address, publicKey } = invalidChecksumPuzzle; + const args = { chain: "bitcoin", mnemonic, path }; + const strict = await client.callTool({ name: "keys_derive_hd_wallet", arguments: args }); + expect(strict.isError).toBe(true); + expect(text(strict.content)).toContain("Invalid BIP39 mnemonic"); + const result = await client.callTool({ + name: "keys_derive_hd_wallet", + arguments: { ...args, allowInvalidChecksum: true }, + }); + expect(result.isError).not.toBe(true); + expect(text(result.content)).toContain(address); + expect(text(result.content)).toContain(publicKey); + expect(text(result.content)).toContain("Warning: BIP39 checksum is invalid."); + expect(text(result.content)).not.toContain(mnemonic); + for (const allowInvalidChecksum of [false, "true", "false", 1, null]) { + const rejected = await client.callTool({ + name: "keys_derive_hd_wallet", + arguments: { ...args, allowInvalidChecksum }, + }); + expect(rejected.isError).toBe(true); + } + const inspection = await client.callTool({ + name: "keys_inspect_mnemonic", + arguments: { mnemonic }, + }); + expect(text(inspection.content)).toContain("Word count valid: yes"); + expect(text(inspection.content)).toContain("Wordlist valid: yes"); + expect(text(inspection.content)).toContain("Checksum valid: no"); + expect(text(inspection.content)).not.toContain("Entropy:"); + }); + it("derives Litecoin through the MCP schema and executor", async () => { const client = await connectTestClient(); const response = await client.callTool({ diff --git a/test/pi-extension.test.ts b/test/pi-extension.test.ts index ccb6716..a296ac5 100644 --- a/test/pi-extension.test.ts +++ b/test/pi-extension.test.ts @@ -7,6 +7,7 @@ import { decredTestVectors, wifTestVectors, localizedMnemonicVectors, + invalidChecksumPuzzle, } from "./fixtures.ts"; import keysExtension from "../packages/pi/extensions/keys.ts"; import { mnemonicToEntropy, validateMnemonic } from "../src/utils/bip39/index.ts"; @@ -54,7 +55,17 @@ describe("keys Pi extension", () => { mnemonic: mnemonic.normalize("NFC"), language, }); - expect(inspected).toMatchObject({ details: { language, valid: true, words: 12, entropy } }); + expect(inspected).toMatchObject({ + details: { + language, + valid: true, + words: 12, + entropy, + wordCountValid: true, + wordlistValid: true, + checksumValid: true, + }, + }); expect(inspected.content[0]?.text).toContain(`Language: ${language}`); expect(inspected.content[0]?.text).not.toContain(mnemonic); for (const [tool, args] of [ @@ -394,6 +405,9 @@ describe("keys Pi extension", () => { expect(validText).toContain("Words: 12"); expect(validText).toContain("Entropy: 00000000000000000000000000000000"); expect(validText).not.toContain(mnemonic); + expect(validResult).toMatchObject({ + details: { valid: true, wordCountValid: true, wordlistValid: true, checksumValid: true }, + }); const invalidResult = await tool.execute("call-2", { mnemonic: @@ -404,6 +418,18 @@ describe("keys Pi extension", () => { expect(invalidText).toContain("Valid BIP39: no"); expect(invalidText).toContain("Words: 12"); expect(invalidText).not.toContain("Entropy:"); + expect(invalidResult).toMatchObject({ + details: { valid: false, wordCountValid: true, wordlistValid: true, checksumValid: false }, + }); + const unknown = await tool.execute("unknown-word", { + mnemonic: mnemonic.replace("about", "notaword"), + }); + expect(unknown).toMatchObject({ + details: { valid: false, wordCountValid: true, wordlistValid: false, checksumValid: null }, + }); + expect(unknown.content.map((part) => part.text ?? "").join("\n")).toContain( + "Checksum valid: not checked", + ); expect(Value.Check(tool.parameters, { mnemonic: " " })).toBe(false); }); @@ -491,6 +517,34 @@ describe("keys Pi extension", () => { ).rejects.toThrow("Invalid BIP39 mnemonic"); }); + it("exposes the checksum override and warning in Pi content and details", async () => { + const tool = registerTools().get("keys_derive_hd_wallet"); + if (!tool) throw new Error("keys_derive_hd_wallet was not registered"); + const { mnemonic, path, address, publicKey } = invalidChecksumPuzzle; + const args = { chain: "bitcoin", mnemonic, path, allowInvalidChecksum: true }; + expect(Value.Check(tool.parameters, args)).toBe(true); + const result = await tool.execute("puzzle", args); + const text = result.content.map((part) => part.text ?? "").join("\n"); + expect(text).toContain(address); + expect(text).toContain("Warning: BIP39 checksum is invalid."); + expect(text).not.toContain(mnemonic); + expect(result).toMatchObject({ + details: { address, publicKey, warnings: [expect.stringContaining("checksum is invalid")] }, + }); + await expect(tool.execute("strict", { ...args, allowInvalidChecksum: false })).rejects.toThrow( + "Invalid BIP39 mnemonic", + ); + for (const allowInvalidChecksum of ["true", "false", 1, null]) { + expect(Value.Check(tool.parameters, { ...args, allowInvalidChecksum })).toBe(false); + await expect(tool.execute("invalid-flag", { ...args, allowInvalidChecksum })).rejects.toThrow( + "allowInvalidChecksum must be a boolean", + ); + } + await expect( + tool.execute("invalid-words", { ...args, mnemonic: mnemonic.replace("path", "notaword") }), + ).rejects.toThrow("Invalid BIP39 mnemonic"); + }); + it("keeps the BIP44 path schema portable and enforces one mode", async () => { const tool = registerTools().get("keys_bip44_path"); if (!tool) throw new Error("keys_bip44_path was not registered"); diff --git a/test/public-exports.test.ts b/test/public-exports.test.ts index c90d906..cdcddb8 100644 --- a/test/public-exports.test.ts +++ b/test/public-exports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { DecodedWIF, WIFOptions } from "@agntn/keys"; -import { wifTestVectors, localizedMnemonicVectors } from "./fixtures.ts"; +import type { DecodedWIF, WIFOptions, HDWalletOptions } from "@agntn/keys"; +import type { BIP39MnemonicInspection } from "@agntn/keys/bip39"; +import { wifTestVectors, localizedMnemonicVectors, invalidChecksumPuzzle } from "./fixtures.ts"; const EXPORTS = [ ["@agntn/keys/bip32", "/dist/utils/bip32/index.mjs"], @@ -23,6 +24,25 @@ describe("Public WIF exports", () => { }); describe("Public derivation exports", () => { + it("exports checksum diagnostics and the explicit HD override from the built package", async () => { + const { blockchains } = await import("@agntn/keys"); + const { inspectBIP39Mnemonic } = await import("@agntn/keys/bip39"); + const { mnemonic, path, address } = invalidChecksumPuzzle; + const inspection: BIP39MnemonicInspection = inspectBIP39Mnemonic(mnemonic); + expect(inspection).toMatchObject({ + valid: false, + wordCountValid: true, + wordlistValid: true, + checksumValid: false, + }); + const options: HDWalletOptions = { allowInvalidChecksum: true }; + const chain = await blockchains.bitcoin()(); + expect(() => chain.deriveHDWallet(mnemonic, path)).toThrow("Invalid BIP39 mnemonic"); + const wallet = chain.deriveHDWallet(mnemonic, path, options); + expect(wallet.address).toBe(address); + expect(wallet.warnings).toEqual([expect.stringContaining("checksum is invalid")]); + }); + it("loads localized lists for the published BIP39 codec", async () => { const { loadBIP39Wordlist, bip39, generateMnemonic, validateMnemonic } = await import("@agntn/keys/bip39"); diff --git a/test/utils/bip39.test.ts b/test/utils/bip39.test.ts index 9f60eb9..ab73ce8 100644 --- a/test/utils/bip39.test.ts +++ b/test/utils/bip39.test.ts @@ -6,6 +6,7 @@ import { mnemonicToEntropy, entropyToMnemonic, getMnemonicWordCandidates, + inspectBIP39Mnemonic, BIP39_LANGUAGES, isBIP39Language, lookupBIP39Indices, @@ -13,7 +14,7 @@ import { loadBIP39Wordlist, } from "../../src/utils/bip39"; import { hexToBytes } from "@noble/hashes/utils.js"; -import { bip39TestVectors } from "../fixtures"; +import { bip39TestVectors, invalidChecksumPuzzle, localizedMnemonicVectors } from "../fixtures"; describe("BIP39 Utils", () => { // Test vectors from BIP39 specification @@ -157,6 +158,80 @@ describe("BIP39 Utils", () => { ); }); + it("reports a checksum failure independently from word count and dictionary membership", () => { + const { mnemonic } = invalidChecksumPuzzle; + expect(inspectBIP39Mnemonic(mnemonic)).toEqual({ + valid: false, + words: 24, + wordCountValid: true, + wordlistValid: true, + checksumValid: false, + }); + expect(inspectBIP39Mnemonic(bip39TestVectors.mnemonic)).toEqual({ + valid: true, + words: 12, + wordCountValid: true, + wordlistValid: true, + checksumValid: true, + }); + expect(inspectBIP39Mnemonic(mnemonic.replace("path", "notaword"))).toMatchObject({ + valid: false, + wordCountValid: true, + wordlistValid: false, + checksumValid: null, + }); + expect(inspectBIP39Mnemonic("abandon")).toMatchObject({ + valid: false, + wordCountValid: false, + wordlistValid: true, + checksumValid: null, + }); + expect(inspectBIP39Mnemonic("")).toMatchObject({ + valid: false, + words: 0, + wordCountValid: false, + wordlistValid: false, + checksumValid: null, + }); + expect(inspectBIP39Mnemonic(bip39TestVectors.mnemonic.replaceAll("a", "\uFF41"))).toMatchObject( + { valid: true }, + ); + expect(getMnemonicWordCandidates(mnemonic.replace(/shine$/u, "?"))).not.toContain("shine"); + expect(validateMnemonic(mnemonic)).toBe(false); + expect(() => mnemonicToEntropy(mnemonic)).toThrow("Invalid checksum"); + }); + + it.each(localizedMnemonicVectors)( + "inspects $language with the explicitly selected word list", + async ({ language, mnemonic }) => { + const wordlist = await loadBIP39Wordlist(language); + expect(inspectBIP39Mnemonic(mnemonic.normalize("NFC"), wordlist)).toEqual({ + valid: true, + words: 12, + wordCountValid: true, + wordlistValid: true, + checksumValid: true, + }); + }, + ); + + it("distinguishes a localized checksum failure from the wrong dictionary", async () => { + const wordlist = await loadBIP39Wordlist("spanish"); + const mnemonic = Array.from({ length: 12 }, () => "ábaco").join(" "); + expect(inspectBIP39Mnemonic(mnemonic, wordlist)).toMatchObject({ + valid: false, + wordCountValid: true, + wordlistValid: true, + checksumValid: false, + }); + expect(inspectBIP39Mnemonic(mnemonic)).toMatchObject({ + valid: false, + wordCountValid: true, + wordlistValid: false, + checksumValid: null, + }); + }); + it("converts mnemonic to seed correctly", () => { for (const vector of testVectors) { const seed = mnemonicToSeed(vector.mnemonic); diff --git a/test/utils/hd.test.ts b/test/utils/hd.test.ts new file mode 100644 index 0000000..1160800 --- /dev/null +++ b/test/utils/hd.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { HDNodeWallet } from "ethers"; +import { blockchains } from "../../src/index.ts"; +import { mnemonicToSeed } from "../../src/utils/bip39/index.ts"; +import { getMasterKeyFromSeed } from "../../src/utils/slip10/index.ts"; +import { bip39TestVectors, invalidChecksumPuzzle } from "../fixtures.ts"; + +describe("HD checksum policy", () => { + const { mnemonic, path, address, publicKey } = invalidChecksumPuzzle; + + it("requires an explicit override and reproduces the claimed puzzle address without repair", async () => { + const chain = await blockchains.bitcoin()(); + expect(() => chain.deriveHDWallet(mnemonic, path)).toThrow("Invalid BIP39 mnemonic"); + expect(() => chain.deriveHDWallet(mnemonic, path, { allowInvalidChecksum: false })).toThrow( + "Invalid BIP39 mnemonic", + ); + const wallet = chain.deriveHDWallet(mnemonic, path, { allowInvalidChecksum: true }); + expect(wallet.address).toBe(address); + expect(wallet.keys.public).toBe(publicKey); + expect(wallet.warnings).toEqual([ + "BIP39 checksum is invalid. Derived from the supplied words without repairing the checksum.", + ]); + expect(chain.deriveHDWallet(mnemonic.replace(/shine$/u, "solve"), path).address).not.toBe( + address, + ); + }); + + it("preserves passphrase, path, network and explicit address type with the override", async () => { + const chain = await blockchains.bitcoin({ network: "testnet" })(); + const changedPath = "m/84'/0'/0'/1/2"; + const passphrase = " e\u0301 "; + const expected = HDNodeWallet.fromSeed(mnemonicToSeed(mnemonic, passphrase)).derivePath( + changedPath, + ); + const wallet = chain.deriveHDWallet( + mnemonic, + changedPath, + { passphrase, allowInvalidChecksum: true }, + "legacy", + ); + expect(wallet.keys.public).toBe(expected.publicKey.slice(2)); + expect(wallet.address).toBe(chain.getAddress(expected.publicKey.slice(2), "legacy")); + expect(wallet.address).not.toBe(address); + expect(wallet.warnings).toHaveLength(1); + }); + + it("keeps valid mnemonics unchanged and preserves existing whitespace normalization", async () => { + const chain = await blockchains.bitcoin()(); + const valid = bip39TestVectors.mnemonic; + const original = chain.deriveHDWallet(valid, path); + expect(chain.deriveHDWallet(valid, path, { allowInvalidChecksum: true })).toEqual(original); + expect(original).not.toHaveProperty("warnings"); + const messy = ` ${mnemonic.replaceAll(" ", "\n ")} `; + expect(chain.deriveHDWallet(messy, path, { allowInvalidChecksum: true })).toEqual( + chain.deriveHDWallet(mnemonic, path, { allowInvalidChecksum: true }), + ); + }); + + it.each([ + "", + "abandon", + Array.from({ length: 13 }, () => "abandon").join(" "), + invalidChecksumPuzzle.mnemonic.replace("path", "notaword"), + invalidChecksumPuzzle.mnemonic.toUpperCase(), + ])("does not bypass word count or dictionary checks for %j", async (candidate) => { + const chain = await blockchains.bitcoin()(); + expect(() => chain.deriveHDWallet(candidate, path, { allowInvalidChecksum: true })).toThrow( + "Invalid BIP39 mnemonic", + ); + }); + + it.each(["true", "false", 1, null])( + "rejects a non-boolean override %j", + async (allowInvalidChecksum) => { + const chain = await blockchains.bitcoin()(); + expect(() => { + Reflect.apply(chain.deriveHDWallet.bind(chain), undefined, [ + mnemonic, + path, + { allowInvalidChecksum }, + ]); + }).toThrow("allowInvalidChecksum must be a boolean"); + }, + ); + + it("applies the same checksum policy to SLIP-10 without bypassing hardened paths", async () => { + const chain = await blockchains.solana()(); + const solanaPath = "m/44'/501'/0'/0'"; + const key = getMasterKeyFromSeed(mnemonicToSeed(mnemonic)).derive(solanaPath).privateKey; + const wallet = chain.deriveHDWallet(mnemonic, solanaPath, { allowInvalidChecksum: true }); + expect(wallet.address).toBe(chain.deriveWallet(bytesToHex(key)).address); + expect(wallet.warnings).toHaveLength(1); + expect(() => chain.deriveHDWallet(mnemonic, solanaPath)).toThrow("Invalid BIP39 mnemonic"); + expect(() => + chain.deriveHDWallet(mnemonic, "m/44'/501'/0'/0", { allowInvalidChecksum: true }), + ).toThrow("Non-hardened"); + }); +}); diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json index b219e87..cb6b9c5 100644 --- a/tsconfig.type-tests.json +++ b/tsconfig.type-tests.json @@ -9,6 +9,7 @@ "test/mcp.test.ts", "test/pi-extension.test.ts", "test/utils/bip39.test.ts", + "test/utils/hd.test.ts", "test/utils/slip10.test.ts", "test/utils/signing.test.ts", "test/utils/wif.test.ts"