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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docs/1.guide/4.wallets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions packages/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions packages/pi/extensions/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,18 @@ 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: [
"Provide a chain, an English BIP39 mnemonic, and a full derivation path",
"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",
Expand All @@ -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,
}),
Expand All @@ -187,6 +197,7 @@ export default function keysExtension(pi: ExtensionAPI) {
params.passphrase,
params.addressType,
params.network,
params.allowInvalidChecksum,
);
},
});
Expand All @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions src/blockchain.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.",
],
};
}
}

Expand Down
13 changes: 10 additions & 3 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
Expand All @@ -185,6 +191,7 @@ const tools: readonly ToolDefinition[] = [
args["passphrase"],
args["addressType"],
args["network"],
args["allowInvalidChecksum"],
),
},
{
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
29 changes: 25 additions & 4 deletions src/tool-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
bip39,
loadBIP39Wordlist,
getMnemonicWordCandidates,
inspectBIP39Mnemonic,
lookupBIP39Indices,
lookupBIP39Words,
} from "./utils/bip39/index.ts";
Expand Down Expand Up @@ -67,13 +68,17 @@ export interface DerivedWalletDetails {
publicKey: string;
address: string;
path?: string;
warnings?: readonly string[];
}

/** BIP39 inspection result without the supplied mnemonic. */
export interface MnemonicInspectionDetails {
language: BIP39Language;
valid: boolean;
words: number;
wordCountValid: boolean;
wordlistValid: boolean;
checksumValid: boolean | null;
entropy?: string;
}

Expand Down Expand Up @@ -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<ToolResult<DerivedWalletDetails>>} Derived public wallet material.
*/
export async function deriveHdWallet(
Expand All @@ -424,7 +430,12 @@ export async function deriveHdWallet(
passphraseValue?: unknown,
addressTypeValue?: unknown,
networkValue?: unknown,
allowInvalidChecksumValue?: unknown,
): Promise<ToolResult<DerivedWalletDetails>> {
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");
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down
37 changes: 37 additions & 0 deletions src/utils/bip39/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `?`
Expand Down
Loading
Loading