firmware/testing/test_sssp.py
2025-09-16 10:35:07 -04:00

622 lines
21 KiB
Python

# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# tests related to Single Signer Spending Policy feature (SSSP)
#
# run simulator without --eff
#
#
import pytest, time, base64, os
from psbt import BasicPSBT
@pytest.fixture
def goto_sssp_menu(goto_home, pick_menu_item, is_mark4):
def doit():
goto_home()
pick_menu_item("Advanced/Tools")
pick_menu_item("Spending Policy")
pick_menu_item("Single-Signer")
return doit
@pytest.fixture
def setup_sssp(goto_sssp_menu, pick_menu_item, cap_story, press_select, pass_word_quiz, is_q1,
seed_story_to_words, cap_menu, OK, word_menu_entry, press_cancel, press_delete,
enter_number, scan_a_qr, cap_screen, settings_get, need_keypress, microsd_path,
master_settings_get, enter_pin, settings_remove, sim_exec):
def doit(pin=None, mag=None, vel=None, whitelist=None, w2fa=None, has_violation=None,
word_check=None, notes_and_pws=None, rel_keys=None):
goto_sssp_menu()
time.sleep(.1)
title, story = cap_story()
# it is possible that PIN was set beforehand
if title == "Spending Policy":
assert "stops you from signing transactions unless conditions are met" in story
assert "locked into a special mode" in story
assert "First step is to define a new PIN" in story
press_select()
time.sleep(.1)
scr = cap_screen()
if "Spending Policy" in scr:
what = "Enter first part of PIN" if is_q1 else "Enter PIN Prefix"
assert what in scr
enter_pin(pin)
time.sleep(.1)
scr = cap_screen()
what = "Confirm PIN value"if is_q1 else "CONFIRM PIN VALUE"
assert what in scr
enter_pin(pin)
time.sleep(.1)
m = cap_menu()
assert "Edit Policy..." in m
if has_violation is not None:
if has_violation:
assert "Last Violation" in m
else:
assert "last Violation" not in m
assert "Word Check" in m
assert "Allow Notes" in m
assert "Related Keys" in m
assert "Remove Policy" in m
assert "Test Drive" in m
assert "ACTIVATE" in m
pick_menu_item("Edit Policy...")
whitelist_mi = "Whitelist Addresses" if is_q1 else "Whitelist"
mag_mi = "Max Magnitude"
vel_mi = "Limit Velocity"
mi_2fa = "Web 2FA"
time.sleep(.1)
m = cap_menu()
assert mag_mi in m
assert vel_mi in m
assert whitelist_mi in m
assert mi_2fa in m
# setting above values here
if mag:
pick_menu_item(mag_mi)
enter_number(mag)
time.sleep(.1)
title, story = cap_story()
assert f"{mag} {'BTC' if int(mag) < 1000 else 'SATS'}" in story
press_select()
time.sleep(.1)
assert settings_get("sssp")["pol"]["mag"] == mag
if vel:
if not settings_get("sssp")["pol"].get("mag", None):
pick_menu_item(vel_mi)
title, story = cap_story()
assert 'Velocity limit requires' in story
assert 'starting value' in story
press_select()
else:
pick_menu_item(vel_mi)
if vel == "Unlimited":
target = 0
else:
target = int(vel.split()[0])
pick_menu_item(vel) # actually a full menu item
time.sleep(.3)
assert settings_get("sssp")["pol"]["vel"] == target
if whitelist:
pick_menu_item(whitelist_mi)
time.sleep(.1)
m = cap_menu()
assert "(none yet)" in m
assert "Import from File" in m
if is_q1:
assert "Scan QR" in m
pick_menu_item("Scan QR")
for i, addr in enumerate(whitelist, start=1):
scan_a_qr(addr)
for _ in range(10):
scr = cap_screen()
if (f"Got {i} so far" in scr) and ("ENTER to apply" in scr):
break
time.sleep(.2)
else:
assert False, "updating whitelist failed"
press_select()
else:
assert "Scan QR" not in m
fname = "ccc_addrs.txt"
with open(microsd_path(fname), "w") as f:
for a in whitelist:
f.write(f"{a}\n")
pick_menu_item("Import from File")
time.sleep(.1)
_, story = cap_story()
if "Press (1)" in story:
need_keypress("1")
pick_menu_item(fname)
time.sleep(.1)
_, story = cap_story()
if len(whitelist) == 1:
assert "Added new address to whitelist" in story
else:
assert f"Added {len(whitelist)} new addresses to whitelist" in story
for addr in whitelist:
assert addr in story
# check menu correct
press_select()
time.sleep(.1)
m = cap_menu()
mi_addrs = [a for a in m if '' in a]
for mia, addr in zip(mi_addrs, reversed(whitelist)):
_start, _end = mia.split('')
assert addr.startswith(_start)
assert addr.endswith(_end)
press_cancel()
assert settings_get("sssp")["pol"]["addrs"] == whitelist
if w2fa:
pick_menu_item(mi_2fa)
press_cancel() # leave Edit Policy... (shared settings with CCC)
# now rest of sssp specific settings
if word_check is not None:
pick_menu_item("Word Check")
time.sleep(.1)
title, story = cap_story()
assert "addition to special PIN" in story
assert "provide the first and last seed words" in story
if word_check:
assert "Enable?" in story
press_select() # confirm action
assert settings_get("sssp")["words"]
else:
assert "Disable?" in story
pol = settings_get("sssp")
if "words" in pol:
assert not pol["words"]
if notes_and_pws is not None:
pick_menu_item("Allow Notes")
time.sleep(.1)
title, story = cap_story()
assert "Allow (read-only) access to secure notes and passwords?" in story
if notes_and_pws:
assert "Enable?" in story
press_select() # confirm action
assert settings_get("sssp")["notes"]
else:
assert "Disable?" in story
pol = settings_get("sssp")
if "notes" in pol:
assert not pol["notes"]
if rel_keys is not None:
pick_menu_item("Related Keys")
time.sleep(.1)
title, story = cap_story()
assert "Allow access to BIP-39 passphrase wallets" in story
assert "or Seed Vault (if any)" in story
if rel_keys:
assert "Enable?" in story
press_select() # confirm action
assert settings_get("sssp")["okeys"]
else:
assert "Disable?" in story
pol = settings_get("sssp")
if "okeys" in pol:
assert not pol["okeys"]
yield doit
# cleanup code -- all users of this fixture will get this code
settings_remove("sssp")
sim_exec('from pincodes import pa;pa.hobbled_mode = False; from actions import goto_top_menu; goto_top_menu()')
@pytest.fixture
def policy_sign(start_sign, end_sign, cap_story, get_last_violation):
def doit(wallet, psbt, violation=None):
start_sign(base64.b64decode(psbt))
time.sleep(.1)
title, story = cap_story()
if violation:
# assume SSSP cases
assert title == "Failure"
assert 'warning' not in story
assert "Spending Policy violation." in story
assert violation in get_last_violation()
return
assert 'OK TO SEND?' == title
assert "warning" not in story
signed = end_sign(accept=True)
po = BasicPSBT().parse(signed)
tx_hex = None
if violation is None:
assert not get_last_violation()
assert len(po.inputs[0].part_sigs) or po.inputs[0].taproot_key_sig
res = wallet.finalizepsbt(base64.b64encode(signed).decode())
assert res["complete"]
tx_hex = res["hex"]
res = wallet.testmempoolaccept([tx_hex])
assert res[0]["allowed"]
res = wallet.sendrawtransaction(tx_hex)
assert len(res) == 64 # tx id
return signed, tx_hex
return doit
@pytest.fixture
def remove_settings_slots(settings_slots):
for s in settings_slots():
try:
os.remove(s)
except: pass
@pytest.mark.bitcoind
@pytest.mark.parametrize("mag_ok", [True, False])
@pytest.mark.parametrize("mag", [1000000, 2])
def test_magnitude(mag_ok, mag, setup_sssp, bitcoind, settings_set, pick_menu_item,
bitcoind_d_sim_watch, policy_sign, press_select,
reset_seed_words, settings_path, remove_settings_slots):
wo = bitcoind_d_sim_watch
settings_set("chain", "XRT")
if mag_ok:
# always try limit/border value
if mag is None:
to_send = 1
else:
to_send = mag / 100000000 if mag > 1000 else mag
else:
if mag is None:
to_send = 1.1
else:
to_send = ((mag / 100000000)+1) if mag > 1000 else (mag+0.001)
setup_sssp("11-11", mag=mag)
pick_menu_item("ACTIVATE")
press_select()
addr = wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=addr, amount=5.0)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# create funded PSBT
psbt_resp = wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): to_send}], 0, {"fee_rate": 2}
)
psbt = psbt_resp.get("psbt")
policy_sign(wo, psbt, violation=None if mag_ok else "magnitude")
@pytest.mark.bitcoind
@pytest.mark.parametrize("whitelist_ok", [True, False])
def test_whitelist(whitelist_ok, setup_sssp, bitcoind, settings_set, policy_sign,
bitcoind_d_sim_watch):
wo = bitcoind_d_sim_watch
settings_set("chain", "XRT")
whitelist = [
"bcrt1qqca9eefwz8tzn7rk6aumhwhapyf5vsrtrddxxp",
"bcrt1q7nck280nje50gzjja3gyguhp2ds6astu5ndhkj",
"bcrt1qhexpvdhwuerqq0h24j06g8y5eumjjdr28ng4vv",
"bcrt1q3ylr55pk7rl0rc06d8th7h25zmcuvvg8wt0yl3",
]
if whitelist_ok:
send_to = whitelist[0]
else:
send_to = bitcoind.supply_wallet.getnewaddress()
setup_sssp("11-11", whitelist=whitelist)
multi_addr = wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=5.0)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# create funded PSBT
psbt_resp = wo.walletcreatefundedpsbt(
[], [{send_to: 1}], 0, {"fee_rate": 2}
)
psbt = psbt_resp.get("psbt")
policy_sign(wo, psbt, violation=None if whitelist_ok else "whitelist")
@pytest.mark.bitcoind
@pytest.mark.parametrize("velocity_mi", ['6 blocks (hour)', '48 blocks (8h)'])
def test_velocity(velocity_mi, setup_sssp, bitcoind, settings_set,
policy_sign, settings_get, bitcoind_d_sim_watch):
wo = bitcoind_d_sim_watch
wo.keypoolrefill(20)
settings_set("chain", "XRT")
blocks = int(velocity_mi.split()[0])
setup_sssp("11-11", vel=velocity_mi)
assert "block_h" not in settings_get("sssp")["pol"]
multi_addr = wo.getnewaddress()
bitcoind.supply_wallet.sendtoaddress(address=multi_addr, amount=49)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# create funded PSBT, first tx
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
init_block_height) # nLockTime set to current block height
psbt = psbt_resp.get("psbt")
po = BasicPSBT().parse(base64.b64decode(psbt))
assert po.parsed_txn.nLockTime == init_block_height
policy_sign(wo, psbt) # success as this is first tx that sets block height from 0
assert settings_get("sssp")["pol"]["block_h"] == init_block_height
# mine some, BUT not enough to satisfy velocity policy
# - check velocity is exactly right to block number vs. required gap
bitcoind.supply_wallet.generatetoaddress(blocks - 1, bitcoind.supply_wallet.getnewaddress())
block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"]
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
block_height)
psbt = psbt_resp.get("psbt")
po = BasicPSBT().parse(base64.b64decode(psbt))
assert po.parsed_txn.nLockTime == block_height
policy_sign(wo, psbt, violation="velocity")
assert settings_get("sssp")["pol"]["block_h"] == init_block_height # still initial block height as above failed
# mine the remaining one block to satisfy velocity policy
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"]
psbt_resp = wo.walletcreatefundedpsbt([], [{bitcoind.supply_wallet.getnewaddress(): 1}],
block_height)
psbt = psbt_resp.get("psbt")
po = BasicPSBT().parse(base64.b64decode(psbt))
assert po.parsed_txn.nLockTime == block_height
policy_sign(wo, psbt) # success
assert settings_get("sssp")["pol"]["block_h"] == block_height # updated block height
# check txn re-sign fails (if velocity in effect)
policy_sign(wo, psbt, violation="rewound")
# check decreasing nLockTime
policy_sign(
wo,
wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], block_height - 1
)["psbt"],
violation="rewound"
)
# check nLockTime disabled when velocity enabled - fail
policy_sign(
wo,
wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], 0
)["psbt"],
violation="no nLockTime"
)
# unix timestamp
policy_sign(
wo,
wo.walletcreatefundedpsbt(
[], [{bitcoind.supply_wallet.getnewaddress(): 1}], 500000000
)["psbt"],
violation="nLockTime not height"
)
@pytest.mark.bitcoind
def test_warnings(setup_sssp, bitcoind, settings_set, policy_sign,
bitcoind_d_sim_watch, settings_get):
wo = bitcoind_d_sim_watch
wo.keypoolrefill(20)
settings_set("chain", "XRT")
whitelist = ["bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e",
"2Mxp1Dy2MyR4w36J2VaZhrFugNNFgh6LC1j",
"mjR14oKxYzRg9RAZdpu3hrw8zXfFgGzLKm"]
setup_sssp("11-11", mag=10000000, vel='6 blocks (hour)', whitelist=whitelist)
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# create funded PSBT, first tx
# whitelist OK, velocity OK, & magnitude OK - but fee high
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
psbt_resp = wo.walletcreatefundedpsbt([], [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
init_block_height, {"fee_rate":48000})
psbt = psbt_resp.get("psbt")
po = BasicPSBT().parse(base64.b64decode(psbt))
assert po.parsed_txn.nLockTime == init_block_height
policy_sign(wo, psbt, violation="has warnings")
# invalidate nLockTime with use of nSequence max values
utxos = wo.listunspent()
ins = []
for i, utxo in enumerate(utxos):
# block height based RTL
inp = {
"txid": utxo["txid"],
"vout": utxo["vout"],
"sequence": 0xffffffff,
}
ins.append(inp)
psbt_resp = wo.walletcreatefundedpsbt(ins, [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
0, {"fee_rate":2, "replaceable": False}) # locktime needs to be zero, otherwise exception from core (contradicting parameters)
po = BasicPSBT().parse(base64.b64decode(psbt_resp.get("psbt")))
assert po.parsed_txn.nLockTime == 0
po.parsed_txn.nLockTime = init_block_height # add locktime
po.txn = po.parsed_txn.serialize_with_witness()
# num_warn=2, warn_list=["Bad Locktime"]
policy_sign(wo, po.as_b64_str(), violation="has warnings")
# exotic sighash warning
settings_set("sighshchk", 1) # needed to only get warning instead of failure
psbt_resp = wo.walletcreatefundedpsbt([], [{whitelist[0]: 0.06},{whitelist[1]: 0.01},{whitelist[2]: 0.03}],
init_block_height, {"fee_rate":2, "replaceable": True})
po = BasicPSBT().parse(base64.b64decode(psbt_resp.get("psbt")))
for idx, i in enumerate(po.inputs):
i.sighash = 2 # NONE
# num_warn=2, warn_list=["sighash NONE"]
policy_sign(wo, po.as_b64_str(), violation="has warnings")
def test_remove_sssp(setup_sssp, pick_menu_item, press_select, cap_story, cap_menu, settings_get):
setup_sssp("11-11", mag=10000000, vel='6 blocks (hour)')
# check test drive
pick_menu_item("Test Drive")
time.sleep(.1)
_, story = cap_story()
assert "COLDCARD operation will look like with Spending Policy" in story
press_select()
time.sleep(.1)
m = cap_menu()
assert "EXIT TEST DRIVE" in m
assert "Settings" not in m
pick_menu_item("EXIT TEST DRIVE")
time.sleep(.1)
m = cap_menu()
assert "Edit Policy..." in m # back in policy settings
pick_menu_item("Remove Policy")
time.sleep(.1)
_, story = cap_story()
assert "Bypass PIN will be removed" in story
assert "spending policy settings forgotten" in story
press_select()
time.sleep(.1)
assert not settings_get("sssp")
tps = settings_get("tp")
if tps:
assert "11-11" not in tps
assert not settings_get("sssp")
def test_use_main_pin_as_unlock(setup_sssp, cap_story):
# not allowed
# simulator PIN
with pytest.raises(Exception):
setup_sssp("12-12")
_, story = cap_story()
assert "already in use" in story
assert "PIN codes must be unique" in story
@pytest.mark.parametrize("hide", [True, False])
def test_use_trick_pin_as_unlock(hide, setup_sssp, cap_story, new_trick_pin, pick_menu_item,
press_select, clear_all_tricks):
clear_all_tricks()
pin = "11-11"
new_trick_pin(pin, 'Wipe Seed', 'Wipe the seed and maybe do more')
pick_menu_item('Wipe & Reboot')
press_select()
press_select()
if hide:
pick_menu_item(f"{pin}")
pick_menu_item("Hide Trick")
press_select() # confirm
with pytest.raises(Exception):
setup_sssp(pin)
_, story = cap_story()
assert "already in use" in story
assert "PIN codes must be unique" in story
@pytest.mark.parametrize("active_policy", [False, True])
def test_deltamode_signature(active_policy, setup_sssp, bitcoind, settings_set,
start_sign, end_sign,
set_deltamode, bitcoind_d_sim_watch, settings_get):
# verify that "deltamode" trick pins will work in SSSP mode
# - and that resulting signature is bad
# - device should **not** wipe itself
dest = "bcrt1qlk39jrclgnawa42tvhu2n7se987qm96qg8v76e"
wo = bitcoind_d_sim_watch
wo.keypoolrefill(20)
settings_set("chain", "XRT")
if active_policy:
setup_sssp("11-11", mag=100)
bitcoind.supply_wallet.sendtoaddress(address=wo.getnewaddress(), amount=2)
bitcoind.supply_wallet.generatetoaddress(1, bitcoind.supply_wallet.getnewaddress())
# create funded PSBT, first tx
# - within active policy.
init_block_height = bitcoind.supply_wallet.getblockchaininfo()["blocks"] # block height
psbt_resp = wo.walletcreatefundedpsbt([], [{dest: 0.06}],
init_block_height, {"fee_rate":2, "replaceable": False})
psbt = psbt_resp.get("psbt")
po = BasicPSBT().parse(base64.b64decode(psbt))
assert po.parsed_txn.nLockTime == init_block_height
start_sign(base64.b64decode(psbt), finalize=True)
signed = end_sign(accept=True, finalize=True)
set_deltamode(True)
start_sign(base64.b64decode(psbt), finalize=True)
signed2 = end_sign(accept=True, finalize=True)
# check wrong signature happened
assert signed != signed2
probs = wo.testmempoolaccept([signed2.hex()])[0]
assert 'Signature must be zero' in probs['reject-reason'], probs
assert not probs['allowed']
# check right signature
no_probs = wo.testmempoolaccept([signed.hex()])[0]
assert no_probs['allowed']
# EOF