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) 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) 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(' 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_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 diff --git a/testing/test_slip19.py b/testing/test_slip19.py new file mode 100644 index 000000000..db5466b65 --- /dev/null +++ b/testing/test_slip19.py @@ -0,0 +1,178 @@ +# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC. +# +# SLIP-19 ownership proofs (slp9) outside HSM mode, and the identifier itself. +# +# 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. +# +# Run with: py.test test_slip19.py --sim +# +import pytest, struct, time +from hashlib import sha256 + +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") + + +# --- 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_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 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