Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins - #685
Open
kravens wants to merge 17 commits into
Open
Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins#685kravens wants to merge 17 commits into
kravens wants to merge 17 commits into
Conversation
kravens
force-pushed
the
feature/slip19-coinjoin
branch
from
July 24, 2026 20:28
4e5a5d3 to
83cef62
Compare
Author
|
First mainnet coinjoin with a ColdCard remote-signed input: https://mempool.space/tx/cacadb13ce52cc5b63b24fbb4d95f8a9e2a5d3bb799cf0abfc9e8bc42d5849fc |
…or coinjoin remote signing
Taproot key-spend proof: BIP-86 tweak the internal key (libsecp keypair_xonly_tweak_add handles internal even-Y + output parity), then a BIP-340 schnorr signature over the same SLIP-19 digest. Simulator-verified against Wasabi OwnershipProof.VerifyOwnership with an independent NBitcoin BIP-86 derivation. Note: a full taproot coinjoin round also needs taproot PSBT spend-signing, which is EDGE-firmware only.
The stm32 frozen-module manifest lists files explicitly; slip19.py was missing, so real hardware raised ImportError on the first slp9 command (err_Confused at the import in usb.py). The simulator loads shared/ from the filesystem and never hit it. Found on a Mk4 running the dev build.
…ubpath Two issues found testing HSM mode end-to-end in the simulator: - slp9 was never added to HSM_WHITELIST, so HSM mode rejected the command before the slip19_paths policy gate could run. That defeats the whole point: ownership proofs are needed during unattended coinjoin operation. - the handler passed the raw subpath string to approve_slip19, but match_deriv_path requires both sides in canonical hard notation (84h not 84-prime), so every path was denied. Clean it with cleanup_deriv_path first (which also validates junk input). Note: building this tree also requires the ckcc-protocol submodule at its recorded pin (3d1dfa8) — an older checkout lacks PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE in ckcc/constants.py (symlinked as shared/public_constants.py), which breaks the auth/hsm_ux import chain at runtime and crashes the HSM menu.
Review of the command surfaced three problems, all reachable from a host. The script type was inferred from the derivation path's purpose field with startswith(86), which also matches 860h and friends, and silently produced a P2WPKH proof for any other purpose - including 44h and 49h, where the wallet does not use that script. A proof over the wrong scriptPubKey is useless rather than dangerous, but it fails in a way that is hard to diagnose. The caller now states the address format, the same way smsg already does, and anything other than AF_P2WPKH or AF_P2TR is refused instead of guessed. This makes the AF_ constants the module already imported actually load-bearing. The user-confirmation flag is an assertion to a coinjoin coordinator that a human approved this input, but it was taken verbatim from the host and no confirmation was ever sought, so the device would happily sign a claim that nobody had made. It is now refused unless an HSM policy is active, where the approved policy is the user's standing consent - which is the point of running one. Neither the command nor its policy gate had tests. testing/test_slip19.py covers proof shape for both script types, determinism, that the commitment is bound, the rejected address format, the refused confirmation flag, junk paths, and the slip19_paths whitelist including the empty-list case that must deny everything. Note the path handling was already sound and stays that way: usb.py canonicalises the subpath once and hands the same string to both approve_slip19 and make_ownership_proof, so no caller can get one path approved and a different key signed.
compute_policy_hash builds its own canonical copy of a policy from a field table, and that table has to mirror HSMPolicy.save() or the hash it predicts will not match the one the device shows. Adding slip19_paths to the policy without adding it here meant the start_hsm fixture failed its policy-hash assertion for any policy carrying the field, which is every coinjoin policy. Inserted in the same position as in save(), typed as a derivation list so it gets the same 'p'/apostrophe to 'h' canonicalisation as msg_paths. Verified: test_slip19.py 11 passed, and test_hsm.py 120 passed with no change in behaviour for policies that do not use the field.
The HSM-gate cases need the hsmcmd setting enabled on the simulator. test_hsm supplies that through an autouse fixture, but autouse only applies within its own module, so importing hsm_reset/start_hsm was not enough: the tests passed only when an earlier module had happened to leave hsmcmd set, and failed on a freshly started simulator. Pull enable_hsm_commands in explicitly so the file passes standalone, which is how a reviewer will run it.
The proof body carried 32 zero bytes where the ownership identifier belongs. A coordinator could still verify the signature, because the id is inside the signed digest either way, but a constant tells it nothing and the result was not SLIP-19. Derives it properly: id = HMAC-SHA256(k, scriptPubKey) with k = Key(m/"SLIP-0019"/"Ownership identification key") built per SLIP-21 from the wallet seed. Checked three ways - a host reference implementation against the published SLIP-19 and SLIP-21 vectors, this firmware against that reference on the simulator seed (key plus ids over four scripts, byte-identical), and an independent reimplementation in the Wasabi test suite that reproduces SLIP-19 vector 1. An xprv-imported secret has no seed, so no identifier can exist for it; that case is refused rather than given a fabricated id. Seeds reached through BIP-39 words need PBKDF2, so the derived key is cached against the master fingerprint - a different seed, including the same words under a different passphrase, produces a different fingerprint, so a cached key cannot cross wallets.
The existing tests covered the proof envelope and the HSM path gate, but nothing checked the identifier itself. A wrong SLIP-21 label or the wrong half of the node would still have produced a well-formed proof that no other wallet agrees with. Adds a vector-pinned check of the derivation, an end-to-end check that a slp9 proof carries that same value rather than the zeros it replaced, and a guard that two scripts under one seed do not share an identifier.
EVAL/EXEC/XKEY were dispatched ahead of the HSM whitelist, so on a debug build they ran whatever the HSM state was. EXEC is arbitrary code execution, so any host could read the seed off a device the owner had deliberately locked into HSM mode and walked away from -- which is exactly how unattended coinjoin signing is meant to be used. Refuse them while HSM is active on real hardware. The simulator still allows them, because the test suite drives HSM over this same path. Verified both directions on the simulator: before the change EXEC returned its payload with hsm_active set, after it the device answers "Not allowed in HSM mode".
_master_seed hands back a fresh copy of the 64-byte BIP-39 seed, the value every key on the device derives from. SensitiveValues blanks what it owns, not that copy, and nothing else in the firmware extracts the raw seed, so the exposure arrives with this feature. Blank it as soon as the one HMAC that needs it has run, and blank each seed-derived intermediate node once consumed, using the same blank_object() the rest of stash.py uses.
min_pct_self_transfer bounds what a single transaction can move, not the total. With no count on the device, a host that had been taken over can keep presenting fresh transactions that each sit just inside the floor and drain the wallet a slice at a time. The budget that should stop that otherwise lives in the host, which is the thing being assumed compromised. That matters for unattended coinjoin signing, where the device is left connected for hours by design. max_txn is counted per HSM session, like the existing velocity counter: a reboot clears it and the user re-approves the policy, which is the moment they should be re-consenting anyway. It appears in the on-screen summary, so the limit being approved is visible, and in to_json, so it is covered by the policy hash. Absent means unlimited, so existing policies are unaffected.
min_pct_self_transfer is a ratio, so what it permits scales with the amount being mixed while
mining fees scale the other way: they are a large share of a small coin and a trivial share of a
big one. A percentage tight enough to protect large amounts refuses ordinary rounds on small ones
-- observed on hardware, legitimate rounds landing at 96.7-98.9% against a 99% floor.
Two rules, both ANDed with the ratio rather than replacing it:
max_sats_leaving - an absolute cap on our own value leaving in one transaction. The device
already computes own_in_value and own_out_value for the ratio, so their
difference (our fee share plus any leak) costs nothing extra to check. This is
the guard a ratio cannot be: the percentage binds on small amounts, the
absolute cap binds on large ones.
max_txn_per_period - a rate limit. max_txn bounds the total but says nothing about how fast it is
spent, so a coordinator that keeps proposing rounds can burn the whole budget
in minutes and farm a mining fee off each one. Reuses the period the velocity
limit already defines and resets in step with it, requires that period the
same way per_period does, and is pre-burned on boot-to-HSM like the sats
velocity, since a reboot cannot tell whether the period was already used.
Both are absent by default, so existing policies are unaffected, and both appear in the on-screen
summary and in to_json, so the limits being approved are visible and covered by the policy hash.
Also refuses rather than dividing by zero when a transaction has none of our inputs.
Unattended coinjoin signing is silent by design, so nothing on the device distinguished a working session from an idle one. PSBT signing already announced itself through the HSM screen's busy line; ownership proofs did not, which left the SLIP-19 half of a coinjoin invisible. The slp9 handler now writes "Signing ownership proof" to that line while it works and clears it afterwards. Only in HSM mode: outside it, taking over the screen would interrupt whatever the user is doing. The busy line also falls back to the tiny font when a message will not fit. dis.text centres but neither wraps nor shrinks, so anything past 128px silently lost its ends — this message is 161px in the normal font and 92px in the tiny one. That applies to every busy message, not just this one. Tested: the phrase is confirmed to overflow the normal font and fit the fallback, so the logic cannot be quietly dropped, and a real slp9 under an HSM policy is checked to announce itself.
min_pct_self_transfer and max_sats_leaving bound what a round may cost us, and max_txn bounds how many we will sign, but none of them says anything about whether a round is worth joining at all. The host picks the round, so a host that has been taken over can pick one containing nobody but us and a coordinator that then learns the entire mapping, at a cost well inside every existing limit. The device is handed the whole round transaction, so it can count the participants itself instead of trusting the host to have done it. min_inputs counts every input in the transaction, not the subset we own -- a floor on our own inputs would say nothing about the round. This rules out the degenerate round. It is not an anonymity set: a coordinator willing to register its own inputs can pad any round to any count and still know every link. The comment and the test file both say so, because the field name invites the wrong reading. Absent means no floor, so existing policies are unaffected. It appears in the on-screen summary so the limit being approved is visible, and in to_json so it is covered by the policy hash.
If a host stops talking part way through an upload, the rest never arrives and nothing raises, so the restore_menu() in usb.py that normally clears the progress screen never runs. The device is left reading "Receiving..." while it sits idle waiting for a packet that will not come. Nothing is actually wrong -- a fresh upld at offset 0 resets the transfer -- but for a device meant to be left signing unattended, an indicator that says it is working when it is not is the one thing it must not do. It is also unfalsifiable by looking: the same screen means both states. Give the busy line an expiry. Progress updates refresh it, so a real transfer keeps its indicator however long it takes; 30s of no movement at all clears it and the HSM status screen returns. The timeout is far longer than the gap between chunks, so ordinary work never trips it.
The existing value rules bound our loss in sats, absolutely or as a share of what we put in. Neither asks whether that loss is a sane price for the bytes we add, and in a coinjoin nothing else does: the other participants' input amounts are unknown, so total_value_in is None, calculate_fee() returns None and consider_outputs skips the fee check entirely. This rule needs only our own values. It measures (own inputs - own outputs) over the vsize of our own inputs and outputs, so it still works when the transaction-wide fee cannot be computed. It is an upper bound on the mining feerate we pay, since our loss also absorbs coordinator fees and any value genuinely leaving. Both estimates round in the refusing direction: the witness sizes are floors and the vsize is rounded down, so a transaction this rule passes is never above the stated limit. Input weights are a table rather than a guess, and an input type not in it is refused - a wrong weight would silently mis-scale the very number the rule exists to check.
kravens
force-pushed
the
feature/slip19-coinjoin
branch
from
August 3, 2026 20:06
a05c8e7 to
5d0dd98
Compare
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
marked this pull request as ready for review
August 3, 2026 20:35
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SLIP-19 ownership proofs + HSM support for coinjoin remote signing
Adds what a Coldcard needs to act as an unattended coinjoin signer: SLIP-19 ownership proofs over
USB, gated by the HSM policy; five policy rules that bound what unattended signing may do; two
screen changes so an unattended device reads honestly; and two safety fixes found while testing on
hardware.
17 commits, 14 files, +1009/−13. Rebased onto current master (
c849c4e0), so it carries the 5.6.0entropy hotfix. Verified on a retail Mk4 and in the simulator.
Happy to split this: the proofs and the two safety fixes stand alone, and the policy rules could
follow as a second PR.
What it adds
slp9USB command — returns 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
letting the device infer one from the path purpose: the purpose need not match the script actually
used, and a proof over the wrong scriptPubKey is silently useless.
Real SLIP-19 ownership identifiers, derived per SLIP-21 from the seed. Pinned against the
published vectors and cross-checked against an independent host implementation. An xprv-imported
secret has no seed, so proofs are refused rather than given a fabricated id.
slip19_pathsHSM policy field — proofs are only produced for whitelisted paths while a policyis active. Outside HSM a proof may not claim the user-confirmation flag, since nobody confirmed
anything and the host picks the flag.
Five HSM rules bounding unattended signing.
min_pct_self_transferbounds the ratio onetransaction may move; nothing bounded the total, the rate, the price per byte, or whether the round
was worth joining. Each is absent-means-off, appears in the on-screen summary, and is in
to_jsonso the policy hash covers it.
max_txnmax_txn_per_periodmax_sats_leavingmax_fee_per_kvbytemin_inputsWhy these and not the existing velocity limits:
per_periodandmax_amountmeasure non-changeoutputs, 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, added this roundThe other value rules ask how much we lose. None asks whether that loss is a sane price for the
bytes we add — and in a coinjoin nothing else does either: the other participants' input amounts are
unknown, so
total_value_inisNone,calculate_fee()returnsNone, andconsider_outputsskips the transaction-wide fee check entirely.
This rule needs only our own values:
(own inputs − own outputs)over the vsize of our own inputsand outputs. It is an upper bound on the mining feerate we pay, since our loss also absorbs
coordinator fees and any value genuinely leaving.
Both estimates round in the refusing direction — witness sizes are floors, vsize is rounded down —
so a transaction this rule passes is never above the stated limit. Input weights are a table, not a
guess, and an input type absent from it is refused rather than sized wrong; a wrong weight would
silently mis-scale the one number the rule exists to check.
It also makes a loose ratio defensible. A 50%
min_pct_self_transferon its own would allow around that halves a small coin; with a feerate cap that round is refused for what it costs per byte,
while an honest round paying a few sat/vB passes at any coin size. On hardware, against a 5000
sats/kvB limit:
feerate too high: 23570 sats/kvB of ours, limit is 5000.How the ratio is measured
Per transaction, over totals — one evaluation per signing, not per UTXO:
So a round mixing coin sizes is judged as a whole, which is more forgiving than judging each coin.
Ownership is by key derivation rather than address role, so our coinjoin outputs count as ours and
an honest round sits near 100% instead of looking like a total loss. A PSBT containing none of our
inputs is refused rather than divided by zero.
The limit is on one transaction, not a sequence: ten rounds each losing 4.9% compound to ~39% while
every one passes a 95% floor. That is what
max_txnandmax_txn_per_periodare for, and why theyare meant to be chosen together.
min_inputsis a floor on how degenerate a round may be, not an anonymity set — a coordinatorwilling to register its own inputs can pad any round. It exists because the host picks the round, so
a compromised host would otherwise be free to pick one containing nothing but this wallet and the
coordinator, at a cost inside every other limit.
Screen honesty
Signing indicator. The HSM screen now says "Signing ownership proof" while it works. Unattended
signing was otherwise silent, so a working 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()inusb.pynever runs and the screen keeps reading "Receiving..." on a devicethat is idle. Nothing is broken — a fresh
upldat offset 0 resets the transfer — but an indicatorthat 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.
Two safety fixes
Devmode test commands no longer bypass HSM mode.
EVAL/EXEC/XKEYwere dispatched ahead ofthe HSM whitelist, so on a debug build they ran whatever the HSM state was.
EXECis arbitrary codeexecution, so any host could read the seed off a device the owner had deliberately locked into HSM
mode and walked away from — exactly how unattended coinjoin signing is meant to be used. Now refused
on real hardware while HSM is active; the simulator keeps them, because the test suite drives HSM
over this same path.
This does not make a debug build safe to leave connected: the serial REPL and
dev_helperkeypadinjection are enabled at boot and have no HSM concept. Debug builds belong on test units.
Master seed blanking.
_master_seedhands back a copy of the 64-byte BIP-39 seed, which nothingelse 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
30 new tests across eight files, on the simulator:
test_slip19.py(10) — proof shapes, determinism, commitment binding, the HSM path gate, and theownership id pinned to official vector 1. Mutation-checked: a wrong SLIP-21 label, the wrong half
of the node, and a wrong root label are each caught.
test_hsm_max_txn.py(5),test_hsm_max_sats.py(3),test_hsm_min_inputs.py(4),test_hsm_max_feerate.py(4) — each rule is shown on screen and enforced, absent means off, andeach composes with the self-transfer floor.
min_inputsis mutation-checked: counting only ourown inputs instead of every participant's fails the test that distinguishes them.
test_slip19_indicator.py(2),test_hsm_busy_timeout.py(2) — the message is announced and fitsthe screen; a stalled message clears, an advancing one does not. The timeout was also confirmed on
the device by announcing a 100,000 byte upload, sending 1,024 bytes and disconnecting.
test_devmode_hsm.py— markedonetime, since it leaves the simulator locked.test_usb.py— 66 passed, 1 skipped.usb.pyis on every command path and this branch modifiesit, so it is where a careless change shows up first.
The full suite was also run per file against upstream at the fork point, with a fresh simulator each
time: every suite producing a complete summary is identical on both sides, including the 30 errors
in
test_hsm.py, which cascade from four pre-existinguser/rmurfailures. Being straight aboutthe rest — eight suites (
test_backup,test_ephemeral,test_export,test_msg,test_multisig,test_sign,test_ux,test_wif) stop early without printing a summary, equallyon upstream and on this branch; where partial output can be compared it matches. The local
secp256k1 lacks the schnorr module, so this baseline fails more heavily than yours would, and
test_bip322cannot be collected at all.On hardware (Mk4). Proofs accepted at input registration; PSBTs signed unattended under the
policy; coinjoins confirmed on-chain on regtest and on mainnet, including a round where the device
signed five of its own inputs in one PSBT. Rules exercised from both sides, not only asserted:
refusals at 77%, 93.3%, 97.8% and 98.9% self-transfer;
max_sats_leavingrefusing at 125,415 satsagainst a 100,000 cap;
max_txn_per_periodrefusing once the hourly count was reached;max_fee_per_kvbyterefusing at 23,570 against 5,000;min_inputsrefusing a 4-input round againsta floor of 21 and signing a 13-input round against a floor of 3.
Known limit: signing speed
Signing a coinjoin PSBT on an Mk4 costs about 2.7 ms per PSBT byte, end to end, measured over five
mainnet rounds. Small rounds are fine — a ~14 KB PSBT signs in 40 s and confirmed on mainnet — but a
typical ~40 KB round takes 100–117 s and misses a coordinator signing phase of roughly 90 s. The
device signs correctly every time; it is simply not asked early enough.
Nothing in this PR addresses that, and it should not: the cost is in
psbt.py, which re-deserializesthe 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 because a single-pass rewrite looks worth roughly 2x if you ever
want it.
Notes for review
hsmcmdmust be enabled on the device for any of this; a factory-fresh unit ships with it off.min_pct_self_transferexisted.compute_policy_hashintesting/test_hsm.pymirrors the firmware'sto_jsonfield order, soslip19_pathsand the five new rules had to be added there too, in the same order.hsm_ux.py, so it only applies while a policy is active. The samestale screen is reachable outside HSM mode, but that path has a user present to press a key.
Say the word if you would rather it were general.