Skip to content

feat: sign LUD-25 address proofs on a pinned ADDRESS PROOF card - #218

Merged
TheCryptoDonkey merged 3 commits into
mainfrom
feat/lud25-address-proof
Oct 5, 2026
Merged

TheCryptoDonkey merged 3 commits into
mainfrom
feat/lud25-address-proof

Conversation

@TheCryptoDonkey

@TheCryptoDonkey TheCryptoDonkey commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Why

Since LUD-25 50d740a (moneyer 0.17) a mint changes or clears a name's cx1 only with "sig": a BIP-340 signature by the purpose-0 index-0 key of the branch CURRENTLY on file, over sha256(utf8("LNURLcash:<register|unregister>:<domain>:<name>")) (moneyer registerName / addressProofVerifies). The board could not make that signature, so a name registered on the superseded m/139'/1' branch could not be moved to the current one.

What

  • common/src/cash_key.rs: AddressAction, BranchKind, valid_username (moneyer's NAME_RULE, exact, never folded), address_proof_digest, sign_address_proof (purpose-0 index-0 key, aux_rand zero), branch_kind_of, and address_proof_for(identity, host, cx1, action, username), which signs with whichever of this identity's two branches at host has that exact cx1 (compared as decoded bytes, so all-uppercase bech32m matches) and refuses any other. Domain is spend_domain(host).
  • New NIP-46 extension heartwood_note_address_proof, params [{host, name, action, cx1}], wire command cash_address_proof. Answers {ok, host, domain, name, action, cx1, branch, sig, pubkey}: the 64-byte signature hex and the index-0 x-only pubkey, never a key.
  • Validation before any card, by one pure function (note_cmd::address_proof_ask) shared by the relay precheck and the dispatcher: valid mint host, name rule, action, cx1 decodes, cx1 is one of ours.
  • Card (note_cmd::address_proof_card, two lines): ADDRESS PROOF / <name>@<domain> / <action>, current keys or <action>, old keys. The address is middle-elided past one card line (12..8, as the trust and mint cards do).
  • Pinned ButtonRequired (always_requires_button + is_note_method), method_uses_served_key, advertised in heartwood_capabilities and as note_address_proof_v1 in get_status. notes::relay_card / relay_precheck now take the served key. relay.rs note_card_header gives the deferred card its header.
  • approval_queue::never_shares_card / unshared_kind_key: an address-proof ask never joins another ask's card. Without that, two proofs from one client would collapse and draw note_batch_card ("ADDRESS PROOF 2 NOTES / 0 sats"), and one hold would sign a proof the owner was never shown.
  • Cable 0x70: no identity there, so cash_address_proof refuses before the card, as cash_address does. The hook is still wired to draw the same card.
  • CLAUDE.md key-notes section and checklist section 34 (NOT YET BENCH-RUN).

Vectors

All four addressProofs in lud25-part2.json match: digest, the index-0 key (= mint.example branch purpose 0 index 0), pubkey, and the signature byte for byte with aux_rand zero. That is what the conformance generator uses (SCHNORR_AUX = new Uint8Array(32)). Graded on both backends.

Trade-offs worth a look

  • A guardian verdict may answer this card. The pin makes it verdict_may_answer_card() (pinned and not device-press-only), and the guardian's notice carries the same card text. That keeps it consistent with the note set. A proof is a reusable, non-expiring signature for one (action, domain, name), but on its own it does nothing for an existing name: moneyer also wants the owner's NIP-98, which the device signs on its own card. For a fresh name, a leaked register proof can only point a free name at this owner's own branch. If you'd rather it were device-press-only, it's a one-line change in device_press_only.
  • USB-bridged NIP-46 path: the card is the generic extension card (master heading, method name, then the preview), not the titled ADDRESS PROOF card. show_master_sign_request cuts the preview to one small line's worth of characters, so the address shows but the action is cut to regi.... Every note card is already cut this way on that path (trust loses "notes skip the hold"), so it isn't a regression. A follow-up could put the titled card on that path for all note cards.
  • Strict (exact v2) slots deny methods they don't name, before the pin applies. So a pairing compiled with an explicit method list gets unauthorised until its policy compiler (Notecase/Signet) adds heartwood_note_address_proof. Legacy slots get the card.
  • Cost: address_proof_ask derives both branches, and a relay request runs it on the precheck, relay_card and the dispatcher, so a card costs a handful of BIP-32 walks. That's fine at the relay loop's pace.
  • Error detail: over NIP-46 only the error code (bad_request) reaches the client, so Notecase cannot tell "not our branch" from a bad name. The message is in the JSON on the cable surface.
  • Pre-existing, not fixed here: two heartwood_note_trust asks from one client for different keys can collapse onto one card, which then draws as a note batch ("TRUST SENDER 2 NOTES / 0 sats"). The same never_shares_card would fix it.

Tests

  • cargo test --manifest-path common/Cargo.toml --no-default-features --features mnemonic-gen,cash: 300 passed
  • cargo test --manifest-path common/Cargo.toml --features nip44,nip46,nip04,ota-sign,device-identity,seed-encrypt,cash,compact-hashes: 964 passed
  • ui-preview: 22 passed. It has no coverage of the titled note cards, so nothing was added there.
  • heartwoodd: 52 passed
  • The firmware can't be built locally (x86_64 toolchain vs arm64 libclang), so that's left to CI's firmware-build.

Not flashed, not bench-run.

Since LUD-25 50d740a a mint changes or clears a name's cx1 only with a
BIP-340 signature by the purpose-0 index-0 key of the branch on file, so a
name registered on the superseded m/139'/1' branch could not be moved.

heartwood_note_address_proof {host, name, action, cx1} (wire command
cash_address_proof, capability note_address_proof_v1) signs
sha256("LNURLcash:<action>:<domain>:<name>") with whichever of the served
identity's two branches at the mint has that exact cx1, aux_rand zero,
and refuses any other. Graded against all four addressProofs vectors.
Validation runs before the card on the relay precheck and in the
dispatcher alike; the method is pinned ButtonRequired, scoped to the served
key, and an ask never shares a card. Answers the signature and the index-0
pubkey, never a key.
@TheCryptoDonkey
TheCryptoDonkey merged commit 0a3e0b1 into main Oct 5, 2026
14 checks passed
@TheCryptoDonkey
TheCryptoDonkey deleted the feat/lud25-address-proof branch October 5, 2026 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant