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
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,35 @@ with; nothing new is handed out there, so re-register a name
(`heartwood address keys`) to move it to the current branch. The ECDSA
`recovery`/`ecdsa` backend features are gone.

Address proofs (2026-10-05, checklist section 34, NOT YET BENCH-RUN): since
`50d740a` 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("LNURLcash:<register|unregister>:<domain>:<name>"), so a name on the
superseded branch can be moved only with that branch's agreement.
`heartwood_note_address_proof {host, name, action, cx1}` (wire command
`cash_address_proof`, capability `note_address_proof_v1`) signs it with
whichever of the served identity's two branches at `host` has that exact cx1
(`cash_key::address_proof_for`, compared as decoded bytes; any other cx1 is
refused) over `spend_domain(host)`, aux_rand zero, which reproduces all four
of part2.json's `addressProofs`. It answers the 64-byte `sig`, the index-0
`pubkey` and `branch` (`current`/`superseded`), never a key. Everything that
could refuse (mint, name rule `^[a-z0-9][a-z0-9._-]{2,31}$`, action, a cx1
that is ours) is checked by the one pure `note_cmd::address_proof_ask` before
the card, on the relay precheck and in the dispatcher alike; the card
(`note_cmd::address_proof_card`) is ADDRESS PROOF over `<name>@<domain>` and
`<action>, current keys` or `old keys`. Pinned ButtonRequired like the other
note mutations, and device-press-only (`Nip46Method::device_press_only`, as a
wallet pairing is): a proof never expires and decides where a name pays, so no
guardian verdict may answer it and an escalate slot is refused outright. It
never shares a card (`approval_queue::never_shares_card`), and nor, since the
same change, does `heartwood_note_trust`, which used to batch so one hold
trusted every sender behind the first npub shown; scoped to the served key (`method_uses_served_key`), and an
ask never shares a card (`approval_queue::never_shares_card`): a batch card
speaks in notes and sats and could not name a second proof. The cable has no
identity, so `cash_address_proof` there refuses before the card, as
`cash_address` does; on the USB-bridged NIP-46 path the card is the generic
extension card, as for every note method.

Next: bench the note locker (checklist section 13) and the remaining hardware verification of the encrypted-at-rest flows (USB auto-unlock and Hard-mode signing passed on real hardware 2026-08-13; see docs/HARDWARE-TEST-CHECKLIST.md section 7), the 2026-08-14 fixes and features (checklist section 8, not yet bench-run), and the Soft-mode approval path (fixed 2026-08-08: approvals were re-queued and the signed envelope dropped). Task watchdog landed 2026-08-08 (60 s, panic → crash crumb, fed by every blocking loop). JTAG disable is deliberately excluded — it requires eFuse burning, which permanently locks the chip (see docs/memory/feedback_no_efuse.md); physical security is the model. Sapwood tier badge/unlock/approvals/backup UI is in the sapwood repo.

## Build & flash
Expand Down
48 changes: 48 additions & 0 deletions common/src/approval_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ pub fn login_kind_key(base: &str, seq: u32) -> String {
format!("{base}#login{seq}")
}

/// Methods whose ask never shares a card, whoever sends it: each one's card
/// names a single decision that a batch card has no way to say. An address
/// proof's card names one lightning address, one action and one branch; a
/// trust card names one sender's npub. A batch card speaks in notes and sats,
/// so a second proof or a second sender joining the first would be approved
/// on a hold that showed the owner neither.
pub fn never_shares_card(method: &str) -> bool {
matches!(method, "heartwood_note_address_proof" | "heartwood_note_trust")
}

/// The batch key of an ask that must never share a card
/// ([`never_shares_card`]): unique per ask, from a device counter, like
/// [`login_kind_key`], so nothing the client sends can make two equal.
pub fn unshared_kind_key(base: &str, seq: u32) -> String {
format!("{base}#once{seq}")
}

/// What to do with an incoming interactive ask.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Admission {
Expand Down Expand Up @@ -273,4 +290,35 @@ mod tests {
// A login ask does not join an ordinary card of the same kind either.
assert_eq!(admit(Some(&plain), 1, 0, &a), Admission::Wait);
}

#[test]
fn two_trusts_never_share_a_hold() {
// One hold used to trust every sender batched behind the card, while
// the card showed only the first npub.
let base = "heartwood_note_trust";
assert!(never_shares_card(base));
let kk = |kind_key: &str| AskKey::new(1, "cc".to_string(), "dd".to_string(), kind_key.to_string());
let a = kk(&unshared_kind_key(base, 0));
let b = kk(&unshared_kind_key(base, 1));
assert_eq!(admit(Some(&a), 1, 0, &b), Admission::Wait);
}

#[test]
fn an_address_proof_never_shares_a_card() {
let base = "heartwood_note_address_proof";
assert!(never_shares_card(base));
for method in ["heartwood_note_export", "heartwood_note_send", "sign_event", "heartwood_note_address"] {
assert!(!never_shares_card(method), "{method}");
}
let kk = |kind_key: &str| AskKey::new(1, "cc".to_string(), "dd".to_string(), kind_key.to_string());
let a = kk(&unshared_kind_key(base, 0));
let b = kk(&unshared_kind_key(base, 1));
assert_ne!(a, b);
assert_eq!(admit(Some(&a), 1, 0, &b), Admission::Wait);
// Nor does it join, or take in, an ordinary card of the same method.
assert_eq!(admit(Some(&kk(base)), 1, 0, &a), Admission::Wait);
assert_eq!(admit(Some(&a), 1, 0, &kk(base)), Admission::Wait);
// And it is never mistaken for a login challenge's key.
assert_ne!(unshared_kind_key(base, 0), login_kind_key(base, 0));
}
}
Loading
Loading