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: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const HELP = `notecase - a case for Lightning bearer notes (LNURLcash, LUD-25)
notecase list [--all]
notecase mint <sats> [--mint <host>] [--manual] [--wait <seconds>]
notecase receive [note] [--force] [--offline] [--accept-key-rotation]
notecase device-node --force [--mint <host>] print a mint's note tree for a locker
notecase check [--apply] [--resign] [--mint <host>]
notecase ladder [set <sats,sats,...>] [--copies <n>] [--mint <host>]
notecase prepare [--apply] [--mint <host>]
Expand Down Expand Up @@ -662,6 +663,34 @@ const main = async (): Promise<void> => {
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,
Expand Down
70 changes: 66 additions & 4 deletions src/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
42 changes: 42 additions & 0 deletions src/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
buildNoteUrl,
defaultRandomSecret,
deriveCashRoot,
deriveCashDomainNode,
deriveCashSecret,
cashNodeToHex,
cashSecretSource,
fetchInvoiceVerification,
fetchNoteInfo,
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions test/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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
)
})
})