Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion shared/hsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions shared/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
'seed.py',
'selftest.py',
'serializations.py',
'slip19.py',
'sffile.py',
'stash.py',
'tapsigner.py',
Expand Down
81 changes: 81 additions & 0 deletions shared/slip19.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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 _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, 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

oid = bytes(32) # ownership id (see _ownership_id note)
proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + _cs(1) + oid

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
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 (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 <output key>
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

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
28 changes: 28 additions & 0 deletions shared/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,14 @@
'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
})

# 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",
Expand Down Expand Up @@ -459,6 +463,30 @@ 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).
addr_fmt, flags, len_subpath, len_commit = unpack_from('<IIII', args)
assert len(args) == (16 + len_subpath + len_commit), 'badlen'
from utils import cleanup_deriv_path
# One canonical path is used for BOTH the policy check and the derivation below, so a
# caller cannot get one string approved and a different key signed.
subpath = cleanup_deriv_path(args[16:16+len_subpath])
commitment = bytes(args[16+len_subpath:])

from glob import hsm_active
if hsm_active:
if not hsm_active.approve_slip19(subpath):
raise HSMDenied
elif flags & SLIP19_USER_CONFIRMATION:
# The confirmation flag is an assertion to the coordinator that a human approved
# this input. Outside HSM mode nobody has, and the host picks the flag, so refuse
# rather than sign a claim we cannot back. Under HSM the approved policy is the
# standing consent, which is the whole point of the policy.
raise ValueError('user confirmation flag requires an approved HSM policy')

from slip19 import make_ownership_proof
return b'biny' + make_ownership_proof(subpath, addr_fmt, flags, commitment)

if cmd == 'p2sh':
# show P2SH (probably multisig) address on screen (also provides it back)
# - must provide redeem script, and list of [xfp+path]
Expand Down
2 changes: 1 addition & 1 deletion testing/test_hsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
119 changes: 119 additions & 0 deletions testing/test_slip19.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# (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
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('<IIII', addr_fmt, flags, len(subpath), len(commitment))
+ subpath + commitment)


@pytest.fixture
def slp9(dev):
def doit(subpath=SEGWIT_PATH, addr_fmt=AF_P2WPKH, flags=0, commitment=COMMITMENT):
return dev.send_recv(slp9_request(subpath, addr_fmt, flags, commitment))
return doit


def check_proof_shape(proof, flags, witness_items):
# proof_body = magic || flags || varint(count) || 32-byte ownership id
assert proof[0:4] == SLIP19_MAGIC
assert proof[4] == flags
assert proof[5] == 1
assert len(proof) > 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