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
80 changes: 80 additions & 0 deletions shared/slip19.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 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, 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 (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
14 changes: 14 additions & 0 deletions shared/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<III', args)
assert len(args) == (12 + len_subpath + len_commit), 'badlen'
subpath = bytes(args[12:12+len_subpath]).decode()
commitment = bytes(args[12+len_subpath:])

from glob import hsm_active
if hsm_active and not hsm_active.approve_slip19(subpath):
raise HSMDenied

from slip19 import make_ownership_proof
return b'biny' + make_ownership_proof(subpath, 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