Skip to content

Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins - #685

Open
kravens wants to merge 6 commits into
Coldcard:new_edgefrom
kravens:feature/slip19-coinjoin
Open

Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins#685
kravens wants to merge 6 commits into
Coldcard:new_edgefrom
kravens:feature/slip19-coinjoin

Conversation

@kravens

@kravens kravens commented Jul 11, 2026

Copy link
Copy Markdown

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 only
the 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-final and branch archive/slip19-on-5.6.0 on 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

slp9 USB 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() through 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.

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_paths whitelists the paths and the
proof comes straight back — unattended signing. Without a policy the user approves each proof on
screen, like message signing: slp9 returns nothing, the screen shows path, address and
SHA-256(commitment), and the host collects the proof with slok. Refusing returns refu.
So the SLIP-19 user-confirmation flag is only ever set when somebody actually confirmed.

Five HSM rules bounding unattended signing. min_pct_self_transfer bounds the ratio one
transaction 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_json so the
policy hash covers it.

rule bounds
max_txn transactions one approved policy may sign, counted per session
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, counting every participant

Why not the existing velocity limits: 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, because in a coinjoin calculate_fee() is None
and 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 upld at offset 0 resets the transfer — but an indicator that says "working" when it is not
is 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_seed handed back a copy of the 64-byte BIP-39 seed, which
nothing 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 of stash.py
uses.

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_inputs is mutation-checked against counting only our own inputs.

On this base, with a simulator built from it: test_slip19.py 16 passed, and the six rule/screen
suites 20 passed, 36 together.

test_usb.py (32 failed, 43 passed, 1 skipped) and test_hsm.py (58 failed, 121 passed, 9 errors)
give identical counts on bare new_edge without this branch applied, checked by rebuilding the
simulator from 38c17eb0 and 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_leaving at 125,415 against a 100,000 cap, max_txn_per_period once the
hourly count was reached, max_fee_per_kvbyte at 23,570 against 5,000, min_inputs refusing a
4-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 the
unsigned 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

  • hsmcmd must be enabled for the HSM path; a factory-fresh unit ships with it off. The
    user-approved path needs no policy and no hsmcmd.
  • Mk4 only for HSM: version.py clears supports_hsm on Q1. The attended approval path has no such
    restriction, and test_slip19.py passes on the Q simulator (12 cases): title bar, 24-char address
    groups and the ENTER/CANCEL prompt all come out right. The policy gate lives in test_slip19_hsm.py
    so that it, not the whole module, skips on Q.
  • slp9 is deliberately not in HOBBLED_CMDS: a proof moves no funds. Say the word if you would
    rather a spending policy blocked it too.
  • approve_transaction returns early for por322 PSBTs before the rules loop, so the five counters
    never see one. Coinjoin PSBTs are not por322, so I left that alone — but BIP-322 proof-of-reserves
    and SLIP-19 ownership proofs are neighbours, and you may want them reconciled.
  • ckcc-protocol has no packer for slp9/slok yet, so hosts use raw send_recv today.
  • compute_policy_hash in testing/test_hsm.py mirrors the firmware's to_json field order, so
    slip19_paths and the five rules had to be added there too, in the same order.
  • The busy-line timeout is in hsm_ux.py, so it only applies under a policy. The same stale screen
    is reachable outside HSM, but that path has a user present. Say the word if you want it general.

@kravens
kravens force-pushed the feature/slip19-coinjoin branch from 4e5a5d3 to 83cef62 Compare July 24, 2026 20:28
@kravens

kravens commented Jul 28, 2026

Copy link
Copy Markdown
Author

First mainnet coinjoin with a ColdCard remote-signed input: https://mempool.space/tx/cacadb13ce52cc5b63b24fbb4d95f8a9e2a5d3bb799cf0abfc9e8bc42d5849fc
Doing further tests with a nodebug firmware to confirm everything works safely.

@kravens
kravens force-pushed the feature/slip19-coinjoin branch from a05c8e7 to 5d0dd98 Compare August 3, 2026 20:06
@kravens

kravens commented Aug 3, 2026

Copy link
Copy Markdown
Author

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.

