Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins - #685
Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins#685kravens wants to merge 6 commits into
Conversation
4e5a5d3 to
83cef62
Compare
|
First mainnet coinjoin with a ColdCard remote-signed input: https://mempool.space/tx/cacadb13ce52cc5b63b24fbb4d95f8a9e2a5d3bb799cf0abfc9e8bc42d5849fc |
a05c8e7 to
5d0dd98
Compare
|
Updated / synced on the new base, ready for review (I know you won't have time due to more pressing firmware issues, let me know where I can help). At least ColdCards can still work as USB-connected remote signers with this PR. |
|
Maybe the USB command for the slip-19 proof should be reachable even if HSM not active? It does not require policy settings (right?). If so, it might need user on-screen approval? I don't know if that would be a useful feature by itself. |
|
Yeah, I'd probably use it for debugging an attended coinjoin round to follow every step. I'd also be interested to test this user-approved flow on a ColdCard Q, which sadly has no HSM-mode yet (I bricked mine whilst trying to enable it with custom firmware...😭 lesson learned). Here some more notes from Claude about the last commits addressing your points:
|
|
Thanks for those revisions! My understanding is Wasabi relies on Tap Root these days. If so, perhaps this change belongs in the "edge" version which can sign TR transactions. To do that, your PR should be rebased against branch |
|
Oh that's true, Wasabi has support for both bech32/native-segwit and taproot. So I agree we should rebase against the Edge firmware. |
A coinjoin coordinator will not let a coin into a round without proof the registrant owns it. SLIP-19 is the proof Wasabi's WabiSabi asks for, and a Coldcard had no way to produce one. slp9 returns a serialized ownership proof for a derivation path, P2WPKH (ECDSA) or P2TR (BIP-340 key-spend, BIP-86 tweak). The caller states the address format rather than the device inferring it from the path purpose, which need not match the script actually used; a proof over the wrong scriptPubKey is silently useless. The ownership identifier is the real SLIP-19 one, derived per SLIP-21 from the seed and cached against a hash of the root chain code. An xprv-imported secret has no seed, so proofs are refused rather than given a fabricated id. Two ways to authorise. Under an HSM policy the whitelist gates the command and the proof returns at once, which is what unattended signing needs. Without a policy the user approves on screen like message signing: slp9 returns nothing, and the host collects the proof with slok. The two completion polls refuse to consume each other's result. Taproot uses the primitives this branch already has: chains.taptweak() via script_pubkey() for the output key, and TAP_TWEAK_H with ngu.hash.sha256t() for the tweak, the same shape psbt.py signs key-path inputs with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
slip19_paths whitelists the derivation paths a proof may be produced for while a policy is active. Without it, no proof is signed under HSM at all. Five rules bound what an unattended policy may do. min_pct_self_transfer already bounded the ratio one transaction moves; nothing bounded the total, the rate, the price per byte, or whether the round was worth joining: max_txn transactions one approved policy may sign max_txn_per_period how fast those may be spent, using the existing period max_sats_leaving own value leaving in one transaction, absolute max_fee_per_kvbyte own loss per 1000 vbytes of our own contribution min_inputs fewest inputs the transaction may have, everyone's The existing velocity limits do not work here: per_period and max_amount measure non-change outputs, which in a coinjoin are the other participants' outputs, so any value tight enough to matter refuses honest rounds. These five measure our own inputs and outputs, or the transaction itself. max_fee_per_kvbyte needs only our own values, which matters because a coinjoin has unknown input amounts, so calculate_fee() returns None and the transaction-wide fee check is skipped entirely. Both estimates round in the refusing direction, and an input type absent from the weight table is refused rather than sized wrong. Each rule is absent-means-off, appears in the on-screen summary, and is in to_json so the policy hash covers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
Two changes so an unattended device reads honestly. The status screen now says "Signing ownership proof" while it works. Unattended signing was otherwise silent, so a working coinjoin session looked identical to an idle one. The busy line now expires. If a host stops talking part way through an upload nothing raises, so restore_menu() in usb.py never runs and the screen keeps reading "Receiving..." on a device that is doing nothing. A fresh upld at offset 0 resets the transfer, so nothing is broken -- but an indicator that says "working" when it is not is the one thing an unattended device must not do, and it is unfalsifiable by looking, since the same screen means both states. Progress updates refresh the line; 30s of no movement clears it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
test_slip19.py covers proof shape for both script types, determinism, commitment binding, the HSM path gate, the on-screen approval and refusal outside HSM, and that a pending proof cannot be collected through smok. The ownership id is pinned to SLIP-19 official vector 1, mutation-checked against a wrong SLIP-21 label, the wrong half of the node, and a wrong root label. One suite per rule: each is shown on screen and enforced, absent means off, and each composes with the self-transfer floor. min_inputs is mutation-checked -- counting only our own inputs instead of every participant's fails the test that distinguishes them. test_slip19_indicator.py and test_hsm_busy_timeout.py cover the screen changes: the message is announced and fits, a stalled message clears, an advancing one does not. compute_policy_hash in test_hsm.py mirrors the firmware's to_json field order, so slip19_paths and the five rules are added there too, in the same order. The PSBT builders use this branch's fake_txn signature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
92d7aa0 to
3df999b
Compare
|
Rebased onto What changed in the move, beyond the base:
Checked rather than assumed: the proofs are byte-identical to the 5.6.0 version for the same seed, path and commitment, P2WPKH and P2TR both. Two things I decided one way and would rather you confirm: The reviewed 5.6.0 version is preserved at tag |
The reason this branch moved to the edge line: Wasabi rounds are taproot, and only edge signs P2TR. One test walks the whole path a coordinator drives -- slp9 proves a taproot coin under the policy, then a PSBT with two of our P2TR inputs, our P2TR change and one foreign P2TR output is signed unattended. A second PSBT losing 1,000,000 sats passes the ratio floor and the absolute cap but is refused for its feerate, which can only happen if max_fee_per_kvbyte sized the P2TR inputs rather than refusing to size them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
…he Q test_slip19.py imported enable_hsm_commands from test_hsm.py for the one test that needs a policy. That fixture is autouse and skips on the Q, and importing it re-registers it as autouse here too, so all twelve cases skipped on the Q simulator -- including the eleven that never touch HSM. The policy gate now lives in test_slip19_hsm.py with the HSM harness import; test_slip19.py has no HSM dependency left. On the Q simulator it passes as is: slp9/slok sit outside the supports_hsm block in usb.py, ux_show_story maps ENTER to 'y', and the address chunker uses 24-char groups on the wide screen. Nothing in the firmware changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB
SLIP-19 ownership proofs + HSM support for coinjoin remote signing
Adds what a Coldcard needs to sign coinjoins: SLIP-19 ownership proofs over USB, five policy rules
bounding what unattended signing may do, two screen changes so an unattended device reads honestly,
and one safety fix found while testing on hardware.
Rebased onto
new_edge(6.6.1X) at doc-hex's suggestion, because Wasabi rounds are taproot and onlythe edge line signs P2TR. 4 commits, 13 files, +1079/−13. The 5.6.0-based version this replaces is
kept at tag
slip19-5.6.0-finaland brancharchive/slip19-on-5.6.0on my fork.Verified in the simulator on this base; the hardware numbers below were measured on a retail Mk4
running the 5.6.0 version, and the proofs are byte-identical across the two bases.
Happy to split this: the proofs stand alone, and the policy rules could follow as a second PR.
What it adds
slp9USB command — a serialized SLIP-19 ownership proof for a derivation path. P2WPKH (ECDSA)and P2TR (BIP-340 key-spend, BIP-86 tweak). The caller states the address format rather than the
device inferring it from the path purpose, which need not match the script actually used.
The taproot half is why this belongs on edge: proving ownership of a P2TR coin is only useful on a
build that can then sign the round's PSBT. The proof uses this branch's own primitives —
chains.taptweak()throughscript_pubkey()for the output key, andTAP_TWEAK_Hwithngu.hash.sha256t()for the tweak, the same shapepsbt.pysigns key-path inputs with.Real SLIP-19 ownership identifiers, derived per SLIP-21 from the seed, pinned against the
published vectors. An xprv-imported secret has no seed, so proofs are refused rather than given a
fabricated id.
Two ways to authorise a proof. Under an HSM policy,
slip19_pathswhitelists the paths and theproof comes straight back — unattended signing. Without a policy the user approves each proof on
screen, like message signing:
slp9returns nothing, the screen shows path, address andSHA-256(commitment), and the host collects the proof with
slok. Refusing returnsrefu.So the SLIP-19 user-confirmation flag is only ever set when somebody actually confirmed.
Five HSM rules bounding unattended signing.
min_pct_self_transferbounds the ratio onetransaction moves; nothing bounded the total, the rate, the price per byte, or whether the round was
worth joining. Each is absent-means-off, shown in the on-screen summary, and in
to_jsonso thepolicy hash covers it.
max_txnmax_txn_per_periodmax_sats_leavingmax_fee_per_kvbytemin_inputsWhy not the existing velocity limits:
per_periodandmax_amountmeasure non-change outputs,which in a coinjoin are the other participants' outputs, so any value tight enough to matter
refuses honest rounds. These five measure our own inputs and outputs, or the transaction itself.
max_fee_per_kvbyteneeds only our own values, because in a coinjoincalculate_fee()isNoneand the transaction-wide fee check is skipped entirely.
Screen honesty
Signing indicator. The HSM screen says "Signing ownership proof" while it works; unattended
signing was otherwise indistinguishable from idle.
The busy line expires. If a host stops talking mid-upload nothing raises, so
restore_menu()never runs and the screen keeps reading "Receiving..." on an idle device. Nothing is broken — a
fresh
upldat offset 0 resets the transfer — but an indicator that says "working" when it is notis the one thing an unattended device must not do. Progress updates refresh the line; 30s of no
movement clears it.
Safety fix
Master seed blanking.
_master_seedhanded back a copy of the 64-byte BIP-39 seed, whichnothing else in the firmware extracts. It is blanked as soon as the one HMAC that needs it has run,
along with each seed-derived intermediate, using the same
blank_object()the rest ofstash.pyuses.
Testing
32 new tests across seven files, on the simulator: proof shapes, determinism, commitment binding,
the HSM path gate, the on-screen approval and refusal, and the ownership id pinned to official
vector 1 (mutation-checked against a wrong SLIP-21 label, the wrong half of the node, and a wrong
root label). Each of the five rules is shown on screen and enforced, absent means off, and composes
with the self-transfer floor;
min_inputsis mutation-checked against counting only our own inputs.On this base, with a simulator built from it:
test_slip19.py16 passed, and the six rule/screensuites 20 passed, 36 together.
test_usb.py(32 failed, 43 passed, 1 skipped) andtest_hsm.py(58 failed, 121 passed, 9 errors)give identical counts on bare
new_edgewithout this branch applied, checked by rebuilding thesimulator from
38c17eb0and rerunning. Those failures are my local environment, not this change.The proofs are byte-identical to the 5.6.0 version for the same seed, path and commitment — P2WPKH
and P2TR both — which is what says the switch to
chains.taptweak()changed nothing observable.On hardware (Mk4). Proofs accepted at input registration; PSBTs signed unattended under the
policy; coinjoins confirmed on regtest and mainnet, including a round signing five of our own inputs
in one PSBT. Every rule exercised from both sides — refusals at 77%/93.3%/97.8%/98.9%
self-transfer,
max_sats_leavingat 125,415 against a 100,000 cap,max_txn_per_periodonce thehourly count was reached,
max_fee_per_kvbyteat 23,570 against 5,000,min_inputsrefusing a4-input round against a floor of 21 and signing a 13-input round against a floor of 3.
Known limit: signing speed
A coinjoin PSBT costs ~2.7 ms per PSBT byte on an Mk4, end to end, over five mainnet rounds. A
~14 KB PSBT signs in 40s and confirmed on mainnet, but a typical ~40 KB round takes 100–117s and
misses a ~90s coordinator signing phase. The device signs correctly every time; it is simply not
asked early enough.
Nothing here addresses that, and it should not: the cost is in
psbt.py, which re-deserializes theunsigned transaction on each of five traversals, and rewriting that touches every signing flow you
have. Flagging it because it decides where unattended coinjoin signing is usable today (~20 KB
PSBTs, ~150 inputs), and a single-pass rewrite looks worth roughly 2x if you ever want it.
Notes for review
hsmcmdmust be enabled for the HSM path; a factory-fresh unit ships with it off. Theuser-approved path needs no policy and no
hsmcmd.version.pyclearssupports_hsmon Q1. The attended approval path has no suchrestriction, and
test_slip19.pypasses on the Q simulator (12 cases): title bar, 24-char addressgroups and the ENTER/CANCEL prompt all come out right. The policy gate lives in
test_slip19_hsm.pyso that it, not the whole module, skips on Q.
slp9is deliberately not inHOBBLED_CMDS: a proof moves no funds. Say the word if you wouldrather a spending policy blocked it too.
approve_transactionreturns early forpor322PSBTs before the rules loop, so the five countersnever see one. Coinjoin PSBTs are not
por322, so I left that alone — but BIP-322 proof-of-reservesand SLIP-19 ownership proofs are neighbours, and you may want them reconciled.
ckcc-protocolhas no packer forslp9/slokyet, so hosts use rawsend_recvtoday.compute_policy_hashintesting/test_hsm.pymirrors the firmware'sto_jsonfield order, soslip19_pathsand the five rules had to be added there too, in the same order.hsm_ux.py, so it only applies under a policy. The same stale screenis reachable outside HSM, but that path has a user present. Say the word if you want it general.