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/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', diff --git a/shared/slip19.py b/shared/slip19.py new file mode 100644 index 00000000..0931af4e --- /dev/null +++ b/shared/slip19.py @@ -0,0 +1,139 @@ +# 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]) + +# --- 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 the master fingerprint. A different seed - including the same +# words under a different BIP-39 passphrase - yields a different xfp, so a cached key can +# never leak across wallets. +_oid_key_cache = None + + +def _master_seed(sv): + # SLIP-21 needs the seed the BIP-32 tree was built from, not the tree itself. + if sv.mode == 'master': + return bytes(sv.raw) + if sv.mode == 'words': + # Re-derives through PBKDF2 (seconds); that cost is why the key is cached. + import bip39 + return bip39.master_secret(bip39.b2a_words(sv.raw), sv._bip39pw) + # 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') + + +def _ownership_id_key(): + global _oid_key_cache + + from glob import settings + xfp = settings.get('xfp', 0) + if _oid_key_cache is not None and _oid_key_cache[0] == xfp: + return _oid_key_cache[1] + + with stash.SensitiveValues() as sv: + seed = _master_seed(sv) + 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 = (xfp, k) + return k + + +def ownership_id(spk): + # SLIP-19 ownership identifier for one of this wallet's scriptPubKeys. + return bytes(ngu.hmac.hmac_sha256(_ownership_id_key(), spk)) + + +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 + + if addr_fmt == AF_P2WPKH: + h160 = ngu.hash.ripemd160(ngu.hash.sha256s(pubkey)) + spk = bytes([0x00, 0x14]) + h160 + proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + _cs(1) + ownership_id(spk) + 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 + proof_body = SLIP19_MAGIC + bytes([flags & 0xff]) + _cs(1) + ownership_id(spk) + 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 diff --git a/shared/usb.py b/shared/usb.py index bb5c3c9a..c1dd121a 100644 --- a/shared/usb.py +++ b/shared/usb.py @@ -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", @@ -358,10 +362,18 @@ async def handle(self, cmd, args): if is_devmode and cmd[0].isupper(): # special hacky commands to support testing w/ the simulator + # + # EVAL/EXEC are arbitrary code execution and XKEY injects keypresses, so they must + # not also be a way around HSM mode. HSM exists so the device can be left unattended + # with a host that may be compromised - which is exactly what unattended coinjoin + # signing is. The simulator keeps them, because the test suite drives HSM over this + # same path. + if hsm_active and not is_simulator(): + raise HSMDenied try: from usb_test_commands import do_usb_command return do_usb_command(cmd, args) - except: + except: pass if hsm_active: @@ -459,6 +471,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('': 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() + + +# --- 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_eval): + # 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_eval("__import__(\"binascii\").hexlify(" + "__import__(\"slip19\").ownership_id(%r)).decode()" % VECTOR_SPK) + assert rv.strip().strip("\x27\"") == 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