From e5b306c5bde856f593940ffd4096d73566235226 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Wed, 8 Jul 2026 15:53:12 +0200 Subject: [PATCH 1/7] Add SLIP-19 ownership proofs (slp9 USB cmd) + HSM slip19_paths gate for coinjoin remote signing --- shared/hsm.py | 9 ++++++- shared/slip19.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ shared/usb.py | 14 +++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 shared/slip19.py diff --git a/shared/hsm.py b/shared/hsm.py index 09f33cb3..c0b653e4 100644 --- a/shared/hsm.py +++ b/shared/hsm.py @@ -489,6 +489,7 @@ def load(self, j): # a list of paths we can accept for signing self.msg_paths = pop_deriv_list(j, 'msg_paths') + self.slip19_paths = pop_deriv_list(j, 'slip19_paths') # SLIP-19 ownership proofs (coinjoin) self.share_xpubs = pop_deriv_list(j, 'share_xpubs') self.share_addrs = pop_deriv_list(j, 'share_addrs', 'p2sh') @@ -528,7 +529,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: @@ -818,6 +819,12 @@ def approve_address_share(self, subpath=None, is_p2sh=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() diff --git a/shared/slip19.py b/shared/slip19.py new file mode 100644 index 00000000..f7bc24f8 --- /dev/null +++ b/shared/slip19.py @@ -0,0 +1,62 @@ +# 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). +# +# The wire result is the full serialized ownership proof: proof_body || bip322_sig +# (bip322_sig = empty scriptSig (varint 0) || witness stack). +import ngu, stash +from public_constants import AF_P2WPKH, AF_P2TR + +SLIP19_MAGIC = bytes([0x53, 0x4c, 0x00, 0x19]) +FLAG_USER_CONFIRMATION = 0x01 + +def _cs(n): + # Bitcoin compact-size varint (values used here are small). + if n < 0xfd: + return bytes([n]) + return bytes([0xfd, n & 0xff, (n >> 8) & 0xff]) + +def make_ownership_proof(subpath, flags, commitment): + # subpath: str like "m/84h/0h/0h/1/0"; flags: int; commitment: bytes. + with stash.SensitiveValues() as sv: + node = sv.derive_path(subpath) + pk = node.privkey() + pubkey = node.pubkey() # 33-byte compressed + + # scriptPubKey: default Wasabi paths -> P2WPKH for 84h, P2TR for 86h. Decide by path purpose. + purpose = subpath.split("/")[1] if "/" in subpath else "" + is_taproot = purpose.startswith("86") + + oid = bytes(32) # ownership id (see _ownership_id note) + proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + _cs(1) + oid + + if not is_taproot: + h160 = ngu.hash.ripemd160(ngu.hash.sha256s(pubkey)) + spk = bytes([0x00, 0x14]) + h160 + preimage = proof_body + _cs(len(spk)) + spk + _cs(len(commitment)) + commitment + digest = ngu.hash.sha256s(preimage) + sig65 = ngu.secp256k1.sign(pk, digest, 0).to_bytes() + r = sig65[1:33] + s = sig65[33:65] + der = _der_sig(r, s) + bytes([0x01]) # + SIGHASH_ALL + witness = _cs(2) + _cs(len(der)) + der + _cs(len(pubkey)) + pubkey + else: + # P2TR: BIP86 output key + BIP-340 schnorr keyspend over the same digest. + raise ValueError("taproot slip19 not yet implemented") # productionization TODO + + bip322_sig = _cs(0) + witness # empty scriptSig, then witness stack + return proof_body + bip322_sig + +def _der_sig(r, s): + def der_int(x): + x = bytes(x) + i = 0 + while i < len(x) - 1 and x[i] == 0: + i += 1 + x = x[i:] + if x[0] & 0x80: + x = bytes([0]) + x + return bytes([0x02, len(x)]) + x + body = der_int(r) + der_int(s) + return bytes([0x30, len(body)]) + body diff --git a/shared/usb.py b/shared/usb.py index bb5c3c9a..706d511b 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -459,6 +459,20 @@ 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). + flags, len_subpath, len_commit = unpack_from(' Date: Sat, 11 Jul 2026 02:47:40 +0200 Subject: [PATCH 2/7] Implement P2TR/BIP-86 taproot ownership proofs in slp9 Taproot key-spend proof: BIP-86 tweak the internal key (libsecp keypair_xonly_tweak_add handles internal even-Y + output parity), then a BIP-340 schnorr signature over the same SLIP-19 digest. Simulator-verified against Wasabi OwnershipProof.VerifyOwnership with an independent NBitcoin BIP-86 derivation. Note: a full taproot coinjoin round also needs taproot PSBT spend-signing, which is EDGE-firmware only. --- shared/slip19.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/shared/slip19.py b/shared/slip19.py index f7bc24f8..2144ed3c 100644 --- a/shared/slip19.py +++ b/shared/slip19.py @@ -17,6 +17,11 @@ def _cs(n): return bytes([n]) return bytes([0xfd, n & 0xff, (n >> 8) & 0xff]) +def _tagged_hash(tag, msg): + # BIP-340 tagged hash: SHA256( SHA256(tag) || SHA256(tag) || msg ). + th = ngu.hash.sha256s(tag) + return ngu.hash.sha256s(th + th + msg) + def make_ownership_proof(subpath, flags, commitment): # subpath: str like "m/84h/0h/0h/1/0"; flags: int; commitment: bytes. with stash.SensitiveValues() as sv: @@ -42,8 +47,21 @@ def make_ownership_proof(subpath, flags, commitment): der = _der_sig(r, s) + bytes([0x01]) # + SIGHASH_ALL witness = _cs(2) + _cs(len(der)) + der + _cs(len(pubkey)) + pubkey else: - # P2TR: BIP86 output key + BIP-340 schnorr keyspend over the same digest. - raise ValueError("taproot slip19 not yet implemented") # productionization TODO + # P2TR (BIP-86 key-spend): tweak the internal key with the taproot tweak (no script tree), + # then sign the same SLIP-19 digest as a BIP-340 key-spend. libsecp256k1's + # keypair_xonly_tweak_add handles the internal even-Y negation and output-key parity, so the + # resulting signature verifies against the tweaked output key in the scriptPubKey. + kp = ngu.secp256k1.keypair(pk) + internal_xonly = kp.xonly_pubkey().to_bytes() # 32-byte internal x-only key + out_kp = kp.xonly_tweak_add(_tagged_hash(b'TapTweak', internal_xonly)) + out_xonly = out_kp.xonly_pubkey().to_bytes() # 32-byte output x-only key + spk = bytes([0x51, 0x20]) + out_xonly # P2TR: OP_1 push32 + preimage = proof_body + _cs(len(spk)) + spk + _cs(len(commitment)) + commitment + digest = ngu.hash.sha256s(preimage) + # 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(out_kp, digest, bytes(32)) + witness = _cs(1) + _cs(len(sig)) + sig bip322_sig = _cs(0) + witness # empty scriptSig, then witness stack return proof_body + bip322_sig From ab8c7bfa34a45348aa33d47c3f5bbf605afbe332 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 18 Jul 2026 16:14:46 +0200 Subject: [PATCH 3/7] Freeze slip19.py into firmware builds The stm32 frozen-module manifest lists files explicitly; slip19.py was missing, so real hardware raised ImportError on the first slp9 command (err_Confused at the import in usb.py). The simulator loads shared/ from the filesystem and never hit it. Found on a Mk4 running the dev build. --- shared/manifest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/manifest.py b/shared/manifest.py index d8fa2841..0680985b 100644 --- a/shared/manifest.py +++ b/shared/manifest.py @@ -46,6 +46,7 @@ 'seed.py', 'selftest.py', 'serializations.py', + 'slip19.py', 'sffile.py', 'stash.py', 'tapsigner.py', From 3df9b9fadc1a5b9a91213320af33791a47abb44c Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 18 Jul 2026 16:34:40 +0200 Subject: [PATCH 4/7] Fix slp9 under HSM mode: whitelist the command and canonicalize the subpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues found testing HSM mode end-to-end in the simulator: - slp9 was never added to HSM_WHITELIST, so HSM mode rejected the command before the slip19_paths policy gate could run. That defeats the whole point: ownership proofs are needed during unattended coinjoin operation. - the handler passed the raw subpath string to approve_slip19, but match_deriv_path requires both sides in canonical hard notation (84h not 84-prime), so every path was denied. Clean it with cleanup_deriv_path first (which also validates junk input). Note: building this tree also requires the ckcc-protocol submodule at its recorded pin (3d1dfa8) — an older checkout lacks PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE in ckcc/constants.py (symlinked as shared/public_constants.py), which breaks the auth/hsm_ux import chain at runtime and crashes the HSM menu. --- shared/usb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/usb.py b/shared/usb.py index 706d511b..6c757e36 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -55,6 +55,7 @@ 'stok', 'smok', # completion check: sign txn or msg 'xpub', 'msck', # quick status checks 'p2sh', 'show', # 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 }) @@ -463,7 +464,8 @@ async def handle(self, cmd, args): # SLIP-19 ownership proof, for coinjoin remote signing (Wasabi WabiSabi). flags, len_subpath, len_commit = unpack_from(' Date: Fri, 24 Jul 2026 22:28:41 +0200 Subject: [PATCH 5/7] Harden slp9: explicit address format, honest confirmation flag, tests Review of the command surfaced three problems, all reachable from a host. The script type was inferred from the derivation path's purpose field with startswith(86), which also matches 860h and friends, and silently produced a P2WPKH proof for any other purpose - including 44h and 49h, where the wallet does not use that script. A proof over the wrong scriptPubKey is useless rather than dangerous, but it fails in a way that is hard to diagnose. The caller now states the address format, the same way smsg already does, and anything other than AF_P2WPKH or AF_P2TR is refused instead of guessed. This makes the AF_ constants the module already imported actually load-bearing. The user-confirmation flag is an assertion to a coinjoin coordinator that a human approved this input, but it was taken verbatim from the host and no confirmation was ever sought, so the device would happily sign a claim that nobody had made. It is now refused unless an HSM policy is active, where the approved policy is the user's standing consent - which is the point of running one. Neither the command nor its policy gate had tests. testing/test_slip19.py covers proof shape for both script types, determinism, that the commitment is bound, the rejected address format, the refused confirmation flag, junk paths, and the slip19_paths whitelist including the empty-list case that must deny everything. Note the path handling was already sound and stays that way: usb.py canonicalises the subpath once and hands the same string to both approve_slip19 and make_ownership_proof, so no caller can get one path approved and a different key signed. --- shared/slip19.py | 15 +++--- shared/usb.py | 26 ++++++--- testing/test_slip19.py | 117 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) create mode 100755 testing/test_slip19.py diff --git a/shared/slip19.py b/shared/slip19.py index 2144ed3c..d242a33c 100644 --- a/shared/slip19.py +++ b/shared/slip19.py @@ -22,21 +22,22 @@ def _tagged_hash(tag, msg): th = ngu.hash.sha256s(tag) return ngu.hash.sha256s(th + th + msg) -def make_ownership_proof(subpath, flags, commitment): - # subpath: str like "m/84h/0h/0h/1/0"; flags: int; commitment: bytes. +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. + # The caller states the address format, the same way smsg does. Deriving it from the path + # purpose instead would be a guess: the purpose does not have to match the script actually + # used at that path, and a proof over the wrong scriptPubKey is silently useless. + assert addr_fmt in (AF_P2WPKH, AF_P2TR), 'unsupported address format for ownership proof' + with stash.SensitiveValues() as sv: node = sv.derive_path(subpath) pk = node.privkey() pubkey = node.pubkey() # 33-byte compressed - # scriptPubKey: default Wasabi paths -> P2WPKH for 84h, P2TR for 86h. Decide by path purpose. - purpose = subpath.split("/")[1] if "/" in subpath else "" - is_taproot = purpose.startswith("86") - oid = bytes(32) # ownership id (see _ownership_id note) proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + _cs(1) + oid - if not is_taproot: + if addr_fmt == AF_P2WPKH: h160 = ngu.hash.ripemd160(ngu.hash.sha256s(pubkey)) spk = bytes([0x00, 0x14]) + h160 preimage = proof_body + _cs(len(spk)) + spk + _cs(len(commitment)) + commitment diff --git a/shared/usb.py b/shared/usb.py index 6c757e36..8361416c 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -60,6 +60,9 @@ 'gslr', # read storage locker; hsm mode only, limited usage }) +# SLIP-19 proof flag asserting a human approved this input (mirrors slip19.FLAG_USER_CONFIRMATION). +SLIP19_USER_CONFIRMATION = const(0x01) + # HSM related commands that are not allowed if 'hsmcmd' is disabled. HSM_DISABLE_CMDS = frozenset({ "user", @@ -462,18 +465,27 @@ async def handle(self, cmd, args): if cmd == 'slp9': # SLIP-19 ownership proof, for coinjoin remote signing (Wasabi WabiSabi). - flags, len_subpath, len_commit = unpack_from('': 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 outside HSM mode, + # so long as they do not claim a user confirmation that never happened. + 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_confirmation_flag_needs_hsm(slp9): + # The flag asserts to a coordinator that a human approved this input. Outside HSM mode + # nobody did, and the host chooses the flag, so the device must refuse to make the claim. + with pytest.raises(Exception) as ee: + slp9(flags=FLAG_USER_CONFIRMATION) + assert 'user confirmation' in str(ee.value) + + +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() + +# EOF From 161302a8011e324033c28a3cbc2068ae103589cf Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Fri, 24 Jul 2026 23:28:23 +0200 Subject: [PATCH 6/7] Teach the test harness about the slip19_paths policy field compute_policy_hash builds its own canonical copy of a policy from a field table, and that table has to mirror HSMPolicy.save() or the hash it predicts will not match the one the device shows. Adding slip19_paths to the policy without adding it here meant the start_hsm fixture failed its policy-hash assertion for any policy carrying the field, which is every coinjoin policy. Inserted in the same position as in save(), typed as a derivation list so it gets the same 'p'/apostrophe to 'h' canonicalisation as msg_paths. Verified: test_slip19.py 11 passed, and test_hsm.py 120 passed with no change in behaviour for policies that do not use the field. --- testing/test_hsm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_hsm.py b/testing/test_hsm.py index 446cd97f..de42d1f8 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() From 22f00f102d79b6891843c8a1bb247a969ab68509 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Fri, 24 Jul 2026 23:58:07 +0200 Subject: [PATCH 7/7] Make the slip19 tests self-contained about hsmcmd The HSM-gate cases need the hsmcmd setting enabled on the simulator. test_hsm supplies that through an autouse fixture, but autouse only applies within its own module, so importing hsm_reset/start_hsm was not enough: the tests passed only when an earlier module had happened to leave hsmcmd set, and failed on a freshly started simulator. Pull enable_hsm_commands in explicitly so the file passes standalone, which is how a reviewer will run it. --- testing/test_slip19.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/test_slip19.py b/testing/test_slip19.py index a378623e..d910458e 100755 --- a/testing/test_slip19.py +++ b/testing/test_slip19.py @@ -9,7 +9,9 @@ from ckcc.protocol import CCProtocolPacker # The HSM harness fixtures live next door; importing them here makes pytest resolve them. -from test_hsm import hsm_reset, hsm_status, start_hsm +# 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