diff --git a/CHANGELOG.md b/CHANGELOG.md index 45383b4..3c940b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +**A hardware locker can now hold notes this wallet's seed phrase can find +again.** LUD-25 derives note secrets under `m/139'/d1/d2/d3/d4/i'`, but that +path hangs off the BIP-32 master and a locker keeps no seed - it stores a tree +root from a different branch, and the recovery phrase only ever exists on the +owner's screen. So the wallet derives one mint's **domain node** and hands it +over; beneath that node the device walks only `i'`, which is why this works on +hardware with no elliptic curve at all. + +- `Wallet.cashDomainNodeFor(host)` returns that node and the index the device + must not start below. It goes through `mintEntry`, so the host is spelled the + way `serverOf` spells it - one byte of difference is a different tree neither + side can see - and a mint this wallet does not track is refused, because its + counter could not be kept in step. +- `VaultClient` gains `provisionCashNode`, `forgetCashNode`, `listCashMints` + and `setCashIndex`; `newSecret` and `newSecretPair` take an optional host and + derive when one is given. +- `notecase device-node --force` prints a mint's subtree for provisioning. It + needs the flag because whoever reads it can derive every note this wallet + ever mints at that mint, with no amount bounding it and no way to revoke. + ## 0.15.0 - 2026-09-06 **A mint's new signing key is now a question, not a fact.** LUD-25 gained the diff --git a/src/cli.ts b/src/cli.ts index b15ff56..81f771c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,6 +35,7 @@ const HELP = `notecase - a case for Lightning bearer notes (LNURLcash, LUD-25) notecase list [--all] notecase mint [--mint ] [--manual] [--wait ] notecase receive [note] [--force] [--offline] [--accept-key-rotation] + notecase device-node --force [--mint ] print a mint's note tree for a locker notecase check [--apply] [--resign] [--mint ] notecase ladder [set ] [--copies ] [--mint ] notecase prepare [--apply] [--mint ] @@ -662,6 +663,34 @@ const main = async (): Promise => { return } + case 'device-node': { + // Prints one mint's subtree so a locker can be provisioned with it. + // + // This is the only command that prints key material the holder did not + // ask to spend, so it says what it is and takes a --force, the way + // `receive` takes one to accept a note that failed its check. Whoever + // ends up holding this can derive every note secret this wallet will + // ever mint at that mint - and unlike a note, that is not bounded by an + // amount. It goes to stdout and therefore into a shell history if you + // let it. + const {host, node, nextIndex} = wallet.cashDomainNodeFor(values.mint) + if (!values.force) { + console.error(`This prints the note tree for ${host}.`) + console.error( + 'Anyone who reads it can derive every note this wallet ever mints there, with no limit and no way to revoke it.' + ) + console.error('Re-run with --force if you meant to, ideally not into a shell that keeps history.') + process.exitCode = 1 + return + } + console.log(node) + console.error(` host ${host}`) + // The device must not start below this or it re-issues an index this + // wallet has already minted at. notelocker set-cash-index raises it. + console.error(` next index ${nextIndex}`) + return + } + case 'send': { const amountMsat = parseAmountMsat(rest[0], values.msat) // --notes says which notes to spend, by the short ids `list` prints, diff --git a/src/vault.ts b/src/vault.ts index ffe4215..c857687 100644 --- a/src/vault.ts +++ b/src/vault.ts @@ -217,16 +217,78 @@ export class VaultClient { } } - async newSecret(parentIds: string[], label?: string): Promise<{id: string; h: string}> { + // `host`, when given, asks the device to draw the secret off that mint's + // LUD-25 ladder instead of its RNG, so a seed phrase can find the note + // again. The device refuses a host it has no subtree for rather than + // quietly drawing at random, which is the answer this wallet wants: asking + // for a recoverable note and silently getting an unrecoverable one is the + // failure nobody notices until a restore comes up empty. + async newSecret( + parentIds: string[], + label?: string, + host?: string + ): Promise<{id: string; h: string}> { return this.send<{id: string; h: string}>({ cmd: 'new_secret', parent_ids: parentIds, - ...(label === undefined ? {} : {label}) + ...(label === undefined ? {} : {label}), + ...(host === undefined ? {} : {host}) + }) + } + + async newSecretPair( + parentIds: string[], + host?: string + ): Promise<{id: string; h: string; id2: string; h2: string}> { + return this.send({ + cmd: 'new_secret_pair', + parent_ids: parentIds, + ...(host === undefined ? {} : {host}) }) } - async newSecretPair(parentIds: string[]): Promise<{id: string; h: string; id2: string; h2: string}> { - return this.send({cmd: 'new_secret_pair', parent_ids: parentIds}) + // ---- LUD-25 seed-recoverable note secrets ---- + + /** + * Hand the device one mint's subtree, `m/139'/d1/d2/d3/d4`, as 64 bytes of + * hex: a 32-byte key then a 32-byte chain code. + * + * Needs the button, and should. Whoever supplies this can derive every note + * secret the device will ever hold at that mint - one mint's subtree, not + * the wallet, and this wallet holds the seed the subtree came from anyway. + * The device's card names the host, because nobody checks 64 bytes of hex + * by eye. + * + * `m/139'` hangs off the BIP-32 master and the device keeps no seed, which + * is why it is provisioned rather than derived there. Every unhardened level + * of the path sits at or above this node, so beneath it the device walks + * only `i'`. + */ + async provisionCashNode( + host: string, + nodeHex: string + ): Promise<{host: string; replaced: boolean; next_index: number}> { + return this.send({cmd: 'provision_cash_node', host, node: nodeHex}, {gated: true}) + } + + async forgetCashNode(host: string): Promise<{changed: boolean}> { + return this.send({cmd: 'forget_cash_node', host}) + } + + /** The mints and each one's next index. Never the nodes. */ + async listCashMints(): Promise<{mints: Array<{host: string; next_index: number}>}> { + return this.send({cmd: 'list_cash_mints'}) + } + + /** + * Raise the device's next index to meet this wallet's own counter. + * + * Raising only, and the device enforces it: an index handed out twice is + * two notes answering to one `k1`. Worth doing after a restore, when this + * wallet has walked further up a ladder than the device knows about. + */ + async setCashIndex(host: string, nextIndex: number): Promise<{next_index: number}> { + return this.send({cmd: 'set_cash_index', host, next_index: nextIndex}) } async confirm(id: string, amountMsat: number, host: string, sig?: string): Promise { diff --git a/src/wallet.ts b/src/wallet.ts index 0ea1a7e..368612a 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -10,7 +10,9 @@ import { buildNoteUrl, defaultRandomSecret, deriveCashRoot, + deriveCashDomainNode, deriveCashSecret, + cashNodeToHex, cashSecretSource, fetchInvoiceVerification, fetchNoteInfo, @@ -566,6 +568,46 @@ export class Wallet { return seed ? deriveCashRoot(hexToBytes(seed)) : null } + /** + * One mint's subtree, `m/139'/d1/d2/d3/d4`, as the 64 bytes a hardware + * locker is provisioned with. + * + * This is the only thing in the wallet that hands out key material the + * holder did not ask to spend, so it is worth being plain about what it is. + * Whoever holds it can derive every note secret this wallet will ever mint + * AT THAT MINT - one mint's subtree, never the wallet, and never anything + * that reaches the Nostr identity. A device given it can mint recoverable + * notes on its own; a device without it draws at random and its notes are + * findable only from a file. + * + * It exists because `m/139'` hangs off the BIP-32 master and a locker keeps + * no seed. Every unhardened level of the path sits at or above this node, so + * beneath it the device walks only `i'` - which is why this works on + * hardware with no elliptic curve at all. + */ + cashDomainNodeFor(host?: string): {host: string; node: string; nextIndex: number} { + const root = this.cashRoot() + if (!root) { + throw new WalletUsageError( + 'This wallet has no recovery words, so it has no note tree to give a device.' + ) + } + // Through mintEntry rather than off a raw string, for two reasons. It is + // already spelled the way `serverOf` spells it - lowercase, port included + // - and one byte of difference is a different tree whose notes neither + // side can see. And a mint this wallet does not track is one whose counter + // it cannot keep in step with the device's, which is how an index gets + // handed out twice. + const entry = this.mintEntry(host) + return { + host: entry.host, + node: cashNodeToHex(deriveCashDomainNode(root, entry.host)), + // What to raise the device to, so it does not re-issue an index this + // wallet has already minted at. + nextIndex: this.cashCounterFor(entry.host) + } + } + // The pre-spec ladder has no root helper here any more: restoreFromSeed // takes the seed and derives both ladders itself, and nothing else in // this wallet touches the legacy scheme. diff --git a/test/vault.test.ts b/test/vault.test.ts index 2793809..9f2be8a 100644 --- a/test/vault.test.ts +++ b/test/vault.test.ts @@ -16,6 +16,10 @@ import { type VaultTransport } from '../src/vault.ts' import {freshK1, makeWallet} from './helpers.ts' +import {cashNodeToHex, deriveCashDomainNode, deriveCashRoot} from 'lnurlcash-kit' +import {seedFromMnemonic} from '../src/store.ts' +import {Wallet} from '../src/wallet.ts' +import {emptyWallet} from '../src/types.ts' // A hardware vault on the end of a cable. // @@ -499,3 +503,65 @@ describe('what the device says about itself', () => { expect(mint.state.noteState(deviceK1)).toBe('outstanding') }) }) + +// ---- LUD-25 seed-recoverable notes on a locker ---- +// +// The device keeps no seed, so `m/139'` cannot be walked there. This wallet +// holds the seed and hands over one mint's subtree; beneath it the device +// walks only `i'`. + +describe('provisioning a locker with a mint subtree', () => { + const seedPhrase = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + + const seededWallet = () => { + const data = emptyWallet() + data.seedHex = seedFromMnemonic(seedPhrase) + data.mnemonic = seedPhrase + data.mints = [{host: 'mint.example', payUrl: 'https://mint.example/.well-known/lnurlp/mint', input: 'mint@mint.example', addedAt: 1}] + return new Wallet(data, async () => {}, {timeoutMs: 3_000}) + } + + it('derives the same node the kit does, for the wallet-normalised host', async () => { + const wallet = seededWallet() + const {host, node} = wallet.cashDomainNodeFor('mint.example') + + expect(host).toBe('mint.example') + const expected = cashNodeToHex( + deriveCashDomainNode(deriveCashRoot(hexToBytes(seedFromMnemonic(seedPhrase))), 'mint.example') + ) + expect(node).toBe(expected) + // 32-byte key then 32-byte chain code + expect(node).toMatch(/^[0-9a-f]{128}$/) + }) + + it('hands back the index the device must not start below', async () => { + // A device starting lower re-issues an index this wallet already minted + // at, and both notes then answer to one k1. + const wallet = seededWallet() + expect(wallet.cashDomainNodeFor('mint.example').nextIndex).toBe( + wallet.cashCounterFor('mint.example') + ) + }) + + it('refuses when the wallet has no recovery words', async () => { + // Nothing to derive from, and a random-secret wallet has no tree to give. + const data = emptyWallet() + data.mints = [{host: 'mint.example', payUrl: 'https://mint.example/.well-known/lnurlp/mint', input: 'mint@mint.example', addedAt: 1}] + const wallet = new Wallet(data, async () => {}, {timeoutMs: 3_000}) + expect(() => wallet.cashDomainNodeFor('mint.example')).toThrow(/recovery words/) + }) + + it('gives two mints different subtrees', async () => { + const data = emptyWallet() + data.seedHex = seedFromMnemonic(seedPhrase) + data.mints = [ + {host: 'mint.example', payUrl: 'https://mint.example/.well-known/lnurlp/mint', input: 'mint@mint.example', addedAt: 1}, + {host: 'other.example', payUrl: 'https://other.example/.well-known/lnurlp/mint', input: 'mint@other.example', addedAt: 1} + ] + const wallet = new Wallet(data, async () => {}, {timeoutMs: 3_000}) + expect(wallet.cashDomainNodeFor('mint.example').node).not.toBe( + wallet.cashDomainNodeFor('other.example').node + ) + }) +})