Skip to content

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

Open
kravens wants to merge 17 commits into
Coldcard:masterfrom
kravens:feature/slip19-coinjoin
Open

Feature: Add SLIP-19 support for safe remote-signing of WabiSabi coinjoins#685
kravens wants to merge 17 commits into
Coldcard:masterfrom
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 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.0
entropy 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

slp9 USB 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_paths HSM policy field — proofs are only produced for whitelisted paths while a policy
is 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_transfer bounds the ratio one
transaction 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_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 these and 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, added this round

The 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_in is None, calculate_fee() returns None, and consider_outputs
skips the transaction-wide fee check entirely.

This rule needs only our own values: (own inputs − own outputs) over the vsize of our own inputs
and 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_transfer on its own would allow a
round 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:

own_in  = sum(i.amount for i in psbt.inputs if i.num_our_keys)
own_out = sum(txo.nValue for idx, txo in psbt.output_iter() if psbt.outputs[idx].num_our_keys)
percentage = own_out / own_in * 100

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_txn and max_txn_per_period are for, and why they
are meant to be chosen together.

min_inputs is a floor on how degenerate a round may be, not an anonymity set — a coordinator
willing 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() in usb.py never runs and the screen keeps reading "Receiving..." on a device
that is idle. 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, 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/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 — 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_helper keypad
injection are enabled at boot and have no HSM concept. Debug builds belong on test units.

Master seed blanking. _master_seed hands 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

30 new tests across eight files, on the simulator:

  • test_slip19.py (10) — proof shapes, determinism, commitment binding, the HSM path gate, and the
    ownership 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, 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 (2), test_hsm_busy_timeout.py (2) — the message is announced and fits
    the 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 — marked onetime, since it leaves the simulator locked.
  • test_usb.py — 66 passed, 1 skipped. usb.py is on every command path and this branch modifies
    it, 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-existing user/rmur failures. Being straight about
the 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, equally
on 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_bip322 cannot 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_leaving refusing at 125,415 sats
against a 100,000 cap; max_txn_per_period refusing once the hourly count was reached;
max_fee_per_kvbyte refusing 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

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-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 because a single-pass rewrite looks worth roughly 2x if you ever
want it.

Notes for review

  • hsmcmd must be enabled on the device for any of this; a factory-fresh unit ships with it off.
  • Mk4 only in practice: the Q disables the classic HSM command set, and the Mk3 line ended before
    min_pct_self_transfer existed.
  • compute_policy_hash in testing/test_hsm.py mirrors the firmware's to_json field order, so
    slip19_paths and the five new rules had to be added there too, in the same order.
  • The busy-line timeout is in hsm_ux.py, so it only applies while a policy is active. The same
    stale 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.

@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 added 17 commits August 2, 2026 02:12
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
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
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