From b078637dc72e37b6978df3060af18dc87582cb1a Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Thu, 3 Sep 2026 21:56:25 +0200 Subject: [PATCH 1/6] Add SLIP-19 ownership proofs (slp9/slok) for coinjoin remote signing 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- shared/manifest.py | 1 + shared/slip19.py | 233 +++++++++++++++++++++++++++++++++++++++++++++ shared/usb.py | 24 ++++- 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 shared/slip19.py diff --git a/shared/manifest.py b/shared/manifest.py index 76cd5828d..a048b3718 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -50,6 +50,7 @@ 'seed.py', 'selftest.py', 'serializations.py', + 'slip19.py', 'sffile.py', 'stash.py', 'tapsigner.py', diff --git a/shared/slip19.py b/shared/slip19.py new file mode 100644 index 000000000..ee0b5c31d --- /dev/null +++ b/shared/slip19.py @@ -0,0 +1,233 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# - Thanks to [Kevin Ravensberg](https://github.com/kravens) +# +# SLIP-19 ownership proofs (BIP-322-style), for coinjoin remote-signing (e.g. Wasabi WabiSabi). +# Produces a proof a coordinator verifier accepts: a signature over +# SHA256( proof_body || cs(scriptPubKey) || scriptPubKey || cs(commitment) || commitment ) +# where proof_body = magic(SL\x00\x19) || flags || varint(count) || 32-byte ownership id(s). +# flags bit 0 is SLIP-19's user-confirmation claim; the host picks it, we only honour it below. +# +# The wire result is the full serialized ownership proof: proof_body || bip322_sig +# (bip322_sig = empty scriptSig (varint 0) || witness stack). +# +# Two ways in, both over the 'slp9' USB command: +# - HSM mode: the policy's slip19_paths list is the standing consent, so the proof is returned +# at once (unattended coinjoin signing). +# - otherwise: a human approves each proof on screen, exactly like message signing, and the +# host collects the result with 'slok'. Only then can the user-confirmation flag be honest. +import ngu, stash, chains +from public_constants import AF_P2WPKH, AF_P2TR +from serializations import ser_compact_size, ser_string, ser_string_vector, ser_sig_der +from precomp_tag_hash import TAP_TWEAK_H +from auth import UserAuthorizedAction +from ux import OK, X, ux_show_story, abort_and_goto + +SLIP19_MAGIC = bytes([0x53, 0x4c, 0x00, 0x19]) + +# --- SLIP-19 ownership identifier ------------------------------------------------- +# id = HMAC-SHA256(key = k, msg = scriptPubKey), where +# k = Key(m/"SLIP-0019"/"Ownership identification key") [SLIP-19] +# derived per SLIP-21: +# m = HMAC-SHA512(key=b"Symmetric key seed", msg=seed) +# Child(N,l) = HMAC-SHA512(key=N[0:32], msg=b"\x00" + l) +# Key(N) = N[32:64] +# The key is cached against a hash of the root chain code: 256 bits of HMAC output, so unlike +# the 32-bit fingerprint it cannot collide between two seeds, and it changes with the BIP-39 +# passphrase too, so a cached key can never leak across wallets. +_oid_key_cache = None + + +def _ownership_id_key(sv): + global _oid_key_cache + + ident = ngu.hash.sha256s(sv.node.chain_code()) + if _oid_key_cache is not None and _oid_key_cache[0] == ident: + return _oid_key_cache[1] + + # SLIP-21 needs the seed the BIP-32 tree was built from, not the tree itself. + if sv.mode == 'master': + seed = bytes(sv.raw) + elif sv.mode == 'words': + # Re-derives through PBKDF2 (seconds); that cost is why the key is cached. + import bip39 + seed = bip39.master_secret(bip39.b2a_words(sv.raw), sv._bip39pw) + else: + # An xprv-imported secret has no seed, so no ownership identifier exists for it. Refuse + # rather than emit a proof carrying a made-up id. + raise ValueError('ownership proofs need a seed; this wallet was imported as xprv') + + try: + root = ngu.hmac.hmac_sha512(b'Symmetric key seed', seed) + finally: + # Only this one HMAC needs the seed, so it does not outlive the block. The + # intermediate nodes are seed-derived too, so each is blanked once consumed. + stash.blank_object(seed) + + n1 = ngu.hmac.hmac_sha512(root[0:32], b'\x00' + b'SLIP-0019') + stash.blank_object(root) + n2 = ngu.hmac.hmac_sha512(n1[0:32], b'\x00' + b'Ownership identification key') + stash.blank_object(n1) + k = bytes(n2[32:64]) + stash.blank_object(n2) + + _oid_key_cache = (ident, k) + return k + + +def ownership_id(spk, sv): + # SLIP-19 ownership identifier for one of this wallet's scriptPubKeys. + return bytes(ngu.hmac.hmac_sha256(_ownership_id_key(sv), spk)) + + +def _script_and_key(node, addr_fmt): + # For the key at this node: (scriptPubKey, what signs for it). + # - P2WPKH: (privkey, compressed pubkey), for an ECDSA/DER witness + # - P2TR (BIP-86 key-spend): the tweaked keypair, for a BIP-340 witness + # Anything else would be signed as taproot below, so it is refused here. + assert addr_fmt in (AF_P2WPKH, AF_P2TR), 'unsupported address format for ownership proof' + + chain = chains.current_chain() + + if addr_fmt == AF_P2WPKH: + pubkey = node.pubkey() # 33-byte compressed + spk, _ = chain.script_pubkey(AF_P2WPKH, pubkey=pubkey) + return spk, (node.privkey(), pubkey) + + # Key-spend only, no script tree, so the tweak is over the internal key alone - the same + # BIP-86 case psbt.py signs. libsecp256k1's keypair_xonly_tweak_add handles the internal + # even-Y negation and the output-key parity, so the signature verifies against the output + # key that chains.taptweak() puts in the scriptPubKey. + kp = ngu.secp256k1.keypair(node.privkey()) + internal_xonly = kp.xonly_pubkey().to_bytes() # 32-byte internal x-only key + out_kp = kp.xonly_tweak_add(ngu.hash.sha256t(TAP_TWEAK_H, internal_xonly, True)) + spk, _ = chain.script_pubkey(AF_P2TR, pubkey=internal_xonly) + return spk, out_kp + + +def make_ownership_proof(subpath, addr_fmt, flags, commitment): + # subpath: str like "m/84h/0h/0h/1/0"; addr_fmt: AF_P2WPKH or AF_P2TR; commitment: bytes. + with stash.SensitiveValues() as sv: + node = sv.derive_path(subpath) + spk, signer = _script_and_key(node, addr_fmt) + oid = ownership_id(spk, sv) + + proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + ser_compact_size(1) + oid + preimage = proof_body + ser_string(spk) + ser_string(commitment) + digest = ngu.hash.sha256s(preimage) + + if addr_fmt == AF_P2WPKH: + pk, pubkey = signer + sig65 = ngu.secp256k1.sign(pk, digest, 0).to_bytes() + der = ser_sig_der(sig65[1:33], sig65[33:65]) # DER + SIGHASH_ALL + witness = ser_string_vector([der, pubkey]) + else: + # aux_rand = 0: BIP-340 permits it; sign32 still binds (secret, message) so it is safe and + # deterministic for a proof. Witness is a single key-spend sig (SigHash.Default -> 64 bytes). + sig = ngu.secp256k1.sign_schnorr(signer, digest, bytes(32)) + witness = ser_string_vector([sig]) + + bip322_sig = ser_compact_size(0) + witness # empty scriptSig, then witness stack + return proof_body + bip322_sig + + +# --- USB entry points -------------------------------------------------------------- + +PROOF_TEMPLATE = '''\ +Sign ownership proof? + +Proves to a coinjoin coordinator that this Coldcard owns: + +{subpath} => +{addr} + +Commitment (SHA256): +{commit} + +Nothing is spent. The coordinator checks the coin is yours before letting it into a round. + +Press %s to continue, otherwise %s to cancel.''' % (OK, X) + + +class ApproveOwnershipProof(UserAuthorizedAction): + # Outside HSM mode: show the human what is about to be proven, and sign only if they agree. + # Result is collected by the host over 'slok', which is the only poll command that may have it. + is_slip19 = True + + def __init__(self, subpath, addr_fmt, flags, commitment): + super().__init__() + self.subpath = subpath + self.addr_fmt = addr_fmt + self.flags = flags + self.commitment = commitment + + from glob import dis + dis.fullscreen('Wait...') + + with stash.SensitiveValues() as sv: + node = sv.derive_path(subpath) + spk, _ = _script_and_key(node, addr_fmt) + self.address = sv.chain.render_address(spk) + + dis.progress_bar_show(1) + + async def interact(self): + from utils import show_single_address, B2A + + story = PROOF_TEMPLATE.format(subpath=self.subpath, + addr=show_single_address(self.address), + commit=B2A(ngu.hash.sha256s(self.commitment))) + + # 12 chars is all the Mk4 title bar fits (see ux_confirm in ux.py) + ch = await ux_show_story(story, title='Ownership') + + if ch != 'y': + self.refused = True + else: + from glob import dis + dis.fullscreen('Signing...') + self.result = make_ownership_proof(self.subpath, self.addr_fmt, self.flags, + self.commitment) + + self.done() + + +def usb_ownership_proof(subpath, addr_fmt, flags, commitment): + # Handle the 'slp9' USB command. Returns the full response (b'biny' + proof) when it can be + # answered at once (HSM mode), or None once on-screen approval has been started. + from utils import cleanup_deriv_path + from glob import dis, hsm_active + from exceptions import HSMDenied + + # Reject before deriving anything: the approval screen below renders an address for this + # format, and the caller states the format rather than it being guessed from the path. + assert addr_fmt in (AF_P2WPKH, AF_P2TR), 'unsupported address format for ownership proof' + + # One canonical path is used for BOTH the policy check and the derivation, so a caller + # cannot get one string approved and a different key signed. + subpath = cleanup_deriv_path(subpath) + commitment = bytes(commitment) + + if not hsm_active: + # A human decides. The confirmation flag, if the host asked for it, is then a claim that + # somebody did in fact confirm. + UserAuthorizedAction.check_busy() + UserAuthorizedAction.active_request = ApproveOwnershipProof(subpath, addr_fmt, flags, + commitment) + abort_and_goto(UserAuthorizedAction.active_request) + return None + + if not hsm_active.approve_slip19(subpath): + raise HSMDenied + + # Say what the device is doing. Unattended signing is otherwise silent, so there is no way + # to tell a working coinjoin session from an idle one by looking at the Coldcard. This lands + # on the HSM status screen's busy line. + dis.fullscreen('Signing ownership proof') + try: + return b'biny' + make_ownership_proof(subpath, addr_fmt, flags, commitment) + finally: + # A finished progress bar is how the busy line gets cleared again. + dis.progress_bar(1) + +# EOF diff --git a/shared/usb.py b/shared/usb.py index 5821e22ba..6dea03f12 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -58,6 +58,7 @@ 'stok', 'smok', # completion check: sign txn or msg 'xpub', # quick status checks 'show', 'msas', # limited by HSM policy + 'slp9', # SLIP-19 ownership proof; limited by slip19_paths policy 'user', # auth HSM user, other user cmds not allowed 'gslr', # read storage locker; hsm mode only, limited usage }) @@ -573,6 +574,17 @@ async def handle(self, cmd, args): sign_msg(msg, subpath, addr_fmt) return None + if cmd == 'slp9': + # SLIP-19 ownership proof, for coinjoin remote signing (Wasabi WabiSabi). + # - under HSM, the policy is the consent and the proof comes back right away + # - otherwise the user approves on-screen, and the host collects it with 'slok' + addr_fmt, flags, len_subpath, len_commit = unpack_from(' Date: Thu, 3 Sep 2026 21:57:40 +0200 Subject: [PATCH 2/6] HSM: gate ownership proofs by path, and bound unattended signing 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- shared/hsm.py | 133 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 8 deletions(-) diff --git a/shared/hsm.py b/shared/hsm.py index 701a59092..bdbb71950 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -10,7 +10,7 @@ from pincodes import AE_LONG_SECRET_LEN from stash import blank_object from users import Users, MAX_NUMBER_USERS, calc_local_pincode -from public_constants import MAX_USERNAME_LEN +from public_constants import MAX_USERNAME_LEN, AF_CLASSIC, AF_P2WPKH, AF_P2TR, AF_P2WPKH_P2SH from wallet import MiniScriptWallet from ubinascii import hexlify as b2a_hex from uhashlib import sha256 @@ -30,6 +30,29 @@ # too many refusals will cause reset ABSOLUTE_MAX_REFUSALS = const(100) +# Weight units of one of our inputs, for max_fee_per_kvbyte. Base is always +# 32 txid + 4 index + 1 empty scriptSig + 4 sequence = 41 bytes. +# +# The witness figures are deliberate floors: a smaller estimate means a smaller vsize, which means +# a higher computed feerate, which means the rule refuses sooner. Erring the other way would let a +# transaction slip past a limit it actually exceeds. Signatures are counted at 71 bytes because +# that is what this firmware produces - it grinds the nonce until the low-S DER form fits. +INPUT_WEIGHT = { + AF_P2WPKH: (41 * 4) + (1 + 1 + 71 + 1 + 33), # marker, sig, pubkey + AF_P2TR: (41 * 4) + (1 + 1 + 64), # marker, schnorr sig + AF_P2WPKH_P2SH: ((41 + 1 + 22) * 4) + (1 + 1 + 71 + 1 + 33), + AF_CLASSIC: (41 + 106) * 4, # scriptSig carries sig + pubkey +} + + +def input_weight(addr_fmt): + # Refuse rather than guess. A wrong weight here silently mis-scales the feerate, and this rule + # exists precisely because the transaction-wide fee cannot be checked in a coinjoin. + try: + return INPUT_WEIGHT[addr_fmt] + except KeyError: + raise AssertionError('max_fee_per_kvbyte cannot size a 0x%x input' % (addr_fmt or 0)) + # you have this many seconds after boot to escape HSM # mode, if you enable the boot_to_hsm feature BOOT_LOCKOUT_TIME = const(60) @@ -180,6 +203,17 @@ class ApprovalRule: # - local_conf: local user must also confirm w/ code # - wallet: which miniscript wallet to restrict to, or '1' for single signer only # - min_pct_self_transfer: minimum percentage of own input value that must go back to self + # - max_fee_per_kvbyte: most we will pay per 1000 vbytes of our own contribution, in sats. + # Our loss (own inputs minus own outputs) over the vsize of our own inputs and outputs. In a + # coinjoin the other participants' input amounts are unknown, so the transaction-wide fee + # cannot be computed at all and upstream's fee check is skipped; this one needs only our own + # values and so still works. It is an upper bound: our loss also covers coordinator fees and + # any value genuinely leaving, so the mining feerate we actually pay is at most this. + # - min_inputs: fewest inputs the whole transaction may have, ours and everyone else's. + # Meant for coinjoins: a round with only a couple of participants tells the coordinator + # almost everything, so refuse to sign one. Note this counts inputs, which a coordinator + # willing to add its own can inflate at will -- it rules out the degenerate round, it is + # not an anonymity set. # - patterns: list of transaction patterns to check for. Valid values: # * EQ_NUM_INS_OUTS: the number of inputs and outputs must be equal # * EQ_NUM_OWN_INS_OUTS: the number of **own** inputs and outputs must be equal @@ -188,6 +222,8 @@ class ApprovalRule: def __init__(self, j, idx): # read json dict provided self.spent_so_far = 0 # for velocity + self.txn_count = 0 # for max_txn + self.txn_this_period = 0 # for max_txn_per_period def check_user(u): if not Users.valid_username(u): @@ -204,6 +240,11 @@ def check_user(u): self.local_conf = pop_bool(j, 'local_conf') self.wallet = pop_string(j, 'wallet', 1, 20) self.min_pct_self_transfer = pop_float(j, 'min_pct_self_transfer', 0, 100.0) + self.max_sats_leaving = pop_int(j, 'max_sats_leaving', 0, MAX_SATS) + self.max_fee_per_kvbyte = pop_int(j, 'max_fee_per_kvbyte', 0, MAX_SATS) + self.max_txn = pop_int(j, 'max_txn', 1, 10000) + self.max_txn_per_period = pop_int(j, 'max_txn_per_period', 1, 10000) + self.min_inputs = pop_int(j, 'min_inputs', 1, 10000) self.patterns = pop_list(j, 'patterns') assert sorted(set(self.users)) == sorted(self.users), 'dup users' @@ -233,14 +274,16 @@ def check_user(u): @property def has_velocity(self): - return self.per_period is not None + # Anything measured per period needs the policy to define one. + return self.per_period is not None or self.max_txn_per_period is not None def to_json(self): # remote users need to know what's happening, and we save this # cleaned up data flds = [ 'per_period', 'max_amount', 'users', 'min_users', 'local_conf', 'whitelist', 'wallet', - 'min_pct_self_transfer', 'patterns' ] + 'min_pct_self_transfer', 'max_sats_leaving', 'max_fee_per_kvbyte', 'max_txn', + 'max_txn_per_period', 'min_inputs', 'patterns' ] rv = OrderedDict() for f in flds: val = getattr(self, f, None) @@ -299,6 +342,21 @@ def render(n): if self.min_pct_self_transfer: rv += ' if self-transfer percentage is at least %.2f' % self.min_pct_self_transfer + if self.max_sats_leaving is not None: + rv += ', and at most %s may leave the wallet per txn' % render(self.max_sats_leaving) + + if self.max_fee_per_kvbyte is not None: + rv += ', paying at most %s per 1000 vbytes of our own' % render(self.max_fee_per_kvbyte) + + if self.max_txn is not None: + rv += ', for at most %d transaction(s)' % self.max_txn + + if self.max_txn_per_period is not None: + rv += ', no more than %d transaction(s) per period' % self.max_txn_per_period + + if self.min_inputs is not None: + rv += ', only in transactions with %d or more inputs' % self.min_inputs + if self.patterns: rv += ' with the following patterns: ' for p in self.patterns: @@ -319,6 +377,15 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain): if self.max_amount is not None: assert total_out <= self.max_amount, 'amount exceeded' + if self.max_txn is not None: + assert self.txn_count < self.max_txn, 'transaction count exceeded' + + if self.min_inputs is not None: + # every participant's inputs, not just ours: a coinjoin round is only worth joining + # if enough others are in it. + assert psbt.num_inputs >= self.min_inputs, \ + 'too few inputs: %d, need %d' % (psbt.num_inputs, self.min_inputs) + attest_mode = self.whitelist_opts and self.whitelist_opts.attest allow_zeroval = self.whitelist_opts and self.whitelist_opts.allow_zeroval_outs @@ -371,16 +438,47 @@ def matches_transaction(self, psbt, users, total_out, local_oked, chain): # check this txn would not exceed the velocity limit assert self.spent_so_far + total_out <= self.per_period, 'would exceed period spending' - # check the self-transfer percentage - if self.min_pct_self_transfer: + if self.max_txn_per_period is not None: + assert self.txn_this_period < self.max_txn_per_period, 'too many transactions this period' + + # what we put in versus what comes back: the ratio, the absolute loss and the feerate are + # all checked against it, so walk the PSBT once. + if self.min_pct_self_transfer or self.max_sats_leaving is not None \ + or self.max_fee_per_kvbyte is not None: own_in_value = sum([i.amount for i in psbt.inputs if i.sp_idxs]) own_out_value = 0 + own_weight = 0 + + for i in psbt.inputs: + if i.sp_idxs: + own_weight += input_weight(i.af) + for idx, txo in psbt.output_iter(): o = psbt.outputs[idx] if o.sp_idxs: own_out_value += txo.nValue - percentage = (float(own_out_value) / own_in_value) * 100.0 - assert percentage >= self.min_pct_self_transfer, 'does not meet self transfer threshold, expected: %.2f, actual: %.2f' % (self.min_pct_self_transfer, percentage) + # 8 value + 1 script-length + the script itself; ours are never over 252 bytes + own_weight += (8 + 1 + len(txo.scriptPubKey)) * 4 + + # None of our own inputs means the ratio is undefined; refuse rather than divide by zero. + assert own_in_value, 'no inputs of ours to compare against' + + if self.min_pct_self_transfer: + percentage = (float(own_out_value) / own_in_value) * 100.0 + assert percentage >= self.min_pct_self_transfer, 'does not meet self transfer threshold, expected: %.2f, actual: %.2f' % (self.min_pct_self_transfer, percentage) + + if self.max_sats_leaving is not None: + leaving = own_in_value - own_out_value + assert leaving <= self.max_sats_leaving, 'too much value leaving: %d sats, limit is %d' % (leaving, self.max_sats_leaving) + + if self.max_fee_per_kvbyte is not None: + # Rounding down the vsize rounds the feerate up, so the limit is never exceeded by + # a transaction this passes. Same reason the witness estimate above is a floor. + vsize = own_weight // 4 + assert vsize, 'no weight of ours to divide by' + feerate = ((own_in_value - own_out_value) * 1000) // vsize + assert feerate <= self.max_fee_per_kvbyte, \ + 'feerate too high: %d sats/kvB of ours, limit is %d' % (feerate, self.max_fee_per_kvbyte) # check various patterns @@ -488,6 +586,7 @@ def load(self, j): # a list of paths we can accept for signing self.msg_paths = pop_deriv_list(j, 'msg_paths', ['any']) + self.slip19_paths = pop_deriv_list(j, 'slip19_paths', ['any']) # SLIP-19 proofs (coinjoin) self.share_xpubs = pop_deriv_list(j, 'share_xpubs', ['any']) self.share_addrs = pop_deriv_list(j, 'share_addrs', ['any', 'msas']) @@ -527,7 +626,7 @@ def period_reset_time(self): def save(self): # Create JSON document for next time. - simple = ['must_log', 'never_log', 'msg_paths', 'share_xpubs', 'share_addrs', + simple = ['must_log', 'never_log', 'msg_paths', 'slip19_paths', 'share_xpubs', 'share_addrs', 'notes', 'period', 'allow_sl', 'warnings_ok', 'boot_to_hsm', 'priv_over_ux'] rv = OrderedDict() for fn in simple: @@ -685,11 +784,15 @@ def activate(self, new_file): for r in self.rules: if r.per_period: self.record_spend(r, r.per_period) + if r.max_txn_per_period: + r.txn_this_period = r.max_txn_per_period + self.record_spend(r, 0) def reset_period(self): # new period has begun for r in self.rules: r.spent_so_far = 0 + r.txn_this_period = 0 self.period_started = 0 def record_spend(self, rule, amt): @@ -817,6 +920,12 @@ def approve_address_share(self, subpath=None, miniscript=False): return match_deriv_path(self.share_addrs, subpath) + def approve_slip19(self, subpath=None): + # Are we allowing SLIP-19 ownership proofs (coinjoin remote signing) over USB? + if not self.slip19_paths: + return False + return match_deriv_path(self.slip19_paths, subpath) + @property def uptime(self): now = utime.ticks_ms() @@ -970,6 +1079,14 @@ async def approve_transaction(self, psbt, psbt_sha, story): if rule.per_period is not None: self.record_spend(rule, total_out) + if rule.max_txn is not None: + rule.txn_count += 1 + + if rule.max_txn_per_period is not None: + rule.txn_this_period += 1 + # starts the period clock without recording a spend + self.record_spend(rule, 0) + return 'y' except BaseException as exc: # sys.print_exception(exc) From 8f02fbad1a6a33553e30692a9e0091ae5aa019fa Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Thu, 3 Sep 2026 21:57:58 +0200 Subject: [PATCH 3/6] HSM screen: say when a proof is being signed, and stop lying when idle 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- shared/hsm_ux.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/shared/hsm_ux.py b/shared/hsm_ux.py index 8c0bc8c1d..40f7166fc 100644 --- a/shared/hsm_ux.py +++ b/shared/hsm_ux.py @@ -166,8 +166,17 @@ async def start_hsm_approval(sf_len=0, usb_mode=False, startup_mode=False): class hsmUxInteraction: # Based on Menu() class, but just skeleton: blocks everything + # How long a busy message may sit unchanged before it is cleared. A host that stops talking part + # way through an upload never sends the rest, and since nothing raised, the code that normally + # restores the screen never runs -- so the device is left reading "Receiving..." while it is in + # fact idle and waiting. Harmless, but it is the worst possible reading for something meant to + # be left signing unattended. Comfortably longer than the gap between progress updates during a + # real transfer, so ordinary work never trips it. + BUSY_TIMEOUT_MS = const(30000) + def __init__(self): self.busy_text = None + self.busy_since = None self.percent = None self.digits = '' self.phase = 0 @@ -264,7 +273,7 @@ def show(self): update_contents = show def draw_busy(self, msg, percent): - from display import FontTiny + from display import FontTiny, FontSmall from glob import dis self.last_percent = 0.5 @@ -279,14 +288,29 @@ def draw_busy(self, msg, percent): if percent >= 0.995: # ~ last pixel self.percent = None self.busy_text = msg = None + self.busy_since = None if msg is not None: self.busy_text = msg + if msg is not None or percent is not None: + # something is still happening, so keep it on screen + self.busy_since = utime.ticks_ms() + elif (self.busy_text is not None and self.busy_since is not None + and utime.ticks_diff(utime.ticks_ms(), self.busy_since) > BUSY_TIMEOUT_MS): + # nothing has moved for a while: the host is gone, so stop claiming to be busy + self.busy_text = None + self.percent = None + self.busy_since = None + if self.busy_text is not None: # clear under it dis.clear_rect(0,y, 128, 64-y) - dis.text(None, y, self.busy_text) + + # Drop to the tiny font rather than run off the screen: dis.text centres but does not + # wrap or shrink, so anything wider than 128px silently loses its ends. + font = FontSmall if dis.width(self.busy_text, FontSmall) <= 128 else FontTiny + dis.text(None, y, self.busy_text, font) if self.percent is not None: x = int(128 * self.percent) From 3df999bdaaeddd6dc184f623399401c7e30ea16d Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Thu, 3 Sep 2026 21:58:56 +0200 Subject: [PATCH 4/6] Tests for the ownership proofs and the five HSM rules 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- testing/test_hsm.py | 4 +- testing/test_hsm_busy_timeout.py | 63 ++++++++++ testing/test_hsm_max_feerate.py | 98 +++++++++++++++ testing/test_hsm_max_sats.py | 69 ++++++++++ testing/test_hsm_max_txn.py | 90 +++++++++++++ testing/test_hsm_min_inputs.py | 82 ++++++++++++ testing/test_slip19.py | 208 +++++++++++++++++++++++++++++++ testing/test_slip19_indicator.py | 59 +++++++++ 8 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 testing/test_hsm_busy_timeout.py create mode 100644 testing/test_hsm_max_feerate.py create mode 100644 testing/test_hsm_max_sats.py create mode 100644 testing/test_hsm_max_txn.py create mode 100644 testing/test_hsm_min_inputs.py create mode 100644 testing/test_slip19.py create mode 100644 testing/test_slip19_indicator.py diff --git a/testing/test_hsm.py b/testing/test_hsm.py index c23a146fd..fbce7d6a9 100644 --- a/testing/test_hsm.py +++ b/testing/test_hsm.py @@ -81,7 +81,7 @@ def cleanup(type_, value): rv = type_(value) return rv - top_keys = [('must_log', bool), ('never_log', bool), ('msg_paths', Deriv), ('share_xpubs', Deriv), ('share_addrs', Deriv), + top_keys = [('must_log', bool), ('never_log', bool), ('msg_paths', Deriv), ('slip19_paths', Deriv), ('share_xpubs', Deriv), ('share_addrs', Deriv), ('notes', str), ('period', int), ('allow_sl', int), ('warnings_ok', bool), ('boot_to_hsm', str), ('priv_over_ux', bool)] canonical = OrderedDict() @@ -92,7 +92,7 @@ def cleanup(type_, value): rules_keys = [ ('per_period', int), ('max_amount', int), ('users', list), ('min_users', int), ('local_conf', bool), ('whitelist', list), ('wallet', str), ('min_pct_self_transfer', float), - ('patterns', list), ('whitelist_opts', WhitelistOpts) ] + ('max_sats_leaving', int), ('max_fee_per_kvbyte', int), ('max_txn', int), ('max_txn_per_period', int), ('min_inputs', int), ('patterns', list), ('whitelist_opts', WhitelistOpts) ] canonical["rules"] = [] for rule in policy.get("rules", []): diff --git a/testing/test_hsm_busy_timeout.py b/testing/test_hsm_busy_timeout.py new file mode 100644 index 000000000..fd8900ae4 --- /dev/null +++ b/testing/test_hsm_busy_timeout.py @@ -0,0 +1,63 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# The HSM screen should stop claiming to be busy once the host has stopped talking. +# +# A host that dies part way through an upload never sends the rest. Nothing raises, so the +# restore_menu() in usb.py that normally clears the progress screen never runs, and the device is +# left reading "Receiving..." while it sits idle waiting for a packet that will not arrive. The +# device is fine -- a fresh upload at offset 0 resets the transfer -- but for something meant to be +# left signing unattended, "looks hung forever" is the wrong thing to show. +# +# Run with: py.test test_hsm_busy_timeout.py --sim +# +import pytest +from test_hsm import hsm_reset, hsm_status, start_hsm, enable_hsm_commands + +SIMPLE_POLICY = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=95)]) + + +def busy_state(sim_eval): + return sim_eval('__import__("hsm_ux").hsm_ux_obj.busy_text') + + +def test_busy_text_expires_when_nothing_moves(dev, start_hsm, hsm_reset, sim_exec, sim_eval): + start_hsm(SIMPLE_POLICY) + + # Put the screen in the state an interrupted upload leaves it in, then age it past the limit by + # winding the timestamp back rather than waiting 30 seconds of wall clock. + sim_exec(''' +import utime +from hsm_ux import hsm_ux_obj, hsmUxInteraction +hsm_ux_obj.draw_busy("Receiving...", 0) +hsm_ux_obj.busy_since = utime.ticks_add(utime.ticks_ms(), -(hsmUxInteraction.BUSY_TIMEOUT_MS + 1000)) +RV.write(repr(hsm_ux_obj.busy_text).encode()) +''') + + # The redraw the HSM loop performs every 100ms is what notices. + sim_exec('from hsm_ux import hsm_ux_obj; hsm_ux_obj.draw_busy(None, None); RV.write(b"ok")') + + assert 'None' in busy_state(sim_eval), "stale message survived the timeout" + + hsm_reset() + + +def test_busy_text_survives_while_work_continues(dev, start_hsm, hsm_reset, sim_exec, sim_eval): + # The other half of it: progress updates have to keep the message alive, or a slow transfer + # would clear its own indicator and the screen would lie in the other direction. + start_hsm(SIMPLE_POLICY) + + sim_exec(''' +import utime +from hsm_ux import hsm_ux_obj, hsmUxInteraction +hsm_ux_obj.draw_busy("Receiving...", 0) +hsm_ux_obj.busy_since = utime.ticks_add(utime.ticks_ms(), -(hsmUxInteraction.BUSY_TIMEOUT_MS + 1000)) +hsm_ux_obj.draw_busy(None, 0.5) # a chunk arrived: still working +hsm_ux_obj.draw_busy(None, None) # the idle redraw +RV.write(b"ok") +''') + + assert 'Receiving' in busy_state(sim_eval), "an active transfer lost its indicator" + + hsm_reset() + +# EOF diff --git a/testing/test_hsm_max_feerate.py b/testing/test_hsm_max_feerate.py new file mode 100644 index 000000000..299be2eb9 --- /dev/null +++ b/testing/test_hsm_max_feerate.py @@ -0,0 +1,98 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# max_fee_per_kvbyte: a cap on what we pay per 1000 vbytes of our own contribution. +# +# The other value rules bound our loss in sats, absolutely or as a share of what we put in. Neither +# says anything about whether that loss is a reasonable price for the bytes we are adding, and in a +# coinjoin nothing else does either: the other participants' input amounts are unknown, so the +# transaction-wide fee cannot be computed and upstream's fee check is skipped entirely. This rule +# needs only our own values, so it still works there. +# +# It measures (own inputs - own outputs) / (vsize of our own inputs and outputs). That is an upper +# bound on the mining feerate we pay, because our loss also absorbs coordinator fees and any value +# genuinely leaving. Erring high is the safe direction: the rule can refuse a transaction that is +# really cheaper, but it cannot pass one that is really more expensive. +# +# Run with: py.test test_hsm_max_feerate.py --sim +# +import pytest +from test_hsm import (hsm_reset, hsm_status, start_hsm, attempt_psbt, tweak_rule, + enable_hsm_commands) + +# One p2wpkh input (41*4 base + 107 witness = 271 WU) plus one p2wpkh output of ours +# ((8 + 1 + 22) * 4 = 124 WU) is 395 WU, so 98 vbytes after the rule rounds down. +# Every case below uses that shape, which makes the expected feerate exactly loss * 1000 // 98. +OUR_VSIZE = 98 + + +def test_feerate_cap_is_shown_and_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # 100,000 sats per 1000 vbytes, i.e. 100 sat/vB. + policy = dict(warnings_ok=True, rules=[dict(max_fee_per_kvbyte=100000)]) + + stat = start_hsm(policy) + assert 'per 1000 vbytes' in stat.summary + + # Losing 9,800 sats over 98 vbytes is exactly 100,000 sats/kvB: at the limit, so allowed. + assert (9800 * 1000) // OUR_VSIZE == 100000 + at_limit = fake_txn(1, [(None, 99990200, True, None), (None, 9800, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(at_limit) + + # Twice the loss over the same bytes is twice the feerate. + over = fake_txn(1, [(None, 99980000, True, None), (None, 20000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(over, 'feerate too high') + + hsm_reset() + + +def test_feerate_catches_what_the_ratio_permits(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # The two rules measure different things and neither implies the other. A 1% loss satisfies a + # 95% self-transfer floor comfortably, but on a 1 BTC input that 1% is 1,000,000 sats for 99 + # vbytes - about 10,000 sat/vB, which no honest transaction pays. + policy = dict(warnings_ok=True, + rules=[dict(min_pct_self_transfer=95, max_fee_per_kvbyte=100000)]) + + start_hsm(policy) + + passes_ratio_fails_feerate = fake_txn( + 1, [(None, 99000000, True, None), (None, 1000000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(passes_ratio_fails_feerate, 'feerate too high') + + hsm_reset() + + +def test_legacy_inputs_are_sized_too(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # A p2pkh input is 588 WU against p2wpkh's 272, so the same loss spread over a bigger input is + # a lower feerate. Guards against the weight table being wired only for the coinjoin case. + # 588 + (8 + 1 + 25) * 4 = 724 WU -> 181 vbytes. + policy = dict(warnings_ok=True, rules=[dict(max_fee_per_kvbyte=100000)]) + + start_hsm(policy) + + assert (18100 * 1000) // 181 == 100000 + at_limit = fake_txn(1, [(None, 99981900, True, None), (None, 18100, False, None)], + dev.master_xpub, addr_fmt="p2pkh", fee=0) + attempt_psbt(at_limit) + + over = fake_txn(1, [(None, 99960000, True, None), (None, 40000, False, None)], + dev.master_xpub, addr_fmt="p2pkh", fee=0) + attempt_psbt(over, 'feerate too high') + + hsm_reset() + + +def test_absent_cap_changes_nothing(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # Existing policies must behave exactly as before. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=95)]) + + start_hsm(policy) + + psbt = fake_txn(1, [(None, 95000000, True, None), (None, 5000000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(psbt) + + hsm_reset() + +# EOF diff --git a/testing/test_hsm_max_sats.py b/testing/test_hsm_max_sats.py new file mode 100644 index 000000000..c30e1f4a1 --- /dev/null +++ b/testing/test_hsm_max_sats.py @@ -0,0 +1,69 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# max_sats_leaving: an absolute cap on how much of our own value may leave in one transaction. +# +# 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 therefore refuses ordinary rounds on +# small ones. The two together fix that — the ratio binds on small amounts, the absolute cap binds +# on large ones — which is why they are ANDed rather than offered as alternatives. +# +# Run with: py.test test_hsm_max_sats.py --sim +# +import pytest +from test_hsm import (hsm_reset, hsm_status, start_hsm, attempt_psbt, tweak_rule, + enable_hsm_commands) + + +def test_absolute_cap_is_shown_and_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # 1 BTC in. Cap is 100k sats, so 10k leaving is fine and 1M is not. + policy = dict(warnings_ok=True, rules=[dict(max_sats_leaving=100000)]) + + stat = start_hsm(policy) + assert 'may leave the wallet' in stat.summary + + ok = fake_txn(1, [(None, 99990000, True, None), (None, 10000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(ok) + + too_much = fake_txn(1, [(None, 99000000, True, None), (None, 1000000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(too_much, 'too much value leaving') + + hsm_reset() + + +def test_cap_and_floor_are_both_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # The point of the pair. 95% alone lets 5% of a 1 BTC input walk (5M sats); the cap stops it. + # The cap alone would let a small transaction lose almost all of itself; the floor stops that. + # A transaction has to satisfy both. + policy = dict(warnings_ok=True, + rules=[dict(min_pct_self_transfer=95, max_sats_leaving=100000)]) + + start_hsm(policy) + + within_both = fake_txn(1, [(None, 99990000, True, None), (None, 10000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(within_both) + + # Exactly 95% comes back, so the ratio is satisfied, but 5,000,000 sats leaving is not. + passes_ratio_fails_cap = fake_txn(1, [(None, 95000000, True, None), (None, 5000000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(passes_ratio_fails_cap, 'too much value leaving') + + hsm_reset() + + +def test_absent_cap_changes_nothing(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # Existing policies must behave exactly as before. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=95)]) + + start_hsm(policy) + + psbt = fake_txn(1, [(None, 95000000, True, None), (None, 5000000, False, None)], + dev.master_xpub, fee=0) + attempt_psbt(psbt) + + hsm_reset() + +# EOF diff --git a/testing/test_hsm_max_txn.py b/testing/test_hsm_max_txn.py new file mode 100644 index 000000000..673ebcef0 --- /dev/null +++ b/testing/test_hsm_max_txn.py @@ -0,0 +1,90 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# max_txn: a device-side limit on how many transactions one approved HSM policy may sign. +# +# min_pct_self_transfer bounds what a single transaction can move, not the total. Without a 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. +# +# Run with: py.test test_hsm_max_txn.py --sim +# +import pytest +from test_hsm import (hsm_reset, hsm_status, start_hsm, attempt_psbt, tweak_rule, + enable_hsm_commands) + + +def test_max_txn_is_shown_and_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # Two transactions allowed, so the third must be refused by the device itself. + policy = dict(rules=[dict(max_txn=2)]) + + stat = start_hsm(policy) + # The user has to be able to see the limit they are approving. + assert 'at most 2 transaction' in stat.summary + + psbt = fake_txn(1, 1, dev.master_xpub, fee=0) + attempt_psbt(psbt) + attempt_psbt(psbt) + attempt_psbt(psbt, 'transaction count exceeded') + + hsm_reset() + + +def test_max_txn_absent_means_unlimited(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # Policies that do not ask for a count keep behaving exactly as before. + policy = dict(rules=[dict()]) + + start_hsm(policy) + + psbt = fake_txn(1, 1, dev.master_xpub, fee=0) + for _ in range(3): + attempt_psbt(psbt) + + hsm_reset() + + +def test_max_txn_combines_with_the_self_transfer_floor(dev, start_hsm, fake_txn, attempt_psbt, + hsm_reset): + # The pair is the point: the floor caps each transaction, the count caps how many there can + # be, so the total a compromised host can move is bounded. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=99, max_txn=1)]) + + stat = start_hsm(policy) + assert 'at most 1 transaction' in stat.summary + assert 'self-transfer' in stat.summary + + psbt = fake_txn(1, [(None, None, True, None)], dev.master_xpub, fee=0) + attempt_psbt(psbt) + attempt_psbt(psbt, 'transaction count exceeded') + + hsm_reset() + + +def test_rate_limit_is_shown_and_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # max_txn bounds the total but not the rate, so a coordinator that keeps proposing rounds can + # burn the whole budget in minutes and farm a mining fee off each one. Rate is its own axis. + policy = dict(warnings_ok=True, period=60, + rules=[dict(max_txn=10, max_txn_per_period=2)]) + + stat = start_hsm(policy) + assert '2 transaction(s) per period' in stat.summary + + psbt = fake_txn(1, 1, dev.master_xpub, fee=0) + attempt_psbt(psbt) + attempt_psbt(psbt) + # Budget still has 8 left, but the period does not. + attempt_psbt(psbt, 'too many transactions this period') + + hsm_reset() + + +def test_rate_limit_needs_a_period(dev, start_hsm, hsm_reset): + # Anything measured per period is meaningless without one, and the policy already refuses that + # combination for the sats velocity limit. + policy = dict(warnings_ok=True, rules=[dict(max_txn_per_period=2)]) + + with pytest.raises(Exception) as ee: + start_hsm(policy) + assert 'period' in str(ee.value).lower() + +# EOF diff --git a/testing/test_hsm_min_inputs.py b/testing/test_hsm_min_inputs.py new file mode 100644 index 000000000..9f825b6b1 --- /dev/null +++ b/testing/test_hsm_min_inputs.py @@ -0,0 +1,82 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# min_inputs: refuse to sign a transaction that too few parties are taking part in. +# +# For a coinjoin the host decides which round to join, and a host that has been taken over can +# pick a round with nobody in it but us and a coordinator that then learns the whole mapping. The +# device is handed the entire round transaction, so it can count the participants itself rather +# than take the host's word for it. +# +# What this does NOT give you is an anonymity set. A coordinator willing to register its own +# inputs can pad a round to any count while still knowing every link. The rule rules out the +# degenerate round; it does not make a padded one private. +# +# Run with: py.test test_hsm_min_inputs.py --sim +# +import pytest +from test_hsm import (hsm_reset, hsm_status, start_hsm, attempt_psbt, tweak_rule, + enable_hsm_commands) + + +def test_min_inputs_is_shown_and_enforced(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + policy = dict(rules=[dict(min_inputs=3)]) + + stat = start_hsm(policy) + # The user has to be able to see the limit they are approving. + assert '3 or more inputs' in stat.summary + + attempt_psbt(fake_txn(2, 1, dev.master_xpub, fee=0), 'too few inputs: 2, need 3') + attempt_psbt(fake_txn(3, 1, dev.master_xpub, fee=0)) + + hsm_reset() + + +def test_min_inputs_counts_everyone(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # The whole point: it is every participant's inputs, not the subset we happen to own. One + # input of ours alongside four strangers is a round worth joining; four of ours alone is not. + # Had the rule counted only our own inputs these two would have come out the other way round. + policy = dict(warnings_ok=True, rules=[dict(min_inputs=5)]) + + start_hsm(policy) + + def disown_all_but_first(psbt): + # drop the derivation info so the device sees the rest as somebody else's + for i in psbt.inputs[1:]: + i.bip32_paths = {} + i.taproot_bip32_paths = {} + + attempt_psbt(fake_txn(5, 1, dev.master_xpub, fee=0, psbt_hacker=disown_all_but_first)) + + attempt_psbt(fake_txn(4, 1, dev.master_xpub, fee=0), 'too few inputs: 4, need 5') + + hsm_reset() + + +def test_min_inputs_absent_means_no_floor(dev, start_hsm, fake_txn, attempt_psbt, hsm_reset): + # Policies that do not ask for it keep behaving exactly as before, including a lone input. + policy = dict(rules=[dict()]) + + start_hsm(policy) + + attempt_psbt(fake_txn(1, 1, dev.master_xpub, fee=0)) + + hsm_reset() + + +def test_min_inputs_pairs_with_the_self_transfer_floor(dev, start_hsm, fake_txn, attempt_psbt, + hsm_reset): + # The two bound different things: the floor caps what a round may cost us, min_inputs caps how + # pointless a round may be. A cheap round with nobody in it still buys no privacy. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=99, min_inputs=4)]) + + stat = start_hsm(policy) + assert '4 or more inputs' in stat.summary + assert 'self-transfer' in stat.summary + + # Costs us nothing, but it is a round of two. + attempt_psbt(fake_txn(2, [(None, None, True, None)], dev.master_xpub, fee=0), + 'too few inputs: 2, need 4') + + hsm_reset() + +# EOF diff --git a/testing/test_slip19.py b/testing/test_slip19.py new file mode 100644 index 000000000..9c5d5efd4 --- /dev/null +++ b/testing/test_slip19.py @@ -0,0 +1,208 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# SLIP-19 ownership proofs (slp9) and their HSM policy gate. +# +# Run with: py.test test_slip19.py +# +import pytest, struct, json, time +from hashlib import sha256 +from ckcc.protocol import CCProtocolPacker + +# The HSM harness fixtures live next door; importing them here makes pytest resolve them. +# enable_hsm_commands is autouse in test_hsm and must be pulled in explicitly, otherwise these +# tests only pass when some earlier module happens to have left hsmcmd enabled on the simulator. +from test_hsm import hsm_reset, hsm_status, start_hsm, enable_hsm_commands + +AF_P2WPKH = 0x07 +AF_P2TR = 0x23 +AF_CLASSIC = 0x01 + +SLIP19_MAGIC = bytes([0x53, 0x4c, 0x00, 0x19]) +FLAG_USER_CONFIRMATION = 0x01 + +COMMITMENT = b'test-slip19-commitment' +SEGWIT_PATH = b"m/84h/0h/0h/1/0" +TAPROOT_PATH = b"m/86h/0h/0h/1/0" + + +def slp9_request(subpath, addr_fmt, flags, commitment=COMMITMENT): + # '<4sIIII>': tag, addr_fmt, flags, len(subpath), len(commitment) + return (b'slp9' + struct.pack(' 38 + # bip322_sig follows: empty scriptSig, then the witness stack + assert proof[38] == 0 + assert proof[39] == witness_items + + +@pytest.mark.parametrize('addr_fmt, subpath, witness_items', [ + (AF_P2WPKH, SEGWIT_PATH, 2), # DER signature + pubkey + (AF_P2TR, TAPROOT_PATH, 1), # single BIP-340 key-spend signature +]) +def test_slp9_proof_shapes(slp9, addr_fmt, subpath, witness_items): + # Both supported script types produce a well-formed proof once approved on screen. + proof = slp9(subpath=subpath, addr_fmt=addr_fmt, flags=0) + check_proof_shape(proof, flags=0, witness_items=witness_items) + + +def test_slp9_is_deterministic(slp9): + # Same key, same commitment => same proof (RFC6979 / BIP-340 with zero aux). + assert slp9() == slp9() + + +def test_slp9_binds_commitment(slp9): + # The commitment is inside the signed digest, so changing it changes the signature. + assert slp9(commitment=b'aaa') != slp9(commitment=b'bbb') + + +def test_slp9_rejects_unsupported_addr_fmt(slp9): + # The address format is stated by the caller and validated, not guessed from the path. + with pytest.raises(Exception) as ee: + slp9(addr_fmt=AF_CLASSIC) + assert 'unsupported address format' in str(ee.value) + + +def test_slp9_outside_hsm_asks_on_screen(dev, cap_story, press_select): + # No policy, so a human must see what is being proven before it is signed. The story names + # the path and the address the proof is about. + dev.send_recv(slp9_request(SEGWIT_PATH, AF_P2WPKH, FLAG_USER_CONFIRMATION), timeout=None) + + title, story = cap_story() + assert 'Ownership' in title + assert SEGWIT_PATH.decode() in story + assert sha256(COMMITMENT).hexdigest() in story.lower() + + press_select() + proof = poll_slok(dev) + + # somebody did confirm, so the flag is now a claim the device can back + check_proof_shape(proof, flags=FLAG_USER_CONFIRMATION, witness_items=2) + + +def test_slp9_outside_hsm_can_be_refused(dev, press_cancel): + # Refusing must produce no proof at all, not an unsigned or partial one. + from ckcc_protocol.protocol import CCUserRefused + + dev.send_recv(slp9_request(SEGWIT_PATH, AF_P2WPKH, 0), timeout=None) + press_cancel() + + with pytest.raises(CCUserRefused): + poll_slok(dev) + + +def test_slp9_result_is_only_for_slok(dev, press_select): + # A pending proof must not be collectable as if it were a signed message: smok would + # otherwise hand back the proof wrapped in a message-signature response. + dev.send_recv(slp9_request(SEGWIT_PATH, AF_P2WPKH, 0), timeout=None) + press_select() + + with pytest.raises(Exception) as ee: + dev.send_recv(b'smok', timeout=None) + assert 'Wrong completion command' in str(ee.value) + + # and the proof is still there for its own poll + check_proof_shape(poll_slok(dev), flags=0, witness_items=2) + + +def test_slp9_rejects_junk_path(slp9): + with pytest.raises(Exception): + slp9(subpath=b"m/84h/0h/zz/1/0") + + +@pytest.mark.parametrize('policy_paths, subpath, allowed', [ + (["m/84h/0h/0h/1/*"], SEGWIT_PATH, True), + (["m/84h/0h/0h/0/*"], SEGWIT_PATH, False), # wrong branch + (["m/86h/0h/0h/1/*"], TAPROOT_PATH, True), + ([], SEGWIT_PATH, False), # no slip19_paths => never allowed +]) +def test_slp9_hsm_path_gate(slp9, start_hsm, hsm_reset, policy_paths, subpath, allowed): + # Under a policy, only whitelisted paths may be proven, and the confirmation flag is + # permitted because the approved policy is the user's standing consent. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=99)]) + if policy_paths: + policy['slip19_paths'] = policy_paths + start_hsm(policy) + + addr_fmt = AF_P2TR if subpath == TAPROOT_PATH else AF_P2WPKH + if allowed: + proof = slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) + check_proof_shape(proof, flags=FLAG_USER_CONFIRMATION, + witness_items=1 if subpath == TAPROOT_PATH else 2) + else: + with pytest.raises(Exception) as ee: + slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) + assert 'Not allowed in HSM mode' in str(ee.value) + + hsm_reset() + + +# --- the ownership identifier itself --------------------------------------------- +# +# Official SLIP-19 vector 1: BIP-39 seed "all all ... all", no passphrase, P2WPKH at +# m/84h/0h/0h/1/0. The identifier is defined over the scriptPubKey alone, so it does +# not depend on which chain the simulator happens to be set to. + +VECTOR_WORDS = "all all all all all all all all all all all all" +VECTOR_SPK = bytes.fromhex("0014b2f771c370ccf219cd3059cda92bdf7f00cf2103") +VECTOR_OID = bytes.fromhex("a122407efc198211c81af4450f40b235d54775efd934d16b9e31c6ce9bad5707") + + +def test_ownership_id_matches_official_vector(set_seed_words, sim_exec): + # Pin the derivation against the published vector, so a change to the SLIP-21 label + # path or the HMAC ordering fails here rather than in somebody wallet. + set_seed_words(VECTOR_WORDS) + + rv = sim_exec("import slip19, stash, binascii\n" + "with stash.SensitiveValues() as sv:\n" + " RV.write(binascii.hexlify(slip19.ownership_id(%r, sv)))" % VECTOR_SPK) + assert rv.strip().endswith(VECTOR_OID.hex()) + + +def test_slp9_carries_the_real_ownership_id(set_seed_words, slp9): + # End to end: the id inside the proof is the spec value for the key the device just + # derived, not the 32 zero bytes this replaced, which told a coordinator nothing. + set_seed_words(VECTOR_WORDS) + + oid = slp9(subpath=SEGWIT_PATH, addr_fmt=AF_P2WPKH)[6:38] + assert oid != bytes(32) + assert oid == VECTOR_OID + + +def test_ownership_id_is_bound_to_the_script(set_seed_words, slp9): + # One seed, two scripts: the identifiers must differ, or the id is not identifying. + set_seed_words(VECTOR_WORDS) + + segwit = slp9(subpath=SEGWIT_PATH, addr_fmt=AF_P2WPKH)[6:38] + taproot = slp9(subpath=TAPROOT_PATH, addr_fmt=AF_P2TR)[6:38] + assert segwit != taproot + +# EOF diff --git a/testing/test_slip19_indicator.py b/testing/test_slip19_indicator.py new file mode 100644 index 000000000..2709e37fd --- /dev/null +++ b/testing/test_slip19_indicator.py @@ -0,0 +1,59 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# The HSM screen should say when the device is signing an ownership proof. +# +# Unattended coinjoin signing is silent by design, so without this there is no way to tell a working +# session from an idle one by looking at the Coldcard. PSBT signing already announces itself through +# the busy line; ownership proofs did not. +# +# Run with: py.test test_slip19_indicator.py --sim +# +import pytest, struct +from test_hsm import hsm_reset, hsm_status, start_hsm, enable_hsm_commands + +AF_P2WPKH = 0x07 +SEGWIT_PATH = b"m/84h/0h/0h/1/0" +COMMITMENT = b'indicator-check' + + +def slp9_request(subpath, addr_fmt=AF_P2WPKH, flags=0, commitment=COMMITMENT): + return (b'slp9' + struct.pack(' 128, "test is pointless if the message already fits in the normal font" + assert tiny <= 128, "the fallback font has to actually fit, or the text is still clipped" + + +def test_proof_announces_itself_on_the_hsm_screen(dev, start_hsm, hsm_reset, sim_exec): + policy = dict(warnings_ok=True, slip19_paths=["m/84h/0h/0h/1/*"], + rules=[dict(min_pct_self_transfer=95)]) + start_hsm(policy) + + # The busy line is written during signing and cleared after, so sampling it afterwards proves + # nothing. Wrap the display call to record what it was asked to show. + # Record the call without delegating: what matters is that the device announced itself, and + # calling through to the real screen from a replaced bound method upsets MicroPython. + sim_exec(''' +import glob +glob.dis._seen = [] +glob.dis.fullscreen = lambda msg, percent=None: glob.dis._seen.append(msg) +RV.write(b'armed') +''') + + dev.send_recv(slp9_request(SEGWIT_PATH)) + + seen = sim_exec('import glob; RV.write(repr(glob.dis._seen).encode())') + assert 'ownership proof' in seen.lower(), seen + + hsm_reset() + +# EOF From 7ae59e660208ed1d670f87fad32477b51a6a87f0 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Thu, 3 Sep 2026 22:26:43 +0200 Subject: [PATCH 5/6] Test a taproot round end to end under an HSM policy 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- testing/test_hsm_taproot_coinjoin.py | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 testing/test_hsm_taproot_coinjoin.py diff --git a/testing/test_hsm_taproot_coinjoin.py b/testing/test_hsm_taproot_coinjoin.py new file mode 100644 index 000000000..879bf80ce --- /dev/null +++ b/testing/test_hsm_taproot_coinjoin.py @@ -0,0 +1,41 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# End to end on the edge line: a taproot coin is proven with slp9 under an HSM policy, then a +# taproot coinjoin-shaped PSBT is signed unattended under the same policy, and the feerate rule +# sizes the P2TR inputs rather than refusing to size them. +# +# Run with: py.test test_hsm_taproot_coinjoin.py --sim +# +import pytest +from test_hsm import hsm_reset, hsm_status, start_hsm, attempt_psbt, enable_hsm_commands +from test_slip19 import slp9_request, check_proof_shape, AF_P2TR, TAPROOT_PATH, FLAG_USER_CONFIRMATION + +BTC = 100_000_000 + + +def test_taproot_round_signs_under_policy(dev, start_hsm, hsm_reset, fake_txn, attempt_psbt): + policy = dict(warnings_ok=True, + slip19_paths=["m/86h/0h/0h/1/*"], + rules=[dict(min_pct_self_transfer=95, max_sats_leaving=2_000_000, + max_fee_per_kvbyte=100_000, min_inputs=2)]) + start_hsm(policy) + + # input registration: the proof for a taproot coin comes straight back under the policy + proof = dev.send_recv(slp9_request(TAPROOT_PATH, AF_P2TR, FLAG_USER_CONFIRMATION), timeout=None) + check_proof_shape(proof, flags=FLAG_USER_CONFIRMATION, witness_items=1) + + # the round: two of our taproot inputs, our taproot change back, one foreign taproot output. + # ~158 vbytes of ours, 10,000 sats lost -> ~63,000 sats/kvB, inside every limit + ok = fake_txn(2, [["p2tr", 2*BTC - 10_000, True], ["p2tr", 10_000]], + dev.master_xpub, addr_fmt="p2tr", fee=0) + attempt_psbt(ok) + + # same shape, 1,000,000 sats lost: ratio (99.5%) and absolute cap both pass, so the only rule + # that can refuse is the feerate one -- which means it sized the P2TR inputs. + pricey = fake_txn(2, [["p2tr", 2*BTC - 1_000_000, True], ["p2tr", 1_000_000]], + dev.master_xpub, addr_fmt="p2tr", fee=0) + attempt_psbt(pricey, 'feerate too high') + + hsm_reset() + +# EOF From 3ff6438626624012021ba1d85d504acb19ec4758 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Thu, 3 Sep 2026 23:23:38 +0200 Subject: [PATCH 6/6] Split the HSM policy gate out of test_slip19.py so the rest runs on the 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 Claude-Session: https://claude.ai/code/session_0181MnMhWTiezrmCDNkSi2xB --- testing/test_slip19.py | 42 ++++++------------------------------ testing/test_slip19_hsm.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 36 deletions(-) create mode 100644 testing/test_slip19_hsm.py diff --git a/testing/test_slip19.py b/testing/test_slip19.py index 9c5d5efd4..db5466b65 100644 --- a/testing/test_slip19.py +++ b/testing/test_slip19.py @@ -1,17 +1,14 @@ # (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. # -# SLIP-19 ownership proofs (slp9) and their HSM policy gate. +# SLIP-19 ownership proofs (slp9) outside HSM mode, and the identifier itself. # -# Run with: py.test test_slip19.py +# Nothing here needs HSM, so it runs on the Q as well as the Mk4. The policy gate is in +# test_slip19_hsm.py, which pulls in the HSM harness and therefore skips on the Q. # -import pytest, struct, json, time +# Run with: py.test test_slip19.py --sim +# +import pytest, struct, time from hashlib import sha256 -from ckcc.protocol import CCProtocolPacker - -# The HSM harness fixtures live next door; importing them here makes pytest resolve them. -# enable_hsm_commands is autouse in test_hsm and must be pulled in explicitly, otherwise these -# tests only pass when some earlier module happens to have left hsmcmd enabled on the simulator. -from test_hsm import hsm_reset, hsm_status, start_hsm, enable_hsm_commands AF_P2WPKH = 0x07 AF_P2TR = 0x23 @@ -138,33 +135,6 @@ def test_slp9_rejects_junk_path(slp9): slp9(subpath=b"m/84h/0h/zz/1/0") -@pytest.mark.parametrize('policy_paths, subpath, allowed', [ - (["m/84h/0h/0h/1/*"], SEGWIT_PATH, True), - (["m/84h/0h/0h/0/*"], SEGWIT_PATH, False), # wrong branch - (["m/86h/0h/0h/1/*"], TAPROOT_PATH, True), - ([], SEGWIT_PATH, False), # no slip19_paths => never allowed -]) -def test_slp9_hsm_path_gate(slp9, start_hsm, hsm_reset, policy_paths, subpath, allowed): - # Under a policy, only whitelisted paths may be proven, and the confirmation flag is - # permitted because the approved policy is the user's standing consent. - policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=99)]) - if policy_paths: - policy['slip19_paths'] = policy_paths - start_hsm(policy) - - addr_fmt = AF_P2TR if subpath == TAPROOT_PATH else AF_P2WPKH - if allowed: - proof = slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) - check_proof_shape(proof, flags=FLAG_USER_CONFIRMATION, - witness_items=1 if subpath == TAPROOT_PATH else 2) - else: - with pytest.raises(Exception) as ee: - slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) - assert 'Not allowed in HSM mode' in str(ee.value) - - hsm_reset() - - # --- the ownership identifier itself --------------------------------------------- # # Official SLIP-19 vector 1: BIP-39 seed "all all ... all", no passphrase, P2WPKH at diff --git a/testing/test_slip19_hsm.py b/testing/test_slip19_hsm.py new file mode 100644 index 000000000..28a51e53f --- /dev/null +++ b/testing/test_slip19_hsm.py @@ -0,0 +1,44 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# The HSM policy gate for SLIP-19 ownership proofs: slip19_paths decides which paths may be +# proven unattended. Kept apart from test_slip19.py because importing the HSM harness makes the +# whole module skip on the Q, and the rest of slp9 does not need HSM. +# +# Run with: py.test test_slip19_hsm.py --sim +# +import pytest + +# enable_hsm_commands is autouse in test_hsm and must be pulled in explicitly, otherwise these +# tests only pass when some earlier module happens to have left hsmcmd enabled on the simulator. +from test_hsm import hsm_reset, hsm_status, start_hsm, enable_hsm_commands +from test_slip19 import (slp9, check_proof_shape, AF_P2WPKH, AF_P2TR, SEGWIT_PATH, TAPROOT_PATH, + FLAG_USER_CONFIRMATION) + + +@pytest.mark.parametrize('policy_paths, subpath, allowed', [ + (["m/84h/0h/0h/1/*"], SEGWIT_PATH, True), + (["m/84h/0h/0h/0/*"], SEGWIT_PATH, False), # wrong branch + (["m/86h/0h/0h/1/*"], TAPROOT_PATH, True), + ([], SEGWIT_PATH, False), # no slip19_paths => never allowed +]) +def test_slp9_hsm_path_gate(slp9, start_hsm, hsm_reset, policy_paths, subpath, allowed): + # Under a policy, only whitelisted paths may be proven, and the confirmation flag is + # permitted because the approved policy is the user's standing consent. + policy = dict(warnings_ok=True, rules=[dict(min_pct_self_transfer=99)]) + if policy_paths: + policy['slip19_paths'] = policy_paths + start_hsm(policy) + + addr_fmt = AF_P2TR if subpath == TAPROOT_PATH else AF_P2WPKH + if allowed: + proof = slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) + check_proof_shape(proof, flags=FLAG_USER_CONFIRMATION, + witness_items=1 if subpath == TAPROOT_PATH else 2) + else: + with pytest.raises(Exception) as ee: + slp9(subpath=subpath, addr_fmt=addr_fmt, flags=FLAG_USER_CONFIRMATION) + assert 'Not allowed in HSM mode' in str(ee.value) + + hsm_reset() + +# EOF