diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py index ed3047197..94ccfcc7c 100644 --- a/src/seedsigner/helpers/embit_utils.py +++ b/src/seedsigner/helpers/embit_utils.py @@ -128,10 +128,6 @@ def parse_derivation_path(derivation_path: str) -> dict: sections = derivation_path.split("/") - if sections[1] == "48h": - # So far this helper is only meant for single sig message signing - raise Exception("Not implemented") - lookups = { "script_types": { "44h": SettingsConstants.LEGACY_P2PKH, @@ -146,9 +142,27 @@ def parse_derivation_path(derivation_path: str) -> dict: } details = dict() - details["script_type"] = lookups["script_types"].get(sections[1]) - if not details["script_type"]: - details["script_type"] = SettingsConstants.CUSTOM_DERIVATION + + if sections[1] == "48h": + # BIP48 multisig: m/48h////[change]/[index] + # The script type is encoded in sections[4]: + # 1h = p2sh-p2wsh (nested segwit) + # 2h = p2wsh (native segwit) + bip48_script_types = { + "1h": SettingsConstants.NESTED_SEGWIT, + "2h": SettingsConstants.NATIVE_SEGWIT, + } + if len(sections) > 4: + details["script_type"] = bip48_script_types.get(sections[4]) + else: + details["script_type"] = None + + if not details["script_type"]: + details["script_type"] = SettingsConstants.CUSTOM_DERIVATION + else: + details["script_type"] = lookups["script_types"].get(sections[1]) + if not details["script_type"]: + details["script_type"] = SettingsConstants.CUSTOM_DERIVATION details["network"] = lookups["networks"].get(sections[2]) # Check if there's a standard change path diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index bdc57dbc7..2d21aa3a2 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -426,3 +426,47 @@ def test_parse_derivation_path(): assert actual_result["index"] == expected_result[3] else: assert actual_result["index"] == int(derivation_path.split("/")[-1]) + + # BIP48 multisig derivation paths (separate dict since script_type keys + # overlap with single-sig paths above) + bip48_vectors = { + # m/48' native segwit (2h script type) + (SC.MAINNET, SC.NATIVE_SEGWIT, False): "m/48'/0'/0'/2'/0/5", + (SC.TESTNET, SC.NATIVE_SEGWIT, False): "m/48'/1'/0'/2'/0/5", + (SC.REGTEST, SC.NATIVE_SEGWIT, False): "m/48'/1'/0'/2'/0/5", + (SC.MAINNET, SC.NATIVE_SEGWIT, True): "m/48'/0'/0'/2'/1/5", + (SC.TESTNET, SC.NATIVE_SEGWIT, True): "m/48'/1'/0'/2'/1/5", + (SC.REGTEST, SC.NATIVE_SEGWIT, True): "m/48'/1'/0'/2'/1/5", + + # m/48' nested segwit (1h script type) + (SC.MAINNET, SC.NESTED_SEGWIT, False): "m/48'/0'/0'/1'/0/5", + (SC.TESTNET, SC.NESTED_SEGWIT, False): "m/48'/1'/0'/1'/0/5", + (SC.REGTEST, SC.NESTED_SEGWIT, False): "m/48'/1'/0'/1'/0/5", + (SC.MAINNET, SC.NESTED_SEGWIT, True): "m/48'/0'/0'/1'/1/5", + (SC.TESTNET, SC.NESTED_SEGWIT, True): "m/48'/1'/0'/1'/1/5", + (SC.REGTEST, SC.NESTED_SEGWIT, True): "m/48'/1'/0'/1'/1/5", + + # m/48' with unrecognised script type falls back to CUSTOM_DERIVATION + (SC.MAINNET, SC.CUSTOM_DERIVATION, False): "m/48'/0'/0'/3'/0/5", + } + + for expected_result, derivation_path in bip48_vectors.items(): + actual_result = embit_utils.parse_derivation_path(derivation_path) + + if expected_result[0] == SC.MAINNET: + assert actual_result["network"] == expected_result[0] + assert actual_result["clean_match"] is True + elif expected_result[0] is None: + assert actual_result["network"] is None + assert actual_result["clean_match"] is False + else: + assert expected_result[0] in actual_result["network"] + assert actual_result["clean_match"] is True + + assert actual_result["script_type"] == expected_result[1] + assert actual_result["is_change"] == expected_result[2] + + if len(expected_result) == 4: + assert actual_result["index"] == expected_result[3] + else: + assert actual_result["index"] == int(derivation_path.split("/")[-1]) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index fb872e6d3..91c1e36e4 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -501,6 +501,8 @@ class TestMessageSigningFlows(FlowTest): MAINNET_DERIVATION_PATH = "m/84h/0h/0h/0/0" TESTNET_DERIVATION_PATH = "m/84h/1h/0h/0/0" CUSTOM_DERIVATION_PATH = "m/99h/0/0" + BIP48_NATIVE_SEGWIT_PATH = "m/48h/0h/0h/2h/0/0" + BIP48_NESTED_SEGWIT_PATH = "m/48h/0h/0h/1h/0/0" SHORT_MESSAGE = "I attest that I control this bitcoin address blah blah blah" NO_WHITESPACE_MESSAGE = """{"height":841407,"lightning_bolt12":"lno1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}""" MULTIPAGE_MESSAGE = """Chancellor on brink of second bailout for banks @@ -540,6 +542,14 @@ def load_custom_derivation_into_decoder(self, view: View): self.load_signmessage_into_decoder(view, self.CUSTOM_DERIVATION_PATH, self.SHORT_MESSAGE) + def load_bip48_native_segwit_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.BIP48_NATIVE_SEGWIT_PATH, self.SHORT_MESSAGE) + + + def load_bip48_nested_segwit_into_decoder(self, view: View): + self.load_signmessage_into_decoder(view, self.BIP48_NESTED_SEGWIT_PATH, self.SHORT_MESSAGE) + + def inject_mesage_as_paged_message(self, view: View): # Because the Screen won't actually run, we have to do the Screen's work here from seedsigner.gui.components import reflow_text_into_pages, GUIConstants @@ -729,6 +739,36 @@ def load_invalid_signmessage_qr(view: scan_views.ScanView): assert self.controller.resume_main_flow is None + def test_sign_message_bip48_multisig_flow(self): + """ + Should scan a `signmessage` QR with a BIP48 multisig derivation path and + complete the signing flow for both native segwit (m/48'/0'/0'/2') and + nested segwit (m/48'/0'/0'/1') script types. + """ + self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED) + + def expect_successful_signing(load_message: Callable): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_message), + FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True), + FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0), + FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0), + FlowStep(MainMenuView), + ]) + + # BIP48 native segwit (m/48'/0'/0'/2'/0/0) + expect_successful_signing(self.load_bip48_native_segwit_into_decoder) + + # BIP48 nested segwit (m/48'/0'/0'/1'/0/0) + expect_successful_signing(self.load_bip48_nested_segwit_into_decoder) + + def test_sign_message_unsupported_derivation_flow(self): """ Should redirect to NotYetImplementedView if a message's derivation path isn't yet supported