diff --git a/src/krux/pages/home_pages/home.py b/src/krux/pages/home_pages/home.py index a4fdced21..f7061f364 100644 --- a/src/krux/pages/home_pages/home.py +++ b/src/krux/pages/home_pages/home.py @@ -181,6 +181,13 @@ def mnemonic_xor(self): return MENU_CONTINUE + def silent_payment(self): + """Handler for the 'Silent Payment' menu item""" + from .silent_payment import SilentPayment + + sp = SilentPayment(self.ctx) + return sp.export() + def wallet(self): """Handler for the 'wallet' menu item""" @@ -191,6 +198,7 @@ def wallet(self): (t("Passphrase"), self.passphrase), (t("Customize"), self.customize), ("BIP85", self.bip85), + (t("Silent Payment"), self.silent_payment), (t("Mnemonic XOR"), self.mnemonic_xor), ], ) diff --git a/src/krux/pages/home_pages/silent_payment.py b/src/krux/pages/home_pages/silent_payment.py new file mode 100644 index 000000000..3b30786db --- /dev/null +++ b/src/krux/pages/home_pages/silent_payment.py @@ -0,0 +1,98 @@ +from ...display import FONT_HEIGHT, DEFAULT_PADDING +from ...krux_settings import t +from .. import ( + Menu, + Page, + MENU_CONTINUE, +) + + +class SilentPayment(Page): + """UI for BIP-352 Silent Payment address generation""" + + def export(self): + """Main entry point: show silent payment options menu""" + submenu = Menu( + self.ctx, + [ + (t("SP Address"), self._generate_address), + (t("SP Address with Label"), self._generate_labeled_address), + ], + ) + submenu.run_loop() + return MENU_CONTINUE + + def _generate_address(self): + """Generate and display a silent payment address without label""" + return self._show_sp_address(label=None) + + def _generate_labeled_address(self): + """Prompt user for a numeric label, then generate address""" + from ..utils import Utils + + utils = Utils(self.ctx) + label = "" + + while label == "": + label = utils.capture_index_from_keypad(t("Label")) + if label is None: + return MENU_CONTINUE + return self._show_sp_address(label=label) + + def _show_sp_address(self, label=None): + """Derive and display the silent payment address""" + from ...silent_payment import ( + derive_bip352_key, + encode_silent_payment_address, + ) + + self.ctx.display.clear() + self.ctx.display.draw_centered_text(t("Processing…")) + + root = self.ctx.wallet.key.root + network = self.ctx.wallet.key.network + + # Determine network name for address HRP (coin_type 0 = mainnet) + embit_network = "main" if network.get("bip32") == 0 else "test" + + scan_hd = derive_bip352_key(root, network, is_scan_key=True) + spend_hd = derive_bip352_key(root, network, is_scan_key=False) + + scan_privkey = scan_hd.key + spend_pubkey = spend_hd.key.get_public_key() + + sp_address = encode_silent_payment_address( + scan_privkey, spend_pubkey, label=label, network=embit_network + ) + + info = t("Silent Payment Address") + info += ( + "\n\n" + t("Label") + ": " + str(label) + if label is not None + else "" + ) + info += "\n\n" + sp_address + + while True: + menu_items = [ + ( + t("QR Code"), + lambda: self._show_qr(sp_address), + ), + ] + self.ctx.display.clear() + info_len = self.ctx.display.draw_hcentered_text(info) + info_len = info_len * FONT_HEIGHT + DEFAULT_PADDING + submenu = Menu( + self.ctx, + menu_items, + offset=info_len, + ) + index, _ = submenu.run_loop() + if index == submenu.back_index: + break + return MENU_CONTINUE + + def _show_qr(self, sp_address): + """Display the silent payment address as a QR code""" + self.display_qr_codes(data=sp_address) diff --git a/src/krux/silent_payment.py b/src/krux/silent_payment.py new file mode 100644 index 000000000..283629f81 --- /dev/null +++ b/src/krux/silent_payment.py @@ -0,0 +1,55 @@ +"""BIP-352 Silent Payments support. + +Reference: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki + +TODO: we should eventually get rid of this file and replace with embit implementation. +""" + +from embit import bech32 +from embit.ec import PublicKey +from embit.hashes import tagged_hash +from embit.util import secp256k1 + + +# BIP-352 derivation: m/352'/coin_type'/account'/key_type'/0 +# key_type: 0 = spend, 1 = scan +BIP352_PURPOSE = 352 + + +def _bip352_derivation_path(network, account=0, is_scan_key=True): + """Returns the BIP-352 derivation path for scan or spend key""" + coin_type = network["bip32"] + key_type = 1 if is_scan_key else 0 + return "m/%dh/%dh/%dh/%dh/0" % (BIP352_PURPOSE, coin_type, account, key_type) + + +def derive_bip352_key(root, network, account=0, is_scan_key=True): + """Derive a BIP-352 HD key (scan or spend) from the root key""" + path = _bip352_derivation_path(network, account, is_scan_key) + return root.derive(path) + + +def encode_silent_payment_address( + scan_privkey, spend_pubkey, label=None, network="main", version=0 +): + """Encode a BIP-352 silent payment address.""" + scan_pubkey = scan_privkey.get_public_key() + + if label is not None: + if not isinstance(label, int): + raise ValueError("label must be an integer") + if not (0 <= label <= 0xFFFFFFFF): + raise ValueError("label must be a 32-bit unsigned integer") + label_bytes = label.to_bytes(4, "big") + tweak = tagged_hash( + "BIP0352/Label", scan_privkey.secret + label_bytes + ) + spend_pubkey = PublicKey( + secp256k1.ec_pubkey_add( + secp256k1.ec_pubkey_parse(spend_pubkey.sec()), tweak + ) + ) + + data = bech32.convertbits(scan_pubkey.sec() + spend_pubkey.sec(), 8, 5) + hrp = "sp" if network == "main" else "tsp" + return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data) diff --git a/tests/pages/home_pages/test_home.py b/tests/pages/home_pages/test_home.py index 2ee00eaaf..ea0b1eccc 100644 --- a/tests/pages/home_pages/test_home.py +++ b/tests/pages/home_pages/test_home.py @@ -508,7 +508,7 @@ def test_load_bip85_from_wallet_menu(mocker, amigo, tdata): BUTTON_ENTER, # Load words BUTTON_PAGE_PREV, # Move to "< Back" BUTTON_ENTER, # Leave BIP85 - *([BUTTON_PAGE] * 2), # Move to "Back" + *([BUTTON_PAGE] * 3), # Move to "Back" BUTTON_ENTER, # Exit ] @@ -531,7 +531,7 @@ def test_load_xor_not_derive(mocker, amigo, tdata): from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV BUTTON_SEQUENCE = [ - *([BUTTON_PAGE] * 4), + *([BUTTON_PAGE] * 5), BUTTON_ENTER, BUTTON_PAGE, BUTTON_ENTER, @@ -559,7 +559,7 @@ def test_load_xor_from_wallet_menu(mocker, amigo, tdata): from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV BTN_SEQUENCE = [ - *([BUTTON_PAGE] * 4), # Go to Mnemonic XOR + *([BUTTON_PAGE] * 5), # Go to Mnemonic XOR BUTTON_ENTER, # Enter Mnemonic XOR BUTTON_ENTER, # Accept Derive BUTTON_PAGE_PREV, # Go to back diff --git a/tests/pages/home_pages/test_silent_payment.py b/tests/pages/home_pages/test_silent_payment.py new file mode 100644 index 000000000..b3415a44c --- /dev/null +++ b/tests/pages/home_pages/test_silent_payment.py @@ -0,0 +1,121 @@ +from .test_home import create_ctx, tdata + +# Expected SP addresses for tdata.SINGLESIG_12_WORD_KEY +# (mnemonic: "olympic term tissue route sense program under choose bean emerge velvet absurd", mainnet) +EXPECTED_SP_ADDRESS = "sp1qqf702ka9tkw9au8txt9q2z4yv3glhfs2g26msmr2fe96vcmdpegryq6w0c0tw5sctj7fg09t09dl6xjka2zhx06u0wrg4ykedazt3vadyujmlsu7" +EXPECTED_SP_ADDRESS_LABEL_42 = "sp1qqf702ka9tkw9au8txt9q2z4yv3glhfs2g26msmr2fe96vcmdpegryqectr7zkr53sjuur6dwz794aye3gnsmyreergr2a2hmasx07m0k0gsymg4h" + + +def test_silent_payment_export_back(mocker, amigo, tdata): + """Test navigating into the SP menu and going back""" + from krux.pages.home_pages.silent_payment import SilentPayment + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV + + wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY) + ctx = create_ctx( + mocker, + [ + BUTTON_PAGE_PREV, # Move to "< Back" + BUTTON_ENTER, # Press "< Back" + ], + wallet, + ) + sp = SilentPayment(ctx) + sp.export() + + assert ctx.input.wait_for_button.call_count == 2 + + +def test_silent_payment_generate_address(mocker, amigo, tdata): + """Test generating a silent payment address (no label)""" + from krux.pages.home_pages.silent_payment import SilentPayment + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV + + wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY) + ctx = create_ctx( + mocker, + [ + BUTTON_ENTER, # SP Address + BUTTON_PAGE_PREV, # Move to "< Back" in address display + BUTTON_ENTER, # Press "< Back" + BUTTON_PAGE_PREV, # Move to "< Back" in main menu + BUTTON_ENTER, # Press "< Back" + ], + wallet, + ) + sp = SilentPayment(ctx) + sp.export() + + # Verify the exact expected address was drawn + found_sp_address = any( + isinstance(arg, str) and EXPECTED_SP_ADDRESS in arg + for call in ctx.display.draw_hcentered_text.call_args_list + for arg in (call[0] if call[0] else []) + ) + assert found_sp_address + + +def test_silent_payment_generate_labeled_address(mocker, amigo, tdata): + """Test generating a labeled silent payment address""" + from krux.pages.home_pages.silent_payment import SilentPayment + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV + + mocker.patch( + "krux.pages.utils.Utils.capture_index_from_keypad", + return_value=42, + ) + wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY) + ctx = create_ctx( + mocker, + [ + BUTTON_PAGE, # Move to "SP Address with Label" + BUTTON_ENTER, # Select "SP Address with Label" + BUTTON_PAGE_PREV, # Move to "< Back" in address display + BUTTON_ENTER, # Press "< Back" + BUTTON_PAGE, # Move to "< Back" in main menu (from index 1 → 2) + BUTTON_ENTER, # Press "< Back" + ], + wallet, + ) + sp = SilentPayment(ctx) + sp.export() + + found_sp_address = any( + isinstance(arg, str) and EXPECTED_SP_ADDRESS_LABEL_42 in arg + for call in ctx.display.draw_hcentered_text.call_args_list + for arg in (call[0] if call[0] else []) + ) + assert found_sp_address + + +def test_silent_payment_generate_address_m5(mocker, m5stickv, tdata): + """Test generating a silent payment address on m5stickv""" + from krux.pages.home_pages.silent_payment import SilentPayment + from krux.wallet import Wallet + from krux.input import BUTTON_ENTER, BUTTON_PAGE + + wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY) + ctx = create_ctx( + mocker, + [ + BUTTON_ENTER, # SP Address + BUTTON_PAGE, # Move to "< Back" in address display (1 item + back) + BUTTON_ENTER, # Press "< Back" + BUTTON_PAGE, # Move past "SP Address" + BUTTON_PAGE, # Move past "SP Address with Label" to "< Back" + BUTTON_ENTER, # Press "< Back" + ], + wallet, + ) + sp = SilentPayment(ctx) + sp.export() + + found_sp_address = any( + isinstance(arg, str) and EXPECTED_SP_ADDRESS in arg + for call in ctx.display.draw_hcentered_text.call_args_list + for arg in (call[0] if call[0] else []) + ) + assert found_sp_address diff --git a/tests/test_silent_payment.py b/tests/test_silent_payment.py new file mode 100644 index 000000000..2dc5a4208 --- /dev/null +++ b/tests/test_silent_payment.py @@ -0,0 +1,52 @@ +from binascii import unhexlify +import pytest +from embit.ec import PrivateKey + + +BASIC_TEST_VECTORS = [ + { + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "sp_address": "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv", + }, + { + "scan_priv_key": "0000000000000000000000000000000000000000000000000000000000000002", + "spend_priv_key": "0000000000000000000000000000000000000000000000000000000000000001", + "sp_address": "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s", + }, +] + +LABEL_TEST_VECTORS = { + "scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c", + "spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3", + "labels": [2, 3, 1001337], + "addresses": [ + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n", + "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5", + ], +} + + +def test_generate_silent_payment_address(m5stickv): + """Basic SP address derivation against BIP-352 official test vectors""" + from krux.silent_payment import encode_silent_payment_address + + for v in BASIC_TEST_VECTORS: + scan_privkey = PrivateKey(unhexlify(v["scan_priv_key"])) + spend_pubkey = PrivateKey(unhexlify(v["spend_priv_key"])).get_public_key() + assert encode_silent_payment_address(scan_privkey, spend_pubkey) == v["sp_address"] + + +def test_generate_labeled_silent_payment_address(m5stickv): + """Labeled SP address derivation against BIP-352 official test vectors""" + from krux.silent_payment import encode_silent_payment_address + + scan_privkey = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"])) + spend_pubkey = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])).get_public_key() + + for label, expected in zip(LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"]): + assert encode_silent_payment_address(scan_privkey, spend_pubkey, label=label) == expected + + with pytest.raises(ValueError): + encode_silent_payment_address(scan_privkey, spend_pubkey, label=1.0)