@kravens
kravens marked this pull request as ready for review August 3, 2026 20:35
@doc-hex doc-hex added the new-feature New feature proposal. label Aug 5, 2026
Comment thread shared/slip19.py Outdated
Comment thread shared/slip19.py Outdated
Comment thread shared/slip19.py Outdated
Comment thread shared/usb.py Outdated
Comment thread shared/usb.py Outdated
@doc-hex

doc-hex commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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.

@kravens

kravens commented Sep 3, 2026

Copy link
Copy Markdown
Author

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:

It already was reachable, and you are right that it needs no policy: slip19_paths only gates it
while a policy is active. What it lacked was the on-screen approval, so I have added it.

Outside HSM mode slp9 now starts a UserAuthorizedAction exactly like message signing. The
device replies with nothing, shows a story naming the derivation path, the address and the
SHA-256 of the commitment, and the host collects the finished proof with a new slok command
(same poll shape as smok). The screen ends with the same "Press OK to continue, otherwise X to
cancel" line every other approval has, and the text is a module constant like MSG_SIG_TEMPLATE.
Refusing returns refu and no proof.

That also fixes something the previous version had to fudge. The SLIP-19 user-confirmation flag
asserts to the coordinator that a human approved this input; with nobody watching, the old code
refused to set it. Now a human really does approve, so the flag is honest and is no longer
refused outside HSM.

Under a policy nothing changes: the approved slip19_paths list is the standing consent, and the
proof still comes straight back without touching the UX, which is what unattended coinjoin
signing needs.

Useful by itself? Yes, for the attended case: a desktop wallet can register a coin in a round
with the user confirming each proof on the Coldcard, no HSM policy required. It is slower per
input, but it is the natural way to try coinjoin signing before committing to HSM mode.

One more host-visible detail: polling smok while a proof is pending is refused with
Wrong completion command, and vice versa, so the two completion polls cannot consume each
other's result.

Note for hosts: the response shape now depends on the mode. Under HSM slp9 returns the proof;
otherwise it returns nothing and the caller polls slok. ckcc-protocol has no packer for
either command yet, so this is only raw send_recv today.

@doc-hex

doc-hex commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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 new_edge.

@kravens

kravens commented Sep 3, 2026

Copy link
Copy Markdown
Author

Oh that's true, Wasabi has support for both bech32/native-segwit and taproot. So I agree we should rebase against the Edge firmware.
Also tested the flow of manual ownership proof message signing in the simulator: https://claude.ai/code/artifact/641baa75-fa3c-4c00-9151-837daff4fa10

kravens and others added 4 commits September 3, 2026 21:56
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
@kravens
kravens force-pushed the feature/slip19-coinjoin branch from 92d7aa0 to 3df999b Compare September 3, 2026 20:12
@kravens
kravens changed the base branch from master to new_edge September 3, 2026 20:13
@kravens

kravens commented Sep 3, 2026

Copy link
Copy Markdown
Author

Rebased onto new_edge. You are right that this belongs there: proving ownership of a taproot coin is only worth doing on a build that can then sign the round, and Wasabi rounds are taproot.

What changed in the move, beyond the base:

  • The taproot path now uses this branch's own primitives instead of my hand-rolled ones — chains.taptweak() via script_pubkey() for the output key, and TAP_TWEAK_H with ngu.hash.sha256t() for the tweak, written the way psbt.py signs key-path inputs. My _tagged_hash helper is gone.
  • The rules follow the renames on this base: sp_idxs rather than num_our_keys, i.af rather than i.addr_fmt, msas rather than p2sh, and slip19_paths passes ['any'] to pop_deriv_list now that 'any' is no longer built in.
  • The 22 commits collapsed to 4 logical ones, since replaying the old history across ~820 commits would have produced a series that neither builds nor reads.

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. test_usb.py and test_hsm.py produce identical pass/fail counts on bare new_edge without this branch, so the failures there are my local environment.

Two things I decided one way and would rather you confirm: slp9 is not in HOBBLED_CMDS, on the grounds that a proof moves no funds; and approve_transaction's early return for por322 means the five counters never see a proof-of-reserves PSBT, which I left alone since coinjoin PSBTs are not por322.

The reviewed 5.6.0 version is preserved at tag slip19-5.6.0-final and branch archive/slip19-on-5.6.0 on my fork, so nothing from the earlier review is lost.

kravens and others added 2 commits September 3, 2026 22:26
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature New feature proposal.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants