From ee20a641f8097d3f58f87eccc0b8c61b224b79bf Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Mon, 7 Sep 2026 19:13:16 -0400 Subject: [PATCH 01/26] Smartcard menu: drop Common menu, split per-applet; add master fingerprint & Satodime Removes the shared "Common Functions" menu and its Device Filter (fixes #402), which confused users and misbehaved when multiple applets were loaded on one card. The common functions are now duplicated into each applet's own "Card Settings" submenu, with the card filter applied automatically from the launching menu instead of a controller-wide toggle. - Satochip / SeedKeeper / Satodime each get a Card Settings submenu hosting the applicable shared views (Info, Genuine Check, Change PIN/Label/NFC, Configure NDEF, Factory Reset), scoped by an explicit card_filter argument. - Add "View Master Fingerprint" to the Satochip and KeyCard menus (fixes #401): a quick check that reads the master xpub at m without running the full xpub export flow. - Add a Satodime menu + views (deposit addresses, seal/unseal slot, sign tx, transfer ownership), cherry-picked from PR #66 and relocated into smartcard_views.py. Tests: per-applet card-filter unit tests; navigation coverage for the new submenus and fingerprint entries; jcardsim flow coverage for Satodime (real applet); hardware-present keyslot seal/unseal/pubkey tests. Removes the Device Filter flow test. --- src/seedsigner/controller.py | 1 - src/seedsigner/gui/screens/tools_screens.py | 16 +- src/seedsigner/views/smartcard_views.py | 759 +++++++++++++++--- src/seedsigner/views/tools_views.py | 2 +- src/seedsigner/views/view.py | 1 - tests/real_screen_fixtures.py | 34 + tests/test_flows_menu_navigation.py | 52 +- ...st_real_screen_flows_satodime_simulated.py | 120 +++ tests/test_real_screen_flows_smartcard.py | 51 +- ...t_real_screen_flows_smartcard_simulated.py | 14 +- tests/test_smartcard_card_filter.py | 116 +++ tests/test_smartcard_hardware.py | 57 ++ tests/test_split_module_imports.py | 24 +- tests/test_tools_smartcard_keycard_menu.py | 3 +- 14 files changed, 1088 insertions(+), 162 deletions(-) create mode 100644 tests/test_real_screen_flows_satodime_simulated.py create mode 100644 tests/test_smartcard_card_filter.py diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index f435eadf5..b1d5fb039 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -269,7 +269,6 @@ def _load_block_anchor(cls): Satochip_PIN = None Satochip_Last_UID_SHA1 = None GPG_Admin_PIN = None - tools_common_card_filter: list[str] = None javacard_keys: dict | None = None # Destination placeholder for when we need to jump out to a side flow but intend to diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 7b84645cb..f3ce520a8 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -5,13 +5,13 @@ from dataclasses import dataclass from gettext import gettext as _ from pathlib import Path -from typing import Any, List +from typing import Any from PIL import Image, ImageDraw from seedsigner.helpers import mnemonic_generation from seedsigner.gui.renderer import Renderer from seedsigner.hardware.camera import Camera from seedsigner.helpers.qr import QR -from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea, Button, IconButton, CheckboxButton, load_image, resize_image_to_fit +from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea, Button, IconButton, load_image, resize_image_to_fit from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, PagedTextScreen, WarningEdgesMixin, ButtonOption, LoadingScreenThread from seedsigner.hardware.buttons import HardwareButtonsConstants @@ -19,18 +19,6 @@ -@dataclass -class ToolsCommonFilterScreen(ButtonListScreen): - checked_buttons: List[int] = None - - def __post_init__(self): - self.title = _("Device Filter") - self.is_bottom_list = True - self.is_button_text_centered = False - self.Button_cls = CheckboxButton - super().__post_init__() - - @dataclass class ToolsNetworkInfoScreen(PagedTextScreen): def __post_init__(self): diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index a0e0834a7..c3fd96866 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -21,6 +21,7 @@ from embit.bip32 import HDKey from embit.descriptor import Descriptor from embit.psbt import PSBT +from embit import ec, script, networks from gettext import gettext as _ from seedsigner.gui.components import ( @@ -38,7 +39,6 @@ seed_screens, ) from seedsigner.gui.screens.tools_screens import ( - ToolsCommonFilterScreen, ToolsTextQRTextEntryScreen, ToolsTextQRReviewTextScreen, ) @@ -84,16 +84,29 @@ ) +def _applet_card_filter(card_filter, allowed): + """Resolve the effective init_satochip card filter for a shared view. + + ``card_filter`` is the applet(s) the calling menu wants (e.g. ["satochip"]); + ``allowed`` is the set of card types the particular function supports. When a + caller passes nothing we fall back to every allowed type (previously driven by + the removed Device Filter). Intersecting keeps an unsupported pairing from ever + reaching the connector. + """ + requested = card_filter or allowed + return [c for c in requested if c in allowed] + + class ToolsSmartcardMenuView(View): - COMMON = ButtonOption("Common Functions") SATOCHIP = ButtonOption("Satochip Functions") KEYCARD = ButtonOption("KeyCard Functions") SEEDKEEPER = ButtonOption("SeedKeeper Functions") + SATODIME = ButtonOption("Satodime Functions") SPECTER_DIY = ButtonOption("Specter-DIY Functions") Satochip_DIY = ButtonOption("DIY Tools") def run(self): - button_data = [self.COMMON, self.SEEDKEEPER] + button_data = [self.SEEDKEEPER] satochip_enabled = ( self.settings.get_value(SettingsConstants.SETTING__SATOCHIP_SUPPORT) == SettingsConstants.OPTION__ENABLED @@ -110,6 +123,7 @@ def run(self): button_data.append(self.SATOCHIP) if keycard_enabled: button_data.append(self.KEYCARD) + button_data.append(self.SATODIME) if specter_diy_enabled: button_data.append(self.SPECTER_DIY) button_data.append(self.Satochip_DIY) @@ -124,11 +138,6 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == self.COMMON: - # COMMON tools work on Satochip/SeedKeeper cards (pysatochip only) - self.controller.smartcard_backend_preference = "pysatochip" - return Destination(ToolsCommonView) - elif button_data[selected_menu_num] == self.SATOCHIP: # Satochip menu forces pysatochip backend self.controller.smartcard_backend_preference = "pysatochip" @@ -143,6 +152,11 @@ def run(self): self.controller.smartcard_backend_preference = "pysatochip" return Destination(ToolsSeedkeeperView) + elif button_data[selected_menu_num] == self.SATODIME: + # Satodime is pysatochip-only (satodime_* APDUs are not keycard) + self.controller.smartcard_backend_preference = "pysatochip" + return Destination(ToolsSatodimeView) + elif button_data[selected_menu_num] == self.SPECTER_DIY: return Destination(ToolsSpecterDIYView) @@ -151,96 +165,6 @@ def run(self): self.controller.smartcard_backend_preference = "pysatochip" return Destination(ToolsSatochipDIYView) -class ToolsCommonView(View): - FILTER = ButtonOption("Device Filter") - INFO = ButtonOption("Card Info") - GENUINE = ButtonOption("Genuine Check") - CHANGE_PIN = ButtonOption("Change PIN") - CHANGE_LABEL = ButtonOption("Change Label") - CHANGE_NFC = ButtonOption("Change NFC Policy") - CONFIGURE_NDEF = ButtonOption("Configure NDEF") - FACTORY_RESET = ButtonOption("Factory Reset Card") - - def run(self): - - button_data = [ - self.FILTER, - self.INFO, - self.GENUINE, - self.CHANGE_PIN, - self.CHANGE_LABEL, - self.CHANGE_NFC, - self.CONFIGURE_NDEF, - self.FACTORY_RESET, - ] - - selected_menu_num = self.run_screen( - ButtonListScreen, - title="Common Tools", - is_button_text_centered=False, - button_data=button_data - ) - - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - elif button_data[selected_menu_num] == self.FILTER: - return Destination(ToolsCommonFilterView) - - elif button_data[selected_menu_num] == self.INFO: - return Destination(ToolsSmartcardInfoView) - - elif button_data[selected_menu_num] == self.GENUINE: - return Destination(ToolsSmartcardGenuineCheckView) - - elif button_data[selected_menu_num] == self.CHANGE_PIN: - return Destination(ToolsSatochipChangePinView) - - elif button_data[selected_menu_num] == self.CHANGE_LABEL: - return Destination(ToolsSatochipChangeLabelView) - - elif button_data[selected_menu_num] == self.CHANGE_NFC: - return Destination(ToolsSatochipChangeNFCView) - - elif button_data[selected_menu_num] == self.CONFIGURE_NDEF: - return Destination(ToolsCommonNdefView) - - elif button_data[selected_menu_num] == self.FACTORY_RESET: - return Destination(ToolsSatochipFactoryResetView) - - -class ToolsCommonFilterView(View): - def run(self): - devices = [ - ("satochip", "Satochip"), - ("seedkeeper", "Seedkeeper"), - ("satodime", "Satodime"), - ] - - selected = self.controller.tools_common_card_filter or [d[0] for d in devices] - - while True: - button_data = [ButtonOption(name) for _, name in devices] - checked = [i for i, (code, _) in enumerate(devices) if code in selected] - - ret = self.run_screen( - ToolsCommonFilterScreen, - button_data=button_data, - checked_buttons=checked, - ) - - if ret == RET_CODE__BACK_BUTTON: - if len(selected) == len(devices): - self.controller.tools_common_card_filter = None - else: - self.controller.tools_common_card_filter = list(selected) - return Destination(BackStackView) - - code = devices[ret][0] - if code in selected: - selected.remove(code) - else: - selected.append(code) class ToolsCommonNdefView(View): VIEW_NDEF = ButtonOption("View NDEF") @@ -261,6 +185,10 @@ class ToolsCommonNdefView(View): RECORD_TYPE_ANDROID_APP = ButtonOption("Android App Launch") RECORD_TYPE_HEX = ButtonOption("Custom (HEX)") + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + @staticmethod def _extract_ndef_payload(ndef_bytes: bytes) -> bytes: """Accept either raw payload or 2-byte length-prefixed NDEF and return payload bytes.""" @@ -282,8 +210,7 @@ def _to_card_ndef_bytes(ndef_bytes: bytes) -> bytes: def run(self): allowed = ["seedkeeper", "satodime"] - card_filter = self.controller.tools_common_card_filter or allowed - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) connector = seedkeeper_utils.init_satochip( self, @@ -812,11 +739,14 @@ def _load_ndef_from_seedkeeper(self, connector): class ToolsSmartcardInfoView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): allowed = ["satochip", "seedkeeper", "satodime"] - card_filter = self.controller.tools_common_card_filter or allowed - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip( self, init_card_filter=card_filter, require_pin=False @@ -874,11 +804,14 @@ def run(self): return Destination(BackStackView) class ToolsSmartcardGenuineCheckView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): allowed = ["satochip", "seedkeeper", "satodime"] - card_filter = self.controller.tools_common_card_filter or allowed - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip( self, init_card_filter=card_filter @@ -950,11 +883,14 @@ def run(self): return Destination(BackStackView) class ToolsSatochipChangePinView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): allowed = ["satochip", "seedkeeper"] - card_filter = self.controller.tools_common_card_filter or ["satochip", "seedkeeper", "satodime"] - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=card_filter) @@ -993,11 +929,14 @@ def run(self): return Destination(BackStackView) class ToolsSatochipChangeNFCView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): allowed = ["satochip", "seedkeeper"] - card_filter = self.controller.tools_common_card_filter or ["satochip", "seedkeeper", "satodime"] - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=card_filter) @@ -1067,6 +1006,10 @@ def run(self): return Destination(BackStackView) class ToolsSatochipFactoryResetView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): resetStatus = False @@ -1090,8 +1033,7 @@ def run(self): new version currently only implemented on SeedKeeper v0.2 and higher """ allowed = ["satochip", "seedkeeper"] - card_filter = self.controller.tools_common_card_filter or ["satochip", "seedkeeper", "satodime"] - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=card_filter, require_pin = False) @@ -1470,11 +1412,14 @@ def common_reset_factory_new(self, Satochip_Connector): return resetStatus class ToolsSatochipChangeLabelView(View): + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter + def run(self): allowed = ["satochip", "seedkeeper"] - card_filter = self.controller.tools_common_card_filter or ["satochip", "seedkeeper", "satodime"] - card_filter = [c for c in card_filter if c in allowed] + card_filter = _applet_card_filter(self.card_filter, allowed) Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=card_filter) @@ -1528,6 +1473,7 @@ class ToolsSeedkeeperView(View): LOAD_DESCRIPTOR = ButtonOption("Load MultiSig Descriptor") SAVE_DESCRIPTOR = ButtonOption("Save MultiSig Descriptor") CLONE_SECRETS = ButtonOption("Clone Card Secrets") + CARD_SETTINGS = ButtonOption("Card Settings") def run(self): button_data = [ @@ -1538,6 +1484,7 @@ def run(self): self.SAVE_DESCRIPTOR, self.CLONE_SECRETS, self.VIEW_FREE_SPACE, + self.CARD_SETTINGS, ] selected_menu_num = self.run_screen( @@ -1571,6 +1518,68 @@ def run(self): elif button_data[selected_menu_num] == self.CLONE_SECRETS: return Destination(ToolsSeedkeeperCloneSecretsView) + elif button_data[selected_menu_num] == self.CARD_SETTINGS: + return Destination(ToolsSeedkeeperCardSettingsView) + + +class ToolsSeedkeeperCardSettingsView(View): + """Card-management functions scoped to a SeedKeeper card. + + Formerly lived in the shared 'Common Functions' menu; the applet is now fixed + by which menu launched it, so each destination gets an explicit card_filter. + """ + INFO = ButtonOption("Card Info") + GENUINE = ButtonOption("Genuine Check") + CHANGE_PIN = ButtonOption("Change PIN") + CHANGE_LABEL = ButtonOption("Change Label") + CHANGE_NFC = ButtonOption("Change NFC Policy") + CONFIGURE_NDEF = ButtonOption("Configure NDEF") + FACTORY_RESET = ButtonOption("Factory Reset Card") + + _CARD_FILTER = ["seedkeeper"] + + def run(self): + button_data = [ + self.INFO, + self.GENUINE, + self.CHANGE_PIN, + self.CHANGE_LABEL, + self.CHANGE_NFC, + self.CONFIGURE_NDEF, + self.FACTORY_RESET, + ] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="SeedKeeper Settings", + is_button_text_centered=False, + button_data=button_data + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + elif button_data[selected_menu_num] == self.INFO: + return Destination(ToolsSmartcardInfoView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.GENUINE: + return Destination(ToolsSmartcardGenuineCheckView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_PIN: + return Destination(ToolsSatochipChangePinView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_LABEL: + return Destination(ToolsSatochipChangeLabelView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_NFC: + return Destination(ToolsSatochipChangeNFCView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CONFIGURE_NDEF: + return Destination(ToolsCommonNdefView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.FACTORY_RESET: + return Destination(ToolsSatochipFactoryResetView, view_args=dict(card_filter=self._CARD_FILTER)) + class ToolsSeedkeeperFreeSpaceView(View): @@ -2652,16 +2661,20 @@ def run(self): class ToolsSatochipView(View): IMPORT_SEED = ButtonOption("Initialise with Seed") EXPORT_XPUB = ButtonOption("Export Xpub") + VIEW_FINGERPRINT = ButtonOption("View Master Fingerprint") LOAD_DESCRIPTOR = ButtonOption("Load as Descriptor") LOAD_PSBT = ButtonOption("Load PSBT") + CARD_SETTINGS = ButtonOption("Card Settings") ADVANCED = ButtonOption("Advanced") def run(self): button_data = [ self.IMPORT_SEED, self.EXPORT_XPUB, + self.VIEW_FINGERPRINT, self.LOAD_DESCRIPTOR, self.LOAD_PSBT, + self.CARD_SETTINGS, self.ADVANCED, ] selected_menu_num = self.run_screen( @@ -2680,17 +2693,141 @@ def run(self): elif button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SatochipExportXpubSigTypeView) + elif button_data[selected_menu_num] == self.VIEW_FINGERPRINT: + return Destination(ToolsSmartcardViewFingerprintView, view_args=dict(card_filter=["satochip"])) + elif button_data[selected_menu_num] == self.LOAD_DESCRIPTOR: return Destination(SatochipLoadDescriptorScriptTypeView) elif button_data[selected_menu_num] == self.LOAD_PSBT: return Destination(ToolsSatochipLoadPsbtView) + elif button_data[selected_menu_num] == self.CARD_SETTINGS: + return Destination(ToolsSatochipCardSettingsView) elif button_data[selected_menu_num] == self.ADVANCED: return Destination(ToolsSatochipAdvancedView) +class ToolsSatochipCardSettingsView(View): + """Card-management functions scoped to a Satochip card. + + Formerly lived in the shared 'Common Functions' menu; the applet is now fixed + by which menu launched it, so each destination gets an explicit card_filter. + """ + INFO = ButtonOption("Card Info") + GENUINE = ButtonOption("Genuine Check") + CHANGE_PIN = ButtonOption("Change PIN") + CHANGE_LABEL = ButtonOption("Change Label") + CHANGE_NFC = ButtonOption("Change NFC Policy") + FACTORY_RESET = ButtonOption("Factory Reset Card") + + _CARD_FILTER = ["satochip"] + + def run(self): + button_data = [ + self.INFO, + self.GENUINE, + self.CHANGE_PIN, + self.CHANGE_LABEL, + self.CHANGE_NFC, + self.FACTORY_RESET, + ] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Satochip Settings", + is_button_text_centered=False, + button_data=button_data + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + elif button_data[selected_menu_num] == self.INFO: + return Destination(ToolsSmartcardInfoView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.GENUINE: + return Destination(ToolsSmartcardGenuineCheckView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_PIN: + return Destination(ToolsSatochipChangePinView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_LABEL: + return Destination(ToolsSatochipChangeLabelView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CHANGE_NFC: + return Destination(ToolsSatochipChangeNFCView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.FACTORY_RESET: + return Destination(ToolsSatochipFactoryResetView, view_args=dict(card_filter=self._CARD_FILTER)) + + +class ToolsSmartcardViewFingerprintView(View): + """Show the card's BIP-32 master key fingerprint without running the xpub export. + + Issue #401. The fingerprint is derived from the master extended public key at + path ``m``; the script type used to serialize it does not change the underlying + master public key, so a fixed xtype is fine. Works on both the pysatochip and + keycard-compat backends (the parent menu sets ``smartcard_backend_preference``). + """ + + def __init__(self, card_filter=None): + super().__init__() + self.card_filter = card_filter if card_filter else ["satochip"] + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=self.card_filter + ) + if not Satochip_Connector: + return Destination(BackStackView) + + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + is_mainnet = network == SettingsConstants.MAINNET + + loading = LoadingScreenThread(text="Reading master key...") + loading.start() + try: + master_xpub = Satochip_Connector.card_bip32_get_xpub("", "p2wpkh", is_mainnet) + except Exception as e: + loading.stop() + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text=f"Could not read master key:\n{str(e)[:80]}", + show_back_button=True, + ) + return Destination(BackStackView) + finally: + loading.stop() + + try: + fingerprint_hex = hexlify(HDKey.from_string(master_xpub).my_fingerprint).decode("utf-8") + except Exception as e: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text=f"Invalid master key:\n{str(e)[:80]}", + show_back_button=True, + ) + return Destination(BackStackView) + + self.run_screen( + LargeIconStatusScreen, + title="Master Fingerprint", + status_headline=None, + text=fingerprint_hex, + show_back_button=True, + ) + return Destination(BackStackView) + + class ToolsKeycardView(View): IMPORT_SEED = ButtonOption("Initialise with Seed") EXPORT_XPUB = ButtonOption("Export Xpub") + VIEW_FINGERPRINT = ButtonOption("View Master Fingerprint") LOAD_DESCRIPTOR = ButtonOption("Load as Descriptor") LOAD_PSBT = ButtonOption("Load PSBT") CHANGE_PIN = ButtonOption("Change PIN") @@ -2706,6 +2843,7 @@ def run(self): button_data = [ self.IMPORT_SEED, self.EXPORT_XPUB, + self.VIEW_FINGERPRINT, self.LOAD_DESCRIPTOR, self.LOAD_PSBT, self.CHANGE_PIN, @@ -2732,6 +2870,11 @@ def run(self): elif button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SatochipExportXpubSigTypeView) + elif button_data[selected_menu_num] == self.VIEW_FINGERPRINT: + # Keycard-compat backend still keys off the 'satochip' card filter; + # smartcard_backend_preference ("keycard") is set above. + return Destination(ToolsSmartcardViewFingerprintView, view_args=dict(card_filter=["satochip"])) + elif button_data[selected_menu_num] == self.LOAD_DESCRIPTOR: return Destination(SatochipLoadDescriptorScriptTypeView) @@ -4440,6 +4583,398 @@ def run(self): return Destination(MainMenuView) +class ToolsSatodimeView(View): + VIEW_ADDRESSES = ButtonOption("View Deposit Addresses") + SEAL_SLOT = ButtonOption("Seal Slot") + UNSEAL_SLOT = ButtonOption("Unseal Slot") + SIGN_TX = ButtonOption("Sign Transaction") + TRANSFER = ButtonOption("Transfer Ownership") + CARD_SETTINGS = ButtonOption("Card Settings") + + def run(self): + button_data = [ + self.VIEW_ADDRESSES, + self.SEAL_SLOT, + self.UNSEAL_SLOT, + self.SIGN_TX, + self.TRANSFER, + self.CARD_SETTINGS, + ] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Satodime", + is_button_text_centered=False, + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if button_data[selected_menu_num] == self.VIEW_ADDRESSES: + return Destination(ToolsSatodimeAddressesView) + elif button_data[selected_menu_num] == self.SEAL_SLOT: + return Destination(ToolsSatodimeSealSlotView) + elif button_data[selected_menu_num] == self.UNSEAL_SLOT: + return Destination(ToolsSatodimeUnsealSlotView) + elif button_data[selected_menu_num] == self.SIGN_TX: + return Destination(ToolsSatodimeSignTxView) + elif button_data[selected_menu_num] == self.TRANSFER: + return Destination(ToolsSatodimeTransferOwnershipView) + elif button_data[selected_menu_num] == self.CARD_SETTINGS: + return Destination(ToolsSatodimeCardSettingsView) + + +class ToolsSatodimeCardSettingsView(View): + """Card-management functions scoped to a Satodime card. + + Only the subset of the former 'Common Functions' that Satodime supports is + offered here: Card Info, Genuine Check and Configure NDEF (Change PIN/Label/NFC + and Factory Reset are not implemented by the Satodime applet). + """ + INFO = ButtonOption("Card Info") + GENUINE = ButtonOption("Genuine Check") + CONFIGURE_NDEF = ButtonOption("Configure NDEF") + + _CARD_FILTER = ["satodime"] + + def run(self): + button_data = [self.INFO, self.GENUINE, self.CONFIGURE_NDEF] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Satodime Settings", + is_button_text_centered=False, + button_data=button_data, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + elif button_data[selected_menu_num] == self.INFO: + return Destination(ToolsSmartcardInfoView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.GENUINE: + return Destination(ToolsSmartcardGenuineCheckView, view_args=dict(card_filter=self._CARD_FILTER)) + + elif button_data[selected_menu_num] == self.CONFIGURE_NDEF: + return Destination(ToolsCommonNdefView, view_args=dict(card_filter=self._CARD_FILTER)) + + +class ToolsSatodimeAddressesView(View): + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + Satochip_Connector.satodime_set_unlock_secret() + Satochip_Connector.satodime_set_unlock_counter() + + self.loading_screen = LoadingScreenThread(text="Fetching Slots\n\n\n\n\n\n") + self.loading_screen.start() + (_, _, _, status) = Satochip_Connector.satodime_get_status() + self.loading_screen.stop() + + max_keys = status.get("max_num_keys", 0) + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + embit_network = embit_utils.get_embit_network_name(network) + net = networks.NETWORKS[embit_network] + + for key_nbr in range(max_keys): + try: + (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) + (_, _, _, _, pub_comp) = Satochip_Connector.satodime_get_pubkey(key_nbr) + address = script.p2pkh(ec.PublicKey(bytes(pub_comp))).address(network=net) + text = f"{slot_status['key_status_txt']}\n{address}" + except Exception as e: + text = str(e) + + ret = self.run_screen( + LargeIconStatusScreen, + title=f"Slot {key_nbr}", + status_headline=None, + text=text, + show_back_button=True, + button_data=[ButtonOption("Next")], + ) + if ret == RET_CODE__BACK_BUTTON: + break + + return Destination(BackStackView) + + +class ToolsSatodimeSealSlotView(View): + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + Satochip_Connector.satodime_set_unlock_secret() + Satochip_Connector.satodime_set_unlock_counter() + + (_, _, _, status) = Satochip_Connector.satodime_get_status() + max_keys = status.get("max_num_keys", 0) + + available = [] + button_data = [] + for key_nbr in range(max_keys): + (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) + if slot_status.get("key_status_txt") == "Uninitialized": + available.append(key_nbr) + button_data.append(ButtonOption(f"Slot {key_nbr}")) + + if not available: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="No uninitialized slots", + show_back_button=True, + ) + return Destination(BackStackView) + + selected = self.run_screen( + ButtonListScreen, + title="Select Slot", + is_button_text_centered=False, + button_data=button_data, + show_back_button=True, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + slot = available[selected] + # Card-side sealing entropy; never logged or persisted (AGENTS security). + entropy = os.urandom(32) + + self.loading_screen = LoadingScreenThread(text="Sealing Slot\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2, _, pub_comp) = Satochip_Connector.satodime_seal_key(slot, entropy) + self.loading_screen.stop() + + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="Seal failed", + show_back_button=True, + ) + return Destination(BackStackView) + + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + embit_network = embit_utils.get_embit_network_name(network) + net = networks.NETWORKS[embit_network] + address = script.p2pkh(ec.PublicKey(bytes(pub_comp))).address(network=net) + + self.run_screen( + LargeIconStatusScreen, + title="Success", + status_headline=None, + text=f"Slot {slot} sealed\n{address}", + show_back_button=False, + ) + + return Destination(BackStackView) + + +class ToolsSatodimeUnsealSlotView(View): + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + Satochip_Connector.satodime_set_unlock_secret() + Satochip_Connector.satodime_set_unlock_counter() + + (_, _, _, status) = Satochip_Connector.satodime_get_status() + max_keys = status.get("max_num_keys", 0) + + available = [] + button_data = [] + for key_nbr in range(max_keys): + (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) + if slot_status.get("key_status_txt") == "Sealed": + available.append(key_nbr) + button_data.append(ButtonOption(f"Slot {key_nbr}")) + + if not available: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="No sealed slots", + show_back_button=True, + ) + return Destination(BackStackView) + + selected = self.run_screen( + ButtonListScreen, + title="Select Slot", + is_button_text_centered=False, + button_data=button_data, + show_back_button=True, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + slot = available[selected] + + self.loading_screen = LoadingScreenThread(text="Unsealing Slot\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_unseal_key(slot) + self.loading_screen.stop() + + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="Unseal failed", + show_back_button=True, + ) + return Destination(BackStackView) + + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + embit_network = embit_utils.get_embit_network_name(network) + net = networks.NETWORKS[embit_network] + # WIF is secret material shown only on this screen; never logged. priv_list + # is dropped as soon as the display returns below (best-effort, AGENTS security). + wif = ec.PrivateKey(bytes(priv_list), network=net).wif() + del priv_list + + self.run_screen( + LargeIconStatusScreen, + title="Unsealed", + status_headline=None, + text=wif, + show_back_button=True, + ) + wif = None + + return Destination(BackStackView) + + +class ToolsSatodimeSignTxView(View): + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + from seedsigner.models.wif import WIFKey + from seedsigner.views.scan_views import ScanPSBTView + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + Satochip_Connector.satodime_set_unlock_secret() + Satochip_Connector.satodime_set_unlock_counter() + + (_, _, _, status) = Satochip_Connector.satodime_get_status() + max_keys = status.get("max_num_keys", 0) + + available = [] + button_data = [] + for key_nbr in range(max_keys): + (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) + if slot_status.get("key_status_txt") == "Sealed": + available.append(key_nbr) + button_data.append(ButtonOption(f"Slot {key_nbr}")) + + if not available: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="No sealed slots", + show_back_button=True, + ) + return Destination(BackStackView) + + selected = self.run_screen( + ButtonListScreen, + title="Select Slot", + is_button_text_centered=False, + button_data=button_data, + show_back_button=True, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + slot = available[selected] + + self.loading_screen = LoadingScreenThread(text="Unsealing Slot\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_unseal_key(slot) + self.loading_screen.stop() + + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="Unseal failed", + show_back_button=True, + ) + return Destination(BackStackView) + + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) + embit_network = embit_utils.get_embit_network_name(network) + net = networks.NETWORKS[embit_network] + wif = ec.PrivateKey(bytes(priv_list), network=net).wif() + del priv_list + + # The WIF-derived key becomes the PSBT signing seed; the standard PSBT flow + # owns and clears controller.psbt_seed on completion / exit (AGENTS security). + self.controller.psbt_seed = WIFKey(wif) + wif = None + + return Destination(ScanPSBTView) + + +class ToolsSatodimeTransferOwnershipView(View): + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + Satochip_Connector.satodime_set_unlock_secret() + Satochip_Connector.satodime_set_unlock_counter() + + self.loading_screen = LoadingScreenThread(text="Sending Command\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2) = Satochip_Connector.satodime_initiate_ownership_transfer() + self.loading_screen.stop() + + if sw1 == 0x90 and sw2 == 0x00: + self.run_screen( + LargeIconStatusScreen, + title="Success", + status_headline=None, + text="Ownership transfer started", + show_back_button=False, + ) + else: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="Ownership transfer failed", + show_back_button=True, + ) + + return Destination(BackStackView) + + class ToolsSpecterDIYView(View): CHANGE_PIN = ButtonOption("Change Card PIN") LOAD_MNEMONIC = ButtonOption("Load Mnemonic") diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index cecc89e08..98de90c88 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -36,7 +36,7 @@ ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen, ToolsTextQRTextEntryScreen, ToolsTextQRReviewTextScreen, ToolsTextQRTranscribeModePromptScreen, ToolsTranscribeTextQRWholeQRScreen, ToolsTranscribeTextQRZoomedInScreen, - ToolsTranscribeTextQRConfirmQRPromptScreen, ToolsCommonFilterScreen, ToolsNetworkInfoScreen, + ToolsTranscribeTextQRConfirmQRPromptScreen, ToolsNetworkInfoScreen, ToolsBatteryCalibrationIntroScreen, ToolsBatteryCalibrationStartScreen, ToolsBatteryCalibrationRunningScreen) from seedsigner.helpers import embit_utils, mnemonic_generation from seedsigner.helpers import bip85_drng, diceware, password_generation diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 7434bd059..11938812d 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -275,7 +275,6 @@ def run(self): logger.debug("boot-counter clear skipped", exc_info=True) controller.storage.discard_pending_slip39_shares() - controller.tools_common_card_filter = None controller.psbt_from_microsd = False controller.psbt_microsd_save_path = None controller.psbt_microsd_seed_warning_shown = False diff --git a/tests/real_screen_fixtures.py b/tests/real_screen_fixtures.py index be33066ae..360c76db5 100644 --- a/tests/real_screen_fixtures.py +++ b/tests/real_screen_fixtures.py @@ -128,6 +128,40 @@ def simulated_satochip(monkeypatch, applet: str = "satochip", setup_pin: str = " yield connector +@contextmanager +def simulated_satodime(monkeypatch): + """ + Put a *real* Satodime applet behind `init_satochip`, running in jcardsim. + + Unlike Satochip, Satodime needs no ``card_setup`` / PIN enrolment to answer status and + keyslot queries (it keys access off an ownership/unlock secret instead), so this fixture + stops at connector construction. Skips via JCardSimUnavailable when Java or the applet + sources are absent. + """ + import sys + from unittest.mock import MagicMock as _MagicMock + + for name in [m for m in sys.modules if m == "pysatochip" or m.startswith("pysatochip.")]: + if isinstance(sys.modules[name], _MagicMock): + del sys.modules[name] + + from jcardsim import open_card + from jcardsim.pcsc_shim import patched_pcsc + + from seedsigner.helpers import seedkeeper_utils + + with open_card("satodime") as card: + card.select() + with patched_pcsc(card): + from pysatochip.CardConnector import CardConnector + + connector = CardConnector(card_filter=["satodime"]) + monkeypatch.setattr( + seedkeeper_utils, "init_satochip", lambda *a, **kw: connector + ) + yield connector + + class FakePyGP: """ diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 50cf1195f..9987c3408 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -658,19 +658,61 @@ def test_settings_back_navigation_nested(self): # SMARTCARD SUB-MENU # ====================================================================== - def test_smartcard_common(self): - """Tools → Smartcard → Common → BACK.""" + def test_smartcard_satodime(self): + """Tools → Smartcard → Satodime → BACK.""" from seedsigner.views.smartcard_views import ( - ToolsSmartcardMenuView, ToolsCommonView, + ToolsSmartcardMenuView, ToolsSatodimeView, ) self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), - FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.COMMON), - FlowStep(ToolsCommonView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, screen_return_value=RET_CODE__BACK_BUTTON), FlowStep(ToolsSmartcardMenuView), ]) + def test_smartcard_satodime_card_settings(self): + """Tools → Smartcard → Satodime → Card Settings → BACK.""" + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeCardSettingsView, + ) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CARD_SETTINGS), + FlowStep(ToolsSatodimeCardSettingsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSatodimeView), + ]) + + def test_smartcard_satochip_card_settings(self): + """Tools → Smartcard → Satochip → Card Settings → BACK.""" + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatochipView, ToolsSatochipCardSettingsView, + ) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATOCHIP), + FlowStep(ToolsSatochipView, button_data_selection=ToolsSatochipView.CARD_SETTINGS), + FlowStep(ToolsSatochipCardSettingsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSatochipView), + ]) + + def test_smartcard_seedkeeper_card_settings(self): + """Tools → Smartcard → SeedKeeper → Card Settings → BACK.""" + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSeedkeeperView, ToolsSeedkeeperCardSettingsView, + ) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SEEDKEEPER), + FlowStep(ToolsSeedkeeperView, button_data_selection=ToolsSeedkeeperView.CARD_SETTINGS), + FlowStep(ToolsSeedkeeperCardSettingsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSeedkeeperView), + ]) + def test_smartcard_seedkeeper(self): """Tools → Smartcard → SeedKeeper → BACK.""" from seedsigner.views.smartcard_views import ( diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py new file mode 100644 index 000000000..103792c3f --- /dev/null +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -0,0 +1,120 @@ +""" + Satodime flows driven against a *real* applet running in jcardsim. + + The Satodime menu + views were cherry-picked from PR #66 and call the pysatochip + ``satodime_*`` APDUs (status, keyslot status, pubkey). Those calls only mean + something if SeedSigner's client and the applet agree on the wire format -- exactly + the class of bug the jcardsim suites exist to catch. Everything here skips when Java + or the Satochip-DIY sources are absent. + + Two levels: + * connector-level -- prove the APDUs the views depend on round-trip against the applet; + * view-level -- drive ToolsSatodimeAddressesView for real, rendering one slot screen + and backing out (robust to whatever keyslot count / pubkey state the applet has). +""" + +import sys +from unittest.mock import MagicMock + +import pytest + +# Must import test base before the Controller (sets up the hardware mocks) +import base # noqa: F401 +from base import FlowStep, FlowTest + +# base.py stubs pysatochip so the ordinary suite runs cardless; these need it real. +for _name in [m for m in sys.modules if m == "pysatochip" or m.startswith("pysatochip.")]: + if isinstance(sys.modules[_name], MagicMock): + del sys.modules[_name] + +from jcardsim import JCardSimUnavailable, why_unavailable +from real_screen_fixtures import simulated_satodime +from ui_driver import Back, UISession, select + +# tools_views must be imported first: it is a facade that star-imports smartcard_views. +from seedsigner.views import tools_views +from seedsigner.views import smartcard_views +from seedsigner.models.settings import SettingsConstants +from seedsigner.views.view import MainMenuView + + +pytestmark = pytest.mark.skipif( + why_unavailable() is not None, reason=f"jcardsim unavailable: {why_unavailable()}" +) + + +class SatodimeSimulatedFlowTest(FlowTest): + + def setup_method(self): + super().setup_method() + for setting in ( + SettingsConstants.SETTING__SMARTCARD_SUPPORT, + SettingsConstants.SETTING__SATOCHIP_SUPPORT, + ): + self.settings.set_value(setting, SettingsConstants.OPTION__ENABLED) + + +class TestSatodimeConnectorAgainstRealApplet(SatodimeSimulatedFlowTest): + """The APDUs the Satodime views lean on must round-trip against real bytecode.""" + + def test_status_and_card_type(self, monkeypatch): + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx as connector: + assert connector.card_type == "Satodime" + # These are the first three calls every Satodime view makes. + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + (_, sw1, sw2, status) = connector.satodime_get_status() + assert (sw1, sw2) == (0x90, 0x00) + assert "max_num_keys" in status + + +class TestSatodimeAddressesAgainstRealApplet(SatodimeSimulatedFlowTest): + """ + Satodime > View Deposit Addresses renders a real slot screen. + + We render exactly one slot then press BACK, which the view treats as 'stop iterating'. + That exercises satodime_get_status + get_keyslot_status(0) + get_pubkey(0) end to end + without depending on how many slots the applet reports or whether a given pubkey is + initialised (the view already catches per-slot errors and shows them on screen). + """ + + def test_renders_one_slot(self, monkeypatch): + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx as connector: + # Skip (rather than fail) if this jcardsim build can't service the status APDU + # or exposes no slots to render -- neither is a SeedSigner bug. + try: + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + (_, sw1, sw2, status) = connector.satodime_get_status() + except Exception as exc: # pragma: no cover - environment dependent + pytest.skip(f"satodime status unsupported under jcardsim: {exc}") + if (sw1, sw2) != (0x90, 0x00) or not status.get("max_num_keys"): + pytest.skip("no satodime slots to render") + + session = UISession(script=( + select(smartcard_views.ToolsSatodimeView.VIEW_ADDRESSES) + + [Back()] # render slot 0, then stop iterating + )) + self.run_sequence( + [ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, + button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(smartcard_views.ToolsSmartcardMenuView, + button_data_selection=smartcard_views.ToolsSmartcardMenuView.SATODIME), + FlowStep(smartcard_views.ToolsSatodimeView, real_screens=True), + FlowStep(smartcard_views.ToolsSatodimeAddressesView, real_screens=True), + FlowStep(smartcard_views.ToolsSatodimeView), + ], + ui_session=session, + ) diff --git a/tests/test_real_screen_flows_smartcard.py b/tests/test_real_screen_flows_smartcard.py index dc9fbed14..01cc5f100 100644 --- a/tests/test_real_screen_flows_smartcard.py +++ b/tests/test_real_screen_flows_smartcard.py @@ -11,8 +11,8 @@ `seedkeeper_utils.init_satochip` -- so the stand-in can later be swapped for a jcardsim-backed simulator running the real applets without touching these tests. - Two views here need no card at all and are covered directly: ToolsCommonFilterView - (it only mutates a controller attribute) and ToolsDIYMountStatusView (it reads a log + Views here that need no card at all are covered directly: the per-applet 'Card Settings' + submenus (they just build a ButtonListScreen) and ToolsDIYMountStatusView (it reads a log file). """ @@ -63,10 +63,10 @@ class TestSmartcardMenuNavigation(SmartcardFlowTest): @pytest.mark.parametrize( "menu_option, submenu_view", [ - (smartcard_views.ToolsSmartcardMenuView.COMMON, smartcard_views.ToolsCommonView), (smartcard_views.ToolsSmartcardMenuView.SATOCHIP, smartcard_views.ToolsSatochipView), (smartcard_views.ToolsSmartcardMenuView.KEYCARD, smartcard_views.ToolsKeycardView), (smartcard_views.ToolsSmartcardMenuView.SEEDKEEPER, smartcard_views.ToolsSeedkeeperView), + (smartcard_views.ToolsSmartcardMenuView.SATODIME, smartcard_views.ToolsSatodimeView), (smartcard_views.ToolsSmartcardMenuView.SPECTER_DIY, smartcard_views.ToolsSpecterDIYView), (smartcard_views.ToolsSmartcardMenuView.Satochip_DIY, smartcard_views.ToolsSatochipDIYView), ], @@ -117,33 +117,50 @@ def test_advanced_submenu_opens_and_backs_out(self, parent_option, parent_view, -class TestCardFilterFlow(SmartcardFlowTest): +class TestCardSettingsSubmenus(SmartcardFlowTest): """ - Common Functions > Device Filter. This one needs no card -- it only narrows which - card types later flows will accept -- and it was never named in any test. + Each applet's 'Card Settings' submenu opens for real and backs out. These host the + functions that used to live under the removed Common Functions menu, so they must + still construct (a ButtonListScreen with the shared-view options) without a card. """ - def test_choosing_a_filter_records_it(self): + @pytest.mark.parametrize( + "menu_option, parent_view, settings_view", + [ + ( + smartcard_views.ToolsSmartcardMenuView.SATOCHIP, + smartcard_views.ToolsSatochipView, + smartcard_views.ToolsSatochipCardSettingsView, + ), + ( + smartcard_views.ToolsSmartcardMenuView.SEEDKEEPER, + smartcard_views.ToolsSeedkeeperView, + smartcard_views.ToolsSeedkeeperCardSettingsView, + ), + ( + smartcard_views.ToolsSmartcardMenuView.SATODIME, + smartcard_views.ToolsSatodimeView, + smartcard_views.ToolsSatodimeCardSettingsView, + ), + ], + ) + def test_card_settings_opens_and_backs_out(self, menu_option, parent_view, settings_view): session = UISession(script=( - select(smartcard_views.ToolsSmartcardMenuView.COMMON) - + select(smartcard_views.ToolsCommonView.FILTER) - + select("Satochip") # untick it - + [Back()] # the view loops until BACK, which commits the filter + select(menu_option) + + select(parent_view.CARD_SETTINGS) + + [Back()] )) self.run_sequence( self.smartcard_steps() + [ FlowStep(smartcard_views.ToolsSmartcardMenuView, real_screens=True), - FlowStep(smartcard_views.ToolsCommonView, real_screens=True), - FlowStep(smartcard_views.ToolsCommonFilterView, real_screens=True), - FlowStep(smartcard_views.ToolsCommonView), + FlowStep(parent_view, real_screens=True), + FlowStep(settings_view, real_screens=True), + FlowStep(parent_view), ], ui_session=session, ) - # BACK commits whatever is still ticked; deselecting one leaves the other two. - assert self.controller.tools_common_card_filter == ["seedkeeper", "satodime"] - class TestDIYMountStatusFlow(SmartcardFlowTest): diff --git a/tests/test_real_screen_flows_smartcard_simulated.py b/tests/test_real_screen_flows_smartcard_simulated.py index 4bedf4a28..05766b0b3 100644 --- a/tests/test_real_screen_flows_smartcard_simulated.py +++ b/tests/test_real_screen_flows_smartcard_simulated.py @@ -69,7 +69,7 @@ def satochip_steps(self) -> list: class TestSatochipCardInfoAgainstRealApplet(SimulatedCardFlowTest): - """Common > Card Info, reading a card that really answers.""" + """Satochip > Card Settings > Card Info, reading a card that really answers.""" def test_card_info_reports_the_applet(self, monkeypatch): try: @@ -81,8 +81,8 @@ def test_card_info_reports_the_applet(self, monkeypatch): assert connector.card_type == "Satochip" session = UISession(script=( - select(smartcard_views.ToolsSmartcardMenuView.COMMON) - + select(smartcard_views.ToolsCommonView.INFO) + select(smartcard_views.ToolsSatochipView.CARD_SETTINGS) + + select(smartcard_views.ToolsSatochipCardSettingsView.INFO) + select(0) )) self.run_sequence( @@ -90,10 +90,12 @@ def test_card_info_reports_the_applet(self, monkeypatch): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), - FlowStep(smartcard_views.ToolsSmartcardMenuView, real_screens=True), - FlowStep(smartcard_views.ToolsCommonView, real_screens=True), + FlowStep(smartcard_views.ToolsSmartcardMenuView, + button_data_selection=smartcard_views.ToolsSmartcardMenuView.SATOCHIP), + FlowStep(smartcard_views.ToolsSatochipView, real_screens=True), + FlowStep(smartcard_views.ToolsSatochipCardSettingsView, real_screens=True), FlowStep(smartcard_views.ToolsSmartcardInfoView, real_screens=True), - FlowStep(smartcard_views.ToolsCommonView), + FlowStep(smartcard_views.ToolsSatochipCardSettingsView), ], ui_session=session, ) diff --git a/tests/test_smartcard_card_filter.py b/tests/test_smartcard_card_filter.py new file mode 100644 index 000000000..6b47ad0a9 --- /dev/null +++ b/tests/test_smartcard_card_filter.py @@ -0,0 +1,116 @@ +""" + Unit coverage for the per-applet card filtering that replaced the shared + 'Common Functions' menu + Device Filter (issue #402). + + The former Common menu let a single screen target satochip/seedkeeper/satodime via + a controller-held filter. That is gone: each applet menu now passes an explicit + ``card_filter`` into the shared views, and ``_applet_card_filter`` intersects it with + whatever card types the individual function supports. These tests pin that contract + without needing a reader or card -- routing is exercised by faking run_screen, exactly + like test_tools_smartcard_keycard_menu.py. +""" + +from base import BaseTest + +from seedsigner.views import smartcard_views +from seedsigner.views.smartcard_views import _applet_card_filter + + +class TestAppletCardFilter: + def test_explicit_filter_is_intersected_with_allowed(self): + assert _applet_card_filter(["satochip"], ["satochip", "seedkeeper"]) == ["satochip"] + + def test_unsupported_applet_dropped(self): + # Satodime is not factory-resettable; asking for it yields nothing. + assert _applet_card_filter(["satodime"], ["satochip", "seedkeeper"]) == [] + + def test_none_falls_back_to_all_allowed(self): + assert _applet_card_filter(None, ["seedkeeper", "satodime"]) == ["seedkeeper", "satodime"] + + def test_order_and_membership_preserved(self): + out = _applet_card_filter(["seedkeeper", "satochip"], ["satochip", "seedkeeper", "satodime"]) + assert out == ["seedkeeper", "satochip"] + + +class TestSharedViewsAcceptCardFilter: + """Every formerly-Common view must take a card_filter kwarg and stash it.""" + + def test_all_shared_views_construct(self): + for cls in ( + smartcard_views.ToolsSmartcardInfoView, + smartcard_views.ToolsSmartcardGenuineCheckView, + smartcard_views.ToolsSatochipChangePinView, + smartcard_views.ToolsSatochipChangeLabelView, + smartcard_views.ToolsSatochipChangeNFCView, + smartcard_views.ToolsCommonNdefView, + smartcard_views.ToolsSatochipFactoryResetView, + ): + view = cls(card_filter=["satochip"]) + assert view.card_filter == ["satochip"] + + def test_fingerprint_view_defaults_to_satochip(self): + view = smartcard_views.ToolsSmartcardViewFingerprintView() + assert view.card_filter == ["satochip"] + + +class TestCardSettingsRouting(BaseTest): + """Each Card Settings item routes to the shared view scoped to that applet.""" + + def _route(self, menu_view, option): + captured = {} + + def fake_run_screen(screen_cls, **kwargs): + captured["button_data"] = kwargs["button_data"] + return kwargs["button_data"].index(option) + + menu_view.run_screen = fake_run_screen + return menu_view.run() + + def test_satochip_card_settings_scopes_to_satochip(self): + dest = self._route( + smartcard_views.ToolsSatochipCardSettingsView(), + smartcard_views.ToolsSatochipCardSettingsView.INFO, + ) + assert dest.View_cls is smartcard_views.ToolsSmartcardInfoView + assert dest.view_args["card_filter"] == ["satochip"] + + def test_seedkeeper_card_settings_scopes_to_seedkeeper(self): + for option, target in ( + (smartcard_views.ToolsSeedkeeperCardSettingsView.INFO, smartcard_views.ToolsSmartcardInfoView), + (smartcard_views.ToolsSeedkeeperCardSettingsView.CONFIGURE_NDEF, smartcard_views.ToolsCommonNdefView), + (smartcard_views.ToolsSeedkeeperCardSettingsView.FACTORY_RESET, smartcard_views.ToolsSatochipFactoryResetView), + ): + dest = self._route(smartcard_views.ToolsSeedkeeperCardSettingsView(), option) + assert dest.View_cls is target + assert dest.view_args["card_filter"] == ["seedkeeper"] + + def test_satodime_card_settings_scopes_to_satodime(self): + for option, target in ( + (smartcard_views.ToolsSatodimeCardSettingsView.INFO, smartcard_views.ToolsSmartcardInfoView), + (smartcard_views.ToolsSatodimeCardSettingsView.GENUINE, smartcard_views.ToolsSmartcardGenuineCheckView), + (smartcard_views.ToolsSatodimeCardSettingsView.CONFIGURE_NDEF, smartcard_views.ToolsCommonNdefView), + ): + dest = self._route(smartcard_views.ToolsSatodimeCardSettingsView(), option) + assert dest.View_cls is target + assert dest.view_args["card_filter"] == ["satodime"] + + def test_satodime_menu_offers_only_supported_settings(self): + """Satodime has no Change PIN/Label/NFC or Factory Reset in its Card Settings.""" + captured = {} + + def capture_only(screen_cls, **kwargs): + captured["button_data"] = kwargs["button_data"] + return 0 # routes to INFO; the menu itself makes no connector call + + smartcard_views.ToolsSatodimeCardSettingsView().run_screen = capture_only + + menu = smartcard_views.ToolsSatodimeCardSettingsView() + menu.run_screen = capture_only + menu.run() + + labels = [b.button_label for b in captured["button_data"]] + assert "Card Info" in labels + assert "Genuine Check" in labels + assert "Configure NDEF" in labels + assert "Change PIN" not in labels + assert "Factory Reset Card" not in labels diff --git a/tests/test_smartcard_hardware.py b/tests/test_smartcard_hardware.py index 8d300ca42..c0e1faffd 100644 --- a/tests/test_smartcard_hardware.py +++ b/tests/test_smartcard_hardware.py @@ -456,6 +456,63 @@ def test_get_label(self): finally: self._disconnect() + # -- keyslot operations used by the Satodime views (PR #66) ---------- + # Definition order matters: seal/read run before unseal (destructive last). + + def _first_slot(self, connector, want_txt): + (_, _, _, status) = connector.satodime_get_status() + for key_nbr in range(status.get("max_num_keys", 0)): + (_, _, _, slot_status) = connector.satodime_get_keyslot_status(key_nbr) + if slot_status.get("key_status_txt") == want_txt: + return key_nbr + return None + + def test_seal_slot_then_read_pubkey(self): + """Seal an uninitialized slot and read back its pubkey, as the views do.""" + connector = self._connect() + try: + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + + slot = self._first_slot(connector, "Uninitialized") + if slot is None: + pytest.skip("no uninitialized satodime slot available") + + entropy = os.urandom(32) + (_, sw1, sw2, _, pub_comp) = connector.satodime_seal_key(slot, entropy) + assert (sw1, sw2) == (0x90, 0x00), "seal should succeed" + + # A compressed secp256k1 pubkey is 33 bytes. + (_, _, _, _, pub_read) = connector.satodime_get_pubkey(slot) + assert len(bytes(pub_read)) == 33 + + (_, _, _, slot_status) = connector.satodime_get_keyslot_status(slot) + assert slot_status.get("key_status_txt") == "Sealed" + finally: + self._disconnect() + + def test_unseal_slot_returns_privkey(self): + """Unsealing a sealed slot yields the private key (destructive — runs last).""" + connector = self._connect() + try: + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + + # Ensure there is something sealed to open; seal one if needed. + slot = self._first_slot(connector, "Sealed") + if slot is None: + slot = self._first_slot(connector, "Uninitialized") + if slot is None: + pytest.skip("no satodime slot available to unseal") + connector.satodime_seal_key(slot, os.urandom(32)) + + (_, sw1, sw2, _, priv) = connector.satodime_unseal_key(slot) + assert (sw1, sw2) == (0x90, 0x00), "unseal should succeed" + # Private key is a secp256k1 scalar. + assert len(bytes(priv)) in (32, 33) + finally: + self._disconnect() + # ====================================================================== # Phase 2 — SeedKeeper diff --git a/tests/test_split_module_imports.py b/tests/test_split_module_imports.py index 09ee1f6f6..698bb3784 100644 --- a/tests/test_split_module_imports.py +++ b/tests/test_split_module_imports.py @@ -108,14 +108,30 @@ def test_embit_utils_imported(self): assert hasattr(smartcard_views, "embit_utils") - def test_tools_common_filter_screen_imported(self): - """ToolsCommonFilterScreen is imported from tools_screens. + def test_satodime_views_defined(self): + """The Satodime menu + views (cherry-picked from PR #66) live in smartcard_views. - This was missing after the split, causing NameError in ToolsCommonFilterView. + They reference the embit ec/script/networks helpers, so a missing import + would only surface at run() time without this guard. """ from seedsigner.views import smartcard_views - assert hasattr(smartcard_views, "ToolsCommonFilterScreen") + for name in ( + "ToolsSatodimeView", + "ToolsSatodimeAddressesView", + "ToolsSatodimeSealSlotView", + "ToolsSatodimeUnsealSlotView", + "ToolsSatodimeSignTxView", + "ToolsSatodimeTransferOwnershipView", + "ToolsSatodimeCardSettingsView", + ): + assert hasattr(smartcard_views, name) + + def test_fingerprint_view_defined(self): + """The standalone master-fingerprint view (issue #401) is present.""" + from seedsigner.views import smartcard_views + + assert hasattr(smartcard_views, "ToolsSmartcardViewFingerprintView") class TestModuleImportNoNameError: diff --git a/tests/test_tools_smartcard_keycard_menu.py b/tests/test_tools_smartcard_keycard_menu.py index c03889a00..f589a2b1b 100644 --- a/tests/test_tools_smartcard_keycard_menu.py +++ b/tests/test_tools_smartcard_keycard_menu.py @@ -22,7 +22,8 @@ def fake_run_screen(screen_cls, **kwargs): view.run_screen = fake_run_screen destination = view.run() - assert destination.View_cls == tools_views.ToolsCommonView + # Index 0 is now SeedKeeper (the Common menu was removed). + assert destination.View_cls == tools_views.ToolsSeedkeeperView assert tools_views.ToolsSmartcardMenuView.KEYCARD in captured["button_data"] def test_selecting_keycard_routes_to_keycard_view(self): From 4d4112ce17a7dec39924ca2be2bfb6e8340dd35f Mon Sep 17 00:00:00 2001 From: 3rdIteration Date: Tue, 8 Sep 2026 14:05:48 -0400 Subject: [PATCH 02/26] Satodime: fix claim/seal/address bugs, match the official app, back up the unlock code A factory-fresh Satodime was unusable: "View Deposit Addresses" showed a parser error on every slot and "Seal Slot" failed. Three separate faults, all confirmed on hardware: - init_satochip() ran the shared PIN-enrolment branch whenever setup_done was False. Satodime has no PIN, so the user was asked to invent one the applet then ignores. - Nothing ever ran INS_SETUP, and the applet answers every state-changing APDU with 0x9C04 until it has. Backing out of the bogus PIN prompt left the card unclaimed, so seal failed. - ec.PublicKey() takes secp256k1's internal 64-byte point, so passing the card's 33-byte SEC pubkey raised "Pubkey should be 64 bytes long" on every sealed slot. ec.PublicKey.parse() is the constructor that reads SEC. Claiming now lives in ToolsSatodimeClaimView behind an explicit confirmation: setup mints a fresh unlock secret, which on a card mid-ownership-transfer would take it from whoever it was being handed to. Read-only views no longer force a claim, since status/keyslot/pubkey all work on an unclaimed card. Consistency with the official Satodime apps (Toporin/Javacryptotools): - Addresses are bech32 P2WPKH, not P2PKH. BaseCoin.pubToAddress() returns segwit whenever the coin supports it and Bitcoin sets segwit_supported = true, so P2PKH printed a different address for the same key and the official app reported a zero balance on anything deposited to it. - Sealing now tags the slot with BTC's slip44 and the deprecated 34-byte contract/tokenid block, byte-identical to NFCCardService.seal(). Untagged slots read back as slip44 0x00000000 and show as an unknown asset. - A slot sealed for another chain renders "Not Bitcoin ()" rather than a Bitcoin address derived from its key. Unlock-secret handling, for contactless readers: The applet only enforces the unlock code over NFC; a contact interface skips the check entirely. The 20-byte secret is emitted once by INS_SETUP and can never be re-read, so over NFC losing it strands the card -- ownership transfer is gated too. Claiming over contact therefore skips the ceremony entirely, while over NFC it hands off to a backup flow: QR display with a mandatory scan-back, optional MicroSD copy, and a warning that a contact reader can unseal the card without the code at all. Secrets are cached in RAM for the session only, and ToolsSatodimeRestoreUnlockView loads one back from QR or MicroSD. Tests: the jcardsim suite went from 2 tests to 40. The old ones passed while the feature was broken because the fixture monkeypatched init_satochip away (hiding the PIN bug), a fresh applet has no sealed slots (so get_pubkey raised before the views reached the address bug), nothing ever sealed (so 0x9C04 was never hit), and the one view test asserted only on navigation -- a screen whose body was an exception message counted as a pass. A simulated_satodime_raw fixture now patches only PC/SC so the real client runs, and a golden vector pins our derivation to an address the official Android app displayed for a known pubkey. The hardware suite's Satodime _provision() was a no-op; it now claims the card through the app's own helper and asserts the address format and slot tagging. Also adds the 0x9C50/0x9C51/0x9C54 unlock status words, which surfaced as raw hex. Co-Authored-By: Claude Opus 5 --- src/seedsigner/controller.py | 7 + src/seedsigner/helpers/iso7816.py | 6 + src/seedsigner/helpers/seedkeeper_utils.py | 147 ++++- src/seedsigner/views/smartcard_views.py | 580 +++++++++++++++- tests/real_screen_fixtures.py | 30 + ...st_real_screen_flows_satodime_simulated.py | 622 +++++++++++++++++- tests/test_smartcard_hardware.py | 71 +- 7 files changed, 1427 insertions(+), 36 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index b1d5fb039..36aba2229 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -268,6 +268,11 @@ def _load_block_anchor(cls): Satochip_Connector = None Satochip_PIN = None Satochip_Last_UID_SHA1 = None + # Satodime unlock secrets for this session, keyed by card UID. The card emits its + # 20-byte unlock secret exactly once, from INS_SETUP, and it can never be re-read; + # without it, a contactless reader cannot seal, unseal, reset or even transfer the + # card. Held in RAM only -- the user is walked through backing it up at claim time. + Satodime_unlock_secrets: dict | None = None GPG_Admin_PIN = None javacard_keys: dict | None = None @@ -566,6 +571,7 @@ def run(self): self.Satochip_PIN = None self.Satochip_Last_UID_SHA1 = None self.Satochip_Connector = None + self.Satodime_unlock_secrets = None # Always drop any cached OpenPGP admin PIN when returning home self.GPG_Admin_PIN = None @@ -759,6 +765,7 @@ def handle_wipe_timeout(self): self.Satochip_PIN = None self.Satochip_Last_UID_SHA1 = None self.Satochip_Connector = None + self.Satodime_unlock_secrets = None self.GPG_Admin_PIN = None self.image_entropy_preview_frames = None self.image_entropy_final_image = None diff --git a/src/seedsigner/helpers/iso7816.py b/src/seedsigner/helpers/iso7816.py index 64ce4f96b..455177899 100644 --- a/src/seedsigner/helpers/iso7816.py +++ b/src/seedsigner/helpers/iso7816.py @@ -50,6 +50,12 @@ 0x9C32: "Import data too long", 0x9C33: "Wrong MAC during import", 0x9C38: "Wrong secret type", + # Satodime only, and only over a contactless reader: the applet skips the + # unlock-code check entirely on a contact interface, so these two never appear + # there. They mean the caller does not hold the card's unlock secret. + 0x9C50: "Wrong unlock counter", + 0x9C51: "Wrong unlock code", + 0x9C54: "Unknown protocol media", 0x9CFF: "Card internal error", } diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 263f53e2a..50739649e 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -460,6 +460,140 @@ def disconnect_smartcard_connections(controller): pass +def claim_satodime_ownership(connector): + """Run INS_SETUP on a Satodime, claiming it for this device. + + Satodime has no PIN and no seed. A factory-fresh card -- or one whose ownership + has just been handed off with ``satodime_initiate_ownership_transfer()`` -- + reports ``setup_done`` False and refuses every state-changing APDU (seal, unseal, + reset, transfer) with 0x9C04 until setup has run. Setup generates the card's + unlock counter/secret and returns them; pysatochip caches both on the connector. + + ``card_setup()`` is the shared Satochip-family wire format, so it insists on + PIN/PUK arguments. The Satodime applet ignores the whole data field, so these are + random bytes -- never anything a user could mistake for a PIN they must remember. + """ + def junk(): + return list(urandom(16)) + + (response, sw1, sw2) = connector.card_setup( + 0x05, 0x01, junk(), junk(), # pin_tries0, ublk_tries0, pin0, ublk0 + 0x01, 0x01, junk(), junk(), # pin_tries1, ublk_tries1, pin1, ublk1 + 32, 0x0000, # secmemsize, memsize + 0x01, 0x01, 0x01, # create_object/key/pin ACL + option_flags=0, + hmacsha160_key=None, + amount_limit=0, + ) + return (response, sw1, sw2) + + +SATODIME_UNLOCK_PREFIX = "satodime-unlock:" +SIZE_SATODIME_UNLOCK_SECRET = 20 + + +def satodime_card_id(connector) -> str: + """Short, stable id for a Satodime, used to key its unlock secret.""" + uid = getattr(connector, "UID_SHA1", None) or "" + return str(uid)[:16] + + +def format_satodime_unlock_payload(card_id: str, secret) -> str: + """Render an unlock secret as the text that goes in the backup QR / MicroSD file. + + Self-describing and ASCII, so it round-trips through ``QRType.TEXT`` and can be + read back by a phone camera. The card id is carried alongside the secret so a + restore can tell the user when they have presented the wrong card's backup. + """ + return f"{SATODIME_UNLOCK_PREFIX}{card_id}:{bytes(secret).hex()}" + + +def parse_satodime_unlock_payload(text: str): + """Inverse of :func:`format_satodime_unlock_payload`. + + Returns ``(card_id, secret_list)`` or ``None`` when the text is not a Satodime + unlock backup or is malformed. + """ + if not text: + return None + text = text.strip() + if not text.startswith(SATODIME_UNLOCK_PREFIX): + return None + body = text[len(SATODIME_UNLOCK_PREFIX):] + parts = body.split(":") + if len(parts) != 2: + return None + card_id, secret_hex = parts[0].strip(), parts[1].strip() + try: + secret = bytes.fromhex(secret_hex) + except ValueError: + return None + if len(secret) != SIZE_SATODIME_UNLOCK_SECRET: + return None + return (card_id, list(secret)) + + +# Reader-name fragments that mean "this connection is contactless". The Satodime +# applet keys its unlock-code enforcement off the APDU protocol media, not off any +# setting, so the medium of the *actual* connection is what matters -- and PN532 is +# enabled by default, which makes the interface setting alone useless as a signal. +CONTACTLESS_READER_MARKERS = ("nfc", "pn532", "pn53", "acr122", "contactless", "rc522") + + +def satodime_connection_is_contactless(connector) -> bool: + """Whether this card is talking to us over a contactless reader. + + Matters because the applet skips the unlock-code check entirely on a contact + interface: over USB the zeroed placeholder secret is accepted, so there is nothing + to back up and nothing to restore. Over NFC the same operations need the real + 20-byte secret. + + Fails safe: when the reader cannot be identified we answer True, so the user is + offered the backup rather than silently left without one. + """ + try: + name = connector.cardservice.connection.getReader() + except Exception: + return True + if not name: + return True + name = str(name).lower() + return any(marker in name for marker in CONTACTLESS_READER_MARKERS) + + +def satodime_unlock_backup_filename(card_id: str) -> str: + """Deterministic name, so a restore can find the file without the user typing it.""" + return f"satodime_unlock_{card_id}.txt" + + +def cache_satodime_unlock_secret(controller, card_id: str, secret) -> None: + """Hold an unlock secret in RAM for the rest of this session.""" + if controller.Satodime_unlock_secrets is None: + controller.Satodime_unlock_secrets = {} + controller.Satodime_unlock_secrets[card_id] = list(secret) + + +def get_cached_satodime_unlock_secret(controller, card_id: str): + cached = controller.Satodime_unlock_secrets or {} + return cached.get(card_id) + + +def apply_satodime_unlock_secret(controller, connector) -> bool: + """Load this card's cached unlock secret onto the connector. + + Returns True when a real secret was applied. Otherwise the connector is left with + pysatochip's all-zero placeholder, which a *contact* reader accepts (the applet + skips the unlock-code check entirely there) and a contactless one rejects with + 0x9C51. + """ + secret = get_cached_satodime_unlock_secret(controller, satodime_card_id(connector)) + if secret: + connector.satodime_set_unlock_secret(list(secret)) + return True + connector.satodime_set_unlock_secret() + return False + + def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend_preference: str | None = None, allow_unseeded: bool = False): from seedsigner.models.settings import ( Settings, @@ -606,7 +740,18 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend return None # Check if the Seedkeeper needs the initial setup process - if status[3]["setup_done"]: + setup_done = status[3]["setup_done"] + + if getattr(Satochip_Connector, "card_type", None) == "Satodime": + # Satodime is PIN-less, so it must never reach the shared PIN branches below: + # the setup one would ask the user to invent a PIN the applet ignores, and the + # verify one has nothing to verify against. An *unclaimed* Satodime still + # connects fine -- status, keyslot and pubkey reads all work before setup -- + # so hand the connector back either way and let ToolsSatodimeClaimView run the + # claim when a view actually needs a state change. + apply_satodime_unlock_secret(parentObject.controller, Satochip_Connector) + + elif setup_done: if require_pin: # Check for an existing Seedkeeper card that we may have been using with this PIN, diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index c3fd96866..162e301eb 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4583,6 +4583,477 @@ def run(self): return Destination(MainMenuView) +# A Satodime keyslot records the *coin*, never the network: Javacryptotools' +# Constants.MAP_SLIP44_BY_SYMBOL has a single BTC entry (0x80000000) and the official +# apps carry testnet as a separate display flag. So SeedSigner writes BTC's slip44 too, +# and takes mainnet/testnet from SETTING__NETWORK exactly as the app takes it from its +# own settings. +SATODIME_SLIP44_BTC = 0x80000000 +SATODIME_SLIP44_BTC_BYTES = [0x80, 0x00, 0x00, 0x00] + +# key_contract / key_tokenid are deprecated, but the applet still demands 34 bytes of +# each. The official app sends a block whose second byte is the 32-byte length +# (NFCCardService.seal), so a slot sealed here is byte-identical to one it sealed. +SATODIME_EMPTY_CONTRACT = [0x00, 0x20] + [0x00] * 32 + + +def _satodime_pubkey(pub_comp): + """Build an embit PublicKey from the compressed SEC bytes a Satodime returns. + + ``ec.PublicKey()`` takes secp256k1's *internal* 64-byte point, not a serialized + key, so handing it the card's 33-byte SEC blob raises "Pubkey should be 64 bytes + long". ``parse()`` is the constructor that reads SEC. + """ + return ec.PublicKey.parse(bytes(pub_comp)) + + +def _satodime_address(pub_comp, net) -> str: + """Derive a slot's deposit address the way the official Satodime apps do. + + Javacryptotools' ``BaseCoin.pubToAddress()`` returns a segwit address whenever the + coin supports it, and ``Bitcoin`` sets ``segwit_supported = true`` -- so the phone + and desktop apps show bech32 P2WPKH. Deriving P2PKH here would print a *different* + address for the same key: still spendable, but the official app would show a zero + balance for anything deposited to it. + """ + return script.p2wpkh(_satodime_pubkey(pub_comp)).address(network=net) + + +def _satodime_slot_slip44(slot_status) -> int: + """The coin recorded on a keyslot, as an int. + + Slots sealed by SeedSigner before it wrote this metadata read back as 0; treat that + as Bitcoin, which is the only coin this app seals. + """ + raw = slot_status.get("key_slip44") or [] + if not raw: + return SATODIME_SLIP44_BTC + value = int.from_bytes(bytes(raw), "big") + return SATODIME_SLIP44_BTC if value == 0 else value + + +def _satodime_write_slot_metadata(connector, slot) -> bool: + """Tag a freshly sealed slot as Bitcoin, mirroring the official app's seal. + + The app seals and then immediately sends SET_KEYSLOT_STATUS with the coin's slip44 + (NFCCardService.seal). Without it the slot reads back as slip44 0x00000000, which + the official apps show as an unknown asset with no balance lookup. + """ + try: + (_r, sw1, sw2) = connector.satodime_set_keyslot_status_part0( + slot, + 0x00, # RFU1 + 0x00, # RFU2 + 0x00, # key_asset: the app leaves this Undefined + SATODIME_SLIP44_BTC_BYTES, + list(SATODIME_EMPTY_CONTRACT), + list(SATODIME_EMPTY_CONTRACT), + ) + except Exception: + logger.exception("Satodime: failed to tag slot %s as BTC", slot) + return False + if sw1 != 0x90 or sw2 != 0x00: + logger.warning( + "Satodime: failed to tag slot %s as BTC: %s", slot, format_sw_error(sw1, sw2) + ) + return False + return True + + +def _satodime_is_claimed(connector) -> bool: + """Whether INS_SETUP has run on this card. + + Until it has, the applet answers every state-changing APDU with 0x9C04 -- but + status/keyslot/pubkey reads work fine, so read-only views need not care. + """ + return bool(getattr(connector, "setup_done", False)) + + +def _satodime_prepare(view, connector, needs_unlock: bool): + """Get a Satodime ready for a view, or return where the user has to go first. + + ``needs_unlock`` marks the operations the applet gates behind the unlock code over + a contactless reader: seal, unseal, reset, get-privkey and ownership transfer. + Read-only views (status, keyslot, pubkey) pass False -- they work on an unclaimed + card and over either medium, so they must not drag the user through a claim. + + Returns a ``Destination`` to redirect to, or None to carry on. + """ + if needs_unlock and not _satodime_is_claimed(connector): + return Destination(ToolsSatodimeClaimView) + + have_secret = seedkeeper_utils.apply_satodime_unlock_secret(view.controller, connector) + connector.satodime_set_unlock_counter() + + if ( + needs_unlock + and not have_secret + and seedkeeper_utils.satodime_connection_is_contactless(connector) + ): + # Over NFC the applet checks HMAC(unlock_secret, ...), so the zeroed + # placeholder would just earn a 0x9C51. Send the user to restore it rather + # than letting the operation fail with a status word. + selected = view.run_screen( + WarningScreen, + title="Code Required", + status_headline=None, + text="NFC needs this card's\nunlock code.", + show_back_button=True, + button_data=[ButtonOption("Restore Code")], + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + return Destination(ToolsSatodimeRestoreUnlockView) + + return None + + +class ToolsSatodimeClaimView(View): + """Claim an unowned Satodime, then walk the user through backing up its unlock code. + + INS_SETUP is the only time the card ever emits its 20-byte unlock secret. On a + contactless reader that secret is required for every later state change -- seal, + unseal, reset, even handing the card on -- and it cannot be re-read, so losing it + strands the card. Over a contact reader the applet ignores it entirely, so there is + nothing worth backing up and this view claims and returns. + """ + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + if _satodime_is_claimed(Satochip_Connector): + self.run_screen( + WarningScreen, + title="Already Claimed", + status_headline=None, + text="This card already has\nan owner.", + show_back_button=True, + ) + return Destination(BackStackView) + + # Claiming mints a fresh secret, so doing it to a card that is mid-transfer + # takes the card away from whoever it was being handed to. + selected = self.run_screen( + WarningScreen, + title="Card Unclaimed", + status_headline=None, + text="This Satodime has no owner.\nClaim it for this device?", + show_back_button=False, + button_data=[ButtonOption("Claim Card"), ButtonOption("Cancel")], + ) + if selected != 0: + return Destination(BackStackView) + + self.loading_screen = LoadingScreenThread(text="Claiming Card") + self.loading_screen.start() + try: + (_response, sw1, sw2) = seedkeeper_utils.claim_satodime_ownership(Satochip_Connector) + except Exception as e: + logger.exception("Satodime claim failed") + claim_error = str(e)[:100] + else: + claim_error = None if (sw1 == 0x90 and sw2 == 0x00) else format_sw_error(sw1, sw2) + finally: + self.loading_screen.stop() + + if claim_error is not None: + self.run_screen( + WarningScreen, + title="Claim Failed", + status_headline=None, + text=claim_error, + show_back_button=True, + ) + return Destination(BackStackView) + + # card_setup() caches the freshly minted counter + secret on the connector. + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + seedkeeper_utils.cache_satodime_unlock_secret( + self.controller, card_id, list(Satochip_Connector.unlock_secret) + ) + + if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): + # Contact reader: the applet never checks the unlock code, so the secret + # buys the user nothing here and the backup flow would be pure friction. + self.run_screen( + LargeIconStatusScreen, + title="Card Claimed", + status_headline=None, + text="Ready to use.", + show_back_button=False, + ) + return Destination(BackStackView) + + return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id)) + + +class ToolsSatodimeBackupUnlockView(View): + """Show the unlock code as a QR and make the user prove they captured it. + + The read-back is the point: a QR the user never scanned is a backup they cannot be + sure they have. They photograph the code, then hold the photo up to the camera. + MicroSD is offered as a second copy, not as a substitute. + """ + + def __init__(self, card_id: str = None): + super().__init__() + self.card_id = card_id + + def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen + from seedsigner.models.encode_qr import GenericStaticQrEncoder + + card_id = self.card_id + secret = ( + seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) + if card_id + else None + ) + if not secret: + self.run_screen( + WarningScreen, + title="No Unlock Code", + status_headline=None, + text="Claim the card first.", + show_back_button=True, + ) + return Destination(BackStackView) + + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret) + + self.run_screen( + DireWarningScreen, + title="Unlock Code", + status_headline=None, + text="Back this up now. It can\nnever be shown again.", + show_back_button=False, + button_data=[ButtonOption("Continue")], + ) + + # The security property users most often get wrong about Satodime: this code is + # proximity protection, not theft protection. + self.run_screen( + WarningScreen, + title="Not Theft Proof", + status_headline=None, + text="A contact reader can unseal\nthis card without the code.", + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + + while True: + self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=payload)) + + selected = self.run_screen( + ButtonListScreen, + title="Verify Backup", + is_button_text_centered=False, + button_data=[ + ButtonOption("Scan It Back"), + ButtonOption("Show QR Again"), + ButtonOption("Save to MicroSD"), + ButtonOption("Skip Verification"), + ], + show_back_button=False, + ) + + if selected == 1: + continue + + if selected == 2: + self._save_to_microsd(card_id, payload) + continue + + if selected == 3: + confirm = self.run_screen( + DireWarningScreen, + title="Skip Backup?", + status_headline=None, + text="Without this code NFC use\nis lost for good.", + show_back_button=True, + button_data=[ButtonOption("Skip Anyway")], + ) + if confirm == RET_CODE__BACK_BUTTON: + continue + return Destination(BackStackView) + + if self._scan_matches(payload): + self.run_screen( + LargeIconStatusScreen, + title="Backup Verified", + status_headline=None, + text="Keep it safe and private.", + show_back_button=False, + ) + return Destination(BackStackView) + + self.run_screen( + WarningScreen, + title="No Match", + status_headline=None, + text="That is not this card's\nunlock code.", + show_back_button=False, + button_data=[ButtonOption("Try Again")], + ) + + def _scan_matches(self, payload: str) -> bool: + scanned = _satodime_scan_text(self) + return scanned is not None and scanned.strip() == payload + + def _save_to_microsd(self, card_id: str, payload: str): + import os + from seedsigner.hardware.microsd import MicroSD + + if not MicroSD.get_instance().is_inserted: + self.run_screen( + WarningScreen, + title="MicroSD", + status_headline="No card detected", + text="Insert a microSD card\nand try again.", + show_back_button=False, + button_data=[ButtonOption("OK")], + ) + return + + filename = seedkeeper_utils.satodime_unlock_backup_filename(card_id) + filepath = os.path.join(MicroSD.get_microsd_dir(), filename) + try: + with open(filepath, "w", encoding="utf-8") as f: + f.write(payload) + except OSError as e: + self.run_screen( + WarningScreen, + title="MicroSD", + status_headline="Save failed", + text=str(e)[:100], + show_back_button=False, + button_data=[ButtonOption("OK")], + ) + return + + self.run_screen( + LargeIconStatusScreen, + title="Saved", + status_headline=None, + text="Anyone with this microSD\ncan read the code.", + show_back_button=False, + button_data=[ButtonOption("OK")], + ) + + +class ToolsSatodimeRestoreUnlockView(View): + """Load a previously backed-up unlock code back into this session.""" + + SCAN = ButtonOption("Scan Backup QR") + MICROSD = ButtonOption("Load from MicroSD") + + def run(self): + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + + selected = self.run_screen( + ButtonListScreen, + title="Unlock Code", + is_button_text_centered=False, + button_data=[self.SCAN, self.MICROSD], + show_back_button=True, + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + if selected == 0: + payload = _satodime_scan_text(self) + else: + payload = self._read_microsd(card_id) + + if payload is None: + return Destination(BackStackView) + + parsed = seedkeeper_utils.parse_satodime_unlock_payload(payload) + if parsed is None: + self.run_screen( + WarningScreen, + title="Not a Backup", + status_headline=None, + text="That is not a Satodime\nunlock code.", + show_back_button=True, + ) + return Destination(BackStackView) + + backup_card_id, secret = parsed + if backup_card_id != card_id: + self.run_screen( + WarningScreen, + title="Wrong Card", + status_headline=None, + text="That code belongs to a\ndifferent Satodime.", + show_back_button=True, + ) + return Destination(BackStackView) + + seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, secret) + self.run_screen( + LargeIconStatusScreen, + title="Unlock Code Set", + status_headline=None, + text="Loaded for this session.", + show_back_button=False, + ) + return Destination(BackStackView) + + def _read_microsd(self, card_id: str): + import os + from seedsigner.hardware.microsd import MicroSD + + if not MicroSD.get_instance().is_inserted: + self.run_screen( + WarningScreen, + title="MicroSD", + status_headline="No card detected", + text="Insert a microSD card\nand try again.", + show_back_button=True, + ) + return None + + filepath = os.path.join( + MicroSD.get_microsd_dir(), + seedkeeper_utils.satodime_unlock_backup_filename(card_id), + ) + try: + with open(filepath, "r", encoding="utf-8") as f: + return f.read() + except OSError: + self.run_screen( + WarningScreen, + title="Not Found", + status_headline=None, + text="No backup on this microSD\nfor this Satodime.", + show_back_button=True, + ) + return None + + +def _satodime_scan_text(view): + """Scan one plain-text QR, returning its contents or None.""" + from seedsigner.gui.screens.scan_screens import ScanScreen + from seedsigner.models.decode_qr import DecodeQR + from seedsigner.models.qr_type import QRType + + decoder = DecodeQR() + ScanScreen(decoder=decoder, instructions_text="Scan the backup QR").display() + view.controller.reset_screensaver_timeout() + if not decoder.is_complete or decoder.qr_type != QRType.TEXT: + return None + return decoder.get_text() + + class ToolsSatodimeView(View): VIEW_ADDRESSES = ButtonOption("View Deposit Addresses") SEAL_SLOT = ButtonOption("Seal Slot") @@ -4635,11 +5106,19 @@ class ToolsSatodimeCardSettingsView(View): INFO = ButtonOption("Card Info") GENUINE = ButtonOption("Genuine Check") CONFIGURE_NDEF = ButtonOption("Configure NDEF") + BACKUP_UNLOCK = ButtonOption("Back Up Unlock Code") + RESTORE_UNLOCK = ButtonOption("Restore Unlock Code") _CARD_FILTER = ["satodime"] def run(self): - button_data = [self.INFO, self.GENUINE, self.CONFIGURE_NDEF] + button_data = [ + self.INFO, + self.GENUINE, + self.CONFIGURE_NDEF, + self.BACKUP_UNLOCK, + self.RESTORE_UNLOCK, + ] selected_menu_num = self.run_screen( ButtonListScreen, @@ -4660,6 +5139,38 @@ def run(self): elif button_data[selected_menu_num] == self.CONFIGURE_NDEF: return Destination(ToolsCommonNdefView, view_args=dict(card_filter=self._CARD_FILTER)) + elif button_data[selected_menu_num] == self.BACKUP_UNLOCK: + # Re-showing the code only works while it is still cached from this + # session's claim; the card cannot be asked for it a second time. + return Destination(ToolsSatodimeReshowUnlockView) + + elif button_data[selected_menu_num] == self.RESTORE_UNLOCK: + return Destination(ToolsSatodimeRestoreUnlockView) + + +class ToolsSatodimeReshowUnlockView(View): + """Re-open the backup flow for a card claimed earlier in this session.""" + + def run(self): + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=["satodime"], require_pin=False + ) + if not Satochip_Connector: + return Destination(BackStackView) + + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if not seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id): + self.run_screen( + WarningScreen, + title="No Unlock Code", + status_headline=None, + text="The card only reveals it\nwhen first claimed.", + show_back_button=True, + ) + return Destination(BackStackView) + + return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id)) + class ToolsSatodimeAddressesView(View): def run(self): @@ -4669,8 +5180,9 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - Satochip_Connector.satodime_set_unlock_secret() - Satochip_Connector.satodime_set_unlock_counter() + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=False) + if redirect: + return redirect self.loading_screen = LoadingScreenThread(text="Fetching Slots\n\n\n\n\n\n") self.loading_screen.start() @@ -4685,9 +5197,20 @@ def run(self): for key_nbr in range(max_keys): try: (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - (_, _, _, _, pub_comp) = Satochip_Connector.satodime_get_pubkey(key_nbr) - address = script.p2pkh(ec.PublicKey(bytes(pub_comp))).address(network=net) - text = f"{slot_status['key_status_txt']}\n{address}" + status_txt = slot_status.get("key_status_txt", "Unknown") + if status_txt == "Uninitialized": + # An empty slot holds no key, so the card answers get_pubkey with an + # empty body and pysatochip's parser raises. Don't ask. + text = f"{status_txt}\n\nSeal this slot first" + elif _satodime_slot_slip44(slot_status) != SATODIME_SLIP44_BTC: + # Another app sealed this slot for a different chain. Rendering a + # Bitcoin address from its key would invite a deposit that the + # owner's wallet for that coin will never show. + coin = slot_status.get("key_slip44_txt", "another coin") + text = f"{status_txt}\n\nNot Bitcoin ({coin})" + else: + (_, _, _, _, pub_comp) = Satochip_Connector.satodime_get_pubkey(key_nbr) + text = f"{status_txt}\n{_satodime_address(pub_comp, net)}" except Exception as e: text = str(e) @@ -4713,8 +5236,9 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - Satochip_Connector.satodime_set_unlock_secret() - Satochip_Connector.satodime_set_unlock_counter() + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect (_, _, _, status) = Satochip_Connector.satodime_get_status() max_keys = status.get("max_num_keys", 0) @@ -4760,17 +5284,22 @@ def run(self): if sw1 != 0x90 or sw2 != 0x00: self.run_screen( WarningScreen, - title="Failed", + title="Seal Failed", status_headline=None, - text="Seal failed", + text=format_sw_error(sw1, sw2), show_back_button=True, ) return Destination(BackStackView) + # Tag the slot as Bitcoin so the official Satodime apps recognise it. Advisory + # only: the key is already sealed and its address is valid either way, so a + # failure here is logged rather than shown as a failed seal. + _satodime_write_slot_metadata(Satochip_Connector, slot) + network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) embit_network = embit_utils.get_embit_network_name(network) net = networks.NETWORKS[embit_network] - address = script.p2pkh(ec.PublicKey(bytes(pub_comp))).address(network=net) + address = _satodime_address(pub_comp, net) self.run_screen( LargeIconStatusScreen, @@ -4791,8 +5320,9 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - Satochip_Connector.satodime_set_unlock_secret() - Satochip_Connector.satodime_set_unlock_counter() + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect (_, _, _, status) = Satochip_Connector.satodime_get_status() max_keys = status.get("max_num_keys", 0) @@ -4836,9 +5366,9 @@ def run(self): if sw1 != 0x90 or sw2 != 0x00: self.run_screen( WarningScreen, - title="Failed", + title="Unseal Failed", status_headline=None, - text="Unseal failed", + text=format_sw_error(sw1, sw2), show_back_button=True, ) return Destination(BackStackView) @@ -4873,8 +5403,9 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - Satochip_Connector.satodime_set_unlock_secret() - Satochip_Connector.satodime_set_unlock_counter() + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect (_, _, _, status) = Satochip_Connector.satodime_get_status() max_keys = status.get("max_num_keys", 0) @@ -4918,9 +5449,9 @@ def run(self): if sw1 != 0x90 or sw2 != 0x00: self.run_screen( WarningScreen, - title="Failed", + title="Unseal Failed", status_headline=None, - text="Unseal failed", + text=format_sw_error(sw1, sw2), show_back_button=True, ) return Destination(BackStackView) @@ -4947,8 +5478,9 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - Satochip_Connector.satodime_set_unlock_secret() - Satochip_Connector.satodime_set_unlock_counter() + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect self.loading_screen = LoadingScreenThread(text="Sending Command\n\n\n\n\n\n") self.loading_screen.start() @@ -4960,15 +5492,15 @@ def run(self): LargeIconStatusScreen, title="Success", status_headline=None, - text="Ownership transfer started", + text="Ownership released.\nNew owner must set up card.", show_back_button=False, ) else: self.run_screen( WarningScreen, - title="Failed", + title="Transfer Failed", status_headline=None, - text="Ownership transfer failed", + text=format_sw_error(sw1, sw2), show_back_button=True, ) diff --git a/tests/real_screen_fixtures.py b/tests/real_screen_fixtures.py index 360c76db5..4bb368ee7 100644 --- a/tests/real_screen_fixtures.py +++ b/tests/real_screen_fixtures.py @@ -163,6 +163,36 @@ def simulated_satodime(monkeypatch): +@contextmanager +def simulated_satodime_raw(applet="satodime"): + """ + Put a real Satodime applet behind PC/SC and leave ``init_satochip`` alone. + + ``simulated_satodime`` hands the view a ready-made connector, which means every + line of ``init_satochip`` -- card detection, setup-state handling, PIN policy -- + is skipped. That is exactly where the Satodime PIN-prompt bug lived (a factory- + fresh Satodime reports ``setup_done`` False, and the shared setup branch used to + prompt for a PIN the applet does not have). Patching only PC/SC means the views + run the same client code they run on a real card. + + Yields the ``SimulatedCard`` so a test can reason about the applet directly. + """ + import sys + from unittest.mock import MagicMock as _MagicMock + + for name in [m for m in sys.modules if m == "pysatochip" or m.startswith("pysatochip.")]: + if isinstance(sys.modules[name], _MagicMock): + del sys.modules[name] + + from jcardsim import open_card + from jcardsim.pcsc_shim import patched_pcsc + + with open_card(applet) as card: + card.select() + with patched_pcsc(card): + yield card + + class FakePyGP: """ Stand-in for the ``pygp`` native module used by the JavaCard DIY views. diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 103792c3f..d4b78b96e 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -2,17 +2,33 @@ Satodime flows driven against a *real* applet running in jcardsim. The Satodime menu + views were cherry-picked from PR #66 and call the pysatochip - ``satodime_*`` APDUs (status, keyslot status, pubkey). Those calls only mean - something if SeedSigner's client and the applet agree on the wire format -- exactly + ``satodime_*`` APDUs (status, keyslot status, pubkey, seal, unseal). Those calls only + mean something if SeedSigner's client and the applet agree on the wire format -- exactly the class of bug the jcardsim suites exist to catch. Everything here skips when Java or the Satochip-DIY sources are absent. - Two levels: + Three levels: * connector-level -- prove the APDUs the views depend on round-trip against the applet; - * view-level -- drive ToolsSatodimeAddressesView for real, rendering one slot screen - and backing out (robust to whatever keyslot count / pubkey state the applet has). + * view-level with a stubbed connector -- render slot screens; + * view-level through the real ``init_satochip`` -- the only level that covers card + setup state, which is where a factory-fresh Satodime used to demand a PIN. + + Why the earlier version of this file passed while the feature was broken on hardware: + + * ``simulated_satodime`` monkeypatches ``init_satochip`` away, so the setup-state + handling that prompted for a PIN on a fresh Satodime was never executed. + * a fresh applet has no sealed slots, so ``satodime_get_pubkey`` raised before the + views ever reached the address derivation -- hiding ``ec.PublicKey(sec_bytes)``, + which is the wrong embit constructor and always raises. + * the address test asserted only on navigation, so a screen whose body was an + exception message counted as a pass. + * nothing sealed a slot, so the applet's ``setupDone`` gate (0x9C04 on every + state-changing APDU) was never hit at all. + + The tests below close each of those: seal for real, and assert on what is rendered. """ +import re import sys from unittest.mock import MagicMock @@ -28,12 +44,13 @@ del sys.modules[_name] from jcardsim import JCardSimUnavailable, why_unavailable -from real_screen_fixtures import simulated_satodime +from real_screen_fixtures import simulated_satodime, simulated_satodime_raw from ui_driver import Back, UISession, select # tools_views must be imported first: it is a facade that star-imports smartcard_views. from seedsigner.views import tools_views from seedsigner.views import smartcard_views +from seedsigner.helpers import seedkeeper_utils from seedsigner.models.settings import SettingsConstants from seedsigner.views.view import MainMenuView @@ -43,6 +60,80 @@ ) +# A mainnet native-segwit (P2WPKH) address, which is what the Satodime views render for +# a live slot -- matching what the official Satodime apps derive. Deliberately strict: +# an exception message must not satisfy it, and neither must a legacy P2PKH address. +BECH32_ADDRESS = re.compile(r"^bc1q[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38}$") + +SW_SETUP_NOT_DONE = (0x9C, 0x04) + + +def claim(connector): + """Run the Satodime setup the views now perform via ``init_satochip``.""" + from seedsigner.helpers import seedkeeper_utils + + (_resp, sw1, sw2) = seedkeeper_utils.claim_satodime_ownership(connector) + assert (sw1, sw2) == (0x90, 0x00), f"satodime setup failed: {sw1:#x} {sw2:#x}" + + +class ScreenRecorder: + """Stand-in for ``View.run_screen`` that records every screen and scripts returns.""" + + def __init__(self, *returns): + self.calls = [] + self._returns = list(returns) + + def __call__(self, screen_cls, **kwargs): + self.calls.append((screen_cls.__name__, kwargs)) + if not self._returns: + raise AssertionError( + f"unscripted screen: {screen_cls.__name__} title={kwargs.get('title')!r} " + f"text={kwargs.get('text')!r}" + ) + return self._returns.pop(0) + + @property + def titles(self): + return [kwargs.get("title") for _cls, kwargs in self.calls] + + @property + def texts(self): + return [kwargs.get("text") for _cls, kwargs in self.calls if kwargs.get("text")] + + def body_for(self, title): + for _cls, kwargs in self.calls: + if kwargs.get("title") == title: + return kwargs.get("text") + raise AssertionError(f"no screen titled {title!r}; saw {self.titles}") + + +def _fresh_connector(): + """A connector onto whatever card the active jcardsim fixture is serving.""" + from pysatochip.CardConnector import CardConnector + + return CardConnector(card_filter=["satodime"]) + + +class _StubConnection: + def __init__(self, reader): + self._reader = reader + + def getReader(self): + return self._reader + + +class _StubCardService: + def __init__(self, reader): + self.connection = _StubConnection(reader) + + +def _connector_reporting_reader(reader): + class Stub: + cardservice = _StubCardService(reader) + + return Stub() + + class SatodimeSimulatedFlowTest(FlowTest): def setup_method(self): @@ -72,6 +163,67 @@ def test_status_and_card_type(self, monkeypatch): assert (sw1, sw2) == (0x90, 0x00) assert "max_num_keys" in status + def test_fresh_card_refuses_seal_until_setup_has_run(self, monkeypatch): + """ + The applet gates every state-changing APDU behind ``setupDone``. + + A factory-fresh Satodime answers status queries happily but rejects seal with + 0x9C04, which is why "Seal Slot" failed on a real card while every simulated + test passed. Pin both halves: refused before setup, accepted after. + """ + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx as connector: + (_, _, _, status) = connector.card_get_status() + assert status["setup_done"] is False, "fresh applet should report setup not done" + + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + connector.satodime_get_status() + + (_, sw1, sw2, _, _) = connector.satodime_seal_key(0, bytes(32)) + assert (sw1, sw2) == SW_SETUP_NOT_DONE, "seal must be refused before setup" + + claim(connector) + + (_, _, _, status) = connector.card_get_status() + assert status["setup_done"] is True + + connector.satodime_get_status() # refresh the unlock counter + (_, sw1, sw2, _, pub_comp) = connector.satodime_seal_key(0, bytes(range(32))) + assert (sw1, sw2) == (0x90, 0x00), "seal must succeed once setup has run" + assert len(bytes(pub_comp)) == 33, "compressed secp256k1 pubkey" + + def test_sealed_pubkey_parses_as_an_embit_key(self, monkeypatch): + """ + The card hands back a 33-byte SEC pubkey. + + ``ec.PublicKey(...)`` wants secp256k1's internal 64-byte point, so passing the + card's bytes to it raises "Pubkey should be 64 bytes long". Views must go through + ``_satodime_pubkey`` (i.e. ``ec.PublicKey.parse``). Asserting on a real card + pubkey is what makes that a test failure rather than a runtime one. + """ + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + from embit import networks + + with ctx as connector: + claim(connector) + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + connector.satodime_get_status() + (_, sw1, sw2, _, pub_comp) = connector.satodime_seal_key(0, bytes(range(32))) + assert (sw1, sw2) == (0x90, 0x00) + + address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["main"]) + assert BECH32_ADDRESS.match(address), address + class TestSatodimeAddressesAgainstRealApplet(SatodimeSimulatedFlowTest): """ @@ -79,8 +231,7 @@ class TestSatodimeAddressesAgainstRealApplet(SatodimeSimulatedFlowTest): We render exactly one slot then press BACK, which the view treats as 'stop iterating'. That exercises satodime_get_status + get_keyslot_status(0) + get_pubkey(0) end to end - without depending on how many slots the applet reports or whether a given pubkey is - initialised (the view already catches per-slot errors and shows them on screen). + without depending on how many slots the applet reports. """ def test_renders_one_slot(self, monkeypatch): @@ -118,3 +269,458 @@ def test_renders_one_slot(self, monkeypatch): ], ui_session=session, ) + + def test_empty_slot_says_so_instead_of_leaking_a_parser_error(self, monkeypatch): + """ + A fresh card's slots hold no key, so ``satodime_get_pubkey`` returns an empty + body and pysatochip's parser raises. The view used to call it anyway and paint + the exception text -- which is what the user saw on a brand new Satodime. + """ + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx as connector: + claim(connector) + + view = smartcard_views.ToolsSatodimeAddressesView() + recorder = ScreenRecorder(0, 0, 0) # "Next" on each of the 3 slots + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Slot 0", "Slot 1", "Slot 2"] + for body in recorder.texts: + assert body.startswith("Uninitialized"), body + assert "error" not in body.lower(), body + assert "expected at least" not in body, body + + def test_sealed_slot_renders_an_address(self, monkeypatch): + """After sealing, the slot screen must show an address -- not an exception.""" + try: + ctx = simulated_satodime(monkeypatch) + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx as connector: + claim(connector) + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + connector.satodime_get_status() + (_, sw1, sw2, _, _) = connector.satodime_seal_key(0, bytes(range(32))) + assert (sw1, sw2) == (0x90, 0x00) + + view = smartcard_views.ToolsSatodimeAddressesView() + recorder = ScreenRecorder(0, 0, 0) + view.run_screen = recorder + view.run() + + status_line, address = recorder.body_for("Slot 0").split("\n") + assert status_line == "Sealed" + assert BECH32_ADDRESS.match(address), address + + +class TestSatodimeThroughRealInitSatochip(SatodimeSimulatedFlowTest): + """ + The same views, but reaching the applet through the real ``init_satochip``. + + ``simulated_satodime`` replaces that function, so nothing above this class can see + how SeedSigner reacts to a card whose setup has not been done. These tests patch + only PC/SC, which is the level a real reader sits at. + """ + + def test_read_only_view_needs_no_claim_and_no_pin(self): + """ + Satodime has no PIN. The shared 'card needs setup' branch used to prompt for one + anyway, because it keys off ``setup_done`` alone -- so the user got a PIN keyboard + on a card that has no PIN. + + Reads also must not force a claim: status, keyslot and pubkey all work on an + unclaimed card, so browsing deposit addresses should just work. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + view = smartcard_views.ToolsSatodimeAddressesView() + recorder = ScreenRecorder(0, 0, 0) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Slot 0", "Slot 1", "Slot 2"] + for title in recorder.titles: + assert "PIN" not in (title or ""), f"Satodime must never ask for a PIN: {title}" + + def test_state_change_on_an_unclaimed_card_routes_to_the_claim_view(self): + """Seal needs setup, so it must send the user to claim rather than fail 0x9C04.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + view = smartcard_views.ToolsSatodimeSealSlotView() + recorder = ScreenRecorder() # no screen should be shown at all + view.run_screen = recorder + dest = view.run() + + assert dest.View_cls is smartcard_views.ToolsSatodimeClaimView + assert recorder.titles == [] + + def test_claim_over_contact_skips_the_backup_flow(self): + """ + A contact reader ignores the unlock code entirely, so there is nothing to back + up and the user should not be walked through a QR ceremony for nothing. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + assert not seedkeeper_utils.satodime_connection_is_contactless( + _fresh_connector() + ), "the jcardsim shim should look like a contact reader" + + view = smartcard_views.ToolsSatodimeClaimView() + recorder = ScreenRecorder(0, 0) # confirm claim, then acknowledge success + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Card Unclaimed", "Card Claimed"] + + cached = self.controller.Satodime_unlock_secrets or {} + (secret,) = list(cached.values()) + assert len(secret) == 20 + assert any(secret), "the card must hand back a real secret, not zeros" + + def test_claim_over_contactless_routes_to_the_backup_flow(self, monkeypatch): + """Over NFC the secret is the only thing standing between the user and a + stranded card, so claiming must hand straight off to the backup ceremony.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + monkeypatch.setattr( + seedkeeper_utils, "satodime_connection_is_contactless", lambda connector: True + ) + + with ctx: + view = smartcard_views.ToolsSatodimeClaimView() + recorder = ScreenRecorder(0) + view.run_screen = recorder + dest = view.run() + + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + assert dest.view_args["card_id"] + + def test_declining_the_claim_leaves_the_card_untouched(self): + """Choosing Cancel must abort and leave ``setup_done`` False.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + view = smartcard_views.ToolsSatodimeClaimView() + recorder = ScreenRecorder(1) # "Cancel" + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Card Unclaimed"] + (_, _, _, status) = _fresh_connector().card_get_status() + assert status["setup_done"] is False, "cancelling must not claim the card" + + def test_seal_after_claiming_shows_an_address(self): + """ + The whole reported failure, in one test: claim -> Seal Slot -> success. + + This is what would have caught 0x9C04 (no setup ever ran) *and* the bad embit + constructor, because it drives the views and asserts on the address the success + screen actually renders. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run() + + view = smartcard_views.ToolsSatodimeSealSlotView() + recorder = ScreenRecorder(0, 0) # slot picker, then the success screen + view.run_screen = recorder + view.run() + + assert "Seal Failed" not in recorder.titles, recorder.calls + headline, address = recorder.body_for("Success").split("\n") + assert headline == "Slot 0 sealed" + assert BECH32_ADDRESS.match(address), address + + def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): + """ + Over NFC the applet checks HMAC(unlock_secret, ...), so a claimed card whose + secret this session does not hold cannot seal. The user must be sent to restore + it rather than shown a raw 0x9C51. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run() + + # Simulate a later session: card still claimed, secret no longer in RAM. + self.controller.Satodime_unlock_secrets = None + monkeypatch.setattr( + seedkeeper_utils, "satodime_connection_is_contactless", lambda connector: True + ) + + view = smartcard_views.ToolsSatodimeSealSlotView() + recorder = ScreenRecorder(0) # accept "Restore Code" + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Code Required"] + assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView + + +class TestUnlockSecretPayload: + """The backup payload is what a user's phone photo has to survive.""" + + def test_round_trips(self): + secret = list(range(20)) + payload = seedkeeper_utils.format_satodime_unlock_payload("deadbeef", secret) + assert payload.startswith("satodime-unlock:") + assert seedkeeper_utils.parse_satodime_unlock_payload(payload) == ("deadbeef", secret) + + def test_survives_surrounding_whitespace(self): + payload = seedkeeper_utils.format_satodime_unlock_payload("abc", list(range(20))) + assert seedkeeper_utils.parse_satodime_unlock_payload(f" {payload}\n") is not None + + @pytest.mark.parametrize("text", [ + "", + "not a backup", + "satodime-unlock:abc", # no secret + "satodime-unlock:abc:zz", # not hex + "satodime-unlock:abc:" + "00" * 19, # wrong length + "satodime-unlock:abc:" + "00" * 21, + ]) + def test_rejects_junk(self, text): + assert seedkeeper_utils.parse_satodime_unlock_payload(text) is None + + +class TestContactlessDetection: + """Which medium we are on decides whether the secret matters at all.""" + + @pytest.mark.parametrize("reader,expected", [ + ("Identive SCR33xx v2.0 USB SC Reader 0", False), + ("jcardsim simulator", False), + ("SEC1210 Contact Reader", False), + ("ACS ACR122U PICC Interface", True), + ("PN532 via GPIO", True), + ("Some NFC Reader", True), + ]) + def test_reader_names(self, reader, expected): + connector = _connector_reporting_reader(reader) + assert seedkeeper_utils.satodime_connection_is_contactless(connector) is expected + + def test_unknown_reader_fails_safe_to_contactless(self): + """Better to offer a backup that wasn't needed than to skip one that was.""" + class Exploding: + @property + def cardservice(self): + raise RuntimeError("no reader") + + assert seedkeeper_utils.satodime_connection_is_contactless(Exploding()) is True + assert seedkeeper_utils.satodime_connection_is_contactless( + _connector_reporting_reader("") + ) is True + + +class TestBackupAndRestoreViews(SatodimeSimulatedFlowTest): + """The QR ceremony and its restore path, driven without a card.""" + + CARD_ID = "0123456789abcdef" + SECRET = list(range(20)) + + def _seed_cache(self): + seedkeeper_utils.cache_satodime_unlock_secret(self.controller, self.CARD_ID, self.SECRET) + return seedkeeper_utils.format_satodime_unlock_payload(self.CARD_ID, self.SECRET) + + def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): + payload = self._seed_cache() + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + # dire warning, theft caveat, QR, menu -> "Scan It Back", success + recorder = ScreenRecorder(0, 0, None, 0, 0) + view.run_screen = recorder + view.run() + + assert recorder.titles == [ + "Unlock Code", "Not Theft Proof", None, "Verify Backup", "Backup Verified", + ] + + def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): + self._seed_cache() + monkeypatch.setattr( + smartcard_views, "_satodime_scan_text", lambda view: "satodime-unlock:other:" + "11" * 20 + ) + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + # ... menu -> "Scan It Back", "No Match", QR again, menu -> "Skip", confirm skip + recorder = ScreenRecorder(0, 0, None, 0, 0, None, 3, 0) + view.run_screen = recorder + view.run() + + assert "No Match" in recorder.titles + assert "Backup Verified" not in recorder.titles + + def test_the_user_is_told_the_code_is_not_theft_protection(self): + """A contact reader can unseal the card without this code; users must know.""" + self._seed_cache() + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + recorder = ScreenRecorder(0, 0, None, 3, 0) # straight to Skip + view.run_screen = recorder + view.run() + + caveat = recorder.body_for("Not Theft Proof") + assert "contact reader" in caveat.lower() + + def test_backup_refuses_when_nothing_is_cached(self): + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + recorder = ScreenRecorder(0) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["No Unlock Code"] + + def test_restore_rejects_another_card_s_backup(self, monkeypatch): + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + other = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET) + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) + + with ctx: + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) # choose "Scan Backup QR", ack the warning + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Unlock Code", "Wrong Card"] + assert not (self.controller.Satodime_unlock_secrets or {}) + + def test_restore_loads_this_card_s_backup(self, monkeypatch): + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Unlock Code", "Unlock Code Set"] + assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET + + +class TestMatchesTheOfficialSatodimeApp: + """ + Golden vector: a slot sealed by the official Satodime Android app. + + The pubkey below was read off a real Satodime whose first slot the Android app + sealed, and the address is the one that app displayed for it. SeedSigner originally + rendered P2PKH here (1NBbECsXng3GVCuU2PdWAAZAxyB2GfWi6B) -- spendable, but a + different address for the same key, so anything deposited to it showed a zero + balance in the official app. + + Javacryptotools' ``BaseCoin.pubToAddress()`` returns a segwit address whenever the + coin supports it and ``Bitcoin`` sets ``segwit_supported = true``, so bech32 P2WPKH + is the format to match. + """ + + SEALED_PUBKEY = "03de296020fbf9a119db36a513a572ea4936a5729f5a3deb21a9b8c0928c9db8f0" + APP_ADDRESS = "bc1qapd47as9kw384u5pkd4jvvj5pn8ds3s876k048" + + def test_address_matches_what_the_android_app_shows(self): + from embit import networks + + pub_comp = list(bytes.fromhex(self.SEALED_PUBKEY)) + address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["main"]) + assert address == self.APP_ADDRESS + + def test_testnet_uses_the_same_key_with_the_testnet_hrp(self): + """ + The keyslot records only the coin: Javacryptotools' MAP_SLIP44_BY_SYMBOL has a + single BTC entry and the apps carry testnet as a separate display flag. So the + network comes from SeedSigner's own setting, exactly as it comes from the app's. + """ + from embit import networks + + pub_comp = list(bytes.fromhex(self.SEALED_PUBKEY)) + from embit import script + + address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["test"]) + assert address.startswith("tb1q") + # bech32 checksums cover the hrp, so the strings differ past the prefix; what + # must match is the witness program they encode. + assert ( + script.address_to_scriptpubkey(address).data + == script.address_to_scriptpubkey(self.APP_ADDRESS).data + ) + + def test_slot_metadata_matches_the_apps_seal(self): + """ + NFCCardService.seal() follows every seal with SET_KEYSLOT_STATUS carrying the + coin's slip44 and a 34-byte contract/tokenid block whose second byte is 32. + Without it the slot reads back as slip44 0x00000000 and the official apps show + an unknown asset. + """ + assert smartcard_views.SATODIME_SLIP44_BTC_BYTES == [0x80, 0x00, 0x00, 0x00] + assert len(smartcard_views.SATODIME_EMPTY_CONTRACT) == 34 + assert smartcard_views.SATODIME_EMPTY_CONTRACT[1] == 32 + assert set(smartcard_views.SATODIME_EMPTY_CONTRACT) == {0, 32} + + +class TestSlotCoinHandling: + """A slot sealed for another chain must not be shown a Bitcoin address.""" + + def _slot(self, slip44_hex, txt="Sealed"): + return { + "key_status_txt": txt, + "key_slip44": list(bytes.fromhex(slip44_hex)), + "key_slip44_txt": "ETH" if slip44_hex == "8000003c" else "BTC", + } + + def test_btc_slot_is_bitcoin(self): + assert smartcard_views._satodime_slot_slip44(self._slot("80000000")) == \ + smartcard_views.SATODIME_SLIP44_BTC + + def test_untagged_slot_is_treated_as_bitcoin(self): + """SeedSigner sealed slots before it wrote this metadata; they read back as 0.""" + assert smartcard_views._satodime_slot_slip44(self._slot("00000000")) == \ + smartcard_views.SATODIME_SLIP44_BTC + assert smartcard_views._satodime_slot_slip44({"key_status_txt": "Sealed"}) == \ + smartcard_views.SATODIME_SLIP44_BTC + + def test_eth_slot_is_not_bitcoin(self): + assert smartcard_views._satodime_slot_slip44(self._slot("8000003c")) != \ + smartcard_views.SATODIME_SLIP44_BTC diff --git a/tests/test_smartcard_hardware.py b/tests/test_smartcard_hardware.py index c0e1faffd..1c6f7bd78 100644 --- a/tests/test_smartcard_hardware.py +++ b/tests/test_smartcard_hardware.py @@ -24,6 +24,7 @@ import hashlib import logging import os +import re import sys import time import types @@ -404,8 +405,25 @@ def applet(self, gp, cap_dir): logger.warning(f"Satodime cleanup failed (non-fatal): {exc}") def _provision(self, pin: str = "1234"): - """Satodime does not require card provisioning; this is a no-op for API consistency.""" - pass + """Claim the freshly-installed Satodime, exactly as the app does. + + Satodime has no PIN, but it does have a setup step: until INS_SETUP has run, + the applet answers every state-changing APDU (seal, unseal, reset, transfer) + with 0x9C04. This deliberately calls the app's own helper rather than + open-coding card_setup(), so the hardware suite covers the code the device + actually runs. + """ + from seedsigner.helpers.seedkeeper_utils import claim_satodime_ownership + + # Idempotent: the applet is installed once per class, so a later test in the + # class finds it already claimed. Re-running setup would return + # SW_SETUP_ALREADY_DONE. + (_, _, _, status) = self._connector.card_get_status() + if status.get("setup_done"): + return + + (_resp, sw1, sw2) = claim_satodime_ownership(self._connector) + assert (sw1, sw2) == (0x90, 0x00), f"satodime setup failed: {sw1:#x} {sw2:#x}" # -- helpers ------------------------------------------------------- @@ -442,12 +460,32 @@ def test_get_info(self): (resp, sw1, sw2, status) = connector.card_get_status() assert sw1 == 0x90 and sw2 == 0x00 assert connector.card_type == "Satodime" - assert status.get("setup_done") is False assert "protocol_major_version" in status assert "applet_major_version" in status finally: self._disconnect() + def test_fresh_applet_reports_setup_not_done(self): + """A newly installed Satodime is unclaimed until INS_SETUP runs. + + Definition order matters: this must observe the applet before any test calls + _provision(). It is the hardware-side statement of why the views need a setup + step -- without one, every seal/unseal below fails with 0x9C04. + """ + connector = self._connect() + try: + (_, _, _, status) = connector.card_get_status() + if status.get("setup_done"): + pytest.skip("card already claimed by an earlier test in this class") + + connector.satodime_set_unlock_secret() + connector.satodime_set_unlock_counter() + connector.satodime_get_status() + (_, sw1, sw2, _, _) = connector.satodime_seal_key(0, os.urandom(32)) + assert (sw1, sw2) == (0x9C, 0x04), "unclaimed card must refuse to seal" + finally: + self._disconnect() + def test_get_label(self): connector = self._connect() try: @@ -471,6 +509,7 @@ def test_seal_slot_then_read_pubkey(self): """Seal an uninitialized slot and read back its pubkey, as the views do.""" connector = self._connect() try: + self._provision() connector.satodime_set_unlock_secret() connector.satodime_set_unlock_counter() @@ -488,6 +527,31 @@ def test_seal_slot_then_read_pubkey(self): (_, _, _, slot_status) = connector.satodime_get_keyslot_status(slot) assert slot_status.get("key_status_txt") == "Sealed" + + # The card's 33-byte SEC pubkey has to survive the app's own derivation. + # ec.PublicKey() takes secp256k1's internal 64-byte point, so the views + # must use _satodime_pubkey (ec.PublicKey.parse); passing the raw SEC + # bytes raises "Pubkey should be 64 bytes long" on every sealed slot. + # + # Format matters as much as parsing: the official Satodime apps derive + # bech32 P2WPKH, so a legacy address here would send funds somewhere those + # apps never look. + from embit import networks + from seedsigner.views.smartcard_views import _satodime_address + + address = _satodime_address(pub_read, networks.NETWORKS["main"]) + assert re.match(r"^bc1q[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38}$", address), address + + # And the slot must be tagged BTC, or the official apps show it as an + # unknown asset with no balance lookup. + from seedsigner.views.smartcard_views import ( + SATODIME_SLIP44_BTC, _satodime_slot_slip44, _satodime_write_slot_metadata, + ) + + assert _satodime_write_slot_metadata(connector, slot), "tagging the slot failed" + (_, _, _, tagged) = connector.satodime_get_keyslot_status(slot) + assert _satodime_slot_slip44(tagged) == SATODIME_SLIP44_BTC + assert tagged["key_slip44_txt"] == "BTC" finally: self._disconnect() @@ -495,6 +559,7 @@ def test_unseal_slot_returns_privkey(self): """Unsealing a sealed slot yields the private key (destructive — runs last).""" connector = self._connect() try: + self._provision() connector.satodime_set_unlock_secret() connector.satodime_set_unlock_counter() From f02675beca73b32a7913eb146af79acfb8a21ba6 Mon Sep 17 00:00:00 2001 From: 3rdIteration Date: Tue, 8 Sep 2026 14:50:09 -0400 Subject: [PATCH 03/26] Fix WIF bug and handle altcoins --- src/seedsigner/helpers/satodime_coins.py | 255 ++++++++++++++++++ src/seedsigner/models/psbt_parser.py | 59 +++- src/seedsigner/views/psbt_views.py | 11 +- src/seedsigner/views/smartcard_views.py | 158 +++++++---- ...st_real_screen_flows_satodime_simulated.py | 23 +- tests/test_satodime_coins.py | 210 +++++++++++++++ tests/test_smartcard_hardware.py | 39 ++- tests/test_wif.py | 160 +++++++++++ 8 files changed, 836 insertions(+), 79 deletions(-) create mode 100644 src/seedsigner/helpers/satodime_coins.py create mode 100644 tests/test_satodime_coins.py diff --git a/src/seedsigner/helpers/satodime_coins.py b/src/seedsigner/helpers/satodime_coins.py new file mode 100644 index 000000000..753992866 --- /dev/null +++ b/src/seedsigner/helpers/satodime_coins.py @@ -0,0 +1,255 @@ +""" + Deposit addresses and private-key formats for the coins a Satodime keyslot can hold. + + A Satodime slot records only a SLIP-44 coin code; the network (mainnet/testnet) is a + display choice the wallet makes, exactly as the official apps do. This module mirrors + ``Toporin/Javacryptotools`` -- the library Satodime-Android and Satodime-Desktop use -- + so a slot reads the same here as it does on the phone. Where the two could differ, the + Java behaviour wins; the comments name the class each rule comes from. + + Supported set matches the official app exactly. ``Utils.kt`` maps SLIP-44 to a coin + class and falls through to ``UnsupportedCoin`` for everything else, so BTC, LTC, BCH, + ETH, POL and XCP are supported and the remaining codes in ``Constants.java`` (DOGE, + DASH, ETC, RBTC, BSC) are not. We report those as unsupported rather than guessing an + address format the official app would never show. + + Signing stays Bitcoin-only. These addresses and keys are for viewing and for exporting + an unsealed key into another wallet. +""" + +import logging +from dataclasses import dataclass, field +from typing import Callable + +from embit import base58, ec, script +from embit.networks import NETWORKS + +logger = logging.getLogger(__name__) + + +# SLIP-44 codes, hardened, as Javacryptotools' Constants.java writes them. +SLIP44_BTC = 0x80000000 +SLIP44_LTC = 0x80000002 +SLIP44_XCP = 0x80000009 +SLIP44_ETH = 0x8000003C +SLIP44_BCH = 0x80000091 +SLIP44_POL = 0x800003C6 + + +# --------------------------------------------------------------------------- CashAddr +# BCH's address encoding. Same 32-character alphabet as bech32 but a different checksum +# generator and layout, so bech32 code cannot be reused. Ported from CashAddress.java. +_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_CASHADDR_GENERATOR = ( + 0x98F2BC8E61, 0x79B76D99E2, 0xF33E5FB3C4, 0xAE2EABE2A8, 0x1E4F43E470, +) + + +def _convert_bits(data, from_bits: int, to_bits: int, pad: bool = True): + """Regroup a byte string into `to_bits`-wide values (BIP-173's convertbits).""" + acc = 0 + bits = 0 + out = [] + maxv = (1 << to_bits) - 1 + for value in data: + if value < 0 or (value >> from_bits): + raise ValueError("invalid value for convert_bits") + acc = (acc << from_bits) | value + bits += from_bits + while bits >= to_bits: + bits -= to_bits + out.append((acc >> bits) & maxv) + if pad: + if bits: + out.append((acc << (to_bits - bits)) & maxv) + elif bits >= from_bits or ((acc << (to_bits - bits)) & maxv): + raise ValueError("invalid padding in convert_bits") + return out + + +def _cashaddr_polymod(values) -> int: + chk = 1 + for value in values: + top = chk >> 35 + chk = ((chk & 0x07FFFFFFFF) << 5) ^ value + for i, generator in enumerate(_CASHADDR_GENERATOR): + if (top >> i) & 1: + chk ^= generator + return chk ^ 1 + + +def encode_cashaddr(prefix: str, payload: bytes) -> str: + """Encode a CashAddr for `payload` (version byte + hash), including the prefix.""" + payload5 = _convert_bits(payload, 8, 5) + # Checksum covers the prefix's low 5 bits, a separator zero, the payload and + # eight zero placeholders. + prefix5 = [ord(c) & 0x1F for c in prefix] + checksum = _cashaddr_polymod(prefix5 + [0] + payload5 + [0] * 8) + checksum5 = [(checksum >> (5 * (7 - i))) & 0x1F for i in range(8)] + body = "".join(_CHARSET[d] for d in payload5 + checksum5) + return f"{prefix}:{body}" + + +def _bch_address(pub: ec.PublicKey, params: dict) -> str: + """BCH P2PKH as CashAddr (BitcoinCash.pubToAddress).""" + from embit import hashes + + # version byte: address type (0 = P2PKH) << 3 | size bits (0 = 160-bit hash) + payload = bytes([0x00]) + hashes.hash160(pub.sec()) + return encode_cashaddr(params["cashaddr_prefix"], payload) + + +# -------------------------------------------------------------------------------- EVM +def _keccak256(data: bytes) -> bytes: + """Legacy Keccak-256, not NIST SHA3-256: the two use different padding. + + Javacryptotools uses BouncyCastle's ``Keccak.Digest256``; pycryptodomex's ``keccak`` + module is the matching primitive. ``hashlib.sha3_256`` is NOT interchangeable. + """ + from Cryptodome.Hash import keccak + + return keccak.new(digest_bits=256).update(data).digest() + + +def _evm_address(pub: ec.PublicKey, params: dict) -> str: + """An EVM address: last 20 bytes of keccak256 over the uncompressed pubkey's X||Y. + + Returned in EIP-55 mixed case (Ethereum.toChecksumAddress). That is the same address + as the lowercase form ``pubToAddress`` returns -- capitalisation carries a typo check + and nothing else -- and every wallet accepts either. + """ + uncompressed = ec.PublicKey(pub._point, compressed=False) + xy = uncompressed.sec()[1:] # drop the 0x04 prefix + address = _keccak256(xy)[-20:].hex() + + digest = _keccak256(address.encode()).hex() + checksummed = "".join( + c.upper() if int(digest[i], 16) >= 8 else c for i, c in enumerate(address) + ) + return "0x" + checksummed + + +# ---------------------------------------------------------------------- Bitcoin-likes +def _bitcoin_like_network(params: dict) -> dict: + """An embit network dict built from the coin's Javacryptotools parameters.""" + net = dict(NETWORKS["main"]) + net.update({ + "name": params["name"], + "p2pkh": bytes([params["magicbyte"]]), + "p2sh": bytes([params["script_magicbyte"]]), + "bech32": params["segwit_hrp"], + "wif": bytes([params["wif_prefix"]]), + }) + return net + + +def _segwit_address(pub: ec.PublicKey, params: dict) -> str: + """BaseCoin.pubToAddress returns segwit whenever the coin supports it.""" + return script.p2wpkh(pub).address(network=_bitcoin_like_network(params)) + + +def _legacy_address(pub: ec.PublicKey, params: dict) -> str: + """...and falls back to base58 P2PKH when it does not.""" + return script.p2pkh(pub).address(network=_bitcoin_like_network(params)) + + +# ------------------------------------------------------------------------------ Specs +@dataclass(frozen=True) +class CoinSpec: + slip44: int + symbol: str + display_name: str + address_fn: Callable + # WIF for the bitcoin-likes; EVM keys are shown as raw hex, which is what every + # EVM wallet's "import private key" field expects. + privkey_is_wif: bool + mainnet: dict + testnet: dict + + def params(self, is_testnet: bool) -> dict: + return self.testnet if is_testnet else self.mainnet + + def address(self, pub: ec.PublicKey, is_testnet: bool = False) -> str: + return self.address_fn(pub, self.params(is_testnet)) + + def privkey(self, secret: bytes, is_testnet: bool = False) -> str: + """The private key in the form this chain's wallets import.""" + if not self.privkey_is_wif: + return "0x" + bytes(secret).hex() + params = self.params(is_testnet) + # BaseCoin.encodePrivkey: version || key || 0x01 (compressed), base58check. + return base58.encode_check(bytes([params["wif_prefix"]]) + bytes(secret) + b"\x01") + + +def _btc_params(testnet: bool) -> dict: + if testnet: + return dict(name="Bitcoin Testnet", magicbyte=111, script_magicbyte=196, + segwit_hrp="tb", wif_prefix=0xEF) + return dict(name="Bitcoin", magicbyte=0, script_magicbyte=5, + segwit_hrp="bc", wif_prefix=0x80) + + +COINS: dict[int, CoinSpec] = { + SLIP44_BTC: CoinSpec( + slip44=SLIP44_BTC, symbol="BTC", display_name="Bitcoin", + address_fn=_segwit_address, privkey_is_wif=True, + mainnet=_btc_params(False), testnet=_btc_params(True), + ), + SLIP44_LTC: CoinSpec( + slip44=SLIP44_LTC, symbol="LTC", display_name="Litecoin", + address_fn=_segwit_address, privkey_is_wif=True, + mainnet=dict(name="Litecoin", magicbyte=48, script_magicbyte=50, + segwit_hrp="ltc", wif_prefix=0xB0), + testnet=dict(name="Litecoin Testnet", magicbyte=111, script_magicbyte=58, + segwit_hrp="tltc", wif_prefix=0xEF), + ), + # BitcoinCash.java sets segwit_supported = false and overrides pubToAddress with + # CashAddr, so BCH is neither bech32 nor base58 on screen. + SLIP44_BCH: CoinSpec( + slip44=SLIP44_BCH, symbol="BCH", display_name="Bitcoin Cash", + address_fn=_bch_address, privkey_is_wif=True, + mainnet=dict(name="Bitcoin Cash", magicbyte=0, script_magicbyte=5, + segwit_hrp="bc", wif_prefix=0x80, cashaddr_prefix="bitcoincash"), + testnet=dict(name="Bitcoin Cash Testnet", magicbyte=111, script_magicbyte=196, + segwit_hrp="tb", wif_prefix=0xEF, cashaddr_prefix="bchtest"), + ), + # Counterparty rides on Bitcoin and sets segwit_supported = false, so its deposit + # address is a legacy base58 P2PKH one with Bitcoin's version bytes. + SLIP44_XCP: CoinSpec( + slip44=SLIP44_XCP, symbol="XCP", display_name="Counterparty", + address_fn=_legacy_address, privkey_is_wif=True, + mainnet=_btc_params(False), testnet=_btc_params(True), + ), + SLIP44_ETH: CoinSpec( + slip44=SLIP44_ETH, symbol="ETH", display_name="Ethereum", + address_fn=_evm_address, privkey_is_wif=False, + mainnet=_btc_params(False), testnet=_btc_params(True), + ), + # Polygon extends Ethereum: same derivation, same address, different chain. + SLIP44_POL: CoinSpec( + slip44=SLIP44_POL, symbol="POL", display_name="Polygon", + address_fn=_evm_address, privkey_is_wif=False, + mainnet=_btc_params(False), testnet=_btc_params(True), + ), +} + + +# Offered when sealing a slot, in the order the menu shows them. +SEALABLE_COINS = [COINS[s] for s in (SLIP44_BTC, SLIP44_LTC, SLIP44_BCH, SLIP44_ETH, + SLIP44_POL, SLIP44_XCP)] + + +def coin_for_slip44(slip44: int) -> CoinSpec | None: + """The coin a keyslot holds, or None when the official app would not show it either. + + A slot sealed before SeedSigner wrote this metadata reads back as 0; it can only be + one this app sealed, and this app sealed Bitcoin. + """ + if not slip44: + return COINS[SLIP44_BTC] + return COINS.get(slip44) + + +def slip44_bytes(slip44: int) -> list: + """The 4-byte big-endian form the SET_KEYSLOT_STATUS APDU carries.""" + return list(int(slip44).to_bytes(4, "big")) diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index f79031461..9f35c6a0d 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -3,7 +3,7 @@ import logging import time from binascii import hexlify -from embit import psbt, script, ec, bip32 +from embit import psbt, script, ec, bip32, hashes from embit.base import EmbitError from embit.descriptor import Descriptor from embit.networks import NETWORKS @@ -1591,6 +1591,63 @@ def get_input_fingerprints(psbt: PSBT) -> List[str]: return list(fingerprints) + @staticmethod + def wif_can_sign_any_input(psbt: PSBT, wif_key) -> bool: + """ + Returns True if a raw private key controls any input of this psbt. + + A WIF has no BIP32 tree, so the fingerprint routing that steers seeds finds + nothing to match -- and the psbt an Electrum watch-only single-address wallet + exports carries no derivation fields at all, so there is nothing to match + against either. Both facts made ``has_matching_input_fingerprint`` answer + False for keys that sign the transaction perfectly well. + + The test applied here is the one embit's ``PSBT.sign_with`` itself uses: the + key's pubkey, or its hash160, appearing in the input's script. Taproot is + checked against the input's declared internal key, since a p2tr scriptPubkey + holds the *tweaked* output key rather than the one we hold. + + Like has_matching_input_fingerprint this is only a routing hint. It verifies + nothing; real verification happens once the key reaches a PSBTParser. + """ + try: + pub = wif_key.privkey.get_public_key() + except Exception: + return False + + sec = pub.sec() + pkh = hashes.hash160(sec) + xonly = pub.xonly() + + for inp in psbt.inputs: + internal_key = getattr(inp, "taproot_internal_key", None) + if internal_key is not None: + try: + if internal_key.xonly() == xonly: + return True + except Exception: + pass + + script_obj = inp.witness_script or inp.redeem_script + if script_obj is None: + utxo = None + try: + utxo = inp.utxo + except Exception: + utxo = None + if utxo is None: + continue + script_obj = utxo.script_pubkey + + data = getattr(script_obj, "data", None) + if not data: + continue + if sec in data or pkh in data: + return True + + return False + + @staticmethod def has_matching_input_fingerprint( psbt: PSBT, diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 7e7790c99..341b1e0f0 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -71,7 +71,16 @@ def ensure_microsd_seed_warning() -> bool: raise Exception("No transaction currently loaded") if self.controller.psbt_seed: - if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): + from seedsigner.models.wif import WIFKey + + if isinstance(self.controller.psbt_seed, WIFKey): + # A raw key has no BIP32 tree to fingerprint, and the psbt an Electrum + # watch-only single-address wallet exports has no derivation fields at + # all -- so the fingerprint check below always said "no" and quietly + # dropped a key that signs the transaction fine. + if PSBTParser.wif_can_sign_any_input(psbt=self.controller.psbt, wif_key=self.controller.psbt_seed): + return Destination(PSBTOverviewView) + elif PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # skip the seed prompt if a seed was previously selected and has matching input fingerprint return Destination(PSBTOverviewView) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 162e301eb..95bae0d9d 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -44,7 +44,7 @@ ) from seedsigner.gui.screens.screen import ButtonOption from seedsigner.hardware.microsd import MicroSD -from seedsigner.helpers import embit_utils, ndef_helper, seedkeeper_utils +from seedsigner.helpers import embit_utils, ndef_helper, satodime_coins, seedkeeper_utils from seedsigner.helpers.satochip_signer import ( _call_with_timeout, _get_extended_key, @@ -4584,12 +4584,12 @@ def run(self): # A Satodime keyslot records the *coin*, never the network: Javacryptotools' -# Constants.MAP_SLIP44_BY_SYMBOL has a single BTC entry (0x80000000) and the official -# apps carry testnet as a separate display flag. So SeedSigner writes BTC's slip44 too, -# and takes mainnet/testnet from SETTING__NETWORK exactly as the app takes it from its -# own settings. -SATODIME_SLIP44_BTC = 0x80000000 -SATODIME_SLIP44_BTC_BYTES = [0x80, 0x00, 0x00, 0x00] +# Constants.MAP_SLIP44_BY_SYMBOL has one entry per coin and no testnet variants, and the +# official apps carry testnet as a separate display flag. So SeedSigner writes the coin's +# slip44 and takes mainnet/testnet from SETTING__NETWORK exactly as the app takes it from +# its own settings. Per-coin address and key formats live in satodime_coins. +SATODIME_SLIP44_BTC = satodime_coins.SLIP44_BTC +SATODIME_SLIP44_BTC_BYTES = satodime_coins.slip44_bytes(satodime_coins.SLIP44_BTC) # key_contract / key_tokenid are deprecated, but the applet still demands 34 bytes of # each. The official app sends a block whose second byte is the 32-byte length @@ -4607,16 +4607,27 @@ def _satodime_pubkey(pub_comp): return ec.PublicKey.parse(bytes(pub_comp)) -def _satodime_address(pub_comp, net) -> str: - """Derive a slot's deposit address the way the official Satodime apps do. +def _satodime_address(pub_comp, coin, is_testnet: bool) -> str: + """A slot's deposit address, in the format the official Satodime apps derive. - Javacryptotools' ``BaseCoin.pubToAddress()`` returns a segwit address whenever the - coin supports it, and ``Bitcoin`` sets ``segwit_supported = true`` -- so the phone - and desktop apps show bech32 P2WPKH. Deriving P2PKH here would print a *different* - address for the same key: still spendable, but the official app would show a zero - balance for anything deposited to it. + Each coin's rule comes straight from Javacryptotools: BTC and LTC are bech32 + P2WPKH (``BaseCoin.pubToAddress`` returns segwit whenever the coin supports it), + BCH is CashAddr, XCP is legacy base58, and the EVM chains are keccak-derived. + Getting this wrong prints a *different* address for the same key -- still + spendable, but the official app would show a zero balance on anything sent to it. """ - return script.p2wpkh(_satodime_pubkey(pub_comp)).address(network=net) + return coin.address(_satodime_pubkey(pub_comp), is_testnet) + + +def _satodime_is_testnet(view) -> bool: + """Whether to render addresses for testnet, mirroring the app's testnet toggle.""" + network = view.settings.get_value(SettingsConstants.SETTING__NETWORK) + return network != SettingsConstants.MAINNET + + +def _satodime_slot_coin(slot_status): + """The CoinSpec a keyslot holds, or None when the official app cannot show it.""" + return satodime_coins.coin_for_slip44(_satodime_slot_slip44(slot_status)) def _satodime_slot_slip44(slot_status) -> int: @@ -4632,29 +4643,32 @@ def _satodime_slot_slip44(slot_status) -> int: return SATODIME_SLIP44_BTC if value == 0 else value -def _satodime_write_slot_metadata(connector, slot) -> bool: - """Tag a freshly sealed slot as Bitcoin, mirroring the official app's seal. +def _satodime_write_slot_metadata(connector, slot, coin=None) -> bool: + """Tag a freshly sealed slot with its coin, mirroring the official app's seal. The app seals and then immediately sends SET_KEYSLOT_STATUS with the coin's slip44 (NFCCardService.seal). Without it the slot reads back as slip44 0x00000000, which the official apps show as an unknown asset with no balance lookup. """ + if coin is None: + coin = satodime_coins.COINS[satodime_coins.SLIP44_BTC] try: (_r, sw1, sw2) = connector.satodime_set_keyslot_status_part0( slot, 0x00, # RFU1 0x00, # RFU2 0x00, # key_asset: the app leaves this Undefined - SATODIME_SLIP44_BTC_BYTES, + satodime_coins.slip44_bytes(coin.slip44), list(SATODIME_EMPTY_CONTRACT), list(SATODIME_EMPTY_CONTRACT), ) except Exception: - logger.exception("Satodime: failed to tag slot %s as BTC", slot) + logger.exception("Satodime: failed to tag slot %s as %s", slot, coin.symbol) return False if sw1 != 0x90 or sw2 != 0x00: logger.warning( - "Satodime: failed to tag slot %s as BTC: %s", slot, format_sw_error(sw1, sw2) + "Satodime: failed to tag slot %s as %s: %s", + slot, coin.symbol, format_sw_error(sw1, sw2), ) return False return True @@ -5190,27 +5204,27 @@ def run(self): self.loading_screen.stop() max_keys = status.get("max_num_keys", 0) - network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) - embit_network = embit_utils.get_embit_network_name(network) - net = networks.NETWORKS[embit_network] + is_testnet = _satodime_is_testnet(self) for key_nbr in range(max_keys): try: (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) status_txt = slot_status.get("key_status_txt", "Unknown") + coin = _satodime_slot_coin(slot_status) if status_txt == "Uninitialized": # An empty slot holds no key, so the card answers get_pubkey with an # empty body and pysatochip's parser raises. Don't ask. text = f"{status_txt}\n\nSeal this slot first" - elif _satodime_slot_slip44(slot_status) != SATODIME_SLIP44_BTC: - # Another app sealed this slot for a different chain. Rendering a - # Bitcoin address from its key would invite a deposit that the - # owner's wallet for that coin will never show. - coin = slot_status.get("key_slip44_txt", "another coin") - text = f"{status_txt}\n\nNot Bitcoin ({coin})" + elif coin is None: + # Sealed by something for a chain the official app has no address + # format for either. Guessing one would invite a deposit nobody's + # wallet can find. + label = slot_status.get("key_slip44_txt", "unknown") + text = f"{status_txt}\n\nUnsupported coin\n{label}" else: (_, _, _, _, pub_comp) = Satochip_Connector.satodime_get_pubkey(key_nbr) - text = f"{status_txt}\n{_satodime_address(pub_comp, net)}" + address = _satodime_address(pub_comp, coin, is_testnet) + text = f"{status_txt} {coin.symbol}\n{address}" except Exception as e: text = str(e) @@ -5273,6 +5287,24 @@ def run(self): return Destination(BackStackView) slot = available[selected] + + # Which chain this vault is for. The card records only the coin, and the + # official apps read that back to pick an address format and a balance + # explorer -- so an untagged slot shows up there as an unknown asset. + coin_choice = self.run_screen( + ButtonListScreen, + title="Seal As", + is_button_text_centered=False, + button_data=[ + ButtonOption(f"{c.symbol} - {c.display_name}") + for c in satodime_coins.SEALABLE_COINS + ], + show_back_button=True, + ) + if coin_choice == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + coin = satodime_coins.SEALABLE_COINS[coin_choice] + # Card-side sealing entropy; never logged or persisted (AGENTS security). entropy = os.urandom(32) @@ -5291,21 +5323,18 @@ def run(self): ) return Destination(BackStackView) - # Tag the slot as Bitcoin so the official Satodime apps recognise it. Advisory - # only: the key is already sealed and its address is valid either way, so a - # failure here is logged rather than shown as a failed seal. - _satodime_write_slot_metadata(Satochip_Connector, slot) + # Tag the slot with its coin so the official Satodime apps recognise it. + # Advisory only: the key is already sealed and its address is valid either + # way, so a failure here is logged rather than shown as a failed seal. + _satodime_write_slot_metadata(Satochip_Connector, slot, coin) - network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) - embit_network = embit_utils.get_embit_network_name(network) - net = networks.NETWORKS[embit_network] - address = _satodime_address(pub_comp, net) + address = _satodime_address(pub_comp, coin, _satodime_is_testnet(self)) self.run_screen( LargeIconStatusScreen, title="Success", status_headline=None, - text=f"Slot {slot} sealed\n{address}", + text=f"Slot {slot} sealed {coin.symbol}\n{address}", show_back_button=False, ) @@ -5373,22 +5402,26 @@ def run(self): ) return Destination(BackStackView) - network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) - embit_network = embit_utils.get_embit_network_name(network) - net = networks.NETWORKS[embit_network] - # WIF is secret material shown only on this screen; never logged. priv_list - # is dropped as soon as the display returns below (best-effort, AGENTS security). - wif = ec.PrivateKey(bytes(priv_list), network=net).wif() + # The key's import format follows the slot's coin: WIF for the bitcoin-likes, + # raw hex for the EVM chains, which is what those wallets' "import private + # key" fields take. Getting this wrong hands the user a string their wallet + # rejects, with no way to ask the card again. + (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(slot) + coin = _satodime_slot_coin(slot_status) or satodime_coins.COINS[satodime_coins.SLIP44_BTC] + + # Secret material shown only on this screen; never logged. priv_list is dropped + # as soon as the display returns below (best-effort, AGENTS security). + secret = coin.privkey(bytes(priv_list), _satodime_is_testnet(self)) del priv_list self.run_screen( LargeIconStatusScreen, - title="Unsealed", + title=f"Unsealed {coin.symbol}", status_headline=None, - text=wif, + text=secret, show_back_button=True, ) - wif = None + secret = None return Destination(BackStackView) @@ -5410,20 +5443,33 @@ def run(self): (_, _, _, status) = Satochip_Connector.satodime_get_status() max_keys = status.get("max_num_keys", 0) + # Signing is Bitcoin-only: the PSBT flow below is a Bitcoin transaction + # signer, so only Bitcoin slots are offered. Other chains can still be + # viewed and unsealed -- the key is exported and imported elsewhere. available = [] button_data = [] + skipped_coins = set() for key_nbr in range(max_keys): (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - if slot_status.get("key_status_txt") == "Sealed": - available.append(key_nbr) - button_data.append(ButtonOption(f"Slot {key_nbr}")) + if slot_status.get("key_status_txt") != "Sealed": + continue + coin = _satodime_slot_coin(slot_status) + if coin is None or coin.slip44 != satodime_coins.SLIP44_BTC: + skipped_coins.add(coin.symbol if coin else "unknown") + continue + available.append(key_nbr) + button_data.append(ButtonOption(f"Slot {key_nbr}")) if not available: + if skipped_coins: + text = "Signing is Bitcoin only.\nUnseal to export the key." + else: + text = "No sealed slots" self.run_screen( WarningScreen, - title="Failed", + title="No Bitcoin Slots" if skipped_coins else "Failed", status_headline=None, - text="No sealed slots", + text=text, show_back_button=True, ) return Destination(BackStackView) @@ -5456,10 +5502,8 @@ def run(self): ) return Destination(BackStackView) - network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) - embit_network = embit_utils.get_embit_network_name(network) - net = networks.NETWORKS[embit_network] - wif = ec.PrivateKey(bytes(priv_list), network=net).wif() + btc = satodime_coins.COINS[satodime_coins.SLIP44_BTC] + wif = btc.privkey(bytes(priv_list), _satodime_is_testnet(self)) del priv_list # The WIF-derived key becomes the PSBT signing seed; the standard PSBT flow diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index d4b78b96e..369de0dd8 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -50,7 +50,7 @@ # tools_views must be imported first: it is a facade that star-imports smartcard_views. from seedsigner.views import tools_views from seedsigner.views import smartcard_views -from seedsigner.helpers import seedkeeper_utils +from seedsigner.helpers import satodime_coins, seedkeeper_utils from seedsigner.models.settings import SettingsConstants from seedsigner.views.view import MainMenuView @@ -67,6 +67,9 @@ SW_SETUP_NOT_DONE = (0x9C, 0x04) +# The Bitcoin CoinSpec, which is what these Satodime views render by default. +BTC = satodime_coins.COINS[satodime_coins.SLIP44_BTC] + def claim(connector): """Run the Satodime setup the views now perform via ``init_satochip``.""" @@ -211,8 +214,6 @@ def test_sealed_pubkey_parses_as_an_embit_key(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) - from embit import networks - with ctx as connector: claim(connector) connector.satodime_set_unlock_secret() @@ -221,7 +222,7 @@ def test_sealed_pubkey_parses_as_an_embit_key(self, monkeypatch): (_, sw1, sw2, _, pub_comp) = connector.satodime_seal_key(0, bytes(range(32))) assert (sw1, sw2) == (0x90, 0x00) - address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["main"]) + address = smartcard_views._satodime_address(pub_comp, BTC, is_testnet=False) assert BECH32_ADDRESS.match(address), address @@ -316,7 +317,7 @@ def test_sealed_slot_renders_an_address(self, monkeypatch): view.run() status_line, address = recorder.body_for("Slot 0").split("\n") - assert status_line == "Sealed" + assert status_line == "Sealed BTC", "the slot's coin belongs on screen" assert BECH32_ADDRESS.match(address), address @@ -453,13 +454,15 @@ def test_seal_after_claiming_shows_an_address(self): claim_view.run() view = smartcard_views.ToolsSatodimeSealSlotView() - recorder = ScreenRecorder(0, 0) # slot picker, then the success screen + # slot picker -> coin picker (BTC is first) -> success screen + recorder = ScreenRecorder(0, 0, 0) view.run_screen = recorder view.run() assert "Seal Failed" not in recorder.titles, recorder.calls + assert recorder.titles[:2] == ["Select Slot", "Seal As"] headline, address = recorder.body_for("Success").split("\n") - assert headline == "Slot 0 sealed" + assert headline == "Slot 0 sealed BTC" assert BECH32_ADDRESS.match(address), address def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): @@ -661,10 +664,8 @@ class TestMatchesTheOfficialSatodimeApp: APP_ADDRESS = "bc1qapd47as9kw384u5pkd4jvvj5pn8ds3s876k048" def test_address_matches_what_the_android_app_shows(self): - from embit import networks - pub_comp = list(bytes.fromhex(self.SEALED_PUBKEY)) - address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["main"]) + address = smartcard_views._satodime_address(pub_comp, BTC, is_testnet=False) assert address == self.APP_ADDRESS def test_testnet_uses_the_same_key_with_the_testnet_hrp(self): @@ -678,7 +679,7 @@ def test_testnet_uses_the_same_key_with_the_testnet_hrp(self): pub_comp = list(bytes.fromhex(self.SEALED_PUBKEY)) from embit import script - address = smartcard_views._satodime_address(pub_comp, networks.NETWORKS["test"]) + address = smartcard_views._satodime_address(pub_comp, BTC, is_testnet=True) assert address.startswith("tb1q") # bech32 checksums cover the hrp, so the strings differ past the prefix; what # must match is the witness program they encode. diff --git a/tests/test_satodime_coins.py b/tests/test_satodime_coins.py new file mode 100644 index 000000000..2a7a82978 --- /dev/null +++ b/tests/test_satodime_coins.py @@ -0,0 +1,210 @@ +""" + Per-coin address and private-key formats for Satodime keyslots. + + A Satodime slot can be sealed for any of the coins the official apps support, and a + deposit address in the wrong format is worse than no address: the key still controls + the funds, but the owner's wallet for that chain never shows them. So every format + here is pinned to a vector from outside this repo -- the EIP-55 spec, the CashAddr + spec, or the parameters in Toporin/Javacryptotools, which is the library + Satodime-Android and Satodime-Desktop actually use. + + Supported set is deliberately the app's: Utils.kt maps SLIP-44 to a coin class and + falls through to UnsupportedCoin, so BTC/LTC/BCH/ETH/POL/XCP are in and the other + codes listed in Constants.java are not. +""" + +import pytest +from embit import base58, ec + +from seedsigner.helpers import satodime_coins as sc + + +# The key sealed into slot 0 of a real Satodime by the official Android app, and the +# address that app displayed for it. Cross-checked on hardware. +CARD_PUBKEY = "03de296020fbf9a119db36a513a572ea4936a5729f5a3deb21a9b8c0928c9db8f0" +CARD_BTC_ADDRESS = "bc1qapd47as9kw384u5pkd4jvvj5pn8ds3s876k048" + + +def pub(hexstr=CARD_PUBKEY): + return ec.PublicKey.parse(bytes.fromhex(hexstr)) + + +class TestCashAddr: + """BCH's own encoding: same alphabet as bech32, different checksum generator.""" + + def test_matches_the_cashaddr_spec_vector(self): + # The example pairing from the CashAddr specification. + legacy = "1BpEi6DfDAUFd7GtittLSdBeYJvcoaVggu" + expected = "bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a" + + hash160 = base58.decode_check(legacy)[1:] + # version byte 0 = P2PKH with a 160-bit hash + assert sc.encode_cashaddr("bitcoincash", bytes([0]) + hash160) == expected + + def test_prefix_is_part_of_the_checksum(self): + """Changing the prefix must change the body, or testnet/mainnet would collide.""" + payload = bytes([0]) + bytes(range(20)) + main = sc.encode_cashaddr("bitcoincash", payload) + test = sc.encode_cashaddr("bchtest", payload) + assert main.split(":")[1] != test.split(":")[1] + + +class TestEvmAddresses: + """keccak-256, not SHA3-256, and EIP-55 mixed case on the way out.""" + + # The four vectors given in EIP-55 itself. + EIP55_VECTORS = [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", + "0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", + ] + + @pytest.mark.parametrize("address", EIP55_VECTORS) + def test_checksum_reproduces_the_eip55_vectors(self, address): + lowered = address.lower().replace("0x", "") + digest = sc._keccak256(lowered.encode()).hex() + got = "0x" + "".join( + c.upper() if int(digest[i], 16) >= 8 else c for i, c in enumerate(lowered) + ) + assert got == address + + def test_keccak_is_not_sha3(self): + """hashlib.sha3_256 pads differently; swapping them silently breaks every address.""" + import hashlib + + assert sc._keccak256(b"") != hashlib.sha3_256(b"").digest() + assert sc._keccak256(b"").hex().startswith("c5d2460186f7") + + def test_known_private_key_maps_to_its_known_address(self): + priv = ec.PrivateKey(bytes(31) + bytes([1])) + address = sc.COINS[sc.SLIP44_ETH].address(priv.get_public_key()) + assert address.lower() == "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf" + + def test_polygon_shares_ethereums_derivation(self): + """Polygon.java extends Ethereum: one address, two chains.""" + assert ( + sc.COINS[sc.SLIP44_POL].address(pub()) + == sc.COINS[sc.SLIP44_ETH].address(pub()) + ) + + +class TestAddressFormatPerCoin: + """Each coin's format, as Javacryptotools derives it.""" + + def test_btc_is_bech32_and_matches_the_android_app(self): + assert sc.COINS[sc.SLIP44_BTC].address(pub()) == CARD_BTC_ADDRESS + + def test_ltc_is_bech32_under_its_own_hrp(self): + """Litecoin sets segwit_supported = true with segwit_hrp = 'ltc'.""" + address = sc.COINS[sc.SLIP44_LTC].address(pub()) + assert address.startswith("ltc1q") + # Same witness program as BTC -- only the human-readable part differs. + assert address[5:-6] == CARD_BTC_ADDRESS[4:-6] + + def test_bch_is_cashaddr_not_base58(self): + """BitcoinCash overrides pubToAddress; it is neither bech32 nor legacy base58.""" + address = sc.COINS[sc.SLIP44_BCH].address(pub()) + assert address.startswith("bitcoincash:q") + + def test_xcp_is_legacy_base58(self): + """Counterparty sets segwit_supported = false and rides Bitcoin's version bytes.""" + address = sc.COINS[sc.SLIP44_XCP].address(pub()) + assert address.startswith("1") + assert base58.decode_check(address)[0] == 0x00 + + def test_every_supported_coin_produces_a_distinct_address_family(self): + prefixes = {c.symbol: c.address(pub())[:4] for c in sc.SEALABLE_COINS} + # ETH and POL are the one intentional pair that collides. + assert prefixes["ETH"] == prefixes["POL"] + others = {k: v for k, v in prefixes.items() if k != "POL"} + assert len(set(others.values())) == len(others), prefixes + + @pytest.mark.parametrize("coin", sc.SEALABLE_COINS, ids=lambda c: c.symbol) + def test_testnet_differs_from_mainnet(self, coin): + main = coin.address(pub(), is_testnet=False) + test = coin.address(pub(), is_testnet=True) + if coin.slip44 in (sc.SLIP44_ETH, sc.SLIP44_POL): + # EVM addresses carry no network byte at all. + assert main == test + else: + assert main != test + + +class TestPrivateKeyFormats: + """The string a user pastes into the wallet for that chain.""" + + SECRET = bytes(range(1, 33)) + + def test_bitcoin_likes_are_compressed_wif(self): + wif = sc.COINS[sc.SLIP44_BTC].privkey(self.SECRET) + assert wif.startswith("K") or wif.startswith("L") + decoded = base58.decode_check(wif) + assert decoded[0] == 0x80 + assert decoded[1:33] == self.SECRET + assert decoded[33] == 0x01, "compressed flag" + + def test_litecoin_uses_its_own_wif_version(self): + decoded = base58.decode_check(sc.COINS[sc.SLIP44_LTC].privkey(self.SECRET)) + assert decoded[0] == 0xB0 + + def test_testnet_wif_uses_the_testnet_version(self): + decoded = base58.decode_check( + sc.COINS[sc.SLIP44_BTC].privkey(self.SECRET, is_testnet=True)) + assert decoded[0] == 0xEF + + @pytest.mark.parametrize("slip44", [sc.SLIP44_ETH, sc.SLIP44_POL]) + def test_evm_keys_are_raw_hex(self, slip44): + """An EVM wallet's import field takes hex; a WIF there is simply rejected.""" + key = sc.COINS[slip44].privkey(self.SECRET) + assert key == "0x" + self.SECRET.hex() + + def test_the_wif_round_trips_through_embit(self): + wif = sc.COINS[sc.SLIP44_BTC].privkey(self.SECRET) + assert ec.PrivateKey.from_wif(wif).secret == self.SECRET + + +class TestCoinLookup: + """What a keyslot's slip44 resolves to.""" + + def test_known_coins_resolve(self): + for slip44 in (sc.SLIP44_BTC, sc.SLIP44_LTC, sc.SLIP44_BCH, + sc.SLIP44_ETH, sc.SLIP44_POL, sc.SLIP44_XCP): + assert sc.coin_for_slip44(slip44) is not None + + def test_an_untagged_slot_is_bitcoin(self): + """Slots SeedSigner sealed before it wrote this metadata read back as 0.""" + assert sc.coin_for_slip44(0) is sc.COINS[sc.SLIP44_BTC] + + @pytest.mark.parametrize("slip44,name", [ + (0x80000003, "DOGE"), (0x80000005, "DASH"), + (0x8000003D, "ETC"), (0x80000089, "RBTC"), (0x80000207, "BSC"), + ]) + def test_coins_the_official_app_does_not_support_resolve_to_nothing(self, slip44, name): + """ + Constants.java lists these, but Utils.kt has no branch for them so the app + shows UnsupportedCoin. Guessing a format here would invite a deposit to an + address the owner's wallet never derives. + """ + assert sc.coin_for_slip44(slip44) is None, name + + def test_slip44_bytes_are_big_endian_and_four_wide(self): + assert sc.slip44_bytes(sc.SLIP44_BTC) == [0x80, 0x00, 0x00, 0x00] + assert sc.slip44_bytes(sc.SLIP44_ETH) == [0x80, 0x00, 0x00, 0x3C] + assert sc.slip44_bytes(sc.SLIP44_POL) == [0x80, 0x00, 0x03, 0xC6] + + def test_sealable_set_matches_the_official_app(self): + assert {c.symbol for c in sc.SEALABLE_COINS} == { + "BTC", "LTC", "BCH", "ETH", "POL", "XCP"} + + def test_we_carry_our_own_symbols_because_pysatochip_is_incomplete(self): + """ + Why this module holds the symbols instead of reading key_slip44_txt off the + card: pysatochip's label table has no entry for Polygon, so a POL slot reads + back as "Unknown code [128, 0, 3, 198]". Confirmed on hardware -- the card + stores 0x800003C6 correctly, only the label lookup is missing. + """ + from pysatochip.CardDataParser import DICT_SLIP44_BY_CODE + + assert sc.SLIP44_POL not in DICT_SLIP44_BY_CODE + assert sc.COINS[sc.SLIP44_POL].symbol == "POL" diff --git a/tests/test_smartcard_hardware.py b/tests/test_smartcard_hardware.py index 1c6f7bd78..effce47a0 100644 --- a/tests/test_smartcard_hardware.py +++ b/tests/test_smartcard_hardware.py @@ -536,22 +536,43 @@ def test_seal_slot_then_read_pubkey(self): # Format matters as much as parsing: the official Satodime apps derive # bech32 P2WPKH, so a legacy address here would send funds somewhere those # apps never look. - from embit import networks + from seedsigner.helpers import satodime_coins from seedsigner.views.smartcard_views import _satodime_address - address = _satodime_address(pub_read, networks.NETWORKS["main"]) + btc = satodime_coins.COINS[satodime_coins.SLIP44_BTC] + address = _satodime_address(pub_read, btc, is_testnet=False) assert re.match(r"^bc1q[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{38}$", address), address - # And the slot must be tagged BTC, or the official apps show it as an - # unknown asset with no balance lookup. + # And the slot must be tagged with its coin, or the official apps show it + # as an unknown asset with no balance lookup. Tag it as each supported coin + # in turn: this is the only place the round trip through the card's own + # metadata is exercised, and pysatochip resolves the symbol back for us, so + # a wrong slip44 shows up as a wrong name rather than silently passing. from seedsigner.views.smartcard_views import ( - SATODIME_SLIP44_BTC, _satodime_slot_slip44, _satodime_write_slot_metadata, + _satodime_slot_slip44, _satodime_write_slot_metadata, ) - assert _satodime_write_slot_metadata(connector, slot), "tagging the slot failed" - (_, _, _, tagged) = connector.satodime_get_keyslot_status(slot) - assert _satodime_slot_slip44(tagged) == SATODIME_SLIP44_BTC - assert tagged["key_slip44_txt"] == "BTC" + from pysatochip.CardDataParser import DICT_SLIP44_BY_CODE + + for coin in satodime_coins.SEALABLE_COINS: + assert _satodime_write_slot_metadata(connector, slot, coin), \ + f"tagging the slot as {coin.symbol} failed" + (_, _, _, tagged) = connector.satodime_get_keyslot_status(slot) + + # The slip44 the card gives back is the authority. pysatochip's own + # label table is incomplete -- it has no entry for POL -- which is + # exactly why satodime_coins carries the symbols rather than reading + # them off key_slip44_txt. + assert _satodime_slot_slip44(tagged) == coin.slip44, coin.symbol + if coin.slip44 in DICT_SLIP44_BY_CODE: + assert tagged["key_slip44_txt"] == coin.symbol + + # And the address the views render for this slot follows that tag. + coin_address = _satodime_address(pub_read, coin, is_testnet=False) + assert coin_address, coin.symbol + + # Leave it as Bitcoin for the unseal test that follows. + assert _satodime_write_slot_metadata(connector, slot, btc) finally: self._disconnect() diff --git a/tests/test_wif.py b/tests/test_wif.py index 0b6c088d5..98973a3a1 100644 --- a/tests/test_wif.py +++ b/tests/test_wif.py @@ -1,5 +1,8 @@ import os + +import pytest from embit import ec, script, psbt +from embit.finalizer import finalize_psbt from embit.transaction import Transaction, TransactionInput, TransactionOutput from seedsigner.models.wif import WIFKey @@ -125,3 +128,160 @@ def test_wif_setting_disables_options(self): assert psbt_views.PSBTSelectSeedView.SCAN_WIF not in buttons assert psbt_views.PSBTSelectSeedView.TYPE_WIF not in buttons + + +# ====================================================================== +# Raw private key signing, against psbts shaped the way Electrum exports them +# ====================================================================== + +class TestElectrumStyleWifSigning: + """ + A watch-only single-address wallet in Electrum is the reference producer here. + + It holds an address and nothing else, so the psbt it exports carries a utxo and a + script and *no derivation fields at all* -- no bip32_derivations, no fingerprints. + That is the shape a Satodime key has to sign, and it is what broke: routing asked + ``has_matching_input_fingerprint``, which walks bip32_derivations, found none, and + quietly dropped the key on the seed-picker screen even though it signs perfectly. + + The vectors below build that psbt for each script type such a wallet can hold, and + walk the whole path: route -> parse -> sign -> finalize. + """ + + # A fixed key, so a failure is reproducible rather than a one-in-a-run fluke. + PRIV = ec.PrivateKey(bytes.fromhex( + "1111111111111111111111111111111111111111111111111111111111111111")) + KINDS = ("p2pkh", "p2wpkh", "p2sh-p2wpkh", "p2tr") + + def _spk_and_redeem(self, kind, pub): + if kind == "p2pkh": + return script.p2pkh(pub), None + if kind == "p2wpkh": + return script.p2wpkh(pub), None + if kind == "p2sh-p2wpkh": + redeem = script.p2wpkh(pub) + return script.p2sh(redeem), redeem + if kind == "p2tr": + return script.p2tr(pub), None + raise ValueError(kind) + + def _build(self, kind, priv=None): + """A psbt spending one output of `kind` back out to an unrelated address.""" + priv = priv or self.PRIV + pub = priv.get_public_key() + spk, redeem = self._spk_and_redeem(kind, pub) + dest = script.p2wpkh(ec.PrivateKey(bytes(31) + bytes([9])).get_public_key()) + + prev = Transaction( + version=2, + vin=[TransactionInput(b"\x22" * 32, 0)], + vout=[TransactionOutput(100_000, spk)], + locktime=0, + ) + spend = Transaction( + version=2, + vin=[TransactionInput(bytes.fromhex(prev.txid().hex()), 0)], + vout=[TransactionOutput(90_000, dest)], + locktime=0, + ) + p = psbt.PSBT(spend) + inp = p.inputs[0] + # Electrum must ship the whole previous transaction for a legacy input (it is + # the only proof of the amount) and sends a witness_utxo for segwit ones. + if kind == "p2pkh": + inp.non_witness_utxo = prev + else: + inp.witness_utxo = TransactionOutput(100_000, spk) + if redeem is not None: + inp.redeem_script = redeem + if kind == "p2tr": + inp.taproot_internal_key = pub + return p + + def test_psbt_carries_no_derivation_fields(self): + """Guards the premise: if these ever gain derivations the vectors are wrong.""" + for kind in self.KINDS: + inp = self._build(kind).inputs[0] + assert not inp.bip32_derivations, kind + assert not inp.taproot_bip32_derivations, kind + + @pytest.mark.parametrize("kind", KINDS) + def test_routing_agrees_with_signing(self, kind): + """ + The regression. Routing must not claim a key cannot sign what it can sign: + PSBTSelectSeedView drops the key and sends the user to the seed picker, where a + WIF is not on offer at all. + """ + key = WIFKey(self.PRIV.wif()) + p = self._build(kind) + + # The check used for seeds walks bip32_derivations, of which this psbt has + # none -- so it answers False for a key that signs. That is the bug, and it is + # why PSBTSelectSeedView needs a separate branch for raw keys rather than a + # tweak to this one. + assert PSBTParser.has_matching_input_fingerprint( + psbt=p, seed=key, network=SettingsConstants.MAINNET + ) is False, f"{kind}: premise changed -- fingerprint routing now finds something" + + routed = PSBTParser.wif_can_sign_any_input(psbt=p, wif_key=key) + signed = p.sign_with(key.privkey) + + assert signed == 1, f"{kind}: key should sign its own input" + assert routed is True, f"{kind}: routing said the key cannot sign, but it did" + + @pytest.mark.parametrize("kind", KINDS) + def test_parses_signs_and_finalizes(self, kind): + """The whole path a Satodime key takes, ending in a broadcastable transaction.""" + key = WIFKey(self.PRIV.wif()) + p = self._build(kind) + + parser = PSBTParser(p, seed=key, network=SettingsConstants.MAINNET) + assert isinstance(parser.root, ec.PrivateKey) + assert parser.num_inputs == 1 + assert parser.policy["type"] == kind + + assert p.sign_with(parser.root) == 1 + assert PSBTParser.sig_count(p) == 1 + + tx = finalize_psbt(p) + assert tx is not None, f"{kind}: finalize produced no transaction" + assert len(tx.serialize()) > 0 + + @pytest.mark.parametrize("kind", KINDS) + def test_a_key_that_owns_nothing_is_not_routed(self, kind): + """Routing is a hint, but it must not be a hint that points the wrong way.""" + stranger = WIFKey(ec.PrivateKey(bytes(31) + bytes([7])).wif()) + p = self._build(kind) + + assert PSBTParser.wif_can_sign_any_input(psbt=p, wif_key=stranger) is False + assert p.sign_with(stranger.privkey) == 0 + + def test_legacy_input_resolves_its_amount_from_the_previous_transaction(self): + """ + p2pkh carries no witness_utxo, so the input amount can only come from + non_witness_utxo. Worth pinning separately: it is the one script type whose + utxo lookup goes down a different path. + """ + key = WIFKey(self.PRIV.wif()) + p = self._build("p2pkh") + assert p.inputs[0].witness_utxo is None + assert p.inputs[0].non_witness_utxo is not None + + parser = PSBTParser(p, seed=key, network=SettingsConstants.MAINNET) + assert parser.input_amount == 100_000 + assert parser.spend_amount == 90_000 + + def test_signature_verifies_against_the_key(self): + """A signature that does not verify would still count towards sig_count.""" + key = WIFKey(self.PRIV.wif()) + p = self._build("p2wpkh") + p.sign_with(key.privkey) + + pub = self.PRIV.get_public_key() + raw = p.inputs[0].partial_sigs[pub] + assert raw[-1] == 0x01, "SIGHASH_ALL" + + from embit.psbt import SIGHASH + + sighash = p.sighash(0, sighash=SIGHASH.ALL) + assert pub.verify(ec.Signature.parse(raw[:-1]), sighash) From d45add6ec51b291c9a4a11b9e248340ad5672ca2 Mon Sep 17 00:00:00 2001 From: 3rdIteration Date: Tue, 8 Sep 2026 18:57:13 -0400 Subject: [PATCH 04/26] docs: Satodime page, including how to sign with a raw key from Electrum Satodime had no documentation beyond an applet name in a config listing, and the raw-key signing path in particular has non-obvious requirements that cost real debugging time. Covers claiming (and why Satodime never asks for a PIN), the unlock code and the fact that it only matters over NFC, the per-coin address and key formats, and the Electrum procedure for producing a psbt a single raw key can sign. Two things are called out explicitly because they are what broke this in practice and both are now fixed: - SeedSigner used to derive a legacy 1... address where the official apps derive bc1q..., so a watch-only wallet built from the old address was watching a different address than the Satodime app showed. - The seed-selection screen matched on bip32 fingerprints, which an Electrum watch-only export does not carry, so a raw key was silently dropped. States plainly that the unlock code is proximity protection and not theft protection: anyone with the card and a contact reader can unseal it regardless. Co-Authored-By: Claude Opus 5 --- README.md | 5 ++ docs/satodime.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/satodime.md diff --git a/README.md b/README.md index 309aa1611..6e8b9b8a4 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ Support and discussion relating to this fork can happen via this [Telegram Group - Factory Reset Card - Smartcard info screen with card UID - Genuineness check +* Satodime Card features ([docs](./docs/satodime.md)) + - View deposit addresses per key slot, in the same format the official Satodime apps derive + - Seal and unseal key slots, tagged with the slot's coin (BTC, LTC, BCH, ETH, POL, XCP) + - Sign a Bitcoin transaction with an unsealed key (signing is Bitcoin-only) + - Transfer card ownership, with unlock-code backup for contactless readers * Satochip Card features - Load any Seed from the SeedSigner on to the Satochip Card - Enable 2FA on the Satochip Card diff --git a/docs/satodime.md b/docs/satodime.md new file mode 100644 index 000000000..8495efe70 --- /dev/null +++ b/docs/satodime.md @@ -0,0 +1,134 @@ +# Satodime Support + +A [Satodime](https://satochip.io/product/satodime/) is a bearer card. Each of its key +slots holds a private key the card generated and has never revealed: you can read the +slot's deposit address and send funds to it, and the key only becomes visible when you +*unseal* the slot, which is a one-way operation the card records. Whoever holds the card +holds the coins. + +SeedSigner reads slots, seals and unseals them, and can sign a Bitcoin transaction with +an unsealed key. Everything here is designed to agree byte-for-byte with the official +[Satodime apps](https://github.com/Toporin/Satodime-Android), so a card set up on one +reads correctly on the other. + +Tools -> Smartcard -> Satodime. + +## Claiming a card + +A factory-fresh Satodime has no owner. The first `SETUP` command claims it, generating +the card's *unlock code* and handing it back exactly once — it can never be read again. +SeedSigner claims a card the first time you ask it to do something that changes card +state, after an explicit confirmation. + +Satodime has **no PIN**. If you are ever asked to set one for a Satodime, that is a bug. + +### The unlock code, and when it matters + +The applet only checks the unlock code over a **contactless (NFC)** reader. Over a +**contact** reader it skips the check entirely, so: + +- **Contact reader** — nothing to back up. SeedSigner claims the card and moves on. +- **NFC reader** — SeedSigner walks you through backing the code up, because without it + an NFC-only setup can no longer seal, unseal, reset, *or even transfer* the card. + +The backup flow shows the code as a QR and asks you to scan it back, so a code you never +actually captured cannot be mistaken for a backup. You can additionally save it to the +MicroSD card. Codes are held in RAM for the session only — they are never written to the +device. Restore one later with Card Settings -> Restore Unlock Code. + +> **The unlock code is not theft protection.** Anyone holding the card and a contact +> reader can unseal it without the code. It protects against a contactless attacker +> in proximity to the card, and nothing else. The tamper-evident seal is what tells you +> whether a card has been interfered with. + +## Supported coins + +A slot records which coin it holds as a SLIP-44 code. SeedSigner derives the deposit +address and the private-key format from that code, matching +[Javacryptotools](https://github.com/Toporin/Javacryptotools), the library the official +apps use: + +| Coin | Address format | Unsealed key format | +|------|----------------|---------------------| +| BTC | bech32 P2WPKH (`bc1q…`) | WIF | +| LTC | bech32 P2WPKH (`ltc1q…`) | WIF | +| BCH | CashAddr (`bitcoincash:q…`) | WIF | +| XCP | legacy base58 (`1…`) | WIF | +| ETH | keccak, EIP-55 (`0x…`) | raw hex | +| POL | keccak, EIP-55 (`0x…`) | raw hex | + +Mainnet vs testnet follows SeedSigner's own Network setting, exactly as it follows the +testnet toggle in the official apps — the card records the coin, never the network. + +Slots sealed for any other chain are shown as an unsupported coin rather than given a +guessed address. The official apps treat them the same way. + +**Signing is Bitcoin-only.** Sign Transaction offers Bitcoin slots only. Other chains can +be viewed and unsealed; export the key and import it into a wallet for that chain. + +## Signing a Bitcoin transaction from a Satodime + +Unsealing hands SeedSigner a single private key with no BIP32 tree behind it, so the +usual "which seed signs this?" routing does not apply. The transaction has to be built by +a watch-only wallet that knows the slot's address. + +Electrum is the reference: + +1. **Read the slot's deposit address** in SeedSigner: Satodime -> View Deposit Addresses. + Use this address verbatim. +2. **Create a watch-only wallet in Electrum** for that address: + `File -> New/Restore`, choose *Import Bitcoin addresses or private keys*, and paste the + address. This gives a wallet that can build transactions but cannot sign them. +3. **Build the transaction** in Electrum as normal. Because the wallet has no keys, it + produces an unsigned PSBT. +4. **Export it as a QR code** and scan it with SeedSigner. +5. In SeedSigner: Satodime -> **Sign Transaction**, pick the sealed Bitcoin slot, and + confirm. The slot is unsealed (one-way) and the transaction is signed. +6. SeedSigner displays the **finished raw transaction** as a QR code — not a partially + signed PSBT, because a single-key spend is complete once signed. Scan it with a + broadcasting tool, or paste it into Electrum's `Tools -> Load transaction -> From text`. + +### If this did not work for you before + +Two things used to break this, both fixed: + +- **The address format was wrong.** SeedSigner derived a legacy `1…` address where the + official apps derive `bc1q…`. Both are controlled by the same key, but a watch-only + wallet built around the legacy address is watching a different address than the one the + Satodime app shows, and any deposit made to it is invisible in that app. If you set up + an Electrum wallet from an address SeedSigner showed before this fix, re-check it + against View Deposit Addresses. +- **The key was silently dropped.** The screen that decides which key signs a PSBT + matched on BIP32 fingerprints. An Electrum watch-only wallet exports a PSBT with no + derivation data at all, so the check found nothing and fell through to the seed picker — + where a raw key is not on offer. Raw keys are now matched on the key itself, the same + test embit's signer uses. + +### PSBT requirements + +The PSBT needs nothing beyond what Electrum already puts in it: + +- a `witness_utxo` for segwit inputs, or the full previous transaction (`non_witness_utxo`) + for legacy ones — this is how the input amount is proven; +- a `redeem_script` for P2SH-wrapped segwit. + +Derivation fields (`bip32_derivation`) are *not* required and are not expected. + +SeedSigner accepts PSBTs as base64, base43, Specter, UR2 and BBQr QR codes. + +## Transferring a card + +Transfer Ownership releases the card: the applet clears its setup flag so the next holder +can claim it and mint their own unlock code. Over NFC this needs the current unlock code; +over a contact reader it does not. + +## Testing + +- `tests/test_satodime_coins.py` — address and key formats, pinned to the EIP-55 and + CashAddr specification vectors and to Javacryptotools' parameters. +- `tests/test_wif.py` — raw-key signing against PSBTs shaped the way an Electrum + watch-only wallet exports them, across P2PKH, P2WPKH, P2SH-P2WPKH and P2TR. +- `tests/test_real_screen_flows_satodime_simulated.py` — the views driven against a real + Satodime applet in jcardsim, including a golden vector taken off a card the official + Android app sealed. +- `tests/test_smartcard_hardware.py` — the same flows against a real card and reader. From 3281e1f343ff04798e0fb1c78a98b3abcf0486ca Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 09:17:22 -0400 Subject: [PATCH 05/26] Satodime: slot-centric menus; cache slot state on the Controller The old flat menu (View Deposit Addresses / Seal Slot / Unseal Slot) forced a full card read on every entry and re-read each slot for every action. Reworked around one cached snapshot per session: - ToolsSatodimeView is now a plain menu: Key Slots, Transfer Ownership, Card Settings. It never touches the card. - New ToolsSatodimeSlotsView reads every keyslot once (status + keyslot status + pubkey for live slots) and caches (state, coin, address) per slot on the Controller as satodime_slot_cache. Re-entering it reuses the cache; the cache is dropped when returning Home alongside the unlock secrets. - ToolsSatodimeSlotMenuView offers only the actions valid for a slot's state: [Seal] uninitialized, [View Address, Unseal, Sign] sealed, and [View Address, View Private Key, Sign, Load Key, Reset] unsealed (Bitcoin adds Sign/Load). - Seal/Unseal/Reset/Sign/Load/ViewPrivateKey/ViewAddress read their slot's state from the cache instead of re-querying the card, and seal/unseal/reset update the cached entry in place so returning to the menu reflects the new state immediately. A missing cache fails closed (Unknown State) rather than guessing; SlotMenu redirects to SlotsView to rebuild it. Adds 0x9C52 "Incorrect keyslot state" to the ISO7816 status-word table: the applet answers with it when a slot is not in the state the command accepts (SEAL needs Uninitialized, UNSEAL needs Sealed), which the re-seal refusal surfaces as the no-backup warning. Tests: nav flows walk Key Slots -> slot list -> per-slot menu for both an uninitialized and a sealed slot; the jcardsim suite drives the cached views against the real applet (slot list, seal/unseal/reset, view address/privkey, load key) with the cache pre-built exactly as ToolsSatodimeSlotsView would. --- src/seedsigner/controller.py | 10 + src/seedsigner/helpers/iso7816.py | 4 + src/seedsigner/views/smartcard_views.py | 950 ++++++++++++++---- tests/test_flows_menu_navigation.py | 101 ++ ...st_real_screen_flows_satodime_simulated.py | 370 ++++++- tests/test_split_module_imports.py | 7 +- 6 files changed, 1202 insertions(+), 240 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 36aba2229..4ce7443bb 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -273,6 +273,11 @@ def _load_block_anchor(cls): # without it, a contactless reader cannot seal, unseal, reset or even transfer the # card. Held in RAM only -- the user is walked through backing it up at claim time. Satodime_unlock_secrets: dict | None = None + # Cached slot data for the Satodime slot-centric menus: avoids re-reading the card + # when navigating the slot list, per-slot action menus, and view-address QR. Cleared + # on Home alongside the session secrets above. Keys: card_id (str), max_keys (int), + # slots (list of (state, coin, address) tuples), built once by ToolsSatodimeSlotsView. + satodime_slot_cache: dict | None = None GPG_Admin_PIN = None javacard_keys: dict | None = None @@ -575,6 +580,10 @@ def run(self): # Always drop any cached OpenPGP admin PIN when returning home self.GPG_Admin_PIN = None + + # Always drop the cached Satodime slot data (it's read-only display + # state that could go stale across sessions). + self.satodime_slot_cache = None logger.info(f"\nback_stack: {self.back_stack}") @@ -766,6 +775,7 @@ def handle_wipe_timeout(self): self.Satochip_Last_UID_SHA1 = None self.Satochip_Connector = None self.Satodime_unlock_secrets = None + self.satodime_slot_cache = None self.GPG_Admin_PIN = None self.image_entropy_preview_frames = None self.image_entropy_final_image = None diff --git a/src/seedsigner/helpers/iso7816.py b/src/seedsigner/helpers/iso7816.py index 455177899..6163b0414 100644 --- a/src/seedsigner/helpers/iso7816.py +++ b/src/seedsigner/helpers/iso7816.py @@ -55,6 +55,10 @@ # there. They mean the caller does not hold the card's unlock secret. 0x9C50: "Wrong unlock counter", 0x9C51: "Wrong unlock code", + # Satodime only: the slot is not in the right state for the requested command. + # SEAL accepts only Uninitialized slots; UNSEAL only Sealed ones. A slot that + # was sealed and then spent (state Unsealed) can never be re-sealed. + 0x9C52: "Incorrect keyslot state", 0x9C54: "Unknown protocol media", 0x9CFF: "Card internal error", } diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 95bae0d9d..ae94ceb18 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4643,6 +4643,153 @@ def _satodime_slot_slip44(slot_status) -> int: return SATODIME_SLIP44_BTC if value == 0 else value +# A Satodime keyslot holds no seed backup anywhere off the card. That is the feature, +# but it is also the one thing a user must grasp before sealing or re-sealing: if the +# card is lost or destroyed, the funds sitting on that slot's address are gone. Both +# the seal and re-seal guards below say this loudly. +SATODIME_NO_BACKUP_WARNING = ( + "There is no backup for this card.\n" + "If it's lost or destroyed, funds\n" + "are unrecoverable." +) + + +def _satodime_unseal_warning(view, confirm_label: str) -> bool: + """Loud heads-up before the irreversible ``SATODIME_UNSEAL_KEY`` step. + + Unsealing exposes the slot's private key, and the applet then refuses to seal that + slot ever again (state goes to ``Unsealed``). Returns True when the user confirms, + False when they backed out. + """ + selected = view.run_screen( + DireWarningScreen, + title="Unseal Slot", + status_headline=None, + text="Once unsealed, this slot\ncan never be re-sealed.", + show_back_button=True, + button_data=[ButtonOption(confirm_label)], + ) + return selected != RET_CODE__BACK_BUTTON + + +def _satodime_require_rng_health(view) -> bool: + """Fail closed unless the system RNG has passed its health monitor. + + Sealing mints a new key out of these bytes, so a degraded RNG must refuse to seal + rather than ship a weaker key -- the same gate the password generator and the + image-entropy seed flow apply before they produce a secret. Returns True when the + RNG is healthy and sealing may continue. + """ + if view.controller.hardware_rng_is_healthy: + return True + + view.run_screen( + WarningScreen, + title="System RNG Error", + status_headline=None, + text=view.controller.hardware_rng_failure_reason or "System RNG health check failed.", + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + return False + + +# The applet's per-slot state machine (state_array byte). Every command accepts exactly +# one state and answers 0x9C52 otherwise: SEAL needs Uninitialized, UNSEAL needs Sealed, +# and GET_PRIVKEY / RESET need Unsealed. Prefer these over matching key_status_txt. +SATODIME_SLOT_UNINITIALIZED = "uninitialized" +SATODIME_SLOT_SEALED = "sealed" +SATODIME_SLOT_UNSEALED = "unsealed" +SATODIME_SLOT_UNKNOWN = "unknown" + + +def _satodime_slot_state(slot_status) -> str: + """The slot's applet state, from the numeric ``key_status`` byte. + + The byte and ``key_status_txt`` both come from pysatochip's DIC_STATE + (0=Uninitialized, 1=Sealed, 2=Unsealed); reading the byte directly avoids any + dependence on the exact text a pysatochip build emits. + """ + state = slot_status.get("key_status") + if state == 0: + return SATODIME_SLOT_UNINITIALIZED + if state == 1: + return SATODIME_SLOT_SEALED + if state == 2: + return SATODIME_SLOT_UNSEALED + return SATODIME_SLOT_UNKNOWN + + +def _satodime_state_label(state: str) -> str: + if state == SATODIME_SLOT_UNINITIALIZED: + return "Uninitialized" + if state == SATODIME_SLOT_SEALED: + return "Sealed" + if state == SATODIME_SLOT_UNSEALED: + return "Unsealed" + return "Unknown" + + +def _satodime_slot_label(key_nbr, state, coin, address) -> str: + """One line for the slot list: true state, coin, and the address when it has one.""" + label = f"Slot {key_nbr} - {_satodime_state_label(state)}" + if state in (SATODIME_SLOT_SEALED, SATODIME_SLOT_UNSEALED): + if coin is None: + return label + " - Unsupported" + label += f" - {coin.symbol}" + if address: + label += f" - {address}" + return label + + +def _satodime_read_slot(connector, key_nbr, is_testnet: bool): + """Read one keyslot's status, state, coin and (for a live slot) deposit address. + + Returns ``(slot_status, state, coin, address)``. A read failure (e.g. the card + answers get_pubkey with an empty body on an empty slot) yields ``None`` for the + degraded fields so callers can fail gracefully instead of leaking an exception. + """ + try: + (_, _, _, slot_status) = connector.satodime_get_keyslot_status(key_nbr) + state = _satodime_slot_state(slot_status) + coin = None + address = None + if state != SATODIME_SLOT_UNINITIALIZED: + coin = _satodime_slot_coin(slot_status) + if coin is not None: + (_, _, _, _, pub_comp) = connector.satodime_get_pubkey(key_nbr) + address = _satodime_address(pub_comp, coin, is_testnet) + return slot_status, state, coin, address + except Exception: + logger.exception("Satodime: slot %s read failed", key_nbr) + return None, SATODIME_SLOT_UNKNOWN, None, None + + +def _satodime_build_cache(connector, is_testnet: bool) -> dict: + """Snapshot every keyslot's state, coin and address once. + + The result is cached on the Controller so that navigating the slot list, per-slot + action menus, and view-address screens never has to touch the card again. Only + state-changing operations (seal/unseal/reset) invalidate the affected entry. + """ + (_, _, _, status) = connector.satodime_get_status() + max_keys = status.get("max_num_keys", 0) + card_id = seedkeeper_utils.satodime_card_id(connector) + slots = [] + for key_nbr in range(max_keys): + _slot_status, state, coin, address = _satodime_read_slot(connector, key_nbr, is_testnet) + slots.append((state, coin, address)) + return {"card_id": card_id, "max_keys": max_keys, "slots": slots} + + +def _satodime_cached_slot(controller, slot: int): + """(state, coin, address) for ``slot`` from the Controller's cache, or None.""" + cache = controller.satodime_slot_cache + if cache and 0 <= slot < len(cache["slots"]): + return cache["slots"][slot] + return None + + def _satodime_write_slot_metadata(connector, slot, coin=None) -> bool: """Tag a freshly sealed slot with its coin, mirroring the official app's seal. @@ -5069,22 +5216,13 @@ def _satodime_scan_text(view): class ToolsSatodimeView(View): - VIEW_ADDRESSES = ButtonOption("View Deposit Addresses") - SEAL_SLOT = ButtonOption("Seal Slot") - UNSEAL_SLOT = ButtonOption("Unseal Slot") - SIGN_TX = ButtonOption("Sign Transaction") + """Main Satodime menu: Key Slots, Transfer Ownership, Card Settings.""" + KEY_SLOTS = ButtonOption("Key Slots") TRANSFER = ButtonOption("Transfer Ownership") CARD_SETTINGS = ButtonOption("Card Settings") def run(self): - button_data = [ - self.VIEW_ADDRESSES, - self.SEAL_SLOT, - self.UNSEAL_SLOT, - self.SIGN_TX, - self.TRANSFER, - self.CARD_SETTINGS, - ] + button_data = [self.KEY_SLOTS, self.TRANSFER, self.CARD_SETTINGS] selected_menu_num = self.run_screen( ButtonListScreen, @@ -5096,20 +5234,165 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == self.VIEW_ADDRESSES: - return Destination(ToolsSatodimeAddressesView) - elif button_data[selected_menu_num] == self.SEAL_SLOT: - return Destination(ToolsSatodimeSealSlotView) - elif button_data[selected_menu_num] == self.UNSEAL_SLOT: - return Destination(ToolsSatodimeUnsealSlotView) - elif button_data[selected_menu_num] == self.SIGN_TX: - return Destination(ToolsSatodimeSignTxView) + if button_data[selected_menu_num] == self.KEY_SLOTS: + return Destination(ToolsSatodimeSlotsView) elif button_data[selected_menu_num] == self.TRANSFER: return Destination(ToolsSatodimeTransferOwnershipView) elif button_data[selected_menu_num] == self.CARD_SETTINGS: return Destination(ToolsSatodimeCardSettingsView) +class ToolsSatodimeSlotsView(View): + """The slot list. Reads every keyslot from the card once and caches the result so + that subsequent navigation (per-slot menus, View Address) never has to touch the + card again until a state-changing operation invalidates the cache for that slot. + """ + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + # Use the cache if it's still valid for this session. + if self.controller.satodime_slot_cache: + return self._show_list(self.controller.satodime_slot_cache) + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=False) + if redirect: + return redirect + + self.loading_screen = LoadingScreenThread(text="Fetching Slots\n\n\n\n\n\n") + self.loading_screen.start() + try: + is_testnet = _satodime_is_testnet(self) + self.controller.satodime_slot_cache = _satodime_build_cache(Satochip_Connector, is_testnet) + finally: + self.loading_screen.stop() + + if not self.controller.satodime_slot_cache["slots"]: + self.run_screen( + WarningScreen, + title="Failed", + status_headline=None, + text="No slots found", + show_back_button=True, + ) + return Destination(BackStackView) + + return self._show_list(self.controller.satodime_slot_cache) + + def _show_list(self, cache): + slots_data = cache["slots"] + button_data = [ + ButtonOption(_satodime_slot_label(key_nbr, state, coin, address)) + for key_nbr, (state, coin, address) in enumerate(slots_data) + ] + + selected_menu_num = self.run_screen( + ButtonListScreen, + title="Satodime", + is_button_text_centered=False, + button_data=button_data, + show_back_button=True, + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=selected_menu_num)) + + +class ToolsSatodimeSlotMenuView(View): + """Actions for one keyslot, filtered by its current applet state. + + Reads the slot's state from the Controller cache (built by ToolsSatodimeSlotsView) + so it never touches the card on its own. State-changing action views (seal, unseal, + reset) update the cache entry so returning here after an action reflects the new + state immediately. + """ + SEAL = ButtonOption("Seal Slot") + VIEW_ADDRESS = ButtonOption("View Address") + UNSEAL = ButtonOption("Unseal Slot") + VIEW_PRIVKEY = ButtonOption("View Private Key") + SIGN_TX = ButtonOption("Sign Transaction") + LOAD_KEY = ButtonOption("Load Key to SeedSigner") + RESET = ButtonOption("Reset Slot") + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def run(self): + cached = _satodime_cached_slot(self.controller, self.slot) + if cached is None: + # Cache not built yet (e.g. reached via a stale back stack). Rebuild. + return Destination(ToolsSatodimeSlotsView) + + state, coin, _address = cached + + if state == SATODIME_SLOT_UNKNOWN: + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline="Unknown State", + text="This slot is in\nan unknown state.", + show_back_button=True, + ) + return Destination(BackStackView) + + coin_is_btc = coin is not None and coin.slip44 == satodime_coins.SLIP44_BTC + + actions = [] + if state == SATODIME_SLOT_UNINITIALIZED: + actions = [self.SEAL] + elif state == SATODIME_SLOT_SEALED: + if coin is not None: + actions.append(self.VIEW_ADDRESS) + actions.append(self.UNSEAL) + if coin_is_btc: + actions.append(self.SIGN_TX) + else: # SATODIME_SLOT_UNSEALED + # get_pubkey works on unsealed slots too, so the deposit address is + # still viewable (the key is exposed, but the address is public data). + if coin is not None: + actions.append(self.VIEW_ADDRESS) + actions.append(self.VIEW_PRIVKEY) + if coin_is_btc: + actions.append(self.SIGN_TX) + actions.append(self.LOAD_KEY) + actions.append(self.RESET) + + selected = self.run_screen( + ButtonListScreen, + title=f"Slot {self.slot}", + is_button_text_centered=False, + button_data=actions, + show_back_button=True, + ) + + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + choice = actions[selected] + args = dict(slot=self.slot) + if choice == self.SEAL: + return Destination(ToolsSatodimeSealSlotView, view_args=args) + if choice == self.VIEW_ADDRESS: + return Destination(ToolsSatodimeViewAddressView, view_args=args) + if choice == self.UNSEAL: + return Destination(ToolsSatodimeUnsealSlotView, view_args=args) + if choice == self.VIEW_PRIVKEY: + return Destination(ToolsSatodimeViewPrivateKeyView, view_args=args) + if choice == self.SIGN_TX: + return Destination(ToolsSatodimeSignTxView, view_args=args) + if choice == self.LOAD_KEY: + return Destination(ToolsSatodimeLoadKeyView, view_args=args) + if choice == self.RESET: + return Destination(ToolsSatodimeResetSlotView, view_args=args) + + class ToolsSatodimeCardSettingsView(View): """Card-management functions scoped to a Satodime card. @@ -5186,63 +5469,16 @@ def run(self): return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id)) -class ToolsSatodimeAddressesView(View): - def run(self): - from seedsigner.gui.screens.screen import LoadingScreenThread - - Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) - if not Satochip_Connector: - return Destination(BackStackView) - - redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=False) - if redirect: - return redirect - - self.loading_screen = LoadingScreenThread(text="Fetching Slots\n\n\n\n\n\n") - self.loading_screen.start() - (_, _, _, status) = Satochip_Connector.satodime_get_status() - self.loading_screen.stop() - - max_keys = status.get("max_num_keys", 0) - is_testnet = _satodime_is_testnet(self) - - for key_nbr in range(max_keys): - try: - (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - status_txt = slot_status.get("key_status_txt", "Unknown") - coin = _satodime_slot_coin(slot_status) - if status_txt == "Uninitialized": - # An empty slot holds no key, so the card answers get_pubkey with an - # empty body and pysatochip's parser raises. Don't ask. - text = f"{status_txt}\n\nSeal this slot first" - elif coin is None: - # Sealed by something for a chain the official app has no address - # format for either. Guessing one would invite a deposit nobody's - # wallet can find. - label = slot_status.get("key_slip44_txt", "unknown") - text = f"{status_txt}\n\nUnsupported coin\n{label}" - else: - (_, _, _, _, pub_comp) = Satochip_Connector.satodime_get_pubkey(key_nbr) - address = _satodime_address(pub_comp, coin, is_testnet) - text = f"{status_txt} {coin.symbol}\n{address}" - except Exception as e: - text = str(e) - - ret = self.run_screen( - LargeIconStatusScreen, - title=f"Slot {key_nbr}", - status_headline=None, - text=text, - show_back_button=True, - button_data=[ButtonOption("Next")], - ) - if ret == RET_CODE__BACK_BUTTON: - break +class ToolsSatodimeSealSlotView(View): + """Seal an (uninitialized) keyslot: pick a coin, then mint the key on-card.""" - return Destination(BackStackView) + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) -class ToolsSatodimeSealSlotView(View): def run(self): from seedsigner.gui.screens.screen import LoadingScreenThread @@ -5254,39 +5490,29 @@ def run(self): if redirect: return redirect - (_, _, _, status) = Satochip_Connector.satodime_get_status() - max_keys = status.get("max_num_keys", 0) - - available = [] - button_data = [] - for key_nbr in range(max_keys): - (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - if slot_status.get("key_status_txt") == "Uninitialized": - available.append(key_nbr) - button_data.append(ButtonOption(f"Slot {key_nbr}")) + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state = cached[0] if cached else SATODIME_SLOT_UNKNOWN - if not available: + if state != SATODIME_SLOT_UNINITIALIZED: + # Re-seal of a previously used slot. The applet refuses it (0x9C52) and + # the user needs to understand why: no backup, and re-sealing orphans any + # funds on the old address. self.run_screen( - WarningScreen, - title="Failed", + DireWarningScreen, + title="Cannot Re-Seal", status_headline=None, - text="No uninitialized slots", + text=f"{SATODIME_NO_BACKUP_WARNING}\nThis slot was already sealed.", show_back_button=True, + button_data=[ButtonOption("OK")], ) - return Destination(BackStackView) + return self._done() - selected = self.run_screen( - ButtonListScreen, - title="Select Slot", - is_button_text_centered=False, - button_data=button_data, - show_back_button=True, - ) - - if selected == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - slot = available[selected] + # Sealing mints a new key from system-RNG entropy; refuse (fail closed) if the + # background health monitor has flagged the source, mirroring the password + # generator and image-entropy seed flows. + if not _satodime_require_rng_health(self): + return self._done() # Which chain this vault is for. The card records only the coin, and the # official apps read that back to pick an address format and a balance @@ -5302,46 +5528,115 @@ def run(self): show_back_button=True, ) if coin_choice == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) + return self._done() coin = satodime_coins.SEALABLE_COINS[coin_choice] + # Loud, no skips: a Satodime backs its keys on nothing but the card. If the + # card is lost or destroyed, the funds on this fresh address are gone. + selected = self.run_screen( + DireWarningScreen, + title="No Backup", + status_headline=None, + text=SATODIME_NO_BACKUP_WARNING, + show_back_button=True, + button_data=[ButtonOption("Seal Slot")], + ) + if selected == RET_CODE__BACK_BUTTON: + return self._done() + # Card-side sealing entropy; never logged or persisted (AGENTS security). + # + # Re-check the RNG monitor here as well as at flow entry: it runs continuously + # and can turn unhealthy while the user was stepping through the pickers, and + # this is the moment its bytes actually get folded into the key. The per-draw + # quality check then catches a single low-entropy draw the background monitor + # (one sample/minute) could miss. + if not _satodime_require_rng_health(self): + return self._done() + + from seedsigner.views.tools_views import _ensure_entropy_quality entropy = os.urandom(32) + try: + _ensure_entropy_quality( + entropy, + "System RNG entropy too low.\nTry again later.", + min_entropy=4.0, + ) + except ValueError as e: + self.run_screen( + WarningScreen, + title="System RNG Error", + status_headline=None, + text=str(e), + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + return self._done() self.loading_screen = LoadingScreenThread(text="Sealing Slot\n\n\n\n\n\n") self.loading_screen.start() - (_, sw1, sw2, _, pub_comp) = Satochip_Connector.satodime_seal_key(slot, entropy) + (_, sw1, sw2, _, pub_comp) = Satochip_Connector.satodime_seal_key(self.slot, entropy) self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: - self.run_screen( - WarningScreen, - title="Seal Failed", - status_headline=None, - text=format_sw_error(sw1, sw2), - show_back_button=True, - ) - return Destination(BackStackView) + # 0x9C52 means the slot was somehow not Uninitialized -- e.g. the state + # changed under us. That is the re-seal case again. + if sw1 == 0x9C and sw2 == 0x52: + self.run_screen( + DireWarningScreen, + title="Cannot Re-Seal", + status_headline=None, + text=f"{SATODIME_NO_BACKUP_WARNING}\nThis slot was already sealed.", + show_back_button=True, + button_data=[ButtonOption("OK")], + ) + else: + self.run_screen( + WarningScreen, + title="Seal Failed", + status_headline=None, + text=format_sw_error(sw1, sw2), + show_back_button=True, + ) + return self._done() # Tag the slot with its coin so the official Satodime apps recognise it. # Advisory only: the key is already sealed and its address is valid either # way, so a failure here is logged rather than shown as a failed seal. - _satodime_write_slot_metadata(Satochip_Connector, slot, coin) + _satodime_write_slot_metadata(Satochip_Connector, self.slot, coin) - address = _satodime_address(pub_comp, coin, _satodime_is_testnet(self)) + address = _satodime_address(pub_comp, coin, is_testnet) + + # Update the cache so the slot menu immediately reflects the new state. + if self.controller.satodime_slot_cache: + self.controller.satodime_slot_cache["slots"][self.slot] = ( + SATODIME_SLOT_SEALED, coin, address, + ) self.run_screen( LargeIconStatusScreen, title="Success", status_headline=None, - text=f"Slot {slot} sealed {coin.symbol}\n{address}", + text=f"Slot {self.slot} sealed {coin.symbol}\n{address}", show_back_button=False, ) - return Destination(BackStackView) + return self._done() class ToolsSatodimeUnsealSlotView(View): + """Unseal a sealed keyslot: exposes the key permanently (cannot be re-sealed). + + Only flips the state; the key itself is shown via "View Private Key". + """ + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + def run(self): from seedsigner.gui.screens.screen import LoadingScreenThread @@ -5353,43 +5648,26 @@ def run(self): if redirect: return redirect - (_, _, _, status) = Satochip_Connector.satodime_get_status() - max_keys = status.get("max_num_keys", 0) - - available = [] - button_data = [] - for key_nbr in range(max_keys): - (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - if slot_status.get("key_status_txt") == "Sealed": - available.append(key_nbr) - button_data.append(ButtonOption(f"Slot {key_nbr}")) + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state, _coin, _address = cached if cached else (SATODIME_SLOT_UNKNOWN, None, None) - if not available: + if state != SATODIME_SLOT_SEALED: self.run_screen( WarningScreen, - title="Failed", + title=f"Slot {self.slot}", status_headline=None, - text="No sealed slots", + text="This slot cannot be unsealed\nfrom its current state.", show_back_button=True, ) - return Destination(BackStackView) - - selected = self.run_screen( - ButtonListScreen, - title="Select Slot", - is_button_text_centered=False, - button_data=button_data, - show_back_button=True, - ) - - if selected == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) + return self._done() - slot = available[selected] + if not _satodime_unseal_warning(self, "Unseal Slot"): + return self._done() self.loading_screen = LoadingScreenThread(text="Unsealing Slot\n\n\n\n\n\n") self.loading_screen.start() - (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_unseal_key(slot) + (_, sw1, sw2, _, _priv_list) = Satochip_Connector.satodime_unseal_key(self.slot) self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: @@ -5400,33 +5678,40 @@ def run(self): text=format_sw_error(sw1, sw2), show_back_button=True, ) - return Destination(BackStackView) + return self._done() - # The key's import format follows the slot's coin: WIF for the bitcoin-likes, - # raw hex for the EVM chains, which is what those wallets' "import private - # key" fields take. Getting this wrong hands the user a string their wallet - # rejects, with no way to ask the card again. - (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(slot) - coin = _satodime_slot_coin(slot_status) or satodime_coins.COINS[satodime_coins.SLIP44_BTC] - - # Secret material shown only on this screen; never logged. priv_list is dropped - # as soon as the display returns below (best-effort, AGENTS security). - secret = coin.privkey(bytes(priv_list), _satodime_is_testnet(self)) - del priv_list + # Update the cache: the slot is now unsealed (address is unchanged). + if self.controller.satodime_slot_cache: + _s, coin, address = self.controller.satodime_slot_cache["slots"][self.slot] + self.controller.satodime_slot_cache["slots"][self.slot] = ( + SATODIME_SLOT_UNSEALED, coin, address, + ) self.run_screen( LargeIconStatusScreen, - title=f"Unsealed {coin.symbol}", + title="Unsealed", status_headline=None, - text=secret, - show_back_button=True, + text=f"Slot {self.slot} is now unsealed.\nIt can never be re-sealed.", + show_back_button=False, ) - secret = None - return Destination(BackStackView) + return self._done() class ToolsSatodimeSignTxView(View): + """Sign a Bitcoin PSBT with this slot's key. + + On a Sealed slot unsealing is part of signing (irreversible, so warned); on an + already-Unsealed slot the revealed key is read directly. + """ + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + def run(self): from seedsigner.gui.screens.screen import LoadingScreenThread from seedsigner.models.wif import WIFKey @@ -5440,78 +5725,339 @@ def run(self): if redirect: return redirect - (_, _, _, status) = Satochip_Connector.satodime_get_status() - max_keys = status.get("max_num_keys", 0) + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state, coin, _address = cached if cached else (SATODIME_SLOT_UNKNOWN, None, None) - # Signing is Bitcoin-only: the PSBT flow below is a Bitcoin transaction - # signer, so only Bitcoin slots are offered. Other chains can still be - # viewed and unsealed -- the key is exported and imported elsewhere. - available = [] - button_data = [] - skipped_coins = set() - for key_nbr in range(max_keys): - (_, _, _, slot_status) = Satochip_Connector.satodime_get_keyslot_status(key_nbr) - if slot_status.get("key_status_txt") != "Sealed": - continue - coin = _satodime_slot_coin(slot_status) - if coin is None or coin.slip44 != satodime_coins.SLIP44_BTC: - skipped_coins.add(coin.symbol if coin else "unknown") - continue - available.append(key_nbr) - button_data.append(ButtonOption(f"Slot {key_nbr}")) + # The PSBT flow below is a Bitcoin transaction signer. + if coin is None or coin.slip44 != satodime_coins.SLIP44_BTC: + self.run_screen( + WarningScreen, + title="Not Bitcoin", + status_headline=None, + text="Signing only works with\nBitcoin slots.", + show_back_button=True, + ) + return self._done() - if not available: - if skipped_coins: - text = "Signing is Bitcoin only.\nUnseal to export the key." - else: - text = "No sealed slots" + if state == SATODIME_SLOT_SEALED: + if not _satodime_unseal_warning(self, "Sign & Unseal"): + return self._done() + loading_text = "Unsealing Slot\n\n\n\n\n\n" + fetcher = lambda: Satochip_Connector.satodime_unseal_key(self.slot) + elif state == SATODIME_SLOT_UNSEALED: + loading_text = "Loading Key\n\n\n\n\n\n" + fetcher = lambda: Satochip_Connector.satodime_get_privkey(self.slot) + else: self.run_screen( WarningScreen, - title="No Bitcoin Slots" if skipped_coins else "Failed", + title=f"Slot {self.slot}", status_headline=None, - text=text, + text="This slot cannot be used\nto sign from its current state.", show_back_button=True, ) - return Destination(BackStackView) + return self._done() - selected = self.run_screen( - ButtonListScreen, - title="Select Slot", - is_button_text_centered=False, - button_data=button_data, - show_back_button=True, - ) + self.loading_screen = LoadingScreenThread(text=loading_text) + self.loading_screen.start() + (_, sw1, sw2, _, priv_list) = fetcher() + self.loading_screen.stop() - if selected == RET_CODE__BACK_BUTTON: + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Read Failed", + status_headline=None, + text=format_sw_error(sw1, sw2), + show_back_button=True, + ) + return self._done() + + btc = satodime_coins.COINS[satodime_coins.SLIP44_BTC] + wif = btc.privkey(bytes(priv_list), is_testnet) + del priv_list + + # The WIF-derived key becomes the PSBT signing seed; the standard PSBT flow + # owns and clears controller.psbt_seed on completion / exit (AGENTS security). + self.controller.psbt_seed = WIFKey(wif) + wif = None + + return Destination(ScanPSBTView) + + +class ToolsSatodimeLoadKeyView(View): + """Load an unsealed Bitcoin slot's WIF key into SeedSigner as the signing key. + + The key stays loaded for the session; the next PSBT the user scans will auto-select + it as the signer. Bitcoin-only, like signing. + """ + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + from seedsigner.models.wif import WIFKey + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: return Destination(BackStackView) - slot = available[selected] + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect - self.loading_screen = LoadingScreenThread(text="Unsealing Slot\n\n\n\n\n\n") + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state, coin, _address = cached if cached else (SATODIME_SLOT_UNKNOWN, None, None) + + if (state != SATODIME_SLOT_UNSEALED or coin is None + or coin.slip44 != satodime_coins.SLIP44_BTC): + self.run_screen( + WarningScreen, + title="Not Loadable", + status_headline=None, + text="Only an unsealed Bitcoin\nslot can be loaded.", + show_back_button=True, + ) + return self._done() + + self.loading_screen = LoadingScreenThread(text="Loading Key\n\n\n\n\n\n") self.loading_screen.start() - (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_unseal_key(slot) + (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_get_privkey(self.slot) self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: self.run_screen( WarningScreen, - title="Unseal Failed", + title="Read Failed", status_headline=None, text=format_sw_error(sw1, sw2), show_back_button=True, ) - return Destination(BackStackView) + return self._done() btc = satodime_coins.COINS[satodime_coins.SLIP44_BTC] - wif = btc.privkey(bytes(priv_list), _satodime_is_testnet(self)) + wif = btc.privkey(bytes(priv_list), is_testnet) del priv_list + key = WIFKey(wif) + wif = None + # The WIF-derived key becomes the PSBT signing seed; the standard PSBT flow # owns and clears controller.psbt_seed on completion / exit (AGENTS security). - self.controller.psbt_seed = WIFKey(wif) - wif = None + self.controller.psbt_seed = key - return Destination(ScanPSBTView) + # Show what was loaded so the user can confirm the right slot was picked. + pub_comp = list(key.privkey.get_public_key().sec()) + address = _satodime_address(pub_comp, btc, is_testnet) + + self.run_screen( + LargeIconStatusScreen, + title="Key Loaded", + status_headline=None, + text=f"Slot {self.slot} BTC\n{address}", + show_back_button=False, + ) + + return self._done() + + +class ToolsSatodimeViewAddressView(View): + """Show a slot's deposit address as a QR code (from cache -- zero card reads).""" + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + + def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen + from seedsigner.models.encode_qr import GenericStaticQrEncoder + + _state, coin, address = _satodime_cached_slot(self.controller, self.slot) or (None, None, None) + + if address is None: + if coin is None: + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline="Unsupported Coin", + text="This slot holds a coin\nSeedSigner can't address.", + show_back_button=True, + ) + else: + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline="No Address", + text="Could not read an address\nfor this slot.", + show_back_button=True, + ) + return self._done() + + self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=address)) + + return self._done() + + +class ToolsSatodimeViewPrivateKeyView(View): + """Show an unsealed slot's private key (WIF or hex) as a QR code.""" + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen + from seedsigner.models.encode_qr import GenericStaticQrEncoder + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect + + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state, coin, _address = cached if cached else (SATODIME_SLOT_UNKNOWN, None, None) + + if state != SATODIME_SLOT_UNSEALED: + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline=None, + text="Unseal this slot first to\nview its private key.", + show_back_button=True, + ) + return self._done() + + if coin is None: + # No known format for this slot's coin; refuse rather than guess. + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline="Unsupported Coin", + text="No private key format\nis known for this coin.", + show_back_button=True, + ) + return self._done() + + self.loading_screen = LoadingScreenThread(text="Reading Key\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2, _, priv_list) = Satochip_Connector.satodime_get_privkey(self.slot) + self.loading_screen.stop() + + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Read Failed", + status_headline=None, + text=format_sw_error(sw1, sw2), + show_back_button=True, + ) + return self._done() + + # The key's import format follows the slot's coin: WIF for the bitcoin-likes, + # raw hex for the EVM chains. Secret shown only here; dropped as soon as the + # display returns (AGENTS security). + secret = coin.privkey(bytes(priv_list), is_testnet) + del priv_list + + self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=secret)) + secret = None + + return self._done() + + +class ToolsSatodimeResetSlotView(View): + """Reset an unsealed slot back to the Uninitialized state, erasing its key.""" + + def __init__(self, slot: int = 0): + super().__init__() + self.slot = slot + + def _done(self): + return Destination(ToolsSatodimeSlotMenuView, view_args=dict(slot=self.slot)) + + def run(self): + from seedsigner.gui.screens.screen import LoadingScreenThread + + Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) + if not Satochip_Connector: + return Destination(BackStackView) + + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect + + is_testnet = _satodime_is_testnet(self) + cached = _satodime_cached_slot(self.controller, self.slot) + state = cached[0] if cached else SATODIME_SLOT_UNKNOWN + + if state != SATODIME_SLOT_UNSEALED: + self.run_screen( + WarningScreen, + title=f"Slot {self.slot}", + status_headline=None, + text="Only an unsealed slot\ncan be reset.", + show_back_button=True, + ) + return self._done() + + # Resetting abolishes the key entirely; the card has no backup, so say it loudly. + selected = self.run_screen( + DireWarningScreen, + title="Reset Slot", + status_headline=None, + text=f"{SATODIME_NO_BACKUP_WARNING}\nReset erases this slot's key.", + show_back_button=True, + button_data=[ButtonOption("Reset Slot")], + ) + if selected == RET_CODE__BACK_BUTTON: + return self._done() + + self.loading_screen = LoadingScreenThread(text="Resetting Slot\n\n\n\n\n\n") + self.loading_screen.start() + (_, sw1, sw2) = Satochip_Connector.satodime_reset_key(self.slot) + self.loading_screen.stop() + + if sw1 != 0x90 or sw2 != 0x00: + self.run_screen( + WarningScreen, + title="Reset Failed", + status_headline=None, + text=format_sw_error(sw1, sw2), + show_back_button=True, + ) + return self._done() + + # Update the cache: the slot is now Uninitialized again. + if self.controller.satodime_slot_cache: + self.controller.satodime_slot_cache["slots"][self.slot] = ( + SATODIME_SLOT_UNINITIALIZED, None, None, + ) + + self.run_screen( + LargeIconStatusScreen, + title="Reset", + status_headline=None, + text=f"Slot {self.slot} reset.\nIt can be sealed again.", + show_back_button=False, + ) + + return self._done() class ToolsSatodimeTransferOwnershipView(View): diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 9987c3408..01a59351b 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -78,6 +78,39 @@ def _patch_gpg_verify_file(view): view.controller.gpg_keys_imported = True +class MockSatodimeConnector: + """Cardless Stand-in for the pysatochip Satodime connector. + + Satisfies the slot-centric Satodime menu with three slots defaulting to a given + per-slot key_status byte, so the menu-navigation tests can reach the new views + without a physical card or jcardsim. + """ + setup_done = True + + def __init__(self, states=(0, 0, 0), slip44=(0x80, 0x00, 0x00, 0x00)): + self.states = list(states) + self.slip44 = list(slip44) + + def satodime_get_status(self): + return (b"", 0x90, 0x00, {"max_num_keys": len(self.states)}) + + def satodime_get_keyslot_status(self, key_nbr): + return (b"", 0x90, 0x00, {"key_status": self.states[key_nbr], "key_slip44": self.slip44}) + + def satodime_set_unlock_secret(self, *args, **kwargs): pass + def satodime_set_unlock_counter(self, *args, **kwargs): pass + + +def _patch_satodime_connector(monkeypatch, **kwargs): + """Route init_satochip to a MockSatodimeConnector over a (fake) contact reader.""" + from seedsigner.helpers import seedkeeper_utils + + connector = MockSatodimeConnector(**kwargs) + monkeypatch.setattr(seedkeeper_utils, "init_satochip", lambda *a, **k: connector) + monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: False) + return connector + + class _FakePyGP: """Stand-in for the ``pygp`` native module used by the Javacard DIY views. @@ -685,6 +718,74 @@ def test_smartcard_satodime_card_settings(self): FlowStep(ToolsSatodimeView), ]) + def test_smartcard_satodime_key_slots_uninitialized(self, monkeypatch): + """Tools → Smartcard → Satodime → Key Slots → Slots → (uninitialized) → Seal → BACK. + + Exercises the cached slot list (ToolsSatodimeSlotsView), the cache-driven + ToolsSatodimeSlotMenuView, and ToolsSatodimeSealSlotView run() so a missing + import can't hide behind the menus. + """ + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeSlotsView, + ToolsSatodimeSlotMenuView, ToolsSatodimeSealSlotView, + ) + + _patch_satodime_connector(monkeypatch) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.KEY_SLOTS), + FlowStep(ToolsSatodimeSlotsView, screen_return_value=0), # pick "Slot 0" -> SlotMenu + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # pick "Seal Slot" + FlowStep(ToolsSatodimeSealSlotView, screen_return_value=RET_CODE__BACK_BUTTON), # back out of coin picker + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=RET_CODE__BACK_BUTTON), # back to slot list + FlowStep(ToolsSatodimeSlotsView, screen_return_value=RET_CODE__BACK_BUTTON), # back to main menu + FlowStep(ToolsSatodimeView), + ]) + + def test_smartcard_satodime_key_slots_sealed(self, monkeypatch): + """Tools → Smartcard → Satodime → Key Slots → Sealed slot → action menu → BACK. + + Exercises ToolsSatodimeSlotMenuView run() for a sealed Bitcoin slot (which + offers View Address / Unseal / Sign Transaction), and the run() of + ToolsSatodimeViewAddressView and ToolsSatodimeUnsealSlotView. + """ + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views import smartcard_views + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeSlotsView, + ToolsSatodimeSlotMenuView, ToolsSatodimeViewAddressView, + ToolsSatodimeUnsealSlotView, + ) + + btc = smartcard_views.satodime_coins.COINS[smartcard_views.satodime_coins.SLIP44_BTC] + _patch_satodime_connector(monkeypatch, states=(1, 0, 0)) + monkeypatch.setattr( + smartcard_views, "_satodime_read_slot", + lambda connector, key_nbr, is_testnet: (None, smartcard_views.SATODIME_SLOT_SEALED, btc, "bc1qtest"), + ) + monkeypatch.setattr( + seedkeeper_utils, "satodime_card_id", lambda connector: "test", + ) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.KEY_SLOTS), + FlowStep(ToolsSatodimeSlotsView, screen_return_value=0), # pick "Slot 0" -> SlotMenu + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # "View Address" + FlowStep(ToolsSatodimeViewAddressView, screen_return_value=RET_CODE__BACK_BUTTON), # dismiss QR + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=1), # "Unseal Slot" + FlowStep(ToolsSatodimeUnsealSlotView, screen_return_value=RET_CODE__BACK_BUTTON), # back out of warning + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=RET_CODE__BACK_BUTTON), # back to slot list + FlowStep(ToolsSatodimeSlotsView, screen_return_value=RET_CODE__BACK_BUTTON), # back to main menu + FlowStep(ToolsSatodimeView), + ]) + def test_smartcard_satochip_card_settings(self): """Tools → Smartcard → Satochip → Card Settings → BACK.""" from seedsigner.views.smartcard_views import ( diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 369de0dd8..2dfc4ef12 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -48,6 +48,7 @@ from ui_driver import Back, UISession, select # tools_views must be imported first: it is a facade that star-imports smartcard_views. +from seedsigner.gui.screens import RET_CODE__BACK_BUTTON from seedsigner.views import tools_views from seedsigner.views import smartcard_views from seedsigner.helpers import satodime_coins, seedkeeper_utils @@ -79,6 +80,46 @@ def claim(connector): assert (sw1, sw2) == (0x90, 0x00), f"satodime setup failed: {sw1:#x} {sw2:#x}" +def _seal_slot_zero(): + """Claim + seal slot 0 (BTC) by driving the real views. Call inside ``with ctx:``. + + The seal view now reads slot state from the Controller cache, so we pre-build it + first (by reading slot 0 as uninitialized). The seal view updates the cache on + success, so subsequent views see the new Sealed state. + """ + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run() + + # Pre-populate cache: slot 0 is Uninitialized. + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + seal_view = smartcard_views.ToolsSatodimeSealSlotView(0) + seal_view.run_screen = ScreenRecorder(0, 0, 0) # Seal As (BTC) -> No Backup -> Success + seal_view.run() + + +def _unseal_slot_zero(): + """Unseal slot 0 by driving the real UnsealSlotView. Call inside ``with ctx:``. + + The unseal view reads slot state from the cache; _seal_slot_zero already set the + cache to Sealed. The unseal view updates the cache to Unsealed on success. + """ + view = smartcard_views.ToolsSatodimeUnsealSlotView(0) + view.run_screen = ScreenRecorder(0, 0) # confirm unseal warning -> ack Unsealed + view.run() + + +def _populate_cache(slots): + """Set the controller slot cache directly (avoids a card read in test helpers).""" + from seedsigner.controller import Controller + Controller.get_instance().satodime_slot_cache = { + "card_id": "test", + "max_keys": max(len(slots), 3), + "slots": list(slots) + [(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)] * max(0, 3 - len(slots)), + } + + class ScreenRecorder: """Stand-in for ``View.run_screen`` that records every screen and scripts returns.""" @@ -226,16 +267,14 @@ def test_sealed_pubkey_parses_as_an_embit_key(self, monkeypatch): assert BECH32_ADDRESS.match(address), address -class TestSatodimeAddressesAgainstRealApplet(SatodimeSimulatedFlowTest): - """ - Satodime > View Deposit Addresses renders a real slot screen. +class TestSatodimeSlotListAgainstRealApplet(SatodimeSimulatedFlowTest): + """The slot-centric Satodime menu renders the real slot list. - We render exactly one slot then press BACK, which the view treats as 'stop iterating'. - That exercises satodime_get_status + get_keyslot_status(0) + get_pubkey(0) end to end - without depending on how many slots the applet reports. + We open the list then press BACK, exercising satodime_get_status + get_keyslot_status + end to end without depending on how many slots the applet reports. """ - def test_renders_one_slot(self, monkeypatch): + def test_renders_the_slot_list(self, monkeypatch): try: ctx = simulated_satodime(monkeypatch) except JCardSimUnavailable as exc: @@ -254,8 +293,8 @@ def test_renders_one_slot(self, monkeypatch): pytest.skip("no satodime slots to render") session = UISession(script=( - select(smartcard_views.ToolsSatodimeView.VIEW_ADDRESSES) - + [Back()] # render slot 0, then stop iterating + select(0) # ToolsSatodimeView: "Key Slots" + + [Back()] # open the slot list, then leave it )) self.run_sequence( [ @@ -265,17 +304,17 @@ def test_renders_one_slot(self, monkeypatch): FlowStep(smartcard_views.ToolsSmartcardMenuView, button_data_selection=smartcard_views.ToolsSmartcardMenuView.SATODIME), FlowStep(smartcard_views.ToolsSatodimeView, real_screens=True), - FlowStep(smartcard_views.ToolsSatodimeAddressesView, real_screens=True), + FlowStep(smartcard_views.ToolsSatodimeSlotsView, real_screens=True), FlowStep(smartcard_views.ToolsSatodimeView), ], ui_session=session, ) - def test_empty_slot_says_so_instead_of_leaking_a_parser_error(self, monkeypatch): + def test_empty_slot_labelled_uninitialized_not_a_parser_error(self, monkeypatch): """ A fresh card's slots hold no key, so ``satodime_get_pubkey`` returns an empty - body and pysatochip's parser raises. The view used to call it anyway and paint - the exception text -- which is what the user saw on a brand new Satodime. + body and pysatochip's parser raises. The slot-list build must skip the pubkey + lookup for empty slots and label them Uninitialized -- not paint an exception. """ try: ctx = simulated_satodime(monkeypatch) @@ -285,19 +324,25 @@ def test_empty_slot_says_so_instead_of_leaking_a_parser_error(self, monkeypatch) with ctx as connector: claim(connector) - view = smartcard_views.ToolsSatodimeAddressesView() - recorder = ScreenRecorder(0, 0, 0) # "Next" on each of the 3 slots + view = smartcard_views.ToolsSatodimeSlotsView() + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) view.run_screen = recorder view.run() - assert recorder.titles == ["Slot 0", "Slot 1", "Slot 2"] - for body in recorder.texts: - assert body.startswith("Uninitialized"), body - assert "error" not in body.lower(), body - assert "expected at least" not in body, body + list_cls, list_kwargs = recorder.calls[0] + assert list_cls == "ButtonListScreen" + labels = [opt.button_label for opt in list_kwargs["button_data"]] + for label in labels[:3]: + assert "Uninitialized" in label, label + assert not any("error" in l.lower() for l in labels), labels + assert not any("expected at least" in l for l in labels), labels - def test_sealed_slot_renders_an_address(self, monkeypatch): - """After sealing, the slot screen must show an address -- not an exception.""" + def test_sealed_slot_within_a_real_slot_list(self, monkeypatch): + """ + After sealing, the slot row carries the sealed coin and the address, and + selecting the slot routes into its action menu where "View Address" renders + that address as a QR code -- not an exception. + """ try: ctx = simulated_satodime(monkeypatch) except JCardSimUnavailable as exc: @@ -311,13 +356,26 @@ def test_sealed_slot_renders_an_address(self, monkeypatch): (_, sw1, sw2, _, _) = connector.satodime_seal_key(0, bytes(range(32))) assert (sw1, sw2) == (0x90, 0x00) - view = smartcard_views.ToolsSatodimeAddressesView() - recorder = ScreenRecorder(0, 0, 0) - view.run_screen = recorder + list_view = smartcard_views.ToolsSatodimeSlotsView() + recorder = ScreenRecorder(0) # pick "Slot 0" -> its action menu + list_view.run_screen = recorder + dest = list_view.run() + + labels = [opt.button_label for opt in recorder.calls[0][1]["button_data"]] + assert dest.View_cls is smartcard_views.ToolsSatodimeSlotMenuView + assert dest.view_args == {"slot": 0} + assert "Sealed - BTC" in labels[0], labels[0] + address = labels[0].rsplit(" - ", 1)[-1] + assert BECH32_ADDRESS.match(address), address + + # "View Address" on that slot renders the address as a QR code. + view = smartcard_views.ToolsSatodimeViewAddressView(0) + qr_recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # dismiss the QR + view.run_screen = qr_recorder view.run() - status_line, address = recorder.body_for("Slot 0").split("\n") - assert status_line == "Sealed BTC", "the slot's coin belongs on screen" + qr_call = next(c for c in qr_recorder.calls if c[0] == "QRDisplayScreen") + address = qr_call[1]["qr_encoder"].data assert BECH32_ADDRESS.match(address), address @@ -336,8 +394,8 @@ def test_read_only_view_needs_no_claim_and_no_pin(self): anyway, because it keys off ``setup_done`` alone -- so the user got a PIN keyboard on a card that has no PIN. - Reads also must not force a claim: status, keyslot and pubkey all work on an - unclaimed card, so browsing deposit addresses should just work. + Reads also must not force a claim: the slot list is reachable on an unclaimed + card, so browsing it should just work. """ try: ctx = simulated_satodime_raw() @@ -345,12 +403,13 @@ def test_read_only_view_needs_no_claim_and_no_pin(self): pytest.skip(str(exc)) with ctx: - view = smartcard_views.ToolsSatodimeAddressesView() - recorder = ScreenRecorder(0, 0, 0) + view = smartcard_views.ToolsSatodimeSlotsView() + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) view.run_screen = recorder view.run() - assert recorder.titles == ["Slot 0", "Slot 1", "Slot 2"] + # The slot list is reachable, and nothing ever prompts for a PIN. + assert recorder.titles[0] == "Satodime" for title in recorder.titles: assert "PIN" not in (title or ""), f"Satodime must never ask for a PIN: {title}" @@ -362,7 +421,7 @@ def test_state_change_on_an_unclaimed_card_routes_to_the_claim_view(self): pytest.skip(str(exc)) with ctx: - view = smartcard_views.ToolsSatodimeSealSlotView() + view = smartcard_views.ToolsSatodimeSealSlotView(0) recorder = ScreenRecorder() # no screen should be shown at all view.run_screen = recorder dest = view.run() @@ -442,6 +501,8 @@ def test_seal_after_claiming_shows_an_address(self): This is what would have caught 0x9C04 (no setup ever ran) *and* the bad embit constructor, because it drives the views and asserts on the address the success screen actually renders. + + Sealing must pass through the loud no-backup warning first. """ try: ctx = simulated_satodime_raw() @@ -453,18 +514,253 @@ def test_seal_after_claiming_shows_an_address(self): claim_view.run_screen = ScreenRecorder(0, 0) claim_view.run() - view = smartcard_views.ToolsSatodimeSealSlotView() - # slot picker -> coin picker (BTC is first) -> success screen + # What ToolsSatodimeSlotsView would have cached for a fresh card. + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + view = smartcard_views.ToolsSatodimeSealSlotView(0) + # coin picker (BTC is first) -> no-backup warning -> success recorder = ScreenRecorder(0, 0, 0) view.run_screen = recorder view.run() assert "Seal Failed" not in recorder.titles, recorder.calls - assert recorder.titles[:2] == ["Select Slot", "Seal As"] + assert recorder.titles == ["Seal As", "No Backup", "Success"] + # The no-backup warning is the loud point of this screen. + assert "no backup" in recorder.body_for("No Backup").lower(), recorder.calls headline, address = recorder.body_for("Success").split("\n") assert headline == "Slot 0 sealed BTC" assert BECH32_ADDRESS.match(address), address + def test_reseal_of_a_sealed_slot_is_refused_with_the_warning(self): + """ + The applet only ever seals an Uninitialized slot (it answers 0x9C52 otherwise), + and re-sealing a slot that already holds -- or held -- a key would orphan the + funds and there is no backup. The slot-centric SealSlotView must refuse with the + same loud no-backup warning. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + _seal_slot_zero() + + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0) # ack the Cannot Re-Seal refusal + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Cannot Re-Seal"] + assert "no backup" in recorder.calls[0][1]["text"].lower() + + def test_slot_menu_shows_state_appropriate_actions(self): + """The per-slot action menu only offers actions valid for the slot's state: + [Seal] when uninitialized, [View Address, Unseal, Sign] when sealed, and + [View Address, View Private Key, Sign, Load Key, Reset] when unsealed (BTC).""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + # Uninitialized: only Seal. (Cache as ToolsSatodimeSlotsView would build it.) + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + menu = smartcard_views.ToolsSatodimeSlotMenuView(0) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) + menu.run_screen = recorder + menu.run() + assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == ["Seal Slot"] + + # Sealed BTC: View Address, Unseal, Sign Transaction. + _seal_slot_zero() + menu = smartcard_views.ToolsSatodimeSlotMenuView(0) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) + menu.run_screen = recorder + menu.run() + assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ + "View Address", "Unseal Slot", "Sign Transaction", + ] + + # Unsealed BTC: View Address, View Private Key, Sign Transaction, Load Key, Reset Slot. + _unseal_slot_zero() + menu = smartcard_views.ToolsSatodimeSlotMenuView(0) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) + menu.run_screen = recorder + menu.run() + assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ + "View Address", "View Private Key", "Sign Transaction", + "Load Key to SeedSigner", "Reset Slot", + ] + + def test_unseal_warning_blocks_and_confirms(self): + """ + Unsealing permanently exposes a slot's private key and the applet then refuses + to seal it again. The flow must warn before the unseal APDU, and backing out of + that warning must leave the slot sealed. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + _seal_slot_zero() + + # Backing out of the warning leaves the slot sealed. + view = smartcard_views.ToolsSatodimeUnsealSlotView(0) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # back out of the warning + view.run_screen = recorder + view.run() + + (_, _, _, slot_status) = _fresh_connector().satodime_get_keyslot_status(0) + assert slot_status["key_status_txt"] == "Sealed" + + # Confirming the warning unseals; the slot is then Unsealed. + view = smartcard_views.ToolsSatodimeUnsealSlotView(0) + recorder = ScreenRecorder(0, 0) # confirm warning, ack Unsealed + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Unseal Slot", "Unsealed"] + (_, _, _, slot_status) = _fresh_connector().satodime_get_keyslot_status(0) + assert slot_status["key_status_txt"] == "Unsealed" + + def test_view_private_key_shows_wif_qr(self): + """An unsealed BTC slot's private key is shown as a QR (WIF).""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + _seal_slot_zero() + _unseal_slot_zero() + + view = smartcard_views.ToolsSatodimeViewPrivateKeyView(0) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # dismiss the QR + view.run_screen = recorder + view.run() + + qr_call = next(c for c in recorder.calls if c[0] == "QRDisplayScreen") + # A BTC WIF starts with K or L (mainnet) or c (testnet); never an exception. + assert qr_call[1]["qr_encoder"].data[0] in "KLc" + + def test_load_key_loads_an_unsealed_bitcoin_slot(self): + """ + Load Key to SeedSigner reads an already-unsealed BTC slot's WIF and stages it as + psbt_seed, landing back on the slot's action menu. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + _seal_slot_zero() + _unseal_slot_zero() + + view = smartcard_views.ToolsSatodimeLoadKeyView(0) + recorder = ScreenRecorder(0) # ack "Key Loaded" + view.run_screen = recorder + dest = view.run() + + from seedsigner.models.wif import WIFKey + + assert recorder.titles == ["Key Loaded"] + assert dest.View_cls is smartcard_views.ToolsSatodimeSlotMenuView + assert isinstance(self.controller.psbt_seed, WIFKey) + _, address = recorder.body_for("Key Loaded").split("\n") + assert BECH32_ADDRESS.match(address), address + + def test_reset_slot_clears_an_unsealed_slot(self): + """Resetting an unsealed slot erases its key back to Uninitialized, after the + loud no-backup warning.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + _seal_slot_zero() + _unseal_slot_zero() + + view = smartcard_views.ToolsSatodimeResetSlotView(0) + recorder = ScreenRecorder(0, 0) # confirm Reset warning, ack Reset + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Reset Slot", "Reset"] + (_, _, _, slot_status) = _fresh_connector().satodime_get_keyslot_status(0) + assert slot_status["key_status_txt"] == "Uninitialized" + + def test_seal_fails_closed_when_the_rng_health_monitor_has_failed(self, monkeypatch): + """ + Sealing mints a new key from system-RNG entropy, so it must refuse -- not + warn-and-continue -- when the background RNG health monitor has flagged the + source, exactly like the password generator and image-entropy seed flows. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + monkeypatch.setattr( + type(self.controller), "hardware_rng_is_healthy", property(lambda self: False) + ) + monkeypatch.setattr( + type(self.controller), "hardware_rng_failure_reason", property(lambda self: "test RNG failure") + ) + + with ctx: + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run() + + # What ToolsSatodimeSlotsView would have cached for a fresh card. + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0) # ack the RNG error gate; nothing else should render + view.run_screen = recorder + view.run() + + assert recorder.titles == ["System RNG Error"] + assert "Sealing" not in " ".join(recorder.titles) + + def test_seal_fails_closed_when_the_entropy_draw_is_low_quality(self, monkeypatch): + """ + Belt-and-suspenders over the background monitor (which samples once a minute): + the actual bytes folded into the card's key are sanity-checked, and a + low-entropy draw aborts the seal. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run() + + # What ToolsSatodimeSlotsView would have cached for a fresh card. + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + # Stop the background monitor so its own reads don't consume the patch, + # then make the seal's os.urandom(32) always return a constant block. + if self.controller.rng_monitor_thread: + self.controller.rng_monitor_thread.stop() + monkeypatch.setattr(smartcard_views.os, "urandom", lambda n: b"\x00" * n) + + view = smartcard_views.ToolsSatodimeSealSlotView(0) + # coin picker (BTC) -> no-backup warning -> RNG error gate + recorder = ScreenRecorder(0, 0, 0) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Seal As", "No Backup", "System RNG Error"] + def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): """ Over NFC the applet checks HMAC(unlock_secret, ...), so a claimed card whose @@ -487,7 +783,7 @@ def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): seedkeeper_utils, "satodime_connection_is_contactless", lambda connector: True ) - view = smartcard_views.ToolsSatodimeSealSlotView() + view = smartcard_views.ToolsSatodimeSealSlotView(0) recorder = ScreenRecorder(0) # accept "Restore Code" view.run_screen = recorder dest = view.run() diff --git a/tests/test_split_module_imports.py b/tests/test_split_module_imports.py index 698bb3784..6e8070689 100644 --- a/tests/test_split_module_imports.py +++ b/tests/test_split_module_imports.py @@ -118,10 +118,15 @@ def test_satodime_views_defined(self): for name in ( "ToolsSatodimeView", - "ToolsSatodimeAddressesView", + "ToolsSatodimeSlotsView", + "ToolsSatodimeSlotMenuView", + "ToolsSatodimeViewAddressView", + "ToolsSatodimeViewPrivateKeyView", + "ToolsSatodimeResetSlotView", "ToolsSatodimeSealSlotView", "ToolsSatodimeUnsealSlotView", "ToolsSatodimeSignTxView", + "ToolsSatodimeLoadKeyView", "ToolsSatodimeTransferOwnershipView", "ToolsSatodimeCardSettingsView", ): From 5de830b39de8d298f5bea2db4e667f55deadba49 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 14:34:01 -0400 Subject: [PATCH 06/26] Satodime: hide NDEF, refine slot messaging, and harden the Card PIN skip - Remove Configure NDEF from Satodime Card Settings (NDEF only exists in the unreleased v0.2-beta applet, so offering it just fails on every card). - Rename the slot-menu actions to spell out what each does: Seal Slot (Initialise New Key), View Address (QR), Unseal Slot (View Private Key), View Private Key (QR). - Reword the seal 'no backup' warning into one flowing paragraph and rewrite the reset warning to focus on the unrecoverable loss of the private key; give the re-seal refusal its own constant so it fits the warning screen. - Wipe the cached Satodime slot data when backing out to the smartcard menu (not just Home) so re-entering Key Slots reads fresh state. - init_satochip: never prompt Satodime for a PIN, bind card_pin for the cache step, and stop Satodime from overwriting the cached Satochip PIN (which would otherwise leave set_pin(0, None) for a later Satochip reconnect). - Add a regression test that drives the real init_satochip against a simulated Satodime for genuine check (reproduces the on-device UnboundLocalError without the fix). - jcardsim simulator: refuse to spawn a JVM when free RAM is low (SEEDSIGNER_JCARDSIM_MIN_FREE_RAM_MB, default 3GB); document in AGENTS.md. --- AGENTS.md | 8 +++ src/seedsigner/controller.py | 6 ++ src/seedsigner/helpers/seedkeeper_utils.py | 15 ++++- src/seedsigner/views/smartcard_views.py | 37 +++++------ tests/jcardsim/simulator.py | 63 ++++++++++++++++++- tests/test_flows_menu_navigation.py | 36 ++++++++++- ...st_real_screen_flows_satodime_simulated.py | 32 +++++++++- tests/test_smartcard_card_filter.py | 7 ++- 8 files changed, 175 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 247d31893..990924c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,14 @@ When reviewing test results, focus on **new** failures compared to the baseline. **Note:** The `_msys2_path()` helper in `test_gpg_message.py` auto-detects whether the installed GPG binary is from Git-for-Windows (needs MSYS2-style `/c/...` paths) or native Windows Gpg4win (needs native `C:\...` paths). If GPG tests fail on Windows with a "no writable keyring found" error, check that `_msys2_path()` correctly identifies the installed GPG variant. +### jcardsim RAM guard + +Every simulated card is its own JVM (`tests/jcardsim/simulator.py`, ~250-400MB RSS each). CI runners are fresh VMs running one pytest at a time, but on a developer machine the suite shares RAM with everything else — and several pytests run in parallel will page the whole machine to a freeze. `SimulatedCard.start()` therefore refuses to spawn a JVM when free physical RAM is below **3GB** (measured via `GlobalMemoryStatusEx` / `/proc/meminfo`, no new dependency) and raises `JCardSimUnavailable`, which every jcardsim test already turns into a clean skip. The threshold is overridable with `SEEDSIGNER_JCARDSIM_MIN_FREE_RAM_MB`. + +Consequences: +- On a low-memory machine, jcardsim tests may **skip** with "insufficient free RAM for a jcardsim JVM" — that is expected, not a regression. +- Run only **one pytest process at a time** on a dev machine; the guard makes parallel runs safe (they skip instead of OOMing) but they also make each other slower and less useful. + ### Hardware-in-the-loop smartcard tests `tests/test_smartcard_hardware.py` and `tests/test_flows_smartcard_hardware.py` are **local-only** — no CI job runs them. They self-skip when `pygp` is missing or no reader/card is present, so they are safe to leave in the default suite. diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 4ce7443bb..bbd94698a 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -470,6 +470,7 @@ def start(self, initial_destination: Destination = None, skip_startup_interstiti used. Only used by the test suite. """ from seedsigner.views import MainMenuView, BackStackView, RemoveMicroSDWarningView + from seedsigner.views.smartcard_views import ToolsSmartcardMenuView from seedsigner.views.screensaver import OpeningSplashView from seedsigner.models.settings_definition import SettingsConstants from seedsigner.views.desktop_warning import DesktopWarningView @@ -584,6 +585,11 @@ def run(self): # Always drop the cached Satodime slot data (it's read-only display # state that could go stale across sessions). self.satodime_slot_cache = None + + elif next_destination.View_cls == ToolsSmartcardMenuView: + # Returning to the smartcard menu ends the applet session; drop any + # cached Satodime slot data so re-entering Key Slots reads fresh state. + self.satodime_slot_cache = None logger.info(f"\nback_stack: {self.back_stack}") diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 50739649e..111c2a446 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -657,7 +657,11 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend is_keycard_backend = getattr(Satochip_Connector, "is_keycard_backend", False) - if require_pin: + # Satodime has no PIN (the applet ignores it entirely), so shared views that pass + # require_pin=True must not prompt for one -- see the Satodime branch below. + is_satodime = getattr(Satochip_Connector, "card_type", None) == "Satodime" + + if require_pin and not is_satodime: # Prompt for pin if one hasn't been set, otherwise a cached pin will be used if parentObject.controller.Satochip_PIN is None: print("No Cached pin, prompting for pin") @@ -672,6 +676,9 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend card_pin = list(pin_str.encode("utf-8")) else: card_pin = parentObject.controller.Satochip_PIN + elif is_satodime: + # Satodime has no PIN; bind the variable so the cache step below stays safe. + card_pin = None parentObject.loading_screen = LoadingScreenThread(text="Connecting to Card") parentObject.loading_screen.start() @@ -970,8 +977,10 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend parentObject.controller.Satochip_Connector = Satochip_Connector parentObject.controller.Satochip_Last_UID_SHA1 = Satochip_Connector.UID_SHA1 - # Only cache pin if we are using it - if require_pin: + # Only cache pin if we are using it. Satodime never uses (or overwrites) the + # cached Satochip PIN: wiping it here would make a later reconnect to the same + # Satochip card call set_pin(0, None). + if require_pin and not is_satodime: parentObject.controller.Satochip_PIN = card_pin return parentObject.controller.Satochip_Connector diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index ae94ceb18..f9a7cd5d7 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4648,9 +4648,13 @@ def _satodime_slot_slip44(slot_status) -> int: # card is lost or destroyed, the funds sitting on that slot's address are gone. Both # the seal and re-seal guards below say this loudly. SATODIME_NO_BACKUP_WARNING = ( - "There is no backup for this card.\n" - "If it's lost or destroyed, funds\n" - "are unrecoverable." + "There is no backup for this card. If it's lost or destroyed, funds are unrecoverable." +) + +# The re-seal refusal needs both facts in one message; kept as its own constant so the +# combined text stays within the warning screen's 4-line budget. +SATODIME_RESEAL_REFUSAL_TEXT = ( + "This slot is already sealed. No backup exists: if the card is lost or destroyed, funds are gone." ) @@ -5312,10 +5316,10 @@ class ToolsSatodimeSlotMenuView(View): reset) update the cache entry so returning here after an action reflects the new state immediately. """ - SEAL = ButtonOption("Seal Slot") - VIEW_ADDRESS = ButtonOption("View Address") - UNSEAL = ButtonOption("Unseal Slot") - VIEW_PRIVKEY = ButtonOption("View Private Key") + SEAL = ButtonOption("Seal Slot (Initialise New Key)") + VIEW_ADDRESS = ButtonOption("View Address (QR)") + UNSEAL = ButtonOption("Unseal Slot (View Private Key)") + VIEW_PRIVKEY = ButtonOption("View Private Key (QR)") SIGN_TX = ButtonOption("Sign Transaction") LOAD_KEY = ButtonOption("Load Key to SeedSigner") RESET = ButtonOption("Reset Slot") @@ -5397,12 +5401,12 @@ class ToolsSatodimeCardSettingsView(View): """Card-management functions scoped to a Satodime card. Only the subset of the former 'Common Functions' that Satodime supports is - offered here: Card Info, Genuine Check and Configure NDEF (Change PIN/Label/NFC - and Factory Reset are not implemented by the Satodime applet). + offered here: Card Info and Genuine Check (Change PIN/Label/NFC, Factory Reset + and NDEF are not implemented by the Satodime applet -- NDEF only exists in the + unreleased v0.2-beta applet build, so offering it would just fail on every card). """ INFO = ButtonOption("Card Info") GENUINE = ButtonOption("Genuine Check") - CONFIGURE_NDEF = ButtonOption("Configure NDEF") BACKUP_UNLOCK = ButtonOption("Back Up Unlock Code") RESTORE_UNLOCK = ButtonOption("Restore Unlock Code") @@ -5412,7 +5416,6 @@ def run(self): button_data = [ self.INFO, self.GENUINE, - self.CONFIGURE_NDEF, self.BACKUP_UNLOCK, self.RESTORE_UNLOCK, ] @@ -5433,9 +5436,6 @@ def run(self): elif button_data[selected_menu_num] == self.GENUINE: return Destination(ToolsSmartcardGenuineCheckView, view_args=dict(card_filter=self._CARD_FILTER)) - elif button_data[selected_menu_num] == self.CONFIGURE_NDEF: - return Destination(ToolsCommonNdefView, view_args=dict(card_filter=self._CARD_FILTER)) - elif button_data[selected_menu_num] == self.BACKUP_UNLOCK: # Re-showing the code only works while it is still cached from this # session's claim; the card cannot be asked for it a second time. @@ -5502,7 +5502,7 @@ def run(self): DireWarningScreen, title="Cannot Re-Seal", status_headline=None, - text=f"{SATODIME_NO_BACKUP_WARNING}\nThis slot was already sealed.", + text=SATODIME_RESEAL_REFUSAL_TEXT, show_back_button=True, button_data=[ButtonOption("OK")], ) @@ -5586,7 +5586,7 @@ def run(self): DireWarningScreen, title="Cannot Re-Seal", status_headline=None, - text=f"{SATODIME_NO_BACKUP_WARNING}\nThis slot was already sealed.", + text=SATODIME_RESEAL_REFUSAL_TEXT, show_back_button=True, button_data=[ButtonOption("OK")], ) @@ -6016,12 +6016,13 @@ def run(self): ) return self._done() - # Resetting abolishes the key entirely; the card has no backup, so say it loudly. + # Resetting abolishes the key entirely; there is no backup on the card, so say + # it loudly: the private key itself is gone for good. selected = self.run_screen( DireWarningScreen, title="Reset Slot", status_headline=None, - text=f"{SATODIME_NO_BACKUP_WARNING}\nReset erases this slot's key.", + text="This permanently erases the slot's private key.\nFunds are lost unless you backed up the key.", show_back_button=True, button_data=[ButtonOption("Reset Slot")], ) diff --git a/tests/jcardsim/simulator.py b/tests/jcardsim/simulator.py index 626fb760d..0735d0956 100644 --- a/tests/jcardsim/simulator.py +++ b/tests/jcardsim/simulator.py @@ -63,13 +63,74 @@ def why_unavailable() -> str | None: jar = jcardsim_jar() if not jar.is_file(): return f"jcardsim jar not found at {jar} (set {JCARDSIM_JAR_ENV})" - return None + return _why_under_provisioned() def simulator_available() -> bool: return why_unavailable() is None +# Each simulated card is its own JVM (~250-400MB RSS). CI runners are fresh VMs running +# one pytest at a time, but on a developer machine the suite shares RAM with an IDE, a +# browser and -- if someone runs several pytests in parallel -- other test processes. +# Rather than let that pile-up page the whole machine to a freeze, refuse to spawn a JVM +# when there isn't comfortable headroom: every caller already turns JCardSimUnavailable +# into a clean skip with this reason as its message. +_MIN_FREE_RAM_MB_ENV = "SEEDSIGNER_JCARDSIM_MIN_FREE_RAM_MB" +_DEFAULT_MIN_FREE_RAM_MB = 3072 + + +def _free_physical_ram_mb() -> int | None: + """Free physical RAM in MB, or None where it cannot be determined cheaply.""" + if os.name == "nt": + import ctypes + + class _MemoryStatusEx(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("sullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + status = _MemoryStatusEx() + status.dwLength = ctypes.sizeof(_MemoryStatusEx) + if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): + return None + return int(status.ullAvailPhys // (1024 * 1024)) + + try: + for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines(): + if line.startswith("MemAvailable:"): + return int(line.split()[1]) // 1024 + except (OSError, ValueError, IndexError): + pass + return None + + +def _why_under_provisioned() -> str | None: + """A reason to skip on memory grounds, or None if there is headroom.""" + try: + threshold_mb = int(os.environ.get(_MIN_FREE_RAM_MB_ENV, _DEFAULT_MIN_FREE_RAM_MB)) + except ValueError: + threshold_mb = _DEFAULT_MIN_FREE_RAM_MB + + free_mb = _free_physical_ram_mb() + if free_mb is None: + return None # cannot measure; do not block the suite on a guess + if free_mb < threshold_mb: + return ( + f"insufficient free RAM for a jcardsim JVM ({free_mb}MB free, " + f"{threshold_mb}MB required; raise {_MIN_FREE_RAM_MB_ENV} to override)" + ) + return None + + def _launcher_classes() -> Path: """Compile SimLauncher.java once per session; returns the classes directory.""" out = REPO_ROOT / "tests" / "jcardsim" / "build" / "classes" diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 01a59351b..802869c05 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -739,7 +739,7 @@ def test_smartcard_satodime_key_slots_uninitialized(self, monkeypatch): FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.KEY_SLOTS), FlowStep(ToolsSatodimeSlotsView, screen_return_value=0), # pick "Slot 0" -> SlotMenu - FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # pick "Seal Slot" + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # pick "Seal Slot (Initialise New Key)" FlowStep(ToolsSatodimeSealSlotView, screen_return_value=RET_CODE__BACK_BUTTON), # back out of coin picker FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=RET_CODE__BACK_BUTTON), # back to slot list FlowStep(ToolsSatodimeSlotsView, screen_return_value=RET_CODE__BACK_BUTTON), # back to main menu @@ -786,6 +786,40 @@ def test_smartcard_satodime_key_slots_sealed(self, monkeypatch): FlowStep(ToolsSatodimeView), ]) + def test_smartcard_satodime_cache_wiped_at_smartcard_menu(self, monkeypatch): + """The cached slot data must be dropped when the user backs out to the smartcard menu. + + ToolsSatodimeSlotsView builds controller.satodime_slot_cache; backing all the way + out through the Satodime menu to the smartcard menu must clear it so re-entering + Key Slots reads fresh state from the card (same as returning Home does). + """ + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeSlotsView, + ) + + _patch_satodime_connector(monkeypatch) + + def cache_built(view): + assert self.controller.satodime_slot_cache is not None, \ + "ToolsSatodimeSlotsView should have built the slot cache" + + def cache_wiped(view): + assert self.controller.satodime_slot_cache is None, \ + "backing out to the smartcard menu must drop the cached Satodime session" + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.KEY_SLOTS), + # Build the cache, then back out: Slots -> Satodime menu (still cached) -> smartcard menu. + FlowStep(ToolsSatodimeSlotsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSatodimeView, before_run=cache_built, + screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(ToolsSmartcardMenuView, before_run=cache_wiped, + screen_return_value=RET_CODE__BACK_BUTTON), + ]) + def test_smartcard_satochip_card_settings(self): """Tools → Smartcard → Satochip → Card Settings → BACK.""" from seedsigner.views.smartcard_views import ( diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 2dfc4ef12..0117b4f20 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -413,6 +413,30 @@ def test_read_only_view_needs_no_claim_and_no_pin(self): for title in recorder.titles: assert "PIN" not in (title or ""), f"Satodime must never ask for a PIN: {title}" + def test_genuine_check_on_satodime_neither_prompts_nor_clobbers_the_pin_cache(self): + """ + Genuine Check calls init_satochip with the default require_pin=True. Satodime has + no PIN, so that path must not prompt -- and it must still bind card_pin for the + 'cache the pin' step (an unbound read there raised UnboundLocalError on-device). + A cached Satochip PIN from another card type must survive the Satodime connect. + """ + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + self.controller.Satochip_PIN = [0x31, 0x32, 0x33] + + view = smartcard_views.ToolsSmartcardGenuineCheckView(card_filter=["satodime"]) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON, RET_CODE__BACK_BUTTON) + view.run_screen = recorder + view.run() + + for title in recorder.titles: + assert "PIN" not in (title or ""), f"Satodime must never ask for a PIN: {title}" + assert self.controller.Satochip_PIN == [0x31, 0x32, 0x33] + def test_state_change_on_an_unclaimed_card_routes_to_the_claim_view(self): """Seal needs setup, so it must send the user to claim rather than fail 0x9C04.""" try: @@ -570,7 +594,9 @@ def test_slot_menu_shows_state_appropriate_actions(self): recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) menu.run_screen = recorder menu.run() - assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == ["Seal Slot"] + assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ + "Seal Slot (Initialise New Key)", + ] # Sealed BTC: View Address, Unseal, Sign Transaction. _seal_slot_zero() @@ -579,7 +605,7 @@ def test_slot_menu_shows_state_appropriate_actions(self): menu.run_screen = recorder menu.run() assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ - "View Address", "Unseal Slot", "Sign Transaction", + "View Address (QR)", "Unseal Slot (View Private Key)", "Sign Transaction", ] # Unsealed BTC: View Address, View Private Key, Sign Transaction, Load Key, Reset Slot. @@ -589,7 +615,7 @@ def test_slot_menu_shows_state_appropriate_actions(self): menu.run_screen = recorder menu.run() assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ - "View Address", "View Private Key", "Sign Transaction", + "View Address (QR)", "View Private Key (QR)", "Sign Transaction", "Load Key to SeedSigner", "Reset Slot", ] diff --git a/tests/test_smartcard_card_filter.py b/tests/test_smartcard_card_filter.py index 6b47ad0a9..50a37075d 100644 --- a/tests/test_smartcard_card_filter.py +++ b/tests/test_smartcard_card_filter.py @@ -88,14 +88,13 @@ def test_satodime_card_settings_scopes_to_satodime(self): for option, target in ( (smartcard_views.ToolsSatodimeCardSettingsView.INFO, smartcard_views.ToolsSmartcardInfoView), (smartcard_views.ToolsSatodimeCardSettingsView.GENUINE, smartcard_views.ToolsSmartcardGenuineCheckView), - (smartcard_views.ToolsSatodimeCardSettingsView.CONFIGURE_NDEF, smartcard_views.ToolsCommonNdefView), ): dest = self._route(smartcard_views.ToolsSatodimeCardSettingsView(), option) assert dest.View_cls is target assert dest.view_args["card_filter"] == ["satodime"] def test_satodime_menu_offers_only_supported_settings(self): - """Satodime has no Change PIN/Label/NFC or Factory Reset in its Card Settings.""" + """Satodime has no Change PIN/Label/NFC, Factory Reset or NDEF in its Card Settings.""" captured = {} def capture_only(screen_cls, **kwargs): @@ -111,6 +110,8 @@ def capture_only(screen_cls, **kwargs): labels = [b.button_label for b in captured["button_data"]] assert "Card Info" in labels assert "Genuine Check" in labels - assert "Configure NDEF" in labels + # NDEF only exists in the unreleased Satodime v0.2-beta applet; offering it on + # every card would just fail, so it is not offered at all. + assert "Configure NDEF" not in labels assert "Change PIN" not in labels assert "Factory Reset Card" not in labels From 65e53b49c1ba76002404f8f0112c046667743cb9 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 15:26:44 -0400 Subject: [PATCH 07/26] fix: skip backup navigation loop + regression tests ClaimView and ReshowUnlockView now use skip_current_view=True when forwarding to BackupUnlockView, so BackStackView pops straight back to the parent (slot action / card settings) instead of re-running the previous view in a loop. Also fixed MainMenu cache-wipe timing: Satodime_unlock_secrets must be cached after passing through Home via before_run callback. Added two nav regression tests covering both paths. --- src/seedsigner/views/smartcard_views.py | 12 +++- tests/test_flows_menu_navigation.py | 75 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index f9a7cd5d7..30de4d118 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4953,7 +4953,10 @@ def run(self): ) return Destination(BackStackView) - return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id)) + # ClaimView is a transient redirect; skip_current_view omits it from history so + # BackStackView from the backup flow pops straight back to the view that needed + # the claim (the slot-action view / card settings) instead of re-running this view. + return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id), skip_current_view=True) class ToolsSatodimeBackupUnlockView(View): @@ -5038,7 +5041,7 @@ def run(self): DireWarningScreen, title="Skip Backup?", status_headline=None, - text="Without this code NFC use\nis lost for good.", + text="Without this code, a contact reader is needed to reclaim ownership. NFC-only cards are locked.", show_back_button=True, button_data=[ButtonOption("Skip Anyway")], ) @@ -5466,7 +5469,10 @@ def run(self): ) return Destination(BackStackView) - return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id)) + # Transient dispatcher: omit this view from history so the backup flow's + # BackStackView lands back on Card Settings, not on this forwarding view + # (which would re-show the unlock-code menu in a loop). + return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id), skip_current_view=True) class ToolsSatodimeSealSlotView(View): diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 802869c05..2fd1d85c6 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -86,10 +86,12 @@ class MockSatodimeConnector: without a physical card or jcardsim. """ setup_done = True + UID_SHA1 = "aabbccddeeff0011" def __init__(self, states=(0, 0, 0), slip44=(0x80, 0x00, 0x00, 0x00)): self.states = list(states) self.slip44 = list(slip44) + self.unlock_secret = list(range(20)) def satodime_get_status(self): return (b"", 0x90, 0x00, {"max_num_keys": len(self.states)}) @@ -100,6 +102,10 @@ def satodime_get_keyslot_status(self, key_nbr): def satodime_set_unlock_secret(self, *args, **kwargs): pass def satodime_set_unlock_counter(self, *args, **kwargs): pass + def card_setup(self, *args, **kwargs): + self.setup_done = True + return (b"", 0x90, 0x00) + def _patch_satodime_connector(monkeypatch, **kwargs): """Route init_satochip to a MockSatodimeConnector over a (fake) contact reader.""" @@ -718,6 +724,75 @@ def test_smartcard_satodime_card_settings(self): FlowStep(ToolsSatodimeView), ]) + def test_smartcard_satodime_backup_unlock_skip_returns_to_card_settings(self, monkeypatch): + """Card Settings → Back Up Unlock Code → skip backup → returns to Card Settings. + + Regression: the backup flow used to terminate with BackStackView, which pops TWO + views. Because ReshowUnlockView forwarded to BackupUnlockView without + skip_current_view, that double-pop re-ran ReshowUnlockView, which re-read the card + ("connecting to card") and forwarded straight back to the unlock-code menu -- + an endless loop. Marking the forward as skip_current_view lets BackStackView pop + straight back to Card Settings. + """ + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeCardSettingsView, + ToolsSatodimeReshowUnlockView, ToolsSatodimeBackupUnlockView, + ) + + connector = _patch_satodime_connector(monkeypatch) + card_id = seedkeeper_utils.satodime_card_id(connector) + + # The controller wipes Satodime_unlock_secrets when it routes through Home, so + # cache the secret just before ReshowUnlockView reads it (not before run_sequence). + def cache_secret(view): + seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, list(range(20))) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CARD_SETTINGS), + FlowStep(ToolsSatodimeCardSettingsView, button_data_selection=ToolsSatodimeCardSettingsView.BACKUP_UNLOCK, before_run=cache_secret), + FlowStep(ToolsSatodimeReshowUnlockView, is_redirect=True), + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # Skip Verification + FlowStep(ToolsSatodimeCardSettingsView), # back where we started, no loop + ]) + + def test_smartcard_satodime_claim_skip_returns_to_slot_menu(self, monkeypatch): + """Seal on an unclaimed card → claim → skip backup → back to the slot action. + + Regression: the claim→backup is a two-view workflow. Without skip_current_view on + the ClaimView→BackupUnlockView forward, BackStackView from the backup flow popped + two views and re-ran ClaimView ("connecting to card", "Already Claimed") before + finally reaching the slot action. The forward must be skip_current_view so the + backup's BackStackView lands straight back on the slot action (which, now that the + card is claimed, continues the seal). + """ + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeSlotsView, + ToolsSatodimeSlotMenuView, ToolsSatodimeSealSlotView, + ToolsSatodimeClaimView, ToolsSatodimeBackupUnlockView, + ) + + connector = _patch_satodime_connector(monkeypatch) + connector.setup_done = False # unclaimed card -> seal routes to the claim flow + monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.KEY_SLOTS), + FlowStep(ToolsSatodimeSlotsView, screen_return_value=0), # pick "Slot 0" -> SlotMenu + FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # pick "Seal Slot" + FlowStep(ToolsSatodimeSealSlotView, is_redirect=True), # unclaimed -> ClaimView + FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Claim Card" + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # Skip Verification + FlowStep(ToolsSatodimeSealSlotView), # back on the slot action, no loop + ]) + def test_smartcard_satodime_key_slots_uninitialized(self, monkeypatch): """Tools → Smartcard → Satodime → Key Slots → Slots → (uninitialized) → Seal → BACK. From 82da6ff8bd1252f5847bfb296994959d18488b93 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 15:30:14 -0400 Subject: [PATCH 08/26] feat: add Claim Ownership button to Satodime main menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct access to the claim flow from Tools → Smartcard → Satodime, instead of requiring the user to navigate through Key Slots → Seal Slot. Shows 'Already Claimed' warning if the card has an owner. --- src/seedsigner/views/smartcard_views.py | 7 +++++-- tests/test_flows_menu_navigation.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 30de4d118..0b8927e90 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5223,13 +5223,14 @@ def _satodime_scan_text(view): class ToolsSatodimeView(View): - """Main Satodime menu: Key Slots, Transfer Ownership, Card Settings.""" + """Main Satodime menu: Key Slots, Claim Ownership, Transfer Ownership, Card Settings.""" KEY_SLOTS = ButtonOption("Key Slots") + CLAIM_OWNERSHIP = ButtonOption("Claim Ownership") TRANSFER = ButtonOption("Transfer Ownership") CARD_SETTINGS = ButtonOption("Card Settings") def run(self): - button_data = [self.KEY_SLOTS, self.TRANSFER, self.CARD_SETTINGS] + button_data = [self.KEY_SLOTS, self.CLAIM_OWNERSHIP, self.TRANSFER, self.CARD_SETTINGS] selected_menu_num = self.run_screen( ButtonListScreen, @@ -5243,6 +5244,8 @@ def run(self): if button_data[selected_menu_num] == self.KEY_SLOTS: return Destination(ToolsSatodimeSlotsView) + elif button_data[selected_menu_num] == self.CLAIM_OWNERSHIP: + return Destination(ToolsSatodimeClaimView) elif button_data[selected_menu_num] == self.TRANSFER: return Destination(ToolsSatodimeTransferOwnershipView) elif button_data[selected_menu_num] == self.CARD_SETTINGS: diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 2fd1d85c6..821a0b693 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -724,6 +724,23 @@ def test_smartcard_satodime_card_settings(self): FlowStep(ToolsSatodimeView), ]) + def test_smartcard_satodime_claim_ownership_from_menu(self, monkeypatch): + """Tools → Smartcard → Satodime → Claim Ownership → already claimed warning.""" + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeClaimView, + ) + + _patch_satodime_connector(monkeypatch) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), + FlowStep(ToolsSatodimeClaimView, screen_return_value=RET_CODE__BACK_BUTTON), # "Already Claimed" + FlowStep(ToolsSatodimeView), + ]) + def test_smartcard_satodime_backup_unlock_skip_returns_to_card_settings(self, monkeypatch): """Card Settings → Back Up Unlock Code → skip backup → returns to Card Settings. From 1c377d4d1c886f9b624964c84541c554fb34bcae Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 15:38:28 -0400 Subject: [PATCH 09/26] refactor: rename 'unlock code' to 'ownership key' for Satodime All user-facing strings now use 'ownership key' terminology: - Card Settings buttons: Back Up/Restore Ownership Key - Screen titles: Ownership Key, No Ownership Key, Ownership Key Set - Warning texts updated accordingly --- src/seedsigner/views/smartcard_views.py | 36 +++++++++---------- ...st_real_screen_flows_satodime_simulated.py | 12 +++---- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 0b8927e90..cc5cd0cc0 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4860,11 +4860,11 @@ def _satodime_prepare(view, connector, needs_unlock: bool): # than letting the operation fail with a status word. selected = view.run_screen( WarningScreen, - title="Code Required", + title="Key Required", status_headline=None, - text="NFC needs this card's\nunlock code.", + text="NFC needs this card's\nownership key.", show_back_button=True, - button_data=[ButtonOption("Restore Code")], + button_data=[ButtonOption("Restore Key")], ) if selected == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -4874,7 +4874,7 @@ def _satodime_prepare(view, connector, needs_unlock: bool): class ToolsSatodimeClaimView(View): - """Claim an unowned Satodime, then walk the user through backing up its unlock code. + """Claim an unowned Satodime, then walk the user through backing up its ownership key. INS_SETUP is the only time the card ever emits its 20-byte unlock secret. On a contactless reader that secret is required for every later state change -- seal, @@ -4960,10 +4960,10 @@ def run(self): class ToolsSatodimeBackupUnlockView(View): - """Show the unlock code as a QR and make the user prove they captured it. + """Show the ownership key as a QR and make the user prove they captured it. The read-back is the point: a QR the user never scanned is a backup they cannot be - sure they have. They photograph the code, then hold the photo up to the camera. + sure they have. They photograph the key, then hold the photo up to the camera. MicroSD is offered as a second copy, not as a substitute. """ @@ -4984,7 +4984,7 @@ def run(self): if not secret: self.run_screen( WarningScreen, - title="No Unlock Code", + title="No Ownership Key", status_headline=None, text="Claim the card first.", show_back_button=True, @@ -4995,7 +4995,7 @@ def run(self): self.run_screen( DireWarningScreen, - title="Unlock Code", + title="Ownership Key", status_headline=None, text="Back this up now. It can\nnever be shown again.", show_back_button=False, @@ -5008,7 +5008,7 @@ def run(self): WarningScreen, title="Not Theft Proof", status_headline=None, - text="A contact reader can unseal\nthis card without the code.", + text="A contact reader can unseal\nthis card without this key.", show_back_button=False, button_data=[ButtonOption("I Understand")], ) @@ -5041,7 +5041,7 @@ def run(self): DireWarningScreen, title="Skip Backup?", status_headline=None, - text="Without this code, a contact reader is needed to reclaim ownership. NFC-only cards are locked.", + text="Without this key, a contact reader is needed to reclaim ownership. NFC-only cards are locked.", show_back_button=True, button_data=[ButtonOption("Skip Anyway")], ) @@ -5063,7 +5063,7 @@ def run(self): WarningScreen, title="No Match", status_headline=None, - text="That is not this card's\nunlock code.", + text="That is not this card's\nownership key.", show_back_button=False, button_data=[ButtonOption("Try Again")], ) @@ -5114,7 +5114,7 @@ def _save_to_microsd(self, card_id: str, payload: str): class ToolsSatodimeRestoreUnlockView(View): - """Load a previously backed-up unlock code back into this session.""" + """Load a previously backed-up ownership key back into this session.""" SCAN = ButtonOption("Scan Backup QR") MICROSD = ButtonOption("Load from MicroSD") @@ -5128,7 +5128,7 @@ def run(self): selected = self.run_screen( ButtonListScreen, - title="Unlock Code", + title="Ownership Key", is_button_text_centered=False, button_data=[self.SCAN, self.MICROSD], show_back_button=True, @@ -5150,7 +5150,7 @@ def run(self): WarningScreen, title="Not a Backup", status_headline=None, - text="That is not a Satodime\nunlock code.", + text="That is not a Satodime\nownership key.", show_back_button=True, ) return Destination(BackStackView) @@ -5169,7 +5169,7 @@ def run(self): seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, secret) self.run_screen( LargeIconStatusScreen, - title="Unlock Code Set", + title="Ownership Key Set", status_headline=None, text="Loaded for this session.", show_back_button=False, @@ -5413,8 +5413,8 @@ class ToolsSatodimeCardSettingsView(View): """ INFO = ButtonOption("Card Info") GENUINE = ButtonOption("Genuine Check") - BACKUP_UNLOCK = ButtonOption("Back Up Unlock Code") - RESTORE_UNLOCK = ButtonOption("Restore Unlock Code") + BACKUP_UNLOCK = ButtonOption("Back Up Ownership Key") + RESTORE_UNLOCK = ButtonOption("Restore Ownership Key") _CARD_FILTER = ["satodime"] @@ -5465,7 +5465,7 @@ def run(self): if not seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id): self.run_screen( WarningScreen, - title="No Unlock Code", + title="No Ownership Key", status_headline=None, text="The card only reveals it\nwhen first claimed.", show_back_button=True, diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 0117b4f20..fff1bf137 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -810,11 +810,11 @@ def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeSealSlotView(0) - recorder = ScreenRecorder(0) # accept "Restore Code" + recorder = ScreenRecorder(0) # accept "Restore Key" view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Code Required"] + assert recorder.titles == ["Key Required"] assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView @@ -892,7 +892,7 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): view.run() assert recorder.titles == [ - "Unlock Code", "Not Theft Proof", None, "Verify Backup", "Backup Verified", + "Ownership Key", "Not Theft Proof", None, "Verify Backup", "Backup Verified", ] def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): @@ -927,7 +927,7 @@ def test_backup_refuses_when_nothing_is_cached(self): view.run_screen = recorder view.run() - assert recorder.titles == ["No Unlock Code"] + assert recorder.titles == ["No Ownership Key"] def test_restore_rejects_another_card_s_backup(self, monkeypatch): try: @@ -944,7 +944,7 @@ def test_restore_rejects_another_card_s_backup(self, monkeypatch): view.run_screen = recorder view.run() - assert recorder.titles == ["Unlock Code", "Wrong Card"] + assert recorder.titles == ["Ownership Key", "Wrong Card"] assert not (self.controller.Satodime_unlock_secrets or {}) def test_restore_loads_this_card_s_backup(self, monkeypatch): @@ -963,7 +963,7 @@ def test_restore_loads_this_card_s_backup(self, monkeypatch): view.run_screen = recorder view.run() - assert recorder.titles == ["Unlock Code", "Unlock Code Set"] + assert recorder.titles == ["Ownership Key", "Ownership Key Set"] assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET From cddbfd3ad75ef223ac35158f3dd4b70b6bd0d40f Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 15:43:18 -0400 Subject: [PATCH 10/26] fix: ReshowUnlockView distinguishes contact reader from lost key Contact readers never need the ownership key, so show 'Not Applicable' instead of 'The card only reveals it when first claimed.' NFC cards still get the original message explaining the key was lost. --- src/seedsigner/views/smartcard_views.py | 27 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index cc5cd0cc0..034b7cd48 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5462,14 +5462,25 @@ def run(self): return Destination(BackStackView) card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if not seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id): - self.run_screen( - WarningScreen, - title="No Ownership Key", - status_headline=None, - text="The card only reveals it\nwhen first claimed.", - show_back_button=True, - ) + cached_secret = seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) + if not cached_secret: + # Contact readers never need the ownership key; NFC cards only reveal it once. + if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): + self.run_screen( + WarningScreen, + title="No Ownership Key", + status_headline=None, + text="The card only reveals it\nwhen first claimed.", + show_back_button=True, + ) + else: + self.run_screen( + WarningScreen, + title="Not Applicable", + status_headline=None, + text="Contact readers do not use\nthe ownership key.", + show_back_button=True, + ) return Destination(BackStackView) # Transient dispatcher: omit this view from history so the backup flow's From 6c0399c6e5d9d65d3320717ec7c93fdf2097d94b Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 16:05:02 -0400 Subject: [PATCH 11/26] fix: block ownership key operations on contact readers Claim, Back Up, and Restore ownership key now show 'Not Applicable' when connected via a contact reader, since the applet never uses the ownership key over that interface. Tests updated to simulate NFC. --- src/seedsigner/views/smartcard_views.py | 57 +++++++++++++------ tests/test_flows_menu_navigation.py | 2 + ...st_real_screen_flows_satodime_simulated.py | 4 ++ 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 034b7cd48..3b5b4bae6 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4890,6 +4890,17 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) + # Claiming is only meaningful over NFC — contact readers never use the ownership key. + if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): + self.run_screen( + WarningScreen, + title="Not Applicable", + status_headline=None, + text="Contact readers do not use\nthe ownership key.", + show_back_button=True, + ) + return Destination(BackStackView) + if _satodime_is_claimed(Satochip_Connector): self.run_screen( WarningScreen, @@ -5124,6 +5135,17 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) + # Restoring the ownership key is only meaningful over NFC. + if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): + self.run_screen( + WarningScreen, + title="Not Applicable", + status_headline=None, + text="Contact readers do not use\nthe ownership key.", + show_back_button=True, + ) + return Destination(BackStackView) + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) selected = self.run_screen( @@ -5461,26 +5483,27 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) + # Backing up the ownership key is only meaningful over NFC. + if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): + self.run_screen( + WarningScreen, + title="Not Applicable", + status_headline=None, + text="Contact readers do not use\nthe ownership key.", + show_back_button=True, + ) + return Destination(BackStackView) + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) cached_secret = seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) if not cached_secret: - # Contact readers never need the ownership key; NFC cards only reveal it once. - if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): - self.run_screen( - WarningScreen, - title="No Ownership Key", - status_headline=None, - text="The card only reveals it\nwhen first claimed.", - show_back_button=True, - ) - else: - self.run_screen( - WarningScreen, - title="Not Applicable", - status_headline=None, - text="Contact readers do not use\nthe ownership key.", - show_back_button=True, - ) + self.run_screen( + WarningScreen, + title="No Ownership Key", + status_headline=None, + text="The card only reveals it\nwhen first claimed.", + show_back_button=True, + ) return Destination(BackStackView) # Transient dispatcher: omit this view from history so the backup flow's diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 821a0b693..bd85989e2 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -758,6 +758,8 @@ def test_smartcard_satodime_backup_unlock_skip_returns_to_card_settings(self, mo ) connector = _patch_satodime_connector(monkeypatch) + # Simulate NFC — the backup flow is only available over contactless. + monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) card_id = seedkeeper_utils.satodime_card_id(connector) # The controller wipes Satodime_unlock_secrets when it routes through Home, so diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index fff1bf137..01bffcce9 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -935,6 +935,8 @@ def test_restore_rejects_another_card_s_backup(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) + # Simulate NFC — the restore flow is only available over contactless. + monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) other = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET) monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) @@ -953,6 +955,8 @@ def test_restore_loads_this_card_s_backup(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) + # Simulate NFC — the restore flow is only available over contactless. + monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) with ctx: card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) From 25cbe59fc0a1f9bab2280e5d6ccb088e2dea3aff Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 16:10:38 -0400 Subject: [PATCH 12/26] ui: rename unseal button to 'Unseal Slot (Access Private Key)' --- src/seedsigner/views/smartcard_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 3b5b4bae6..4b9476a18 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5346,7 +5346,7 @@ class ToolsSatodimeSlotMenuView(View): """ SEAL = ButtonOption("Seal Slot (Initialise New Key)") VIEW_ADDRESS = ButtonOption("View Address (QR)") - UNSEAL = ButtonOption("Unseal Slot (View Private Key)") + UNSEAL = ButtonOption("Unseal Slot (Access Private Key)") VIEW_PRIVKEY = ButtonOption("View Private Key (QR)") SIGN_TX = ButtonOption("Sign Transaction") LOAD_KEY = ButtonOption("Load Key to SeedSigner") From dbae759fdfe7e2cc290c00b98c365bd900b83b94 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Wed, 9 Sep 2026 22:39:42 -0400 Subject: [PATCH 13/26] Satodime: assume contact, prompt for ownership key when the card asks The connection medium cannot be detected reliably at the PC/SC layer (dual-interface readers report T=1 for both contact and NFC), so stop guessing. Whatever secret is cached (or the zeroed placeholder) is sent; over contact the applet skips the unlock check entirely, over NFC it rejects with 0x9C50/0x9C51 and the caller now turns that into a 'Key Required -> Restore Key' prompt. - Remove satodime_connection_is_contactless + reader-name heuristics - Remove 'Not Applicable' blocks from Claim/Reshow/Restore views - Claim always offers the backup flow (user can skip; medium unknown) - Wire _satodime_handle_unlock_error into seal/unseal/sign/reset/transfer - Tests: NFC rejection now faked at connector level (jcardsim is contact); drop TestContactlessDetection; fix stale unseal label assertion --- src/seedsigner/helpers/seedkeeper_utils.py | 27 ---- src/seedsigner/views/smartcard_views.py | 118 ++++++++--------- tests/test_flows_menu_navigation.py | 6 +- ...st_real_screen_flows_satodime_simulated.py | 123 ++++-------------- 4 files changed, 79 insertions(+), 195 deletions(-) diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 111c2a446..3ad4625f4 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -533,33 +533,6 @@ def parse_satodime_unlock_payload(text: str): return (card_id, list(secret)) -# Reader-name fragments that mean "this connection is contactless". The Satodime -# applet keys its unlock-code enforcement off the APDU protocol media, not off any -# setting, so the medium of the *actual* connection is what matters -- and PN532 is -# enabled by default, which makes the interface setting alone useless as a signal. -CONTACTLESS_READER_MARKERS = ("nfc", "pn532", "pn53", "acr122", "contactless", "rc522") - - -def satodime_connection_is_contactless(connector) -> bool: - """Whether this card is talking to us over a contactless reader. - - Matters because the applet skips the unlock-code check entirely on a contact - interface: over USB the zeroed placeholder secret is accepted, so there is nothing - to back up and nothing to restore. Over NFC the same operations need the real - 20-byte secret. - - Fails safe: when the reader cannot be identified we answer True, so the user is - offered the backup rather than silently left without one. - """ - try: - name = connector.cardservice.connection.getReader() - except Exception: - return True - if not name: - return True - name = str(name).lower() - return any(marker in name for marker in CONTACTLESS_READER_MARKERS) - def satodime_unlock_backup_filename(card_id: str) -> str: """Deterministic name, so a restore can find the file without the user typing it.""" diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 4b9476a18..1baef0b14 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4842,37 +4842,47 @@ def _satodime_prepare(view, connector, needs_unlock: bool): Read-only views (status, keyslot, pubkey) pass False -- they work on an unclaimed card and over either medium, so they must not drag the user through a claim. + The connection medium is never assumed: whatever secret is cached (or the zeroed + placeholder) is applied and the operation proceeds. Over contact the applet skips + the unlock check entirely; over NFC without a real key it rejects with 0x9C51, + which the caller turns into a restore prompt (_satodime_handle_unlock_error). + Returns a ``Destination`` to redirect to, or None to carry on. """ if needs_unlock and not _satodime_is_claimed(connector): return Destination(ToolsSatodimeClaimView) - have_secret = seedkeeper_utils.apply_satodime_unlock_secret(view.controller, connector) + seedkeeper_utils.apply_satodime_unlock_secret(view.controller, connector) connector.satodime_set_unlock_counter() - if ( - needs_unlock - and not have_secret - and seedkeeper_utils.satodime_connection_is_contactless(connector) - ): - # Over NFC the applet checks HMAC(unlock_secret, ...), so the zeroed - # placeholder would just earn a 0x9C51. Send the user to restore it rather - # than letting the operation fail with a status word. - selected = view.run_screen( - WarningScreen, - title="Key Required", - status_headline=None, - text="NFC needs this card's\nownership key.", - show_back_button=True, - button_data=[ButtonOption("Restore Key")], - ) - if selected == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - return Destination(ToolsSatodimeRestoreUnlockView) - return None +def _satodime_handle_unlock_error(view, sw1: int, sw2: int): + """Turn an NFC unlock rejection into a restore prompt. + + The applet answers 0x9C50 (wrong counter) or 0x9C51 (wrong code) when it is on a + contactless reader and the zeroed placeholder secret was sent instead of the real + ownership key. Over contact these status words never occur, so this is a no-op + there. Returns a ``Destination`` to redirect to when the error was an unlock + failure (restore flow, or back out), else None for any other status word. + """ + if sw1 != 0x9C or sw2 not in (0x50, 0x51): + return None + + selected = view.run_screen( + WarningScreen, + title="Key Required", + status_headline=None, + text="This card needs its\nownership key.", + show_back_button=True, + button_data=[ButtonOption("Restore Key")], + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + return Destination(ToolsSatodimeRestoreUnlockView) + + class ToolsSatodimeClaimView(View): """Claim an unowned Satodime, then walk the user through backing up its ownership key. @@ -4890,17 +4900,6 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - # Claiming is only meaningful over NFC — contact readers never use the ownership key. - if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): - self.run_screen( - WarningScreen, - title="Not Applicable", - status_headline=None, - text="Contact readers do not use\nthe ownership key.", - show_back_button=True, - ) - return Destination(BackStackView) - if _satodime_is_claimed(Satochip_Connector): self.run_screen( WarningScreen, @@ -4952,18 +4951,10 @@ def run(self): self.controller, card_id, list(Satochip_Connector.unlock_secret) ) - if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): - # Contact reader: the applet never checks the unlock code, so the secret - # buys the user nothing here and the backup flow would be pure friction. - self.run_screen( - LargeIconStatusScreen, - title="Card Claimed", - status_headline=None, - text="Ready to use.", - show_back_button=False, - ) - return Destination(BackStackView) - + # Always offer the backup: over NFC this key is required for every later state + # change and cannot be re-read; over contact it buys nothing but the user can + # skip. The medium cannot be detected reliably (dual-interface readers), so we + # err on the side of offering rather than silently skipping. # ClaimView is a transient redirect; skip_current_view omits it from history so # BackStackView from the backup flow pops straight back to the view that needed # the claim (the slot-action view / card settings) instead of re-running this view. @@ -5135,17 +5126,6 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - # Restoring the ownership key is only meaningful over NFC. - if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): - self.run_screen( - WarningScreen, - title="Not Applicable", - status_headline=None, - text="Contact readers do not use\nthe ownership key.", - show_back_button=True, - ) - return Destination(BackStackView) - card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) selected = self.run_screen( @@ -5483,17 +5463,6 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) - # Backing up the ownership key is only meaningful over NFC. - if not seedkeeper_utils.satodime_connection_is_contactless(Satochip_Connector): - self.run_screen( - WarningScreen, - title="Not Applicable", - status_headline=None, - text="Contact readers do not use\nthe ownership key.", - show_back_button=True, - ) - return Destination(BackStackView) - card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) cached_secret = seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) if not cached_secret: @@ -5622,6 +5591,9 @@ def run(self): self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: + redirect = _satodime_handle_unlock_error(self, sw1, sw2) + if redirect: + return redirect # 0x9C52 means the slot was somehow not Uninitialized -- e.g. the state # changed under us. That is the re-seal case again. if sw1 == 0x9C and sw2 == 0x52: @@ -5714,6 +5686,9 @@ def run(self): self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: + redirect = _satodime_handle_unlock_error(self, sw1, sw2) + if redirect: + return redirect self.run_screen( WarningScreen, title="Unseal Failed", @@ -5807,6 +5782,9 @@ def run(self): self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: + redirect = _satodime_handle_unlock_error(self, sw1, sw2) + if redirect: + return redirect self.run_screen( WarningScreen, title="Read Failed", @@ -6078,9 +6056,12 @@ def run(self): self.loading_screen.stop() if sw1 != 0x90 or sw2 != 0x00: + redirect = _satodime_handle_unlock_error(self, sw1, sw2) + if redirect: + return redirect self.run_screen( WarningScreen, - title="Reset Failed", + title="Read Failed", status_headline=None, text=format_sw_error(sw1, sw2), show_back_button=True, @@ -6130,6 +6111,9 @@ def run(self): show_back_button=False, ) else: + redirect = _satodime_handle_unlock_error(self, sw1, sw2) + if redirect: + return redirect self.run_screen( WarningScreen, title="Transfer Failed", diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index bd85989e2..4e6163e23 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -108,12 +108,11 @@ def card_setup(self, *args, **kwargs): def _patch_satodime_connector(monkeypatch, **kwargs): - """Route init_satochip to a MockSatodimeConnector over a (fake) contact reader.""" + """Route init_satochip to a MockSatodimeConnector.""" from seedsigner.helpers import seedkeeper_utils connector = MockSatodimeConnector(**kwargs) monkeypatch.setattr(seedkeeper_utils, "init_satochip", lambda *a, **k: connector) - monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: False) return connector @@ -758,8 +757,6 @@ def test_smartcard_satodime_backup_unlock_skip_returns_to_card_settings(self, mo ) connector = _patch_satodime_connector(monkeypatch) - # Simulate NFC — the backup flow is only available over contactless. - monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) card_id = seedkeeper_utils.satodime_card_id(connector) # The controller wipes Satodime_unlock_secrets when it routes through Home, so @@ -797,7 +794,6 @@ def test_smartcard_satodime_claim_skip_returns_to_slot_menu(self, monkeypatch): connector = _patch_satodime_connector(monkeypatch) connector.setup_done = False # unclaimed card -> seal routes to the claim flow - monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 01bffcce9..716426110 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -158,26 +158,6 @@ def _fresh_connector(): return CardConnector(card_filter=["satodime"]) -class _StubConnection: - def __init__(self, reader): - self._reader = reader - - def getReader(self): - return self._reader - - -class _StubCardService: - def __init__(self, reader): - self.connection = _StubConnection(reader) - - -def _connector_reporting_reader(reader): - class Stub: - cardservice = _StubCardService(reader) - - return Stub() - - class SatodimeSimulatedFlowTest(FlowTest): def setup_method(self): @@ -453,54 +433,29 @@ def test_state_change_on_an_unclaimed_card_routes_to_the_claim_view(self): assert dest.View_cls is smartcard_views.ToolsSatodimeClaimView assert recorder.titles == [] - def test_claim_over_contact_skips_the_backup_flow(self): - """ - A contact reader ignores the unlock code entirely, so there is nothing to back - up and the user should not be walked through a QR ceremony for nothing. - """ + def test_claim_always_routes_to_the_backup_flow(self): + """The connection medium cannot be detected reliably (dual-interface readers), + so claiming always hands off to the backup ceremony; a user who doesn't need + the key can skip it there. The freshly minted secret must be cached either way.""" try: ctx = simulated_satodime_raw() except JCardSimUnavailable as exc: pytest.skip(str(exc)) with ctx: - assert not seedkeeper_utils.satodime_connection_is_contactless( - _fresh_connector() - ), "the jcardsim shim should look like a contact reader" - view = smartcard_views.ToolsSatodimeClaimView() - recorder = ScreenRecorder(0, 0) # confirm claim, then acknowledge success + recorder = ScreenRecorder(0) # confirm claim view.run_screen = recorder - view.run() + dest = view.run() - assert recorder.titles == ["Card Unclaimed", "Card Claimed"] + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + assert dest.view_args["card_id"] cached = self.controller.Satodime_unlock_secrets or {} (secret,) = list(cached.values()) assert len(secret) == 20 assert any(secret), "the card must hand back a real secret, not zeros" - def test_claim_over_contactless_routes_to_the_backup_flow(self, monkeypatch): - """Over NFC the secret is the only thing standing between the user and a - stranded card, so claiming must hand straight off to the backup ceremony.""" - try: - ctx = simulated_satodime_raw() - except JCardSimUnavailable as exc: - pytest.skip(str(exc)) - - monkeypatch.setattr( - seedkeeper_utils, "satodime_connection_is_contactless", lambda connector: True - ) - - with ctx: - view = smartcard_views.ToolsSatodimeClaimView() - recorder = ScreenRecorder(0) - view.run_screen = recorder - dest = view.run() - - assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView - assert dest.view_args["card_id"] - def test_declining_the_claim_leaves_the_card_untouched(self): """Choosing Cancel must abort and leave ``setup_done`` False.""" try: @@ -605,7 +560,7 @@ def test_slot_menu_shows_state_appropriate_actions(self): menu.run_screen = recorder menu.run() assert [o.button_label for o in recorder.calls[0][1]["button_data"]] == [ - "View Address (QR)", "Unseal Slot (View Private Key)", "Sign Transaction", + "View Address (QR)", "Unseal Slot (Access Private Key)", "Sign Transaction", ] # Unsealed BTC: View Address, View Private Key, Sign Transaction, Load Key, Reset Slot. @@ -787,11 +742,12 @@ def test_seal_fails_closed_when_the_entropy_draw_is_low_quality(self, monkeypatc assert recorder.titles == ["Seal As", "No Backup", "System RNG Error"] - def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): + def test_nfc_unlock_rejection_routes_to_restore(self, monkeypatch): """ - Over NFC the applet checks HMAC(unlock_secret, ...), so a claimed card whose - secret this session does not hold cannot seal. The user must be sent to restore - it rather than shown a raw 0x9C51. + Over NFC the applet checks HMAC(unlock_secret, ...) and answers 0x9C51 when the + zeroed placeholder was sent instead of the real key. The user must be sent to + restore it rather than shown a raw status word. (jcardsim simulates contact, so + the rejection is faked at the connector level.) """ try: ctx = simulated_satodime_raw() @@ -800,21 +756,28 @@ def test_contactless_without_the_secret_routes_to_restore(self, monkeypatch): with ctx: claim_view = smartcard_views.ToolsSatodimeClaimView() - claim_view.run_screen = ScreenRecorder(0, 0) + claim_view.run_screen = ScreenRecorder(0) # confirm claim claim_view.run() # Simulate a later session: card still claimed, secret no longer in RAM. self.controller.Satodime_unlock_secrets = None - monkeypatch.setattr( - seedkeeper_utils, "satodime_connection_is_contactless", lambda connector: True - ) + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + real_init = seedkeeper_utils.init_satochip + def init_with_nfc_rejection(*a, **kw): + conn = real_init(*a, **kw) + if conn is not None and not getattr(conn, "_nfc_seal_patched", False): + conn.satodime_seal_key = lambda *args, **kwargs: (b"", 0x9C, 0x51, None, None) + conn._nfc_seal_patched = True + return conn + monkeypatch.setattr(seedkeeper_utils, "init_satochip", init_with_nfc_rejection) view = smartcard_views.ToolsSatodimeSealSlotView(0) - recorder = ScreenRecorder(0) # accept "Restore Key" + recorder = ScreenRecorder(0, 0, 0) # coin, no-backup warning, accept "Restore Key" view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Key Required"] + assert recorder.titles == ["Seal As", "No Backup", "Key Required"] assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView @@ -843,34 +806,6 @@ def test_rejects_junk(self, text): assert seedkeeper_utils.parse_satodime_unlock_payload(text) is None -class TestContactlessDetection: - """Which medium we are on decides whether the secret matters at all.""" - - @pytest.mark.parametrize("reader,expected", [ - ("Identive SCR33xx v2.0 USB SC Reader 0", False), - ("jcardsim simulator", False), - ("SEC1210 Contact Reader", False), - ("ACS ACR122U PICC Interface", True), - ("PN532 via GPIO", True), - ("Some NFC Reader", True), - ]) - def test_reader_names(self, reader, expected): - connector = _connector_reporting_reader(reader) - assert seedkeeper_utils.satodime_connection_is_contactless(connector) is expected - - def test_unknown_reader_fails_safe_to_contactless(self): - """Better to offer a backup that wasn't needed than to skip one that was.""" - class Exploding: - @property - def cardservice(self): - raise RuntimeError("no reader") - - assert seedkeeper_utils.satodime_connection_is_contactless(Exploding()) is True - assert seedkeeper_utils.satodime_connection_is_contactless( - _connector_reporting_reader("") - ) is True - - class TestBackupAndRestoreViews(SatodimeSimulatedFlowTest): """The QR ceremony and its restore path, driven without a card.""" @@ -935,8 +870,6 @@ def test_restore_rejects_another_card_s_backup(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) - # Simulate NFC — the restore flow is only available over contactless. - monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) other = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET) monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) @@ -955,8 +888,6 @@ def test_restore_loads_this_card_s_backup(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) - # Simulate NFC — the restore flow is only available over contactless. - monkeypatch.setattr(seedkeeper_utils, "satodime_connection_is_contactless", lambda c: True) with ctx: card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) From 56e913fcecce637303bd6d67a342fafe5bb1cfed Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 08:58:37 -0400 Subject: [PATCH 14/26] test: let jcardsim simulate contactless protocol media SimLauncher gains an optional --protocol argument (e.g. T=CL,TYPE_A,T0) that calls jcardsim's changeProtocol() before serving APDUs, so applets keying security behaviour off APDU.getProtocol() & PROTOCOL_MEDIA_MASK can be exercised over a simulated ISO 14443 Type A card. Without it the launcher command is unchanged (contact default). --- tests/jcardsim/java/SimLauncher.java | 13 +++++++++++++ tests/jcardsim/simulator.py | 11 +++++++++-- tests/real_screen_fixtures.py | 9 +++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/jcardsim/java/SimLauncher.java b/tests/jcardsim/java/SimLauncher.java index 94ffea299..30c743e9f 100644 --- a/tests/jcardsim/java/SimLauncher.java +++ b/tests/jcardsim/java/SimLauncher.java @@ -82,6 +82,7 @@ private static class AppletSpec { public static void main(String[] args) throws Exception { int port = 0; String classesDir = null; + String protocol = null; List applets = new ArrayList<>(); for (int i = 0; i < args.length; i++) { @@ -89,6 +90,11 @@ public static void main(String[] args) throws Exception { case "--port": port = Integer.parseInt(args[++i]); break; case "--classes": classesDir = args[++i]; break; case "--applet": applets.add(new AppletSpec(args[++i])); break; + // The protocol media the applet sees via APDU.getProtocol(). Defaults to + // contact (T=0); pass e.g. "T=CL,TYPE_A,T0" for an ISO 14443 Type A + // contactless card -- some applets (Satodime) key security behaviour off + // the medium and only exercise it that way. + case "--protocol": protocol = args[++i]; break; default: throw new IllegalArgumentException("unknown argument: " + args[i]); } } @@ -133,6 +139,13 @@ public static void main(String[] args) throws Exception { } simulator.selectApplet(firstAid); + if (protocol != null) { + // Make the applet see this as a contactless card, so code paths keyed off + // APDU.getProtocol() & PROTOCOL_MEDIA_MASK are exercised. Must happen before + // any command is served; jcardsim applies it to every subsequent APDU. + simulator.changeProtocol(protocol); + } + try (ServerSocket server = new ServerSocket(port)) { // Announce readiness on stdout so the Python side can wait for a line rather // than sleeping a fixed interval and hoping. diff --git a/tests/jcardsim/simulator.py b/tests/jcardsim/simulator.py index 0735d0956..61456a6b1 100644 --- a/tests/jcardsim/simulator.py +++ b/tests/jcardsim/simulator.py @@ -164,11 +164,16 @@ class SimulatedCard: """ def __init__(self, applet, classes_dir: Path, port: int | None = None, timeout: float = 30.0, - extra_classpath=()): + extra_classpath=(), protocol: str | None = None): self.applet = applet self.classes_dir = Path(classes_dir) # Anything else the applet needs to load, e.g. Keycard's keycard-math.jar. self.extra_classpath = [Path(p) for p in extra_classpath] + # The protocol media the applet sees via APDU.getProtocol(). None means contact + # (jcardsim's default); pass e.g. "T=CL,TYPE_A,T0" to simulate an ISO 14443 + # Type A contactless card -- Satodime keys its unlock-code enforcement off the + # medium and only exercises it over contactless. + self.protocol = protocol self.port = port or _free_port() self.timeout = timeout self._proc: subprocess.Popen | None = None @@ -195,8 +200,10 @@ def start(self) -> "SimulatedCard": "--classes", os.pathsep.join( str(p) for p in [self.classes_dir, *self.extra_classpath] ), - "--applet", spec, ] + if self.protocol: + cmd += ["--protocol", self.protocol] + cmd += ["--applet", spec] self._proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 ) diff --git a/tests/real_screen_fixtures.py b/tests/real_screen_fixtures.py index 4bb368ee7..7eb6f8b32 100644 --- a/tests/real_screen_fixtures.py +++ b/tests/real_screen_fixtures.py @@ -164,7 +164,7 @@ def simulated_satodime(monkeypatch): @contextmanager -def simulated_satodime_raw(applet="satodime"): +def simulated_satodime_raw(applet="satodime", protocol=None): """ Put a real Satodime applet behind PC/SC and leave ``init_satochip`` alone. @@ -175,6 +175,11 @@ def simulated_satodime_raw(applet="satodime"): prompt for a PIN the applet does not have). Patching only PC/SC means the views run the same client code they run on a real card. + ``protocol`` is the medium the applet sees via ``APDU.getProtocol()``: None keeps + jcardsim's contact default; pass e.g. ``"T=CL,TYPE_A,T0"`` to simulate an ISO 14443 + Type A contactless card, which is the only medium on which Satodime enforces its + ownership-key check (counter + HMAC) on state-changing APDUs. + Yields the ``SimulatedCard`` so a test can reason about the applet directly. """ import sys @@ -187,7 +192,7 @@ def simulated_satodime_raw(applet="satodime"): from jcardsim import open_card from jcardsim.pcsc_shim import patched_pcsc - with open_card(applet) as card: + with open_card(applet, protocol=protocol) as card: card.select() with patched_pcsc(card): yield card From ea2a5bbf34797423a98c3a2998e1f22c6eaf167b Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 08:58:47 -0400 Subject: [PATCH 15/26] fix: sync Satodime unlock counter from card before gated APDUs _satodime_prepare called the no-argument satodime_set_unlock_counter(), which resets the local counter to zeros. Over a contactless reader the applet checks that 4-byte counter on every state-changing APDU and answers 0x9C50 for a stale or zeroed value -- so after claiming, sealing failed with 'Key Required', and restoring the ownership key did not help: the next prepare zeroed the counter again and dead-ended forever. Sync via satodime_get_status() instead (INS 0x50 returns the card's real counter without an unlock code; pysatochip caches it, and each successful gated APDU advances both sides in lockstep). Read-only views keep the old zeroing -- they send no counter and are never gated. Adds test_nfc_claim_restore_seal_end_to_end: runs claim -> lose key in a new session -> seal refused 0x9C51 -> restore from backup -> seal success against the real applet with jcardsim reporting contactless Type A media, i.e. exactly what a real NFC reader enforces. Verified to fail at the final seal (Key Required instead of Success) when the sync is reverted. --- src/seedsigner/views/smartcard_views.py | 11 +++- ...st_real_screen_flows_satodime_simulated.py | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 1baef0b14..7a1488fb7 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4853,7 +4853,16 @@ def _satodime_prepare(view, connector, needs_unlock: bool): return Destination(ToolsSatodimeClaimView) seedkeeper_utils.apply_satodime_unlock_secret(view.controller, connector) - connector.satodime_set_unlock_counter() + if needs_unlock: + # Sync the card's current unlock counter before any gated APDU. The applet + # checks it on every state-changing operation over NFC and answers 0x9C50 for + # a stale or zeroed value; satodime_get_status returns it without an unlock + # code, and pysatochip caches it on the connector (each successful gated APDU + # then advances both sides in lockstep). Never use the no-argument + # satodime_set_unlock_counter() here -- it resets the counter to zeros. + connector.satodime_get_status() + else: + connector.satodime_set_unlock_counter() return None diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 716426110..cb002a214 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -780,6 +780,71 @@ def init_with_nfc_rejection(*a, **kw): assert recorder.titles == ["Seal As", "No Backup", "Key Required"] assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView + def test_nfc_claim_restore_seal_end_to_end(self, monkeypatch): + """ + The full NFC workflow against the real applet: jcardsim reports contactless + Type A media (T=CL,TYPE_A,T0), so every state-changing APDU is gated by + counter+HMAC exactly like a real NFC reader. + + Claim -> lose the key in a new session -> seal refused with 'Key Required' + -> restore from backup -> seal succeeds. This pins the regression where + _satodime_prepare zeroed the unlock counter instead of syncing it: without the + satodime_get_status() sync, the first gated APDU answers 0x9C50 and the workflow + dead-ends even after a correct restore -- the reported on-hardware failure. + """ + # The try wraps the `with` too: on a dev machine free RAM can drop below the + # jcardsim guard between collection and this test's JVM start, in which case + # the failure surfaces at __enter__ rather than construction. + try: + with simulated_satodime_raw(protocol="T=CL,TYPE_A,T0"): + # 1. Claim over NFC: INS_SETUP mints counter+secret; the view caches it. + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0) # confirm claim + dest = claim_view.run() + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + + (secret,) = list((self.controller.Satodime_unlock_secrets or {}).values()) + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret) + + # 2. New session: the in-RAM cache is gone (the controller wipes it at Home). + self.controller.Satodime_unlock_secrets = None + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + # 3. Seal without the key: over contactless media the applet rejects with + # 0x9C51 (zeroed placeholder secret) and the view must offer a restore. + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0, 0, 0) # coin, no-backup warning, "Restore Key" + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Seal As", "No Backup", "Key Required"] + assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView + + # 4. Restore the key from the backup payload (scan). + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + restore_view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) # choose "Scan Backup QR", ack success + restore_view.run_screen = recorder + dest = restore_view.run() + + assert recorder.titles == ["Ownership Key", "Ownership Key Set"] + + # 5. Back on the seal action (BackStackView re-runs it): with the key cached, + # counter+HMAC check out and the slot seals for real over NFC. Without the + # counter sync this dead-ends at "Key Required" again -- the bug. + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0, 0, 0) # coin, no-backup warning, success + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Seal As", "No Backup", "Success"] + headline, address = recorder.body_for("Success").split("\n") + assert headline == "Slot 0 sealed BTC" + assert BECH32_ADDRESS.match(address), address + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + class TestUnlockSecretPayload: """The backup payload is what a user's phone photo has to survive.""" From 670cdfab6a99e9630b0295cfb748e8af20249a57 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 10:49:31 -0400 Subject: [PATCH 16/26] feat: transfer then claim owned Satodimes; MicroSD-aware backup/restore flows Claim Ownership on an already-claimed card now confirms once, releases the old owner via ownership transfer, and claims in one flow (no second 'Card Unclaimed' prompt). NFC unlock errors during the transfer route to key restore. BackupUnlockView re-checks the MicroSD each pass: when a matching backup exists the exit button reads 'Finalise Claim' (post-claim) or 'Done' (from Card Settings) and exits without the dire skip warning; otherwise it stays 'Skip Verification'. Save to MicroSD is now the top option. RestoreUnlockView silently checks for this card's backup file: found, it offers [Load Ownership Key from MicroSD, Scan Ownership Key]; not found, scanning starts immediately with no menu. --- src/seedsigner/views/smartcard_views.py | 192 +++++++++++++--- tests/test_flows_menu_navigation.py | 106 ++++++++- ...st_real_screen_flows_satodime_simulated.py | 217 ++++++++++++++++-- 3 files changed, 461 insertions(+), 54 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 7a1488fb7..d3199f5ea 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4900,6 +4900,10 @@ class ToolsSatodimeClaimView(View): unseal, reset, even handing the card on -- and it cannot be re-read, so losing it strands the card. Over a contact reader the applet ignores it entirely, so there is nothing worth backing up and this view claims and returns. + + A card that already has an owner is first released (ownership transfer) and then + claimed: over a contact reader the applet skips the unlock check on the transfer; + over NFC it needs the current owner's key, which we hold when we are that owner. """ def run(self): @@ -4910,27 +4914,69 @@ def run(self): return Destination(BackStackView) if _satodime_is_claimed(Satochip_Connector): - self.run_screen( - WarningScreen, + # Taking ownership erases the current owner's key (their sealed slots and + # funds survive -- only the NFC gate changes), so confirm before doing it. + selected = self.run_screen( + DireWarningScreen, title="Already Claimed", status_headline=None, - text="This card already has\nan owner.", + text="This card has an owner.\nTaking ownership erases their key.", show_back_button=True, + button_data=[ButtonOption("Take Ownership")], ) - return Destination(BackStackView) + if selected != 0: + return Destination(BackStackView) - # Claiming mints a fresh secret, so doing it to a card that is mid-transfer - # takes the card away from whoever it was being handed to. - selected = self.run_screen( - WarningScreen, - title="Card Unclaimed", - status_headline=None, - text="This Satodime has no owner.\nClaim it for this device?", - show_back_button=False, - button_data=[ButtonOption("Claim Card"), ButtonOption("Cancel")], - ) - if selected != 0: - return Destination(BackStackView) + redirect = _satodime_prepare(self, Satochip_Connector, needs_unlock=True) + if redirect: + return redirect + + self.loading_screen = LoadingScreenThread(text="Transferring Ownership") + self.loading_screen.start() + try: + (_response, sw1, sw2) = Satochip_Connector.satodime_initiate_ownership_transfer() + except Exception as e: + logger.exception("Satodime ownership transfer failed") + sw1 = sw2 = None + transfer_error = str(e)[:100] + else: + transfer_error = None if (sw1 == 0x90 and sw2 == 0x00) else format_sw_error(sw1, sw2) + finally: + self.loading_screen.stop() + + if transfer_error is not None: + # Over NFC without the current owner's key the applet answers 0x9C50/0x9C51; + # offer to restore that key rather than showing a raw status word. Backing + # out of this view and re-entering it retries with the restored key cached. + redirect = _satodime_handle_unlock_error(self, sw1 or 0, sw2 or 0) + if redirect: + return redirect + self.run_screen( + WarningScreen, + title="Transfer Failed", + status_headline=None, + text=transfer_error, + show_back_button=True, + ) + return Destination(BackStackView) + + # The card is now unclaimed (setupDone=False); the claim below mints a fresh + # counter+secret that supersedes the old owner's key. Skip the "Card Unclaimed" + # confirm -- the user already confirmed taking ownership above. + Satochip_Connector.setup_done = False + else: + # Claiming mints a fresh secret, so doing it to a card that is mid-transfer + # takes the card away from whoever it was being handed to. + selected = self.run_screen( + WarningScreen, + title="Card Unclaimed", + status_headline=None, + text="This Satodime has no owner.\nClaim it for this device?", + show_back_button=False, + button_data=[ButtonOption("Claim Card"), ButtonOption("Cancel")], + ) + if selected != 0: + return Destination(BackStackView) self.loading_screen = LoadingScreenThread(text="Claiming Card") self.loading_screen.start() @@ -4967,7 +5013,11 @@ def run(self): # ClaimView is a transient redirect; skip_current_view omits it from history so # BackStackView from the backup flow pops straight back to the view that needed # the claim (the slot-action view / card settings) instead of re-running this view. - return Destination(ToolsSatodimeBackupUnlockView, view_args=dict(card_id=card_id), skip_current_view=True) + return Destination( + ToolsSatodimeBackupUnlockView, + view_args=dict(card_id=card_id, from_claim=True), + skip_current_view=True, + ) class ToolsSatodimeBackupUnlockView(View): @@ -4976,11 +5026,17 @@ class ToolsSatodimeBackupUnlockView(View): The read-back is the point: a QR the user never scanned is a backup they cannot be sure they have. They photograph the key, then hold the photo up to the camera. MicroSD is offered as a second copy, not as a substitute. + + Reached two ways: right after a claim (``from_claim=True``, where finishing the + backup finalises the claim) and from Card Settings re-showing a cached key. When a + matching backup already sits on the MicroSD the exit button says so instead of + warning about skipping an unverified backup. """ - def __init__(self, card_id: str = None): + def __init__(self, card_id: str = None, from_claim: bool = False): super().__init__() self.card_id = card_id + self.from_claim = from_claim def run(self): from seedsigner.gui.screens.screen import QRDisplayScreen @@ -5027,27 +5083,39 @@ def run(self): while True: self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=payload)) + # A matching backup on the MicroSD means there is nothing left to verify, so + # exiting becomes a positive completion rather than a scary skip. Re-checked + # every pass so saving one mid-flow flips the button without re-entering this + # view (save -> back here -> "Finalise Claim"). + if self._microsd_backup_matches(card_id, secret): + exit_label = "Finalise Claim" if self.from_claim else "Done" + else: + exit_label = "Skip Verification" + selected = self.run_screen( ButtonListScreen, title="Verify Backup", is_button_text_centered=False, button_data=[ + ButtonOption("Save to MicroSD"), ButtonOption("Scan It Back"), ButtonOption("Show QR Again"), - ButtonOption("Save to MicroSD"), - ButtonOption("Skip Verification"), + ButtonOption(exit_label), ], show_back_button=False, ) - if selected == 1: + if selected == 0: + self._save_to_microsd(card_id, payload) continue if selected == 2: - self._save_to_microsd(card_id, payload) continue if selected == 3: + if exit_label != "Skip Verification": + # A verified copy already exists on the MicroSD; nothing to warn about. + return Destination(BackStackView) confirm = self.run_screen( DireWarningScreen, title="Skip Backup?", @@ -5083,6 +5151,29 @@ def _scan_matches(self, payload: str) -> bool: scanned = _satodime_scan_text(self) return scanned is not None and scanned.strip() == payload + def _microsd_backup_matches(self, card_id: str, secret) -> bool: + """Whether this card's backup file on the MicroSD already holds this exact key. + + Silent by design -- it drives a button label, so no screens here. Any failure to + read or parse (no card, no file, junk content, even a non-path test stand-in) + simply means "no matching backup". + """ + import os + from seedsigner.hardware.microsd import MicroSD + + if not MicroSD.get_instance().is_inserted: + return False + try: + filepath = os.path.join( + MicroSD.get_microsd_dir(), + seedkeeper_utils.satodime_unlock_backup_filename(card_id), + ) + with open(filepath, "r", encoding="utf-8") as f: + parsed = seedkeeper_utils.parse_satodime_unlock_payload(f.read()) + except (OSError, TypeError, ValueError): + return False + return parsed == (card_id, list(secret)) + def _save_to_microsd(self, card_id: str, payload: str): import os from seedsigner.hardware.microsd import MicroSD @@ -5127,8 +5218,8 @@ def _save_to_microsd(self, card_id: str, payload: str): class ToolsSatodimeRestoreUnlockView(View): """Load a previously backed-up ownership key back into this session.""" - SCAN = ButtonOption("Scan Backup QR") - MICROSD = ButtonOption("Load from MicroSD") + LOAD_MICROSD = ButtonOption("Load Ownership Key from MicroSD") + SCAN = ButtonOption("Scan Ownership Key") def run(self): Satochip_Connector = seedkeeper_utils.init_satochip(self, init_card_filter=["satodime"], require_pin=False) @@ -5137,20 +5228,24 @@ def run(self): card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - selected = self.run_screen( - ButtonListScreen, - title="Ownership Key", - is_button_text_centered=False, - button_data=[self.SCAN, self.MICROSD], - show_back_button=True, - ) - if selected == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - - if selected == 0: - payload = _satodime_scan_text(self) + # A backup for this card on the MicroSD is the fastest restore path, so offer it + # first when one exists; with nothing on the card there is no menu -- scanning is + # the only option and starts immediately. + if self._microsd_has_backup(card_id): + selected = self.run_screen( + ButtonListScreen, + title="Ownership Key", + is_button_text_centered=False, + button_data=[self.LOAD_MICROSD, self.SCAN], + show_back_button=True, + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + use_microsd = (selected == 0) else: - payload = self._read_microsd(card_id) + use_microsd = False + + payload = self._read_microsd(card_id) if use_microsd else _satodime_scan_text(self) if payload is None: return Destination(BackStackView) @@ -5187,6 +5282,29 @@ def run(self): ) return Destination(BackStackView) + def _microsd_has_backup(self, card_id: str) -> bool: + """Whether this card's backup file exists on the MicroSD and parses as its key. + + Silent by design -- it decides which restore path to offer first, so no screens + here. A missing, unreadable, or other-card file means "no backup here". (It cannot + tell whether the key is still current: only using it reveals that.) + """ + import os + from seedsigner.hardware.microsd import MicroSD + + if not MicroSD.get_instance().is_inserted: + return False + try: + filepath = os.path.join( + MicroSD.get_microsd_dir(), + seedkeeper_utils.satodime_unlock_backup_filename(card_id), + ) + with open(filepath, "r", encoding="utf-8") as f: + parsed = seedkeeper_utils.parse_satodime_unlock_payload(f.read()) + except (OSError, TypeError, ValueError): + return False + return parsed is not None and parsed[0] == card_id + def _read_microsd(self, card_id: str): import os from seedsigner.hardware.microsd import MicroSD diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 4e6163e23..2353e48d9 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -102,6 +102,11 @@ def satodime_get_keyslot_status(self, key_nbr): def satodime_set_unlock_secret(self, *args, **kwargs): pass def satodime_set_unlock_counter(self, *args, **kwargs): pass + def satodime_initiate_ownership_transfer(self): + # The applet flips setupDone off; the next card_setup (claim) mints a fresh key. + self.setup_done = False + return (b"", 0x90, 0x00) + def card_setup(self, *args, **kwargs): self.setup_done = True return (b"", 0x90, 0x00) @@ -724,7 +729,7 @@ def test_smartcard_satodime_card_settings(self): ]) def test_smartcard_satodime_claim_ownership_from_menu(self, monkeypatch): - """Tools → Smartcard → Satodime → Claim Ownership → already claimed warning.""" + """Tools → Smartcard → Satodime → Claim Ownership on an owned card -> back out of the confirm.""" from seedsigner.views.smartcard_views import ( ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeClaimView, ) @@ -736,10 +741,107 @@ def test_smartcard_satodime_claim_ownership_from_menu(self, monkeypatch): FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), - FlowStep(ToolsSatodimeClaimView, screen_return_value=RET_CODE__BACK_BUTTON), # "Already Claimed" + FlowStep(ToolsSatodimeClaimView, screen_return_value=RET_CODE__BACK_BUTTON), # back out of "Already Claimed" + FlowStep(ToolsSatodimeView), + ]) + + def test_smartcard_satodime_claim_transfers_then_claims_an_owned_card(self, monkeypatch): + """Tools → Smartcard → Satodime → Claim Ownership on an owned card. + + Confirming takes ownership (transfer) and immediately re-claims the card; the + freshly minted key then goes through the backup flow like any fresh claim. + """ + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeClaimView, + ToolsSatodimeBackupUnlockView, + ) + + connector = _patch_satodime_connector(monkeypatch) # setup_done=True: owned card + assert connector.setup_done is True + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), + FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Take Ownership" -> transfer + claim + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # exit the backup flow FlowStep(ToolsSatodimeView), ]) + assert connector.setup_done is True # claimed again after the transfer + + def test_smartcard_satodime_claim_finalises_with_matching_microsd_backup(self, monkeypatch): + """Claim -> backup flow with a matching key already on the MicroSD. + + The exit button becomes "Finalise Claim" (no dire skip warning) and selecting it + completes the claim workflow straight back to the Satodime menu. Label content is + asserted in the simulated suite; this pins routing through the match path. + """ + import tempfile + + from real_screen_fixtures import use_microsd + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeClaimView, + ToolsSatodimeBackupUnlockView, + ) + + connector = _patch_satodime_connector(monkeypatch) + connector.setup_done = False # unclaimed card -> plain claim flow + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_backup_test_"))) + + # The mock's card_setup mints unlock_secret=list(range(20)) for this UID. + card_id = seedkeeper_utils.satodime_card_id(connector) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, list(range(20))) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id)).write_text(payload, encoding="utf-8") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), + FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Claim Card" + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # "Finalise Claim" + FlowStep(ToolsSatodimeView), + ]) + + def test_smartcard_satodime_reshow_done_with_matching_microsd_backup(self, monkeypatch): + """Card Settings -> Back Up Ownership Key with a matching key on the MicroSD. + + No claim is in progress here, so the exit button reads "Done" rather than + "Finalise Claim"; selecting it returns to Card Settings without the dire skip + warning (label content asserted in the simulated suite). + """ + import tempfile + + from real_screen_fixtures import use_microsd + from seedsigner.helpers import seedkeeper_utils + from seedsigner.views.smartcard_views import ( + ToolsSmartcardMenuView, ToolsSatodimeView, ToolsSatodimeCardSettingsView, + ToolsSatodimeReshowUnlockView, ToolsSatodimeBackupUnlockView, + ) + + connector = _patch_satodime_connector(monkeypatch) + card_id = seedkeeper_utils.satodime_card_id(connector) + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_backup_test_"))) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, list(range(20))) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id)).write_text(payload, encoding="utf-8") + + def cache_secret(view): + seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, list(range(20))) + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.SMARTCARD), + FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), + FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CARD_SETTINGS), + FlowStep(ToolsSatodimeCardSettingsView, button_data_selection=ToolsSatodimeCardSettingsView.BACKUP_UNLOCK, before_run=cache_secret), + FlowStep(ToolsSatodimeReshowUnlockView, is_redirect=True), + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # "Done" + FlowStep(ToolsSatodimeCardSettingsView), + ]) + def test_smartcard_satodime_backup_unlock_skip_returns_to_card_settings(self, monkeypatch): """Card Settings → Back Up Unlock Code → skip backup → returns to Card Settings. diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index cb002a214..17df80253 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -30,6 +30,8 @@ import re import sys +import tempfile +from pathlib import Path from unittest.mock import MagicMock import pytest @@ -456,6 +458,44 @@ def test_claim_always_routes_to_the_backup_flow(self): assert len(secret) == 20 assert any(secret), "the card must hand back a real secret, not zeros" + def test_claim_on_an_owned_card_transfers_then_reclaims(self): + """Claim Ownership on an already-claimed card releases the old ownership key and + mints a fresh one in one flow. Over contact the transfer needs no key at all; + sealed slots survive (only the NFC gate changes).""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + # First claim straight through a connector, and seal a slot to prove it survives. + first = _fresh_connector() + claim(first) + old_secret = list(first.unlock_secret) + first.satodime_get_status() # sync the counter for the gated seal APDU + (_, sw1, sw2, _, _) = first.satodime_seal_key(0, bytes(range(32))) + assert (sw1, sw2) == (0x90, 0x00) + + # Now drive ClaimView against the same (already claimed) card. + view = smartcard_views.ToolsSatodimeClaimView() + recorder = ScreenRecorder(0) # "Take Ownership" -- transfer + claim need no further screens + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Already Claimed"] + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + assert dest.view_args["from_claim"] is True + + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + new_secret = self.controller.Satodime_unlock_secrets[card_id] + assert len(new_secret) == 20 + assert any(new_secret), "the re-claim must mint a real secret, not zeros" + assert new_secret != old_secret, "the old owner's key must be invalidated" + + # The sealed slot survives the transfer + reclaim. + (_, _, _, slot_status) = _fresh_connector().satodime_get_keyslot_status(0) + assert slot_status["key_status_txt"] == "Sealed" + def test_declining_the_claim_leaves_the_card_untouched(self): """Choosing Cancel must abort and leave ``setup_done`` False.""" try: @@ -787,11 +827,14 @@ def test_nfc_claim_restore_seal_end_to_end(self, monkeypatch): counter+HMAC exactly like a real NFC reader. Claim -> lose the key in a new session -> seal refused with 'Key Required' - -> restore from backup -> seal succeeds. This pins the regression where - _satodime_prepare zeroed the unlock counter instead of syncing it: without the - satodime_get_status() sync, the first gated APDU answers 0x9C50 and the workflow - dead-ends even after a correct restore -- the reported on-hardware failure. + -> restore from the MicroSD backup -> seal succeeds. This pins the regression + where _satodime_prepare zeroed the unlock counter instead of syncing it: without + the satodime_get_status() sync, the first gated APDU answers 0x9C50 and the + workflow dead-ends even after a correct restore -- the reported on-hardware + failure. """ + from real_screen_fixtures import use_microsd + # The try wraps the `with` too: on a dev machine free RAM can drop below the # jcardsim guard between collection and this test's JVM start, in which case # the failure surfaces at __enter__ rather than construction. @@ -821,10 +864,14 @@ def test_nfc_claim_restore_seal_end_to_end(self, monkeypatch): assert recorder.titles == ["Seal As", "No Backup", "Key Required"] assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView - # 4. Restore the key from the backup payload (scan). - monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + # 4. Restore the key: it sits on the MicroSD, so restore offers that first + # -- one tap, no camera. + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id)).write_text( + payload, encoding="utf-8" + ) restore_view = smartcard_views.ToolsSatodimeRestoreUnlockView() - recorder = ScreenRecorder(0, 0) # choose "Scan Backup QR", ack success + recorder = ScreenRecorder(0, 0) # "Load Ownership Key from MicroSD", ack success restore_view.run_screen = recorder dest = restore_view.run() @@ -882,12 +929,15 @@ def _seed_cache(self): return seedkeeper_utils.format_satodime_unlock_payload(self.CARD_ID, self.SECRET) def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): + from real_screen_fixtures import use_microsd + + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card payload = self._seed_cache() monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # dire warning, theft caveat, QR, menu -> "Scan It Back", success - recorder = ScreenRecorder(0, 0, None, 0, 0) + # dire warning, theft caveat, QR, menu -> "Scan It Back" (index 1), success + recorder = ScreenRecorder(0, 0, None, 1, 0) view.run_screen = recorder view.run() @@ -896,22 +946,28 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): ] def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): + from real_screen_fixtures import use_microsd + + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card self._seed_cache() monkeypatch.setattr( smartcard_views, "_satodime_scan_text", lambda view: "satodime-unlock:other:" + "11" * 20 ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # ... menu -> "Scan It Back", "No Match", QR again, menu -> "Skip", confirm skip - recorder = ScreenRecorder(0, 0, None, 0, 0, None, 3, 0) + # ... menu -> "Scan It Back" (index 1), "No Match", QR again, menu -> "Skip", confirm skip + recorder = ScreenRecorder(0, 0, None, 1, 0, None, 3, 0) view.run_screen = recorder view.run() assert "No Match" in recorder.titles assert "Backup Verified" not in recorder.titles - def test_the_user_is_told_the_code_is_not_theft_protection(self): + def test_the_user_is_told_the_code_is_not_theft_protection(self, monkeypatch): """A contact reader can unseal the card without this code; users must know.""" + from real_screen_fixtures import use_microsd + + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) recorder = ScreenRecorder(0, 0, None, 3, 0) # straight to Skip @@ -921,6 +977,77 @@ def test_the_user_is_told_the_code_is_not_theft_protection(self): caveat = recorder.body_for("Not Theft Proof") assert "contact reader" in caveat.lower() + def test_finalise_claim_shown_when_matching_backup_on_microsd(self, monkeypatch): + """Right after a claim, a matching backup on the MicroSD turns the exit button + into 'Finalise Claim' -- selecting it completes the workflow with no dire skip + warning (a verified copy already exists).""" + from real_screen_fixtures import use_microsd + + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + self._seed_cache() + payload = seedkeeper_utils.format_satodime_unlock_payload(self.CARD_ID, self.SECRET) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(self.CARD_ID)).write_text( + payload, encoding="utf-8" + ) + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) + # dire warning, theft caveat, QR, menu -> "Finalise Claim"; no further screens + recorder = ScreenRecorder(0, 0, None, 3) + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Ownership Key", "Not Theft Proof", None, "Verify Backup"] + menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + assert menu_buttons == [ + "Save to MicroSD", "Scan It Back", "Show QR Again", "Finalise Claim", + ] + assert dest.View_cls is smartcard_views.BackStackView + + def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): + """Re-showing a cached key from Card Settings (no claim in progress) labels the + same exit 'Done'.""" + from real_screen_fixtures import use_microsd + + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + self._seed_cache() + payload = seedkeeper_utils.format_satodime_unlock_payload(self.CARD_ID, self.SECRET) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(self.CARD_ID)).write_text( + payload, encoding="utf-8" + ) + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) # from_claim=False + recorder = ScreenRecorder(0, 0, None, 3) + view.run_screen = recorder + dest = view.run() + + menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + assert menu_buttons[-1] == "Done" + assert dest.View_cls is smartcard_views.BackStackView + + def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): + """Saving the key to the MicroSD mid-flow returns to the backup screen with the + exit button now reading 'Finalise Claim' -- no re-entry into this view needed.""" + from real_screen_fixtures import use_microsd + + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + self._seed_cache() + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) + # dire warning, theft caveat, QR, menu -> "Save to MicroSD" (index 0), + # "Saved" ack, QR again, menu -> "Finalise Claim" (index 3) + recorder = ScreenRecorder(0, 0, None, 0, 0, None, 3) + view.run_screen = recorder + dest = view.run() + + first_menu = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + second_menu = [opt.button_label for opt in recorder.calls[6][1]["button_data"]] + assert first_menu[-1] == "Skip Verification" + assert second_menu[-1] == "Finalise Claim" + # The backup file now holds the current key. + saved = (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(self.CARD_ID)).read_text(encoding="utf-8") + assert seedkeeper_utils.parse_satodime_unlock_payload(saved) == (self.CARD_ID, self.SECRET) + assert dest.View_cls is smartcard_views.BackStackView + def test_backup_refuses_when_nothing_is_cached(self): view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) recorder = ScreenRecorder(0) @@ -930,6 +1057,8 @@ def test_backup_refuses_when_nothing_is_cached(self): assert recorder.titles == ["No Ownership Key"] def test_restore_rejects_another_card_s_backup(self, monkeypatch): + from real_screen_fixtures import use_microsd + try: ctx = simulated_satodime_raw() except JCardSimUnavailable as exc: @@ -939,15 +1068,18 @@ def test_restore_rejects_another_card_s_backup(self, monkeypatch): monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) with ctx: + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no backup on the card -> straight to scan view = smartcard_views.ToolsSatodimeRestoreUnlockView() - recorder = ScreenRecorder(0, 0) # choose "Scan Backup QR", ack the warning + recorder = ScreenRecorder(0) # ack the "Wrong Card" warning (scan is scripted) view.run_screen = recorder view.run() - assert recorder.titles == ["Ownership Key", "Wrong Card"] + assert recorder.titles == ["Wrong Card"] assert not (self.controller.Satodime_unlock_secrets or {}) def test_restore_loads_this_card_s_backup(self, monkeypatch): + from real_screen_fixtures import use_microsd + try: ctx = simulated_satodime_raw() except JCardSimUnavailable as exc: @@ -958,8 +1090,63 @@ def test_restore_loads_this_card_s_backup(self, monkeypatch): payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no backup on the card -> straight to scan + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0) # ack "Ownership Key Set" (scan is scripted) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Ownership Key Set"] + assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET + + def test_restore_offers_microsd_first_when_a_backup_exists(self, monkeypatch): + """With a backup for this card on the MicroSD, restore offers it first (one tap, + no camera) and loading it caches the key.""" + from real_screen_fixtures import use_microsd + + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id)).write_text( + payload, encoding="utf-8" + ) + + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) # "Load Ownership Key from MicroSD", ack success + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Ownership Key", "Ownership Key Set"] + prompt_buttons = [opt.button_label for opt in recorder.calls[0][1]["button_data"]] + assert prompt_buttons == ["Load Ownership Key from MicroSD", "Scan Ownership Key"] + assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET + + def test_restore_prompt_scan_option_still_scans(self, monkeypatch): + """The scan option on the restore prompt goes to the QR reader (scripted here).""" + from real_screen_fixtures import use_microsd + + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id)).write_text( + "satodime-unlock:" + card_id + ":" + "00" * 20, encoding="utf-8" + ) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, self.SECRET) + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + view = smartcard_views.ToolsSatodimeRestoreUnlockView() - recorder = ScreenRecorder(0, 0) + recorder = ScreenRecorder(1, 0) # "Scan Ownership Key" (index 1), ack success view.run_screen = recorder view.run() From c733e06ad9332e65b433b4de8dd6567b3d4f2eb7 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 11:12:07 -0400 Subject: [PATCH 17/26] ui: warn up front about losing the Satodime ownership key The backup flow now opens with a third intro screen carrying the 'Skip Backup?' consequence text (contact reader needed to reclaim; NFC-only cards locked), so users who bail before reaching the skip prompt have still seen what losing the key means. --- src/seedsigner/views/smartcard_views.py | 11 +++++++ ...st_real_screen_flows_satodime_simulated.py | 30 +++++++++---------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index d3199f5ea..182b25015 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5080,6 +5080,17 @@ def run(self): button_data=[ButtonOption("I Understand")], ) + # The consequence of skipping the backup, shown up front as well: users who bail + # out before reading it at skip time have already lost the key. + self.run_screen( + DireWarningScreen, + title="If You Lose It", + status_headline=None, + text="Without this key, a contact reader is needed to reclaim ownership. NFC-only cards are locked.", + show_back_button=False, + button_data=[ButtonOption("I Understand")], + ) + while True: self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=payload)) diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 17df80253..8543b7f05 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -936,13 +936,13 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # dire warning, theft caveat, QR, menu -> "Scan It Back" (index 1), success - recorder = ScreenRecorder(0, 0, None, 1, 0) + # dire warning, theft caveat, lose-it warning, QR, menu -> "Scan It Back" (index 1), success + recorder = ScreenRecorder(0, 0, 0, None, 1, 0) view.run_screen = recorder view.run() assert recorder.titles == [ - "Ownership Key", "Not Theft Proof", None, "Verify Backup", "Backup Verified", + "Ownership Key", "Not Theft Proof", "If You Lose It", None, "Verify Backup", "Backup Verified", ] def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): @@ -956,7 +956,7 @@ def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) # ... menu -> "Scan It Back" (index 1), "No Match", QR again, menu -> "Skip", confirm skip - recorder = ScreenRecorder(0, 0, None, 1, 0, None, 3, 0) + recorder = ScreenRecorder(0, 0, 0, None, 1, 0, None, 3, 0) view.run_screen = recorder view.run() @@ -970,7 +970,7 @@ def test_the_user_is_told_the_code_is_not_theft_protection(self, monkeypatch): use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - recorder = ScreenRecorder(0, 0, None, 3, 0) # straight to Skip + recorder = ScreenRecorder(0, 0, 0, None, 3, 0) # straight to Skip view.run_screen = recorder view.run() @@ -991,13 +991,13 @@ def test_finalise_claim_shown_when_matching_backup_on_microsd(self, monkeypatch) ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, QR, menu -> "Finalise Claim"; no further screens - recorder = ScreenRecorder(0, 0, None, 3) + # dire warning, theft caveat, lose-it warning, QR, menu -> "Finalise Claim"; no further screens + recorder = ScreenRecorder(0, 0, 0, None, 3) view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Ownership Key", "Not Theft Proof", None, "Verify Backup"] - menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + assert recorder.titles == ["Ownership Key", "Not Theft Proof", "If You Lose It", None, "Verify Backup"] + menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] assert menu_buttons == [ "Save to MicroSD", "Scan It Back", "Show QR Again", "Finalise Claim", ] @@ -1016,11 +1016,11 @@ def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) # from_claim=False - recorder = ScreenRecorder(0, 0, None, 3) + recorder = ScreenRecorder(0, 0, 0, None, 3) view.run_screen = recorder dest = view.run() - menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] assert menu_buttons[-1] == "Done" assert dest.View_cls is smartcard_views.BackStackView @@ -1033,14 +1033,14 @@ def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, QR, menu -> "Save to MicroSD" (index 0), + # dire warning, theft caveat, lose-it warning, QR, menu -> "Save to MicroSD" (index 0), # "Saved" ack, QR again, menu -> "Finalise Claim" (index 3) - recorder = ScreenRecorder(0, 0, None, 0, 0, None, 3) + recorder = ScreenRecorder(0, 0, 0, None, 0, 0, None, 3) view.run_screen = recorder dest = view.run() - first_menu = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] - second_menu = [opt.button_label for opt in recorder.calls[6][1]["button_data"]] + first_menu = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] + second_menu = [opt.button_label for opt in recorder.calls[7][1]["button_data"]] assert first_menu[-1] == "Skip Verification" assert second_menu[-1] == "Finalise Claim" # The backup file now holds the current key. From d93fba6a930692261bf0aabfb16073d89f125ca9 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 11:41:28 -0400 Subject: [PATCH 18/26] ui: add a how-to-save chooser at the start of the Satodime backup flow Instead of jumping straight to the QR, the backup flow now opens with a 'Back Up Ownership Key' chooser: Show QR Code (photograph + scan-back verification) or Save to MicroSD. The QR path is an inner loop whose verify menu backs out to the chooser; saving flips the exit button to Finalise Claim/Done on the next pass. --- src/seedsigner/views/smartcard_views.py | 69 +++++++++++-------- tests/test_flows_menu_navigation.py | 10 +-- ...st_real_screen_flows_satodime_simulated.py | 45 ++++++------ 3 files changed, 69 insertions(+), 55 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 182b25015..ea0f2962e 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5021,16 +5021,18 @@ def run(self): class ToolsSatodimeBackupUnlockView(View): - """Show the ownership key as a QR and make the user prove they captured it. + """Back up the ownership key: choose how to save it, then prove you captured it. - The read-back is the point: a QR the user never scanned is a backup they cannot be - sure they have. They photograph the key, then hold the photo up to the camera. - MicroSD is offered as a second copy, not as a substitute. + Opens with a chooser -- photograph the QR (the read-back is the point: a QR the user + never scanned is a backup they cannot be sure they have) or write a copy straight to + the MicroSD. The two are complements, not substitutes: the exit button only turns + into a positive completion once a matching backup sits on the MicroSD or the read-back + has verified one. Reached two ways: right after a claim (``from_claim=True``, where finishing the backup finalises the claim) and from Card Settings re-showing a cached key. When a - matching backup already sits on the MicroSD the exit button says so instead of - warning about skipping an unverified backup. + matching backup already exists, the exit button says so instead of warning about + skipping an unverified backup. """ def __init__(self, card_id: str = None, from_claim: bool = False): @@ -5092,8 +5094,6 @@ def run(self): ) while True: - self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=payload)) - # A matching backup on the MicroSD means there is nothing left to verify, so # exiting becomes a positive completion rather than a scary skip. Re-checked # every pass so saving one mid-flow flips the button without re-entering this @@ -5105,25 +5105,21 @@ def run(self): selected = self.run_screen( ButtonListScreen, - title="Verify Backup", + title="Back Up Ownership Key", is_button_text_centered=False, button_data=[ + ButtonOption("Show QR Code"), ButtonOption("Save to MicroSD"), - ButtonOption("Scan It Back"), - ButtonOption("Show QR Again"), ButtonOption(exit_label), ], show_back_button=False, ) - if selected == 0: + if selected == 1: self._save_to_microsd(card_id, payload) continue if selected == 2: - continue - - if selected == 3: if exit_label != "Skip Verification": # A verified copy already exists on the MicroSD; nothing to warn about. return Destination(BackStackView) @@ -5139,24 +5135,39 @@ def run(self): continue return Destination(BackStackView) - if self._scan_matches(payload): + # Show QR Code: photograph it, then prove the capture by scanning it back. + while True: + self.run_screen(QRDisplayScreen, qr_encoder=GenericStaticQrEncoder(data=payload)) + + scan_selected = self.run_screen( + ButtonListScreen, + title="Verify Backup", + is_button_text_centered=False, + button_data=[ButtonOption("Scan It Back"), ButtonOption("Show QR Again")], + show_back_button=True, # back returns to the save-method chooser + ) + + if scan_selected == RET_CODE__BACK_BUTTON: + break + + if self._scan_matches(payload): + self.run_screen( + LargeIconStatusScreen, + title="Backup Verified", + status_headline=None, + text="Keep it safe and private.", + show_back_button=False, + ) + return Destination(BackStackView) + self.run_screen( - LargeIconStatusScreen, - title="Backup Verified", + WarningScreen, + title="No Match", status_headline=None, - text="Keep it safe and private.", + text="That is not this card's\nownership key.", show_back_button=False, + button_data=[ButtonOption("Try Again")], ) - return Destination(BackStackView) - - self.run_screen( - WarningScreen, - title="No Match", - status_headline=None, - text="That is not this card's\nownership key.", - show_back_button=False, - button_data=[ButtonOption("Try Again")], - ) def _scan_matches(self, payload: str) -> bool: scanned = _satodime_scan_text(self) diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index 2353e48d9..fdc9441a2 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -765,7 +765,7 @@ def test_smartcard_satodime_claim_transfers_then_claims_an_owned_card(self, monk FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Take Ownership" -> transfer + claim - FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # exit the backup flow + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=2), # chooser exit: skip/finalise the backup flow FlowStep(ToolsSatodimeView), ]) @@ -802,7 +802,7 @@ def test_smartcard_satodime_claim_finalises_with_matching_microsd_backup(self, m FlowStep(ToolsSmartcardMenuView, button_data_selection=ToolsSmartcardMenuView.SATODIME), FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CLAIM_OWNERSHIP), FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Claim Card" - FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # "Finalise Claim" + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=2), # chooser exit: "Finalise Claim" FlowStep(ToolsSatodimeView), ]) @@ -838,7 +838,7 @@ def cache_secret(view): FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CARD_SETTINGS), FlowStep(ToolsSatodimeCardSettingsView, button_data_selection=ToolsSatodimeCardSettingsView.BACKUP_UNLOCK, before_run=cache_secret), FlowStep(ToolsSatodimeReshowUnlockView, is_redirect=True), - FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # "Done" + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=2), # chooser exit: "Done" FlowStep(ToolsSatodimeCardSettingsView), ]) @@ -873,7 +873,7 @@ def cache_secret(view): FlowStep(ToolsSatodimeView, button_data_selection=ToolsSatodimeView.CARD_SETTINGS), FlowStep(ToolsSatodimeCardSettingsView, button_data_selection=ToolsSatodimeCardSettingsView.BACKUP_UNLOCK, before_run=cache_secret), FlowStep(ToolsSatodimeReshowUnlockView, is_redirect=True), - FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # Skip Verification + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=2), # chooser exit: Skip Verification FlowStep(ToolsSatodimeCardSettingsView), # back where we started, no loop ]) @@ -906,7 +906,7 @@ def test_smartcard_satodime_claim_skip_returns_to_slot_menu(self, monkeypatch): FlowStep(ToolsSatodimeSlotMenuView, screen_return_value=0), # pick "Seal Slot" FlowStep(ToolsSatodimeSealSlotView, is_redirect=True), # unclaimed -> ClaimView FlowStep(ToolsSatodimeClaimView, screen_return_value=0), # "Claim Card" - FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=3), # Skip Verification + FlowStep(ToolsSatodimeBackupUnlockView, screen_return_value=2), # chooser exit: Skip Verification FlowStep(ToolsSatodimeSealSlotView), # back on the slot action, no loop ]) diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 8543b7f05..b55f3d4b9 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -936,13 +936,15 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # dire warning, theft caveat, lose-it warning, QR, menu -> "Scan It Back" (index 1), success - recorder = ScreenRecorder(0, 0, 0, None, 1, 0) + # dire warning, theft caveat, lose-it warning, chooser -> "Show QR Code" (index 0), + # QR, verify menu -> "Scan It Back" (index 0), success + recorder = ScreenRecorder(0, 0, 0, 0, None, 0, 0) view.run_screen = recorder view.run() assert recorder.titles == [ - "Ownership Key", "Not Theft Proof", "If You Lose It", None, "Verify Backup", "Backup Verified", + "Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key", + None, "Verify Backup", "Backup Verified", ] def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): @@ -955,8 +957,9 @@ def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # ... menu -> "Scan It Back" (index 1), "No Match", QR again, menu -> "Skip", confirm skip - recorder = ScreenRecorder(0, 0, 0, None, 1, 0, None, 3, 0) + # chooser -> "Show QR Code", QR, verify menu -> "Scan It Back" (index 0), "No Match", + # QR again, verify menu -> BACK to the chooser, chooser -> "Skip Verification" (index 2), confirm skip + recorder = ScreenRecorder(0, 0, 0, 0, None, 0, 0, None, RET_CODE__BACK_BUTTON, 2, 0) view.run_screen = recorder view.run() @@ -970,7 +973,7 @@ def test_the_user_is_told_the_code_is_not_theft_protection(self, monkeypatch): use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - recorder = ScreenRecorder(0, 0, 0, None, 3, 0) # straight to Skip + recorder = ScreenRecorder(0, 0, 0, 2, 0) # chooser -> "Skip Verification" (index 2), confirm skip view.run_screen = recorder view.run() @@ -991,15 +994,15 @@ def test_finalise_claim_shown_when_matching_backup_on_microsd(self, monkeypatch) ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, lose-it warning, QR, menu -> "Finalise Claim"; no further screens - recorder = ScreenRecorder(0, 0, 0, None, 3) + # dire warning, theft caveat, lose-it warning, chooser -> "Finalise Claim" (index 2); no further screens + recorder = ScreenRecorder(0, 0, 0, 2) view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Ownership Key", "Not Theft Proof", "If You Lose It", None, "Verify Backup"] - menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] + assert recorder.titles == ["Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key"] + menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] assert menu_buttons == [ - "Save to MicroSD", "Scan It Back", "Show QR Again", "Finalise Claim", + "Show QR Code", "Save to MicroSD", "Finalise Claim", ] assert dest.View_cls is smartcard_views.BackStackView @@ -1016,31 +1019,31 @@ def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) # from_claim=False - recorder = ScreenRecorder(0, 0, 0, None, 3) + recorder = ScreenRecorder(0, 0, 0, 2) # chooser -> "Done" (index 2) view.run_screen = recorder dest = view.run() - menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] - assert menu_buttons[-1] == "Done" + menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Done"] assert dest.View_cls is smartcard_views.BackStackView def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): - """Saving the key to the MicroSD mid-flow returns to the backup screen with the - exit button now reading 'Finalise Claim' -- no re-entry into this view needed.""" + """Saving the key to the MicroSD mid-flow returns to the chooser with the exit + button now reading 'Finalise Claim' -- no re-entry into this view needed.""" from real_screen_fixtures import use_microsd microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, lose-it warning, QR, menu -> "Save to MicroSD" (index 0), - # "Saved" ack, QR again, menu -> "Finalise Claim" (index 3) - recorder = ScreenRecorder(0, 0, 0, None, 0, 0, None, 3) + # dire warning, theft caveat, lose-it warning, chooser -> "Save to MicroSD" (index 1), + # "Saved" ack, chooser again -> "Finalise Claim" (index 2) + recorder = ScreenRecorder(0, 0, 0, 1, 0, 2) view.run_screen = recorder dest = view.run() - first_menu = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] - second_menu = [opt.button_label for opt in recorder.calls[7][1]["button_data"]] + first_menu = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] + second_menu = [opt.button_label for opt in recorder.calls[5][1]["button_data"]] assert first_menu[-1] == "Skip Verification" assert second_menu[-1] == "Finalise Claim" # The backup file now holds the current key. From e12d7d2ac2b575b5d58661fc501dbb8f1fc83af1 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 14:40:54 -0400 Subject: [PATCH 19/26] fix: make the Satodime backup scan-back actually decode the QR Without is_text=True, DecodeQR runs detect_segment_type() on the payload and classifies it as INVALID (it matches no known format), so the scan-back verification could never succeed on device. Same pattern as the GPG text-QR scans. Adds an end-to-end test that renders the QR exactly like the screenshot generator does and feeds the frame back through the real ScanScreen + DecodeQR path -- every other backup/restore test mocks _satodime_scan_text, so only this one catches encoder/decoder mismatches. Verified to fail without the fix. --- src/seedsigner/views/smartcard_views.py | 8 +-- ...st_real_screen_flows_satodime_simulated.py | 49 ++++++++++++++++++- tests/ui_driver.py | 18 +++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index ea0f2962e..b69a8c183 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5363,12 +5363,14 @@ def _satodime_scan_text(view): """Scan one plain-text QR, returning its contents or None.""" from seedsigner.gui.screens.scan_screens import ScanScreen from seedsigner.models.decode_qr import DecodeQR - from seedsigner.models.qr_type import QRType - decoder = DecodeQR() + # is_text=True: without it, DecodeQR runs detect_segment_type() on the payload and + # classifies this card's backup string as INVALID (it matches no known format), so + # the scan-back could never verify. Same pattern as the GPG text-QR scans. + decoder = DecodeQR(is_text=True) ScanScreen(decoder=decoder, instructions_text="Scan the backup QR").display() view.controller.reset_screensaver_timeout() - if not decoder.is_complete or decoder.qr_type != QRType.TEXT: + if not decoder.is_complete: return None return decoder.get_text() diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index b55f3d4b9..7161b9fdb 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -47,7 +47,7 @@ from jcardsim import JCardSimUnavailable, why_unavailable from real_screen_fixtures import simulated_satodime, simulated_satodime_raw -from ui_driver import Back, UISession, select +from ui_driver import Back, UISession, make_noise_frame, select # tools_views must be imported first: it is a facade that star-imports smartcard_views. from seedsigner.gui.screens import RET_CODE__BACK_BUTTON @@ -947,6 +947,53 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): None, "Verify Backup", "Backup Verified", ] + def test_scanning_the_rendered_qr_back_verifies_end_to_end(self, monkeypatch): + """The scan-back loop with the real decode path -- no _satodime_scan_text mock. + + The code is created exactly the way the screenshot generator renders QR screens + (the encoder's part_to_image at display size), that frame is fed back through the + camera stand-in, and the view's real ScanScreen + DecodeQR classify and read it. + Every other test in this class monkeypatches _satodime_scan_text away, so only + this one catches encoder/decoder mismatches -- e.g. a payload that DecodeQR + classifies as anything but TEXT can never verify on device (the reported + scan-back failure).""" + from seedsigner.hardware.buttons import HardwareButtonsConstants as K + from seedsigner.models.decode_qr import DecodeQR + + if not DecodeQR.is_qr_scanner_available(): + pytest.skip(DecodeQR.get_qr_scanner_error()) + + from real_screen_fixtures import use_microsd + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card + payload = self._seed_cache() + + # Create the code the way the screenshot generator renders QR screens. + from seedsigner.models.encode_qr import GenericStaticQrEncoder + encoder = GenericStaticQrEncoder(data=payload) + qr_frame = encoder.part_to_image(encoder.cur_part(), 240, 240, border=2, background_color="ffffff") + + # The rendered frame must actually decode to the payload before we trust it. + assert DecodeQR.extract_qr_data(qr_frame, is_binary=True) == payload.encode("utf-8") + + script = ( + [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings -> continue + + select("Show QR Code") # chooser + + [K.KEY_PRESS] # leave the QR screen + + select("Scan It Back") # verify menu + + [K.KEY_PRESS] # "Backup Verified" OK + ) + session = UISession( + script=script, + camera_frames=[make_noise_frame(), qr_frame, make_noise_frame()], # miss, code, trailing for the preview thread + poll_responses=[False, False], # ScanScreen polls LEFT+RIGHT per non-decoding frame + ) + with session: + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + dest = view.run() + + assert dest.View_cls is smartcard_views.BackStackView + assert len(session.renderer.frames) > 0 + def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): from real_screen_fixtures import use_microsd diff --git a/tests/ui_driver.py b/tests/ui_driver.py index 4d89305d7..60f388243 100644 --- a/tests/ui_driver.py +++ b/tests/ui_driver.py @@ -416,7 +416,12 @@ def make_test_renderer(width=240, height=240) -> MagicMock: renderer.lock = threading.RLock() renderer.frames = [] - def show_image(image=None, alpha_overlay=None, is_background_thread=False): + def show_image(image=None, alpha_overlay=None, is_background_thread=False, show_direct=False): + if show_direct and image is not None: + # Mirrors Renderer.show_image(show_direct=True): the incoming frame is what + # gets displayed (camera preview), bypassing the canvas. + renderer.frames.append(image.copy()) + return if image is not None: renderer.canvas.paste(image) renderer.frames.append(renderer.canvas.copy()) @@ -498,9 +503,13 @@ class MockCameraFeed(MagicMock): """ Camera stand-in playing back scripted frames. preview=True reads peek at the head of the feed (the display frame); other reads consume (entropy frames). + + Undefined attributes (start_video_stream_mode, _video_stream, ...) fall through to + MagicMock's child-mock creation, which re-invokes this class with mock kwargs -- + hence **kwargs. """ - def __init__(self, frames=None): + def __init__(self, frames=None, **kwargs): super().__init__() self._frames = list(frames or []) @@ -514,7 +523,10 @@ def read_video_stream(self, as_image=False, preview=False): "read_video_stream() called but the camera frame feed is exhausted" ) if preview: - return self._frames[0] + # A copy, like a real camera's fresh buffer per read: the preview thread + # annotates its frame (instructions/progress) and must not deface what the + # decoder will later consume from the same feed slot. + return self._frames[0].copy() return self._frames.pop(0) From 4a3e808be280fabe1d80096bef4221175d825819 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 18:31:43 -0400 Subject: [PATCH 20/26] test: use real pyzbar when a native zbar library is available conftest and base installed a MagicMock pyzbar unconditionally, which made DecodeQR.is_qr_scanner_available() report True while extract_qr_data() silently decoded nothing (a MagicMock iterates empty). Any test exercising real QR decoding then failed on CI -- where libzbar0 is installed but the OpenCV fallback that masks this locally is not. Mock pyzbar only when it cannot really be imported, matching the existing pyscard pattern. Two follow-on fixes: - The text-QR decode nav test stubs ScanScreen: with real pyzbar the mocked camera's MagicMock frames crash zbar's pixel unpacking inside the real scan loop (it passed before only because the mock decoded nothing). - The Satodime scan-back e2e test skips when pyzbar is a mock, so it runs against a genuine decoder instead of failing on one. --- tests/base.py | 10 ++++++++-- tests/conftest.py | 11 +++++++++-- tests/test_flows_menu_navigation.py | 19 ++++++++++++------- ...st_real_screen_flows_satodime_simulated.py | 8 ++++++++ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/tests/base.py b/tests/base.py index 1080f5104..0834cdbf8 100644 --- a/tests/base.py +++ b/tests/base.py @@ -30,8 +30,14 @@ sys.modules['seedsigner.hardware.ili9341'] = MagicMock() sys.modules['RPi'] = MagicMock() sys.modules['RPi.GPIO'] = MagicMock() -sys.modules['pyzbar'] = MagicMock() -sys.modules['pyzbar.pyzbar'] = MagicMock() +# Use the real pyzbar when it's importable (e.g. desktop/CI with libzbar0). A blanket +# MagicMock makes DecodeQR.is_qr_scanner_available() report True while extract_qr_data() +# silently decodes nothing, which breaks any test that exercises real QR decoding. +try: + from pyzbar import pyzbar # noqa: F401 +except Exception: + sys.modules['pyzbar'] = MagicMock() + sys.modules['pyzbar.pyzbar'] = MagicMock() sys.modules['pysatochip'] = MagicMock() sys.modules['pysatochip.JCconstants'] = MagicMock() sys.modules['pysatochip.util'] = MagicMock() diff --git a/tests/conftest.py b/tests/conftest.py index 5bb07b918..dce17f5a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,15 @@ # Mock hardware-dependent modules so unit tests can run without them sys.modules.setdefault('RPi', MagicMock()) sys.modules.setdefault('RPi.GPIO', MagicMock()) -sys.modules.setdefault('pyzbar', MagicMock()) -sys.modules.setdefault('pyzbar.pyzbar', MagicMock()) +# Only mock pyzbar if it cannot really be imported (e.g. the native zbar library is +# missing). A blanket MagicMock makes DecodeQR.is_qr_scanner_available() lie -- it +# reports "available" while extract_qr_data() silently decodes nothing, which breaks +# any test that exercises real QR decoding (see test_real_screen_flows_satodime_simulated.py). +try: + from pyzbar import pyzbar # noqa: F401 +except Exception: + sys.modules.setdefault('pyzbar', MagicMock()) + sys.modules.setdefault('pyzbar.pyzbar', MagicMock()) sys.modules.setdefault('pysatochip', MagicMock()) sys.modules.setdefault('pysatochip.JCconstants', MagicMock()) sys.modules.setdefault('pysatochip.util', MagicMock()) diff --git a/tests/test_flows_menu_navigation.py b/tests/test_flows_menu_navigation.py index fdc9441a2..29368fc8d 100644 --- a/tests/test_flows_menu_navigation.py +++ b/tests/test_flows_menu_navigation.py @@ -337,14 +337,19 @@ def test_tools_text_qr_decode(self): (verifies the ``import time`` fix in ``gpg_views.py``). The View calls ``ScanScreen(...).display()`` directly (not ``run_screen``), so mark it as a redirect. ``BackStackView`` pops back to ``ToolsTextQRView``. + + ScanScreen is stubbed: the harness can't drive a live camera loop, and with + a real pyzbar installed the mocked camera's MagicMock frames would crash + zbar's pixel unpacking inside the real scan loop. """ - self.run_sequence([ - FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), - FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.TEXTQRCODE), - FlowStep(tools_views.ToolsTextQRView, button_data_selection=ButtonOption("Decode QR code")), - FlowStep(tools_views.ToolsTextQRScanQRCodeView, is_redirect=True), - FlowStep(tools_views.ToolsTextQRView), - ]) + with patch("seedsigner.gui.screens.scan_screens.ScanScreen"): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.TEXTQRCODE), + FlowStep(tools_views.ToolsTextQRView, button_data_selection=ButtonOption("Decode QR code")), + FlowStep(tools_views.ToolsTextQRScanQRCodeView, is_redirect=True), + FlowStep(tools_views.ToolsTextQRView), + ]) def test_tools_password_generator(self): """Tools → Password Generator → BACK → ToolsMenu.""" diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 7161b9fdb..0cf0f7723 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -957,11 +957,19 @@ def test_scanning_the_rendered_qr_back_verifies_end_to_end(self, monkeypatch): this one catches encoder/decoder mismatches -- e.g. a payload that DecodeQR classifies as anything but TEXT can never verify on device (the reported scan-back failure).""" + from unittest.mock import MagicMock + from seedsigner.hardware.buttons import HardwareButtonsConstants as K from seedsigner.models.decode_qr import DecodeQR + import seedsigner.models.decode_qr as decode_qr_module if not DecodeQR.is_qr_scanner_available(): pytest.skip(DecodeQR.get_qr_scanner_error()) + # conftest installs a MagicMock pyzbar when no native zbar library is present; + # that mock makes is_qr_scanner_available() report True while decoding nothing, + # so skip rather than run against it. + if isinstance(decode_qr_module.pyzbar, MagicMock): + pytest.skip("pyzbar is mocked in this environment (no native zbar library)") from real_screen_fixtures import use_microsd use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card From 20ccac19e3b3a4cd91aa8fa6baa5fc3e18d6a50c Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 18:31:51 -0400 Subject: [PATCH 21/26] docs: describe installing pyzbar on Windows for desktop mode The pinned pyzbar fork ships source only, so on Windows the native ZBar DLLs must be copied from the official PyPI wheel into the installed package. Without them QR scanning is silently disabled at runtime. --- docs/desktop_simulation.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/desktop_simulation.md b/docs/desktop_simulation.md index 933bf1f4e..fcc79d1d1 100644 --- a/docs/desktop_simulation.md +++ b/docs/desktop_simulation.md @@ -19,6 +19,43 @@ seeds or private keys. On start-up a warning splash screen reminds you that desktop mode is for testing only. +### QR scanning (pyzbar) on Windows + +QR scanning needs [pyzbar](https://pypi.org/project/pyzbar/), which wraps the +native ZBar library. `requirements.txt` pins our +[pyzbar fork](https://github.com/seedsigner/pyzbar) (commit `c3c2378`), whose +`decode()` supports the `binary=` keyword argument used for binary QR formats +such as SeedQR. + +On Linux you also install the native library (`sudo apt install libzbar0`). +On Windows there is no equivalent package-manager step: the fork ships only +source, so after installing the requirements you must supply ZBar's DLLs +yourself. The official PyPI `pyzbar` wheel bundles them; copy the two DLLs +out of it into the installed fork's package directory: + +```powershell +# 1. Download and unpack the official Windows wheel (64-bit Python) +python -m pip download pyzbar==0.1.9 --only-binary=:all: -d %TEMP%\pyzbar_dl +python -m zipfile -e %TEMP%\pyzbar_dl\pyzbar-0.1.9-py2.py3-none-win_amd64.whl %TEMP%\pyzbar_dl + +# 2. Copy the ZBar DLLs into the installed fork package +$pkg = python -c "import pyzbar, os; print(os.path.dirname(pyzbar.__file__))" +Copy-Item "%TEMP%\pyzbar_dl\pyzbar\libiconv.dll", "%TEMP%\pyzbar_dl\pyzbar\libzbar-64.dll" $pkg + +# 3. Verify (both checks must succeed) +python -c "from pyzbar import pyzbar; print('pyzbar_ok')" +python -c "import inspect; from pyzbar import pyzbar; assert 'binary' in inspect.signature(pyzbar.decode).parameters; print('fork_ok')" +``` + +The fork's `zbar_library.py` loads `libzbar-64.dll` (and its dependency +`libiconv.dll`) from the package directory on 64-bit Windows. Without them, +importing pyzbar fails with `FileNotFoundError: Could not find module +'libiconv.dll'`, and QR scanning is disabled at run time +(`DecodeQR.is_qr_scanner_available()` returns False). + +The same two DLLs work for any Python version; re-copy them if you ever +reinstall pyzbar into a fresh environment. + ## Controls The Waveshare HAT's physical buttons are mapped to your keyboard: From e1d80dc1cd87821935c9716d4492c626765fd018 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Thu, 10 Sep 2026 22:03:26 -0400 Subject: [PATCH 22/26] test: cover the Satodime QR backup ceremony over simulated NFC end to end --- ...st_real_screen_flows_satodime_simulated.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 0cf0f7723..186c04ff4 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -892,6 +892,128 @@ def test_nfc_claim_restore_seal_end_to_end(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) + def test_nfc_claim_qr_backup_microsd_restore_seal_end_to_end(self, monkeypatch): + """ + The ownership-key lifecycle against the real applet over simulated NFC (T=CL, + TYPE_A,T0), with the backup produced by the QR ceremony itself rather than + hand-written: + + claim -> back up (save to MicroSD, then photograph the rendered code and scan it + back through the real decode path) -> lose the key in a new session -> seal + refused with 'Key Required' -> restore from the MicroSD copy the ceremony wrote + -> seal succeeds. + + test_nfc_claim_restore_seal_end_to_end writes the backup file directly, so only + this one catches a card_id/secret mismatch between what the claim caches and + what the QR encodes -- the 'key is for the wrong card' class of failure that + hand-written backups cannot reproduce. The scan-back runs the real decode path: + the code is rendered exactly as on device (the encoder's part_to_image) and read + back by the view's real ScanScreen + DecodeQR, so an encoder/decoder mismatch + fails here instead of on hardware. + """ + from seedsigner.hardware.buttons import HardwareButtonsConstants as K + from seedsigner.models.decode_qr import DecodeQR + import seedsigner.models.decode_qr as decode_qr_module + + if not DecodeQR.is_qr_scanner_available(): + pytest.skip(DecodeQR.get_qr_scanner_error()) + # conftest installs a MagicMock pyzbar when no native zbar library is present; + # that mock makes is_qr_scanner_available() report True while decoding nothing, + # so skip rather than run against it. + if isinstance(decode_qr_module.pyzbar, MagicMock): + pytest.skip("pyzbar is mocked in this environment (no native zbar library)") + + from real_screen_fixtures import use_microsd + + try: + with simulated_satodime_raw(protocol="T=CL,TYPE_A,T0"): + # 1. Claim over NFC: INS_SETUP mints counter+secret; the view caches it + # and routes into the backup ceremony (from_claim=True). + claim_view = smartcard_views.ToolsSatodimeClaimView() + claim_view.run_screen = ScreenRecorder(0) # confirm claim + dest = claim_view.run() + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + + (secret,) = list((self.controller.Satodime_unlock_secrets or {}).values()) + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret) + + # 2. The ceremony itself writes the MicroSD copy -- nothing hand-written. + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + + # Create the code the way the device renders QR screens, and prove it + # decodes back to the payload before trusting the ceremony with it. + from seedsigner.models.encode_qr import GenericStaticQrEncoder + encoder = GenericStaticQrEncoder(data=payload) + qr_frame = encoder.part_to_image(encoder.cur_part(), 240, 240, border=2, background_color="ffffff") + assert DecodeQR.extract_qr_data(qr_frame, is_binary=True) == payload.encode("utf-8") + + # 3. Run the ceremony with real screens: intro warnings -> save to + # MicroSD (ack "Saved") -> show QR -> scan it back (the camera stand-in + # serves the rendered frame; the view's real ScanScreen + DecodeQR read + # it) -> "Backup Verified" ends the flow. + script = ( + [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings + + select("Save to MicroSD") # chooser + + [K.KEY_PRESS] # ack "Saved" + + select("Show QR Code") # chooser again + + [K.KEY_PRESS] # leave the QR screen + + select("Scan It Back") # verify menu + + [K.KEY_PRESS] # OK on "Backup Verified" + ) + session = UISession( + script=script, + camera_frames=[make_noise_frame(), qr_frame, make_noise_frame()], # miss, code, trailing for the preview thread + poll_responses=[False, False], # ScanScreen polls LEFT+RIGHT per non-decoding frame + ) + with session: + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=card_id, from_claim=True) + dest = view.run() + + assert dest.View_cls is smartcard_views.BackStackView + assert not session.remaining_script + assert len(session.renderer.frames) > 0 + + # The ceremony's MicroSD copy must hold exactly this card's key. + backup_file = microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(card_id) + assert backup_file.read_text(encoding="utf-8") == payload + + # 4. New session: the in-RAM cache is gone (the controller wipes it at Home). + self.controller.Satodime_unlock_secrets = None + _populate_cache([(smartcard_views.SATODIME_SLOT_UNINITIALIZED, None, None)]) + + # 5. Seal without the key: over contactless media the applet rejects with + # 0x9C51 (zeroed placeholder secret) and the view must offer a restore. + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0, 0, 0) # coin, no-backup warning, "Restore Key" + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Seal As", "No Backup", "Key Required"] + assert dest.View_cls is smartcard_views.ToolsSatodimeRestoreUnlockView + + # 6. Restore from the MicroSD copy the ceremony wrote -- one tap, no camera. + restore_view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) # "Load Ownership Key from MicroSD", ack success + restore_view.run_screen = recorder + dest = restore_view.run() + + assert recorder.titles == ["Ownership Key", "Ownership Key Set"] + + # 7. Back on the seal action: with the key cached, counter+HMAC check out + # and the slot seals for real over NFC. + view = smartcard_views.ToolsSatodimeSealSlotView(0) + recorder = ScreenRecorder(0, 0, 0) # coin, no-backup warning, success + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Seal As", "No Backup", "Success"] + headline, address = recorder.body_for("Success").split("\n") + assert headline == "Slot 0 sealed BTC" + assert BECH32_ADDRESS.match(address), address + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + class TestUnlockSecretPayload: """The backup payload is what a user's phone photo has to survive.""" From 48428dfeb928363e9cbce6215ffd4f041e8dc22d Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Fri, 11 Sep 2026 08:10:05 -0400 Subject: [PATCH 23/26] fix: make Satodime card identification self-healing and fail honestly The card id (UID_SHA1) is derived from CPLC/IIN/CIN reads that some readers serve only intermittently, so the same chip could derive different ids across connections -- surfacing as a misleading 'Wrong Card' on restore and cache misses ('Key Required') even with the key in memory. - satodime_card_id() now treats an unset UID_SHA1 or the empty-hash sentinel (sha1 of blank CPLC/IIN/CIN) as unidentified, never a real id - ClaimView identifies the card before claiming (reconnect retries); it aborts with 'Cannot Identify Card' rather than minting a backup keyed to an empty id that can never be restored - RestoreUnlockView shows 'Cannot Identify Card' when derivation fails; on a genuine mismatch it warns and offers Load Anyway instead of dead-ending - ScanScreen.LivePreviewThread no longer dies silently: camera read failures log and exit cleanly, render failures log and continue (a stream stopped by the decode loop used to raise in the preview thread and leave the display undefined) - jcardsim shim now serves deterministic per-card CPLC/IIN/CIN data so simulated cards derive real ids instead of all sharing the empty-hash sentinel --- src/seedsigner/gui/screens/scan_screens.py | 237 ++++++++++-------- src/seedsigner/helpers/seedkeeper_utils.py | 22 +- src/seedsigner/views/smartcard_views.py | 67 ++++- tests/jcardsim/pcsc_shim.py | 20 ++ ...st_real_screen_flows_satodime_simulated.py | 94 ++++++- 5 files changed, 325 insertions(+), 115 deletions(-) diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 8fecb0608..001ab1e3e 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -1,3 +1,4 @@ +import logging import math import time @@ -5,6 +6,8 @@ from gettext import gettext as _ from PIL import Image, ImageDraw +logger = logging.getLogger(__name__) + from seedsigner.gui import renderer from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay @@ -122,126 +125,144 @@ def run(self): num_frames = 0 while self.keep_running: - frame = self.camera.read_video_stream(as_image=True, preview=True) + try: + frame = self.camera.read_video_stream(as_image=True, preview=True) + except Exception: + # The decode loop (or a button press) can stop the stream between our + # check and this read. Letting that exception kill the thread silently + # is how a scan ends with the display stuck in an undefined state; log + # it and exit cleanly instead. + logger.exception("LivePreviewThread: camera read failed; stopping preview") + break if frame is not None: num_frames += 1 - + scan_text = None progress_percentage = self.decoder.get_percent_complete() if progress_percentage == 0: # We've just started scanning, no results yet scan_text = self.instructions_text - with self.renderer.lock: - # Use nearest neighbor resizing for max speed - frame = resize_image_to_fit(frame, self.render_width, self.render_height, sampling_method=Image.Resampling.NEAREST) - - if scan_text: - # Note: shadowed text (adding a 'stroke' outline) can - # significantly slow down the rendering. - # Temp solution: render a slight 1px shadow behind the text - # TODO: Replace the instructions_text with a disappearing - # toast/popup (see: QR Brightness UI)? - draw = ImageDraw.Draw(frame) - draw.text(xy=( - int(self.renderer.canvas_width/2 + 2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2 - ), - text=scan_text, - fill="black", - font=instructions_font, - anchor="ms") - - # Render the onscreen instructions - draw.text(xy=( - int(self.renderer.canvas_width/2), - self.renderer.canvas_height - GUIConstants.EDGE_PADDING - ), - text=scan_text, - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - anchor="ms") - - else: - # Render the progress bar - rectangle = Image.new('RGBA', (self.renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, GUIConstants.BUTTON_HEIGHT), (0, 0, 0, 0)) - draw = ImageDraw.Draw(rectangle) - - # Start with a background rounded rectangle, same dims as the buttons - overlay_color = (0, 0, 0, 191) # opacity ranges from 0-255 - draw.rounded_rectangle( - ( - (0, 0), - (rectangle.width, rectangle.height) - ), - fill=overlay_color, - radius=8, - outline=overlay_color, - width=2, - ) - - progress_bar_thickness = 4 - progress_bar_width = rectangle.width - 2*GUIConstants.EDGE_PADDING - progress_text_width - int(GUIConstants.EDGE_PADDING/2) - progress_bar_xy = ( - (GUIConstants.EDGE_PADDING, int((rectangle.height - progress_bar_thickness) / 2)), - (GUIConstants.EDGE_PADDING + progress_bar_width, int(rectangle.height + progress_bar_thickness) / 2) - ) - draw.rounded_rectangle( - progress_bar_xy, - fill=GUIConstants.INACTIVE_COLOR, - radius=8 - ) - - progress_percentage = self.decoder.get_percent_complete(weight_mixed_frames=True) - draw.rounded_rectangle( - ( - progress_bar_xy[0], - (GUIConstants.EDGE_PADDING + int(progress_percentage * progress_bar_width / 100.0), progress_bar_xy[1][1]) - ), - fill=GUIConstants.GREEN_INDICATOR_COLOR, - radius=8 - ) - - # TRANSLATOR_NOTE: Inserts the percentage value of the animated QR scan progress - text = _("{}%").format(progress_percentage) - - draw.text( - xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)), - text=text, - fill=GUIConstants.BODY_FONT_COLOR, - font=instructions_font, - anchor="rm", # right-justified, middle - ) - - frame.paste(rectangle, (GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - rectangle.height), rectangle) - - # Render the dot to indicate successful QR frame read - indicator_size = 10 - status_color_map = { - ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR, - ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR, - ScanScreen.FRAME__MISS: None, - } - status_color = status_color_map.get(self.frame_decode_status.cur_count) - if status_color: - # Good! Most recent frame successfully decoded. - # Draw the onscreen indicator dot - draw = ImageDraw.Draw(frame) - draw.ellipse( - ( - (self.renderer.canvas_width - GUIConstants.EDGE_PADDING - indicator_size, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING - indicator_size), - (self.renderer.canvas_width - GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING) - ), - fill=status_color, - outline="black", - width=1, - ) - - self.renderer.show_image(frame, show_direct=True) + try: + self._render_preview_frame( + frame, scan_text, instructions_font, progress_text_width + ) + except Exception: + # A single bad frame must not kill the preview thread; log it and + # move on to the next one. + logger.exception("LivePreviewThread: failed to render preview frame") if self.camera._video_stream is None: break + def _render_preview_frame(self, frame, scan_text, instructions_font, progress_text_width): + with self.renderer.lock: + # Use nearest neighbor resizing for max speed + frame = resize_image_to_fit(frame, self.render_width, self.render_height, sampling_method=Image.Resampling.NEAREST) + + if scan_text: + # Note: shadowed text (adding a 'stroke' outline) can + # significantly slow down the rendering. + # Temp solution: render a slight 1px shadow behind the text + # TODO: Replace the instructions_text with a disappearing + # toast/popup (see: QR Brightness UI)? + draw = ImageDraw.Draw(frame) + draw.text(xy=( + int(self.renderer.canvas_width/2 + 2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2 + ), + text=scan_text, + fill="black", + font=instructions_font, + anchor="ms") + + # Render the onscreen instructions + draw.text(xy=( + int(self.renderer.canvas_width/2), + self.renderer.canvas_height - GUIConstants.EDGE_PADDING + ), + text=scan_text, + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + anchor="ms") + + else: + # Render the progress bar + rectangle = Image.new('RGBA', (self.renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, GUIConstants.BUTTON_HEIGHT), (0, 0, 0, 0)) + draw = ImageDraw.Draw(rectangle) + + # Start with a background rounded rectangle, same dims as the buttons + overlay_color = (0, 0, 0, 191) # opacity ranges from 0-255 + draw.rounded_rectangle( + ( + (0, 0), + (rectangle.width, rectangle.height) + ), + fill=overlay_color, + radius=8, + outline=overlay_color, + width=2, + ) + + progress_bar_thickness = 4 + progress_bar_width = rectangle.width - 2*GUIConstants.EDGE_PADDING - progress_text_width - int(GUIConstants.EDGE_PADDING/2) + progress_bar_xy = ( + (GUIConstants.EDGE_PADDING, int((rectangle.height - progress_bar_thickness) / 2)), + (GUIConstants.EDGE_PADDING + progress_bar_width, int((rectangle.height + progress_bar_thickness) / 2)) + ) + draw.rounded_rectangle( + progress_bar_xy, + fill=GUIConstants.INACTIVE_COLOR, + radius=8 + ) + + progress_percentage = self.decoder.get_percent_complete(weight_mixed_frames=True) + draw.rounded_rectangle( + ( + progress_bar_xy[0], + (GUIConstants.EDGE_PADDING + int(progress_percentage * progress_bar_width / 100.0), progress_bar_xy[1][1]) + ), + fill=GUIConstants.GREEN_INDICATOR_COLOR, + radius=8 + ) + + # TRANSLATOR_NOTE: Inserts the percentage value of the animated QR scan progress + text = _("{}%").format(progress_percentage) + + draw.text( + xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)), + text=text, + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + anchor="rm", # right-justified, middle + ) + + frame.paste(rectangle, (GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - rectangle.height), rectangle) + + # Render the dot to indicate successful QR frame read + indicator_size = 10 + status_color_map = { + ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR, + ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR, + ScanScreen.FRAME__MISS: None, + } + status_color = status_color_map.get(self.frame_decode_status.cur_count) + if status_color: + # Good! Most recent frame successfully decoded. + # Draw the onscreen indicator dot + draw = ImageDraw.Draw(frame) + draw.ellipse( + ( + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING - indicator_size, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING - indicator_size), + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING) + ), + fill=status_color, + outline="black", + width=1, + ) + + self.renderer.show_image(frame, show_direct=True) + def _run(self): """ diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 3ad4625f4..628a06d5b 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -19,6 +19,7 @@ from seedsigner.helpers.keycard_connector import KeycardSatochipConnector +import hashlib import os import re import time @@ -491,10 +492,29 @@ def junk(): SATODIME_UNLOCK_PREFIX = "satodime-unlock:" SIZE_SATODIME_UNLOCK_SECRET = 20 +# sha1(b"") -- the UID_SHA1 pysatochip computes when its insertion observer reads +# CPLC/IIN/CIN and all three come back empty. Readers that do not serve those +# GlobalPlatform data objects answer 6E00 with no data, and pysatochip hashes whatever +# it got without checking the status words -- so an unidentified card silently derives +# this constant instead of failing. Treat it as "not derived", never a real id. +_EMPTY_UID_SHA1 = hashlib.sha1(b"").hexdigest() + +# What satodime_card_id() returns when the card could not be identified. Callers must +# treat it as "card unidentified" and offer a re-present/retry path; it is never a key. +SATODIME_CARD_ID_UNAVAILABLE = "" + def satodime_card_id(connector) -> str: - """Short, stable id for a Satodime, used to key its unlock secret.""" + """Short, stable id for a Satodime, used to key its unlock secret. + + Returns SATODIME_CARD_ID_UNAVAILABLE ("") when the card could not be identified -- + UID_SHA1 unset (the insertion observer never ran or failed) or equal to the + empty-hash sentinel (CPLC/IIN/CIN all came back blank). Callers must treat "" as + "card unidentified" and offer a re-present/retry path; it is never a valid key. + """ uid = getattr(connector, "UID_SHA1", None) or "" + if not uid or str(uid) == _EMPTY_UID_SHA1: + return SATODIME_CARD_ID_UNAVAILABLE return str(uid)[:16] diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index b69a8c183..83a4c5a7e 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4913,6 +4913,33 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) + # Identify the card before anything state-changing. The id (UID_SHA1) is derived + # from CPLC/IIN/CIN reads that some readers serve only intermittently -- a + # connection can complete fine while the id comes back empty. Claiming an + # unidentified card would mint a backup keyed to an empty id that can never be + # restored, and if identification failed afterwards the freshly minted secret + # (emitted exactly once) would be lost forever. + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if not card_id: + for _attempt in range(2): + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=["satodime"], require_pin=False + ) + if not Satochip_Connector: + break + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if card_id: + break + if not card_id: + self.run_screen( + WarningScreen, + title="Cannot Identify Card", + status_headline=None, + text="Re-present the card\nand try again.", + show_back_button=True, + ) + return Destination(BackStackView) + if _satodime_is_claimed(Satochip_Connector): # Taking ownership erases the current owner's key (their sealed slots and # funds survive -- only the NFC gate changes), so confirm before doing it. @@ -5000,8 +5027,9 @@ def run(self): ) return Destination(BackStackView) - # card_setup() caches the freshly minted counter + secret on the connector. - card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + # card_setup() caches the freshly minted counter + secret on this connector. The + # id was derived (and retried) before the claim above, so it is valid here: + # CPLC/IIN/CIN are OS-level data that claiming does not change. seedkeeper_utils.cache_satodime_unlock_secret( self.controller, card_id, list(Satochip_Connector.unlock_secret) ) @@ -5249,6 +5277,29 @@ def run(self): return Destination(BackStackView) card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if not card_id: + # The connection completed but the card did not identify itself (CPLC/IIN/CIN + # came back blank -- some readers serve those only intermittently). Reconnect + # and retry before comparing against the backup's id, or every restore would + # fail with a misleading "Wrong Card". + for _attempt in range(2): + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=["satodime"], require_pin=False + ) + if not Satochip_Connector: + break + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if card_id: + break + if not card_id: + self.run_screen( + WarningScreen, + title="Cannot Identify Card", + status_headline=None, + text="Re-present the card\nand try again.", + show_back_button=True, + ) + return Destination(BackStackView) # A backup for this card on the MicroSD is the fastest restore path, so offer it # first when one exists; with nothing on the card there is no menu -- scanning is @@ -5285,14 +5336,20 @@ def run(self): backup_card_id, secret = parsed if backup_card_id != card_id: - self.run_screen( - WarningScreen, + # A genuine mismatch between two successfully derived ids. Historically a + # dead end; since flaky readers can mis-derive an id, let the user force-load + # after a warning instead. A wrong key is self-correcting: it simply fails at + # the applet's gate with "Key Required" until re-restored from the right code. + selected = self.run_screen( + DireWarningScreen, title="Wrong Card", status_headline=None, text="That code belongs to a\ndifferent Satodime.", show_back_button=True, + button_data=[ButtonOption("Load Anyway")], ) - return Destination(BackStackView) + if selected != 0: + return Destination(BackStackView) seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, secret) self.run_screen( diff --git a/tests/jcardsim/pcsc_shim.py b/tests/jcardsim/pcsc_shim.py index c318ff1e4..10d0fedfc 100644 --- a/tests/jcardsim/pcsc_shim.py +++ b/tests/jcardsim/pcsc_shim.py @@ -17,6 +17,7 @@ ``(data, sw1, sw2)`` shape. """ +import hashlib from contextlib import ExitStack, contextmanager from unittest.mock import patch @@ -42,8 +43,27 @@ def disconnect(self): self.connected = False def transmit(self, apdu, protocol=None): + # GlobalPlatform GET DATA for CPLC/IIN/CIN: jcardsim's card OS answers these + # with 6E00/empty (like a reader that does not provide them), which would make + # every simulated card derive the empty-hash id da39a3ee5e6b4b0d. Serve + # deterministic per-card data instead, modelling a well-behaved reader -- + # pysatochip hashes exactly these three responses into UID_SHA1, and real + # readers do return them (intermittently, on some hardware). + head = list(apdu[:4]) + if head == [0x80, 0xCA, 0x9F, 0x7F]: # CPLC + return (self._gp_data(0), 0x90, 0x00) + if head == [0x80, 0xCA, 0x00, 0x42]: # IIN + return (self._gp_data(1), 0x90, 0x00) + if head == [0x80, 0xCA, 0x00, 0x45]: # CIN + return (self._gp_data(2), 0x90, 0x00) return self._card.transmit(apdu) + def _gp_data(self, part: int) -> list[int]: + """Deterministic per simulated-card instance; distinct across the three parts.""" + seed = id(self._card) & 0xFFFFFFFF + digest = hashlib.sha1(bytes([part]) + seed.to_bytes(4, "big")).digest() + return list(digest[:8]) + def getATR(self): return list(SIMULATED_ATR) diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 186c04ff4..4000c4bdc 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -1014,6 +1014,73 @@ def test_nfc_claim_qr_backup_microsd_restore_seal_end_to_end(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) + def test_claim_refuses_to_mint_backup_when_the_card_cannot_be_identified(self, monkeypatch): + """A connection can complete while CPLC/IIN/CIN come back blank (some readers + serve them only intermittently), leaving UID_SHA1 unset or at the empty-hash + sentinel. Claiming must then retry with fresh connectors and, still unidentified, + abort BEFORE claiming: a backup keyed to an empty id can never be restored, and + claiming first would emit the one-time secret that could no longer be backed up.""" + from real_screen_fixtures import use_microsd + + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + monkeypatch.setattr(seedkeeper_utils, "satodime_card_id", lambda connector: "") + + init_calls = [] + real_init = seedkeeper_utils.init_satochip + + def counting_init(*args, **kwargs): + init_calls.append(1) + return real_init(*args, **kwargs) + + monkeypatch.setattr(seedkeeper_utils, "init_satochip", counting_init) + + view = smartcard_views.ToolsSatodimeClaimView() + recorder = ScreenRecorder(0) # ack "Cannot Identify Card" (no claim screens at all) + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Cannot Identify Card"] + assert len(init_calls) == 3 # initial + two reconnect retries + assert not (self.controller.Satodime_unlock_secrets or {}) + assert dest.View_cls is smartcard_views.BackStackView + + def test_restore_refuses_when_the_card_cannot_be_identified(self, monkeypatch): + """Same unidentified-card condition at restore time: retry with fresh connectors, + then say 'cannot identify' instead of the misleading 'Wrong Card' (the backup's id + cannot be compared against one that was never derived).""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + monkeypatch.setattr(seedkeeper_utils, "satodime_card_id", lambda connector: "") + + init_calls = [] + real_init = seedkeeper_utils.init_satochip + + def counting_init(*args, **kwargs): + init_calls.append(1) + return real_init(*args, **kwargs) + + monkeypatch.setattr(seedkeeper_utils, "init_satochip", counting_init) + + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0) # ack "Cannot Identify Card" + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Cannot Identify Card"] + assert len(init_calls) == 3 # initial + two reconnect retries + assert not (self.controller.Satodime_unlock_secrets or {}) + assert dest.View_cls is smartcard_views.BackStackView + class TestUnlockSecretPayload: """The backup payload is what a user's phone photo has to survive.""" @@ -1250,13 +1317,38 @@ def test_restore_rejects_another_card_s_backup(self, monkeypatch): with ctx: use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no backup on the card -> straight to scan view = smartcard_views.ToolsSatodimeRestoreUnlockView() - recorder = ScreenRecorder(0) # ack the "Wrong Card" warning (scan is scripted) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # decline the "Wrong Card" warning (scan is scripted) view.run_screen = recorder view.run() assert recorder.titles == ["Wrong Card"] assert not (self.controller.Satodime_unlock_secrets or {}) + def test_restore_warns_then_force_loads_another_card_s_backup(self, monkeypatch): + """A mismatched id is a warning with an explicit override, not a dead end: ids + can be mis-derived on flaky readers, and a wrong key is self-correcting (it fails + at the applet's gate until re-restored from the right code).""" + from real_screen_fixtures import use_microsd + + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + card_id = seedkeeper_utils.satodime_card_id(_fresh_connector()) + other = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET) + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) + + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no backup on the card -> straight to scan + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0, 0) # "Load Anyway", ack success (scan is scripted) + view.run_screen = recorder + view.run() + + assert recorder.titles == ["Wrong Card", "Ownership Key Set"] + assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET + def test_restore_loads_this_card_s_backup(self, monkeypatch): from real_screen_fixtures import use_microsd From 8a38919db1f674c1ef1ce2419bb6fc8af9f28a0b Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Fri, 11 Sep 2026 13:53:50 -0400 Subject: [PATCH 24/26] fix: re-query card identification in init_satochip and harden all UID checks Readers that serve CPLC/IIN/CIN only intermittently can complete a connection while the derived id is still unset or at the empty-hash sentinel, which misbehaved across several workflows: PIN swap detection could apply a cached PIN to an unconfirmed card (burning one of its limited tries), Satochip_Last_UID_SHA1 got poisoned with sentinels, and Satodime views showed misleading 'Key Required' / 'No Ownership Key' / 'Wrong Card' errors. - add is_usable_uid(): shared predicate for unset/empty/sentinel ids (full or truncated) - init_satochip re-queries the id itself: on a connected-but-unidentified card it rebuilds the connector and reconnects, bounded by MAX_UID_IDENTIFY_ATTEMPTS; plain connect failures keep their single 5-second window - PIN swap check trusts 'same card' only when both ids are usable AND equal; Satochip_Last_UID_SHA1 is never overwritten with a sentinel (recorded as one when previously unset, so continuity cannot be assumed) - Claim/Restore drop their now-redundant view-level retry loops; ReshowUnlock and GenuineCheck guard against unusable ids instead of failing misleadingly; Card Info no longer displays the raw sentinel --- src/seedsigner/helpers/seedkeeper_utils.py | 177 +++++++++++++----- src/seedsigner/views/smartcard_views.py | 64 ++++--- ...st_real_screen_flows_satodime_simulated.py | 129 ++++++++++--- 3 files changed, 270 insertions(+), 100 deletions(-) diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 628a06d5b..853ade081 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -503,6 +503,25 @@ def junk(): # treat it as "card unidentified" and offer a re-present/retry path; it is never a key. SATODIME_CARD_ID_UNAVAILABLE = "" +# Total connect attempts init_satochip makes before accepting an unidentified card +# (initial + reconnects). Re-querying only helps readers that serve CPLC/IIN/CIN +# intermittently, so this stays small: cards/reader combos that never identify must not +# hang the flow -- they proceed unidentified and the callers' guards handle it. +MAX_UID_IDENTIFY_ATTEMPTS = 3 + + +def is_usable_uid(uid) -> bool: + """True when ``uid`` looks like a real card id rather than an identification failure. + + Catches unset/empty values (the insertion observer never ran or failed) and the + empty-hash sentinel (CPLC/IIN/CIN all came back blank). The comparison is on the + first 16 hex chars so truncated UIDs are caught too; a real id sharing that prefix + with sha1(b"") has ~2^-64 odds. + """ + if not uid: + return False + return str(uid)[:16].lower() != _EMPTY_UID_SHA1[:16] + def satodime_card_id(connector) -> str: """Short, stable id for a Satodime, used to key its unlock secret. @@ -513,7 +532,7 @@ def satodime_card_id(connector) -> str: "card unidentified" and offer a re-present/retry path; it is never a valid key. """ uid = getattr(connector, "UID_SHA1", None) or "" - if not uid or str(uid) == _EMPTY_UID_SHA1: + if not is_usable_uid(uid): return SATODIME_CARD_ID_UNAVAILABLE return str(uid)[:16] @@ -587,6 +606,58 @@ def apply_satodime_unlock_secret(controller, connector) -> bool: return False +def _connect_card(parentObject, connector): + """Run the 5-second "spam connecting" loop against ``connector``. + + Returns the card status dict on success, or None when no usable connection was + established within the window (the connector is left disconnected). + """ + parentObject.loading_screen = LoadingScreenThread(text="Connecting to Card") + parentObject.loading_screen.start() + + # Spam connecting for 5 seconds to give the user time to insert the card + status = None + time_end = time.time() + 5 + + while time.time() < time_end: + try: + + time.sleep(0.5) # give some time to initialize reader... + status = connector.card_get_status() + print("Found Card:", connector.UID_SHA1) + print(status[3]) + + if connector.needs_secure_channel: + print("Initiating Secure Channel") + connector.card_initiate_secure_channel() + print("Secure Channel Initialised") + + if ( + len(status[3]) > 0 + ): # Sometimes it's possible to end up with an invalid of zero length here... + break + else: + # Cleanup the connector and try again + try: + connector.card_disconnect() + except Exception: + pass + + except Exception as e: + print("CardConnector Init Failed:" + str(e)) + # Ensure the connector state is clean before trying again + try: + connector.card_disconnect() + except Exception: + pass + time.sleep(0.1) # Sleep for 100ms + + status = None # Reset this every loop... + + parentObject.loading_screen.stop() + return status + + def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend_preference: str | None = None, allow_unseeded: bool = False): from seedsigner.models.settings import ( Settings, @@ -673,49 +744,43 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend # Satodime has no PIN; bind the variable so the cache step below stays safe. card_pin = None - parentObject.loading_screen = LoadingScreenThread(text="Connecting to Card") - parentObject.loading_screen.start() - - # Spam connecting for 5 seconds to give the user time to insert the card - status = None - time_end = time.time() + 5 - - while time.time() < time_end: + # Some readers only serve CPLC/IIN/CIN intermittently, so a connection can complete + # while UID_SHA1 is still unset or at the empty-hash sentinel (see is_usable_uid). + # Everything downstream keys off that id -- PIN swap detection, the Satodime unlock + # cache -- so reconnect and re-derive until we get a real one. Bounded: cards/reader + # combos that never identify must not hang the flow; they proceed unidentified and + # the callers' guards handle it (PIN re-prompt / "Cannot Identify Card"). + status = _connect_card(parentObject, Satochip_Connector) + + # A connection can complete while the card still has no usable id (see above). Only + # retry in that case -- a plain connect failure keeps its single 5-second window. + for attempt in range(1, MAX_UID_IDENTIFY_ATTEMPTS): + if not status or is_usable_uid(getattr(Satochip_Connector, "UID_SHA1", "")): + break + print( + f"Card did not identify itself (attempt {attempt + 1}/{MAX_UID_IDENTIFY_ATTEMPTS}), reconnecting..." + ) try: - - time.sleep(0.5) # give some time to initialize reader... - status = Satochip_Connector.card_get_status() - print("Found Card:", Satochip_Connector.UID_SHA1) - print(status[3]) - - if Satochip_Connector.needs_secure_channel: - print("Initiating Secure Channel") - Satochip_Connector.card_initiate_secure_channel() - print("Secure Channel Initialised") - - if ( - len(status[3]) > 0 - ): # Sometimes it's possible to end up with an invalid of zero length here... - break - else: - # Cleanup the connector and try again - try: - Satochip_Connector.card_disconnect() - except Exception: - pass - + Satochip_Connector.card_disconnect() + except Exception: + pass + parentObject.controller.Satochip_Connector = None + try: + Satochip_Connector = _init_card_connector( + init_card_filter, backend_preference=controller_backend_pref + ) except Exception as e: - print("CardConnector Init Failed:" + str(e)) - # Ensure the connector state is clean before trying again - try: - Satochip_Connector.card_disconnect() - except Exception: - pass - time.sleep(0.1) # Sleep for 100ms - - status = None # Reset this every loop... + print("CardConnector Reconnect Failed:" + str(e)) + parentObject.run_screen( + WarningScreen, + title="Failure", + status_headline=None, + text="No smartcard detected\n\nInsert a card and try again.", + show_back_button=True, + ) + return None - parentObject.loading_screen.stop() + status = _connect_card(parentObject, Satochip_Connector) if not status: # If we never connected, ensure the connector is reset for future attempts @@ -756,14 +821,20 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend if require_pin: # Check for an existing Seedkeeper card that we may have been using with this PIN, # prompt to re-enter pin if the card has been swapped... - if ( - parentObject.controller.Satochip_Last_UID_SHA1 is not None - and parentObject.controller.Satochip_Last_UID_SHA1 - != Satochip_Connector.UID_SHA1 + last_uid = parentObject.controller.Satochip_Last_UID_SHA1 + current_uid = Satochip_Connector.UID_SHA1 + # Trust "same card" only when both ids are usable AND equal: an unusable id on + # either side (empty / empty-hash sentinel) means we cannot confirm it is the + # same physical card, and a cached PIN applied to the wrong one would burn one + # of its limited tries. + if last_uid is not None and not ( + is_usable_uid(last_uid) + and is_usable_uid(current_uid) + and last_uid == current_uid ): - print("Found Card:", Satochip_Connector.UID_SHA1) - print("Expecting Card:", parentObject.controller.Satochip_Last_UID_SHA1) - print("Card has changed, prompting for new PIN") + print("Found Card:", current_uid) + print("Expecting Card:", last_uid) + print("Card has changed or cannot be confirmed, prompting for new PIN") pin_str = prompt_for_pin( parentObject, "Card PIN", @@ -968,7 +1039,15 @@ def init_satochip(parentObject, init_card_filter=None, require_pin=True, backend # Everything works, so save object and also note the PIN & UID of the card we last successfully connected to... parentObject.controller.Satochip_Connector = Satochip_Connector - parentObject.controller.Satochip_Last_UID_SHA1 = Satochip_Connector.UID_SHA1 + if is_usable_uid(Satochip_Connector.UID_SHA1): + parentObject.controller.Satochip_Last_UID_SHA1 = Satochip_Connector.UID_SHA1 + elif parentObject.controller.Satochip_Last_UID_SHA1 is None: + # Connected but could not identify the card. Record the sentinel (never a real id) + # so the next connection's swap check cannot assume continuity -- with Last left as + # None it would skip the comparison and apply the cached PIN unverified, which on a + # *different* unidentified card would burn one of its limited PIN tries. A usable + # record from an earlier connection is kept: comparing against it still works. + parentObject.controller.Satochip_Last_UID_SHA1 = _EMPTY_UID_SHA1 # Only cache pin if we are using it. Satodime never uses (or overwrites) the # cached Satochip PIN: wiping it here would make a later reconnect to the same diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 83a4c5a7e..4b23be2ee 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -763,7 +763,10 @@ def run(self): info_lines.append(f"Type: {card_type}") uid = getattr(Satochip_Connector, "UID_SHA1", None) - if not uid: + if not seedkeeper_utils.is_usable_uid(uid): + # Fall back to the raw UID when the derived id is missing or a sentinel; + # show nothing at all rather than a misleading "da39a3ee..." value. + uid = None uid_raw = getattr(Satochip_Connector, "UID", None) if uid_raw: uid = bytes(uid_raw).hex() @@ -822,6 +825,17 @@ def run(self): try: initial_uid = getattr(Satochip_Connector, "UID_SHA1", None) + if not seedkeeper_utils.is_usable_uid(initial_uid): + # init_satochip already re-queried the id; verifying against an empty or + # sentinel UID would fail with a spurious certificate mismatch. + self.run_screen( + ErrorScreen, + title="Genuine Check", + status_headline=None, + text="Cannot identify card.\nRe-present and try again.", + ) + return Destination(BackStackView) + is_genuine, _, _, _, txt_error = Satochip_Connector.card_verify_authenticity() # Workaround for occasional incorrect UID calculation in pysatochip @@ -4915,21 +4929,11 @@ def run(self): # Identify the card before anything state-changing. The id (UID_SHA1) is derived # from CPLC/IIN/CIN reads that some readers serve only intermittently -- a - # connection can complete fine while the id comes back empty. Claiming an - # unidentified card would mint a backup keyed to an empty id that can never be - # restored, and if identification failed afterwards the freshly minted secret - # (emitted exactly once) would be lost forever. + # connection can complete fine while the id comes back empty, and init_satochip + # has already re-queried it. Claiming an unidentified card would mint a backup + # keyed to an empty id that can never be restored, and if identification failed + # afterwards the freshly minted secret (emitted exactly once) would be lost. card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if not card_id: - for _attempt in range(2): - Satochip_Connector = seedkeeper_utils.init_satochip( - self, init_card_filter=["satodime"], require_pin=False - ) - if not Satochip_Connector: - break - card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if card_id: - break if not card_id: self.run_screen( WarningScreen, @@ -5276,21 +5280,11 @@ def run(self): if not Satochip_Connector: return Destination(BackStackView) + # The connection completed but the card may still have no usable id (CPLC/IIN/CIN + # come back blank on some readers); init_satochip has already re-queried it. If it + # is still unusable, comparing against the backup's id would fail with a + # misleading "Wrong Card". card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if not card_id: - # The connection completed but the card did not identify itself (CPLC/IIN/CIN - # came back blank -- some readers serve those only intermittently). Reconnect - # and retry before comparing against the backup's id, or every restore would - # fail with a misleading "Wrong Card". - for _attempt in range(2): - Satochip_Connector = seedkeeper_utils.init_satochip( - self, init_card_filter=["satodime"], require_pin=False - ) - if not Satochip_Connector: - break - card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if card_id: - break if not card_id: self.run_screen( WarningScreen, @@ -5672,6 +5666,18 @@ def run(self): return Destination(BackStackView) card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if not card_id: + # init_satochip already re-queried the id; without it a cache lookup would + # miss and show a misleading "No Ownership Key". + self.run_screen( + WarningScreen, + title="Cannot Identify Card", + status_headline=None, + text="Re-present the card\nand try again.", + show_back_button=True, + ) + return Destination(BackStackView) + cached_secret = seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) if not cached_secret: self.run_screen( diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 4000c4bdc..dcf7c3182 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -29,6 +29,7 @@ """ import re +import hashlib import sys import tempfile from pathlib import Path @@ -1017,9 +1018,10 @@ def test_nfc_claim_qr_backup_microsd_restore_seal_end_to_end(self, monkeypatch): def test_claim_refuses_to_mint_backup_when_the_card_cannot_be_identified(self, monkeypatch): """A connection can complete while CPLC/IIN/CIN come back blank (some readers serve them only intermittently), leaving UID_SHA1 unset or at the empty-hash - sentinel. Claiming must then retry with fresh connectors and, still unidentified, - abort BEFORE claiming: a backup keyed to an empty id can never be restored, and - claiming first would emit the one-time secret that could no longer be backed up.""" + sentinel. init_satochip must then re-query with fresh connectors and, still + unidentified, ClaimView aborts BEFORE claiming: a backup keyed to an empty id can + never be restored, and claiming first would emit the one-time secret that could no + longer be backed up.""" from real_screen_fixtures import use_microsd try: @@ -1029,16 +1031,16 @@ def test_claim_refuses_to_mint_backup_when_the_card_cannot_be_identified(self, m with ctx: use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) - monkeypatch.setattr(seedkeeper_utils, "satodime_card_id", lambda connector: "") + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", lambda uid: False) - init_calls = [] - real_init = seedkeeper_utils.init_satochip + cc_calls = [] + real_cc = seedkeeper_utils._init_card_connector - def counting_init(*args, **kwargs): - init_calls.append(1) - return real_init(*args, **kwargs) + def counting_cc(*args, **kwargs): + cc_calls.append(1) + return real_cc(*args, **kwargs) - monkeypatch.setattr(seedkeeper_utils, "init_satochip", counting_init) + monkeypatch.setattr(seedkeeper_utils, "_init_card_connector", counting_cc) view = smartcard_views.ToolsSatodimeClaimView() recorder = ScreenRecorder(0) # ack "Cannot Identify Card" (no claim screens at all) @@ -1046,30 +1048,77 @@ def counting_init(*args, **kwargs): dest = view.run() assert recorder.titles == ["Cannot Identify Card"] - assert len(init_calls) == 3 # initial + two reconnect retries + # initial connect + two reconnect re-queries, all inside init_satochip + assert len(cc_calls) == seedkeeper_utils.MAX_UID_IDENTIFY_ATTEMPTS assert not (self.controller.Satodime_unlock_secrets or {}) assert dest.View_cls is smartcard_views.BackStackView + def test_init_satochip_requeries_until_the_card_identifies_itself(self, monkeypatch): + """The reader serves CPLC/IIN/CIN only intermittently: the first connection + completes but derives no usable id, and a reconnect gets one. init_satochip must + re-query on its own (no view-level retry) and hand back the healed connector, + recording the real id -- not the sentinel -- as Satochip_Last_UID_SHA1.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + uid_checks = [] + + def flaky_is_usable(uid): + # The first check (after the initial connect) sees an unidentified + # card; once init_satochip has reconnected, the reader serves + # CPLC/IIN/CIN and every later check passes. + uid_checks.append(uid) + return len(uid_checks) > 1 + + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", flaky_is_usable) + + cc_calls = [] + real_cc = seedkeeper_utils._init_card_connector + + def counting_cc(*args, **kwargs): + cc_calls.append(1) + return real_cc(*args, **kwargs) + + monkeypatch.setattr(seedkeeper_utils, "_init_card_connector", counting_cc) + + view = smartcard_views.ToolsSatodimeSlotsView() + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) + view.run_screen = recorder + view.run() + + # one reconnect: initial connect + re-query, then the healed id is accepted + assert len(cc_calls) == 2 + connector = self.controller.Satochip_Connector + assert connector is not None + real_uid = getattr(connector, "UID_SHA1", None) + assert str(real_uid)[:16] != hashlib.sha1(b"").hexdigest()[:16] + # the healed id -- not a sentinel -- is what gets recorded for swap detection + assert self.controller.Satochip_Last_UID_SHA1 == real_uid + def test_restore_refuses_when_the_card_cannot_be_identified(self, monkeypatch): - """Same unidentified-card condition at restore time: retry with fresh connectors, - then say 'cannot identify' instead of the misleading 'Wrong Card' (the backup's id - cannot be compared against one that was never derived).""" + """Same unidentified-card condition at restore time: init_satochip re-queries with + fresh connectors, then the view says 'cannot identify' instead of the misleading + 'Wrong Card' (the backup's id cannot be compared against one that was never + derived).""" try: ctx = simulated_satodime_raw() except JCardSimUnavailable as exc: pytest.skip(str(exc)) with ctx: - monkeypatch.setattr(seedkeeper_utils, "satodime_card_id", lambda connector: "") + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", lambda uid: False) - init_calls = [] - real_init = seedkeeper_utils.init_satochip + cc_calls = [] + real_cc = seedkeeper_utils._init_card_connector - def counting_init(*args, **kwargs): - init_calls.append(1) - return real_init(*args, **kwargs) + def counting_cc(*args, **kwargs): + cc_calls.append(1) + return real_cc(*args, **kwargs) - monkeypatch.setattr(seedkeeper_utils, "init_satochip", counting_init) + monkeypatch.setattr(seedkeeper_utils, "_init_card_connector", counting_cc) view = smartcard_views.ToolsSatodimeRestoreUnlockView() recorder = ScreenRecorder(0) # ack "Cannot Identify Card" @@ -1077,11 +1126,47 @@ def counting_init(*args, **kwargs): dest = view.run() assert recorder.titles == ["Cannot Identify Card"] - assert len(init_calls) == 3 # initial + two reconnect retries + # initial connect + two reconnect re-queries, all inside init_satochip + assert len(cc_calls) == seedkeeper_utils.MAX_UID_IDENTIFY_ATTEMPTS assert not (self.controller.Satodime_unlock_secrets or {}) assert dest.View_cls is smartcard_views.BackStackView +class TestIsUsableUid: + """is_usable_uid: the shared predicate for "did this card identify itself?".""" + + def test_real_ids_are_usable(self): + uid = hashlib.sha1(b"\x03\x97\x42\x54").hexdigest() + assert seedkeeper_utils.is_usable_uid(uid) + # truncated ids (first 16 hex chars) are usable too + assert seedkeeper_utils.is_usable_uid(uid[:16]) + + def test_unset_and_empty_are_not(self): + assert not seedkeeper_utils.is_usable_uid(None) + assert not seedkeeper_utils.is_usable_uid("") + + def test_empty_hash_sentinel_is_not(self): + sentinel = hashlib.sha1(b"").hexdigest() + # full digest and truncated form are both caught + assert not seedkeeper_utils.is_usable_uid(sentinel) + assert not seedkeeper_utils.is_usable_uid(sentinel[:16]) + + def test_satodime_card_id_uses_the_predicate(self): + class FakeConnector: + pass + + sentinel = hashlib.sha1(b"").hexdigest() + good = FakeConnector() + good.UID_SHA1 = hashlib.sha1(b"\x03\x97\x42\x54").hexdigest() + blank = FakeConnector() + blank.UID_SHA1 = sentinel + unset = FakeConnector() + + assert seedkeeper_utils.satodime_card_id(good) == hashlib.sha1(b"\x03\x97\x42\x54").hexdigest()[:16] + assert seedkeeper_utils.satodime_card_id(blank) == "" + assert seedkeeper_utils.satodime_card_id(unset) == "" + + class TestUnlockSecretPayload: """The backup payload is what a user's phone photo has to survive.""" From c02eff47e62a8dbc5e0bbefcb443acb5a8b4cb7c Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Fri, 11 Sep 2026 23:29:58 -0400 Subject: [PATCH 25/26] feat: name Satodime ownership keys and match them on restore via the card label --- src/seedsigner/helpers/seedkeeper_utils.py | 21 ++- src/seedsigner/views/smartcard_views.py | 116 +++++++++++--- ...st_real_screen_flows_satodime_simulated.py | 146 ++++++++++++++++-- 3 files changed, 241 insertions(+), 42 deletions(-) diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 853ade081..32835229f 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -537,21 +537,27 @@ def satodime_card_id(connector) -> str: return str(uid)[:16] -def format_satodime_unlock_payload(card_id: str, secret) -> str: +def format_satodime_unlock_payload(card_id: str, secret, nickname: str | None = None) -> str: """Render an unlock secret as the text that goes in the backup QR / MicroSD file. Self-describing and ASCII, so it round-trips through ``QRType.TEXT`` and can be read back by a phone camera. The card id is carried alongside the secret so a - restore can tell the user when they have presented the wrong card's backup. + restore can tell the user when they have presented the wrong card's backup; an + optional human nickname (last field) names the key for exactly that purpose. """ - return f"{SATODIME_UNLOCK_PREFIX}{card_id}:{bytes(secret).hex()}" + payload = f"{SATODIME_UNLOCK_PREFIX}{card_id}:{bytes(secret).hex()}" + if nickname and nickname.strip(): + # ":" would break the field split on parse, so normalize it away. + payload += ":" + nickname.strip().replace(":", "-") + return payload def parse_satodime_unlock_payload(text: str): """Inverse of :func:`format_satodime_unlock_payload`. - Returns ``(card_id, secret_list)`` or ``None`` when the text is not a Satodime - unlock backup or is malformed. + Returns ``(card_id, secret_list, nickname)`` -- nickname None for backups written + before nicknames existed -- or ``None`` when the text is not a Satodime unlock + backup or is malformed. """ if not text: return None @@ -560,16 +566,17 @@ def parse_satodime_unlock_payload(text: str): return None body = text[len(SATODIME_UNLOCK_PREFIX):] parts = body.split(":") - if len(parts) != 2: + if len(parts) not in (2, 3): return None card_id, secret_hex = parts[0].strip(), parts[1].strip() + nickname = parts[2].strip() or None if len(parts) == 3 else None try: secret = bytes.fromhex(secret_hex) except ValueError: return None if len(secret) != SIZE_SATODIME_UNLOCK_SECRET: return None - return (card_id, list(secret)) + return (card_id, list(secret), nickname) diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 4b23be2ee..29d8cdafd 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -5092,6 +5092,10 @@ def run(self): ) return Destination(BackStackView) + # Optional human name for this key: carried in the backup payload and written + # to the card's own label, so a restore can identify the card even when its + # UID reads blank. Set from the chooser below; None until then. + nickname = None payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret) self.run_screen( @@ -5135,6 +5139,8 @@ def run(self): else: exit_label = "Skip Verification" + name_label = f"Key named '{nickname[:14]}'" if nickname else "Name This Key" + selected = self.run_screen( ButtonListScreen, title="Back Up Ownership Key", @@ -5143,6 +5149,7 @@ def run(self): ButtonOption("Show QR Code"), ButtonOption("Save to MicroSD"), ButtonOption(exit_label), + ButtonOption(name_label), ], show_back_button=False, ) @@ -5151,6 +5158,20 @@ def run(self): self._save_to_microsd(card_id, payload) continue + if selected == 3: + ret = self.run_screen(ToolsTextQRTextEntryScreen, textToEncode=nickname or "", title="Key Nickname") + # Real screens return a dict; mocked run_screen returns an int -- treat + # anything but a dict as "cancelled" and keep the current nickname. + if isinstance(ret, dict): + if "is_back_button" not in ret: + entered = ret["textToEncode"].strip() + nickname = entered or None + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret, nickname) + # Persist the name on the card itself (best effort): it is what a + # later restore matches against when the UID reads blank. + self._write_card_label(nickname or "") + continue + if selected == 2: if exit_label != "Skip Verification": # A verified copy already exists on the MicroSD; nothing to warn about. @@ -5205,6 +5226,22 @@ def _scan_matches(self, payload: str) -> bool: scanned = _satodime_scan_text(self) return scanned is not None and scanned.strip() == payload + def _write_card_label(self, nickname: str): + """Best-effort: persist the key's nickname on the card itself as its label. + + The Satodime applet only accepts a label write once claimed (0x9C04 before + that), and some readers or applets may not support it at all -- either way this + is optional metadata, so every failure is swallowed silently. A matching label + later lets a restore identify the card when its UID reads blank. + """ + connector = getattr(self.controller, "Satochip_Connector", None) + if connector is None: + return + try: + (_r, _sw1, _sw2) = connector.card_set_label(nickname[:64]) + except Exception: + pass + def _microsd_backup_matches(self, card_id: str, secret) -> bool: """Whether this card's backup file on the MicroSD already holds this exact key. @@ -5226,7 +5263,8 @@ def _microsd_backup_matches(self, card_id: str, secret) -> bool: parsed = seedkeeper_utils.parse_satodime_unlock_payload(f.read()) except (OSError, TypeError, ValueError): return False - return parsed == (card_id, list(secret)) + # The nickname is display metadata only -- the key itself must match. + return parsed[0] == card_id and parsed[1] == list(secret) def _save_to_microsd(self, card_id: str, payload: str): import os @@ -5281,24 +5319,17 @@ def run(self): return Destination(BackStackView) # The connection completed but the card may still have no usable id (CPLC/IIN/CIN - # come back blank on some readers); init_satochip has already re-queried it. If it - # is still unusable, comparing against the backup's id would fail with a - # misleading "Wrong Card". + # come back blank on some readers); init_satochip has already re-queried it. A + # blank id is not a dead end: the key's nickname can still identify the card via + # its own label, and the user may load anyway. card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) - if not card_id: - self.run_screen( - WarningScreen, - title="Cannot Identify Card", - status_headline=None, - text="Re-present the card\nand try again.", - show_back_button=True, - ) - return Destination(BackStackView) + card_label = self._read_card_label(Satochip_Connector) # A backup for this card on the MicroSD is the fastest restore path, so offer it # first when one exists; with nothing on the card there is no menu -- scanning is - # the only option and starts immediately. - if self._microsd_has_backup(card_id): + # the only option and starts immediately. The filename is keyed by the id, so a + # blank id can never be looked up: scan is the only path then. + if card_id and self._microsd_has_backup(card_id): selected = self.run_screen( ButtonListScreen, title="Ownership Key", @@ -5328,23 +5359,44 @@ def run(self): ) return Destination(BackStackView) - backup_card_id, secret = parsed - if backup_card_id != card_id: - # A genuine mismatch between two successfully derived ids. Historically a - # dead end; since flaky readers can mis-derive an id, let the user force-load - # after a warning instead. A wrong key is self-correcting: it simply fails at - # the applet's gate with "Key Required" until re-restored from the right code. + backup_card_id, secret, nickname = parsed + + # Match the presented card against the backup. The UID is the primary channel; + # when either side's id is blank, a matching nickname (backup field vs the + # label written on the card at backup time) identifies it just as well and the + # key loads without further confirmation. Anything else -- no nickname, or one + # that does not match while the UIDs also disagree -- warns and offers a + # force-load: a wrong key is self-correcting, it simply fails at the applet's + # gate with "Key Required" until re-restored from the right code. + uid_match = bool(card_id) and backup_card_id == card_id + nickname_match = bool(nickname) and nickname == (card_label or "") + if not (uid_match or ((not card_id or not backup_card_id) and nickname_match)): + # A blank card id is a different situation from a genuine mismatch: the + # key simply cannot be matched automatically, so say that instead of + # accusing the user of scanning the wrong backup. + if not card_id: + title = "Blank Card ID" + text = "This key can't be matched\nto the card automatically." + elif nickname: + title = "Wrong Card" + text = f"Key named '{nickname[:16]}' belongs\nto a different Satodime." + else: + title = "Wrong Card" + text = "That code belongs to a\ndifferent Satodime." selected = self.run_screen( DireWarningScreen, - title="Wrong Card", + title=title, status_headline=None, - text="That code belongs to a\ndifferent Satodime.", + text=text, show_back_button=True, button_data=[ButtonOption("Load Anyway")], ) if selected != 0: return Destination(BackStackView) + # Cache under the presented card's id -- "" for a blank one, which is exactly + # what apply_satodime_unlock_secret looks up while this unidentified card stays + # in session. seedkeeper_utils.cache_satodime_unlock_secret(self.controller, card_id, secret) self.run_screen( LargeIconStatusScreen, @@ -5355,6 +5407,24 @@ def run(self): ) return Destination(BackStackView) + def _read_card_label(self, connector): + """The nickname persisted on the card itself at backup time, or None. + + pysatochip answers '(none)' for unsupported and '(unknown)' for failed label + reads; both mean "no usable label". Best effort -- a missing label simply + means the nickname channel cannot confirm the card. + """ + try: + (_r, _sw1, _sw2, label) = connector.card_get_label() + except Exception: + return None + if not isinstance(label, str): + return None + label = label.strip() + if not label or label in ("(none)", "(unknown)"): + return None + return label + def _microsd_has_backup(self, card_id: str) -> bool: """Whether this card's backup file exists on the MicroSD and parses as its key. diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index dcf7c3182..843b1c354 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -1098,11 +1098,11 @@ def counting_cc(*args, **kwargs): # the healed id -- not a sentinel -- is what gets recorded for swap detection assert self.controller.Satochip_Last_UID_SHA1 == real_uid - def test_restore_refuses_when_the_card_cannot_be_identified(self, monkeypatch): + def test_restore_blank_uid_warns_instead_of_blocking(self, monkeypatch): """Same unidentified-card condition at restore time: init_satochip re-queries with - fresh connectors, then the view says 'cannot identify' instead of the misleading - 'Wrong Card' (the backup's id cannot be compared against one that was never - derived).""" + fresh connectors, then the view proceeds -- a blank id is not a dead end. A backup + that cannot be matched automatically (no nickname to bridge it) warns that it can't + be matched and offers a force-load instead of refusing outright.""" try: ctx = simulated_satodime_raw() except JCardSimUnavailable as exc: @@ -1120,12 +1120,17 @@ def counting_cc(*args, **kwargs): monkeypatch.setattr(seedkeeper_utils, "_init_card_connector", counting_cc) + secret = list(range(20)) + other = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", secret) + + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: other) + view = smartcard_views.ToolsSatodimeRestoreUnlockView() - recorder = ScreenRecorder(0) # ack "Cannot Identify Card" + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # decline the "Blank Card ID" warning view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Cannot Identify Card"] + assert recorder.titles == ["Blank Card ID"] # initial connect + two reconnect re-queries, all inside init_satochip assert len(cc_calls) == seedkeeper_utils.MAX_UID_IDENTIFY_ATTEMPTS assert not (self.controller.Satodime_unlock_secrets or {}) @@ -1174,12 +1179,18 @@ def test_round_trips(self): secret = list(range(20)) payload = seedkeeper_utils.format_satodime_unlock_payload("deadbeef", secret) assert payload.startswith("satodime-unlock:") - assert seedkeeper_utils.parse_satodime_unlock_payload(payload) == ("deadbeef", secret) + assert seedkeeper_utils.parse_satodime_unlock_payload(payload) == ("deadbeef", secret, None) def test_survives_surrounding_whitespace(self): payload = seedkeeper_utils.format_satodime_unlock_payload("abc", list(range(20))) assert seedkeeper_utils.parse_satodime_unlock_payload(f" {payload}\n") is not None + def test_nickname_round_trips(self): + secret = list(range(20)) + payload = seedkeeper_utils.format_satodime_unlock_payload("deadbeef", secret, "My Card") + + assert seedkeeper_utils.parse_satodime_unlock_payload(payload) == ("deadbeef", secret, "My Card") + @pytest.mark.parametrize("text", [ "", "not a backup", @@ -1187,10 +1198,15 @@ def test_survives_surrounding_whitespace(self): "satodime-unlock:abc:zz", # not hex "satodime-unlock:abc:" + "00" * 19, # wrong length "satodime-unlock:abc:" + "00" * 21, + "satodime-unlock:abc:" + "00" * 20 + ":a:b:c", # too many fields ]) def test_rejects_junk(self, text): assert seedkeeper_utils.parse_satodime_unlock_payload(text) is None + def test_nickname_colons_are_sanitised(self): + payload = seedkeeper_utils.format_satodime_unlock_payload("deadbeef", list(range(20)), "a:b") + assert seedkeeper_utils.parse_satodime_unlock_payload(payload) == ("deadbeef", list(range(20)), "a-b") + class TestBackupAndRestoreViews(SatodimeSimulatedFlowTest): """The QR ceremony and its restore path, driven without a card.""" @@ -1331,7 +1347,7 @@ def test_finalise_claim_shown_when_matching_backup_on_microsd(self, monkeypatch) assert recorder.titles == ["Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key"] menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] assert menu_buttons == [ - "Show QR Code", "Save to MicroSD", "Finalise Claim", + "Show QR Code", "Save to MicroSD", "Finalise Claim", "Name This Key", ] assert dest.View_cls is smartcard_views.BackStackView @@ -1353,7 +1369,7 @@ def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): dest = view.run() menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] - assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Done"] + assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Done", "Name This Key"] assert dest.View_cls is smartcard_views.BackStackView def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): @@ -1373,11 +1389,45 @@ def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): first_menu = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] second_menu = [opt.button_label for opt in recorder.calls[5][1]["button_data"]] - assert first_menu[-1] == "Skip Verification" - assert second_menu[-1] == "Finalise Claim" + # the exit button is index 2; "Name This Key" now trails it at index 3 + assert first_menu[2] == "Skip Verification" + assert second_menu[2] == "Finalise Claim" # The backup file now holds the current key. saved = (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(self.CARD_ID)).read_text(encoding="utf-8") - assert seedkeeper_utils.parse_satodime_unlock_payload(saved) == (self.CARD_ID, self.SECRET) + assert seedkeeper_utils.parse_satodime_unlock_payload(saved) == (self.CARD_ID, self.SECRET, None) + assert dest.View_cls is smartcard_views.BackStackView + + def test_naming_the_key_writes_the_card_label_and_payload(self, monkeypatch): + """"Name This Key" in the chooser: the nickname goes into the backup payload + and is persisted on the card itself as its label -- the channel a later restore + matches against when the UID reads blank.""" + from real_screen_fixtures import MockSatochipConnector, use_microsd + + microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + self._seed_cache() + + connector = MockSatochipConnector() + self.controller.Satochip_Connector = connector + + view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) + # dire warning, theft caveat, lose-it warning, chooser -> "Name This Key" (index 3), + # text entry returns the nickname, chooser -> "Save to MicroSD" (index 1), ack, + # chooser again -> exit (index 2) + recorder = ScreenRecorder(0, 0, 0, 3, {"textToEncode": "My Card"}, 1, 0, 2) + view.run_screen = recorder + dest = view.run() + + # the label write reached the card (the mock records it before its non-tuple + # return is swallowed by the best-effort unpack) + assert connector.label_changes == ["My Card"] + + saved = (microsd_dir / seedkeeper_utils.satodime_unlock_backup_filename(self.CARD_ID)).read_text(encoding="utf-8") + + assert seedkeeper_utils.parse_satodime_unlock_payload(saved) == (self.CARD_ID, self.SECRET, "My Card") + + # the chooser now shows the name instead of the prompt + second_menu = [opt.button_label for opt in recorder.calls[7][1]["button_data"]] + assert second_menu[-1] == "Key named 'My Card'" assert dest.View_cls is smartcard_views.BackStackView def test_backup_refuses_when_nothing_is_cached(self): @@ -1510,6 +1560,78 @@ def test_restore_prompt_scan_option_still_scans(self, monkeypatch): assert recorder.titles == ["Ownership Key", "Ownership Key Set"] assert self.controller.Satodime_unlock_secrets[card_id] == self.SECRET + def test_restore_blank_uid_auto_loads_when_nickname_matches_the_card_label(self, monkeypatch): + """The nickname channel: the card's UID reads blank, but its on-card label + (written at backup time) matches the backup's nickname -- that identifies the + card just as well as a UID would, so the key loads without any warning.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + from real_screen_fixtures import use_microsd + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: scan is the only path + + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", lambda uid: False) # blank UID + + payload = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET, "My Card") + + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + + def fake_label(self_view, connector): + return "My Card" + + monkeypatch.setattr( + smartcard_views.ToolsSatodimeRestoreUnlockView, "_read_card_label", fake_label + ) + + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(0) # only the success screen -- no warning was needed + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Ownership Key Set"] + cached = self.controller.Satodime_unlock_secrets or {} + # cached under the blank id, so apply_satodime_unlock_secret finds it this session + assert list(cached.keys()) == [""], cached + assert cached[""] == self.SECRET + assert dest.View_cls is smartcard_views.BackStackView + + def test_restore_blank_uid_with_mismatched_nickname_still_warns(self, monkeypatch): + """A nickname that does NOT match the card's label cannot bridge a blank UID: + same warning as an unmatched backup, and declining it caches nothing.""" + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + from real_screen_fixtures import use_microsd + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: scan is the only path + + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", lambda uid: False) # blank UID + + payload = seedkeeper_utils.format_satodime_unlock_payload("ffffffffffffffff", self.SECRET, "Other Card") + + monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) + + def fake_label(self_view, connector): + return "My Card" + + monkeypatch.setattr( + smartcard_views.ToolsSatodimeRestoreUnlockView, "_read_card_label", fake_label + ) + + view = smartcard_views.ToolsSatodimeRestoreUnlockView() + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # decline the warning + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Blank Card ID"] + assert not (self.controller.Satodime_unlock_secrets or {}) + assert dest.View_cls is smartcard_views.BackStackView + class TestMatchesTheOfficialSatodimeApp: """ From 5c008d86b0dc533ce38de5ec98c8c78b88952845 Mon Sep 17 00:00:00 2001 From: 3rd Iteration Date: Sat, 12 Sep 2026 11:42:00 -0400 Subject: [PATCH 26/26] feat: name Satodime keys up front and export them by name when the card id reads blank The ownership key can now be re-shown even when CPLC/IIN/CIN come back empty: the 'Cannot Identify Card' screen offers Retry (re-runs identification) or Use Nickname Only, and a key named earlier in the session is found via a new name->secret reverse lookup. Naming moves to an upfront screen at the top of the backup flow (pre-filled from any existing name), replacing the old chooser button; the name is written to the card label and recorded in the session cache so restore can match it. --- src/seedsigner/controller.py | 7 + src/seedsigner/helpers/seedkeeper_utils.py | 35 +++- src/seedsigner/views/smartcard_views.py | 164 +++++++++++++----- ...st_real_screen_flows_satodime_simulated.py | 123 ++++++++----- 4 files changed, 240 insertions(+), 89 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index bbd94698a..baed4bd1b 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -273,6 +273,11 @@ def _load_block_anchor(cls): # without it, a contactless reader cannot seal, unseal, reset or even transfer the # card. Held in RAM only -- the user is walked through backing it up at claim time. Satodime_unlock_secrets: dict | None = None + # Reverse lookup of the names given to those keys at backup time: + # nickname -> (card_id, secret). Lets a key be found by name when its card's + # UID reads blank -- the id channel is dead and the name written on the card + # becomes the only handle. Wiped alongside Satodime_unlock_secrets above. + Satodime_unlock_nicknames: dict | None = None # Cached slot data for the Satodime slot-centric menus: avoids re-reading the card # when navigating the slot list, per-slot action menus, and view-address QR. Cleared # on Home alongside the session secrets above. Keys: card_id (str), max_keys (int), @@ -578,6 +583,7 @@ def run(self): self.Satochip_Last_UID_SHA1 = None self.Satochip_Connector = None self.Satodime_unlock_secrets = None + self.Satodime_unlock_nicknames = None # Always drop any cached OpenPGP admin PIN when returning home self.GPG_Admin_PIN = None @@ -781,6 +787,7 @@ def handle_wipe_timeout(self): self.Satochip_Last_UID_SHA1 = None self.Satochip_Connector = None self.Satodime_unlock_secrets = None + self.Satodime_unlock_nicknames = None self.satodime_slot_cache = None self.GPG_Admin_PIN = None self.image_entropy_preview_frames = None diff --git a/src/seedsigner/helpers/seedkeeper_utils.py b/src/seedsigner/helpers/seedkeeper_utils.py index 32835229f..8d519c50d 100644 --- a/src/seedsigner/helpers/seedkeeper_utils.py +++ b/src/seedsigner/helpers/seedkeeper_utils.py @@ -585,11 +585,20 @@ def satodime_unlock_backup_filename(card_id: str) -> str: return f"satodime_unlock_{card_id}.txt" -def cache_satodime_unlock_secret(controller, card_id: str, secret) -> None: - """Hold an unlock secret in RAM for the rest of this session.""" +def cache_satodime_unlock_secret(controller, card_id: str, secret, nickname: str | None = None) -> None: + """Hold an unlock secret in RAM for the rest of this session. + + ``nickname`` (when given) is also recorded as a name -> (card_id, secret) + reverse lookup so the key can be found by name when its card's UID reads + blank -- see :func:`find_cached_satodime_unlock_by_nickname`. + """ if controller.Satodime_unlock_secrets is None: controller.Satodime_unlock_secrets = {} controller.Satodime_unlock_secrets[card_id] = list(secret) + if nickname and nickname.strip(): + if controller.Satodime_unlock_nicknames is None: + controller.Satodime_unlock_nicknames = {} + controller.Satodime_unlock_nicknames[nickname.strip()] = (card_id, list(secret)) def get_cached_satodime_unlock_secret(controller, card_id: str): @@ -597,6 +606,28 @@ def get_cached_satodime_unlock_secret(controller, card_id: str): return cached.get(card_id) +def find_cached_satodime_unlock_by_nickname(controller, nickname: str): + """The ``(card_id, secret)`` cached under this name, or None. + + The only way to re-export a key whose card's UID reads blank: the id channel + is dead, so the name written at backup time becomes the lookup key. A stale + entry (that id was re-cached without this name) resolves to None rather than + a wrong key. + """ + if not nickname or not nickname.strip(): + return None + entry = (controller.Satodime_unlock_nicknames or {}).get(nickname.strip()) + if entry is None: + return None + card_id, secret = entry + # The name map and the secret cache are wiped together at Home, but a + # re-restore of the same id without this name leaves a stale binding -- only + # trust it while the secret itself is still cached under that id. + if get_cached_satodime_unlock_secret(controller, card_id) != list(secret): + return None + return (card_id, secret) + + def apply_satodime_unlock_secret(controller, connector) -> bool: """Load this card's cached unlock secret onto the connector. diff --git a/src/seedsigner/views/smartcard_views.py b/src/seedsigner/views/smartcard_views.py index 29d8cdafd..05aed1351 100644 --- a/src/seedsigner/views/smartcard_views.py +++ b/src/seedsigner/views/smartcard_views.py @@ -4906,6 +4906,35 @@ def _satodime_handle_unlock_error(view, sw1: int, sw2: int): return Destination(ToolsSatodimeRestoreUnlockView) +def _satodime_prompt_nickname(view, initial: str = "") -> str | None: + """Prompt for a human name for this key; returns stripped text or None. + + Backing out (or leaving it empty) means "no name" -- naming is optional and never + cancels the surrounding workflow. Real screens return a dict; a mocked run_screen + returns an int, which is treated as "cancelled". + """ + ret = view.run_screen(ToolsTextQRTextEntryScreen, textToEncode=initial or "", title="Key Nickname") + if not isinstance(ret, dict) or "is_back_button" in ret: + return None + entered = (ret.get("textToEncode") or "").strip() + return entered or None + + +def _satodime_cached_nickname(controller, card_id: str) -> str | None: + """The name already on file for this card id in the session cache, or None. + + The reverse map is nickname -> (card_id, secret), so this scans for an entry whose + card_id matches. Pre-fills the upfront naming prompt when re-showing a key that was + already named earlier in the session. + """ + if not card_id: + return None + for name, (cid, _secret) in (controller.Satodime_unlock_nicknames or {}).items(): + if cid == card_id: + return name + return None + + class ToolsSatodimeClaimView(View): """Claim an unowned Satodime, then walk the user through backing up its ownership key. @@ -4930,19 +4959,40 @@ def run(self): # Identify the card before anything state-changing. The id (UID_SHA1) is derived # from CPLC/IIN/CIN reads that some readers serve only intermittently -- a # connection can complete fine while the id comes back empty, and init_satochip - # has already re-queried it. Claiming an unidentified card would mint a backup - # keyed to an empty id that can never be restored, and if identification failed - # afterwards the freshly minted secret (emitted exactly once) would be lost. + # has already re-queried it. If it still cannot be identified the user can retry + # or proceed anyway: naming the key in the backup step that follows keeps a + # blank-id backup restorable (the name is matched against the card's own label). card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) if not card_id: - self.run_screen( - WarningScreen, - title="Cannot Identify Card", - status_headline=None, - text="Re-present the card\nand try again.", - show_back_button=True, - ) - return Destination(BackStackView) + # The id (CPLC/IIN/CIN) is missing on some readers; init_satochip already + # re-queried it. Offer to retry the identification, or proceed and name the + # key in the backup step that follows -- a named backup can still be matched + # on restore even though its id reads blank. + while True: + selected = self.run_screen( + WarningScreen, + title="Cannot Identify Card", + status_headline=None, + text="Can't identify card ID\n(Normal for some readers)", + show_back_button=True, + button_data=[ButtonOption("Use Nickname Only"), ButtonOption("Retry")], + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + if selected == 1: + # Retry: full reconnect + re-identify (init_satochip re-queries the id). + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=["satodime"], require_pin=False + ) + if not Satochip_Connector: + return Destination(BackStackView) + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if card_id: + break # identified on retry -> proceed to the claim + continue # still blank -> show the warning again + # Use Nickname Only (selected == 0): proceed with a blank id; the backup + # step that follows is where the key gets named. + break if _satodime_is_claimed(Satochip_Connector): # Taking ownership erases the current owner's key (their sealed slots and @@ -5067,10 +5117,13 @@ class ToolsSatodimeBackupUnlockView(View): skipping an unverified backup. """ - def __init__(self, card_id: str = None, from_claim: bool = False): + def __init__(self, card_id: str = None, from_claim: bool = False, nickname: str | None = None): super().__init__() self.card_id = card_id self.from_claim = from_claim + # A name carried in from the claim step (only set when the card's id read blank + # and the user chose "Use Nickname Only"); pre-fills the upfront prompt below. + self.nickname = nickname def run(self): from seedsigner.gui.screens.screen import QRDisplayScreen @@ -5092,11 +5145,18 @@ def run(self): ) return Destination(BackStackView) - # Optional human name for this key: carried in the backup payload and written - # to the card's own label, so a restore can identify the card even when its - # UID reads blank. Set from the chooser below; None until then. - nickname = None - payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret) + # Name the key up front: it is carried in the backup payload, written to the + # card's own label, and recorded in the session cache -- together these let a + # later restore identify this key even when its UID reads blank. Pre-filled with + # any name already on file (carried from the claim step or read from the card). + initial_name = self.nickname or _satodime_cached_nickname(self.controller, card_id) + nickname = _satodime_prompt_nickname(self, initial=initial_name or "") + if nickname: + seedkeeper_utils.cache_satodime_unlock_secret( + self.controller, card_id, secret, nickname + ) + self._write_card_label(nickname) + payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret, nickname) self.run_screen( DireWarningScreen, @@ -5139,8 +5199,6 @@ def run(self): else: exit_label = "Skip Verification" - name_label = f"Key named '{nickname[:14]}'" if nickname else "Name This Key" - selected = self.run_screen( ButtonListScreen, title="Back Up Ownership Key", @@ -5149,7 +5207,6 @@ def run(self): ButtonOption("Show QR Code"), ButtonOption("Save to MicroSD"), ButtonOption(exit_label), - ButtonOption(name_label), ], show_back_button=False, ) @@ -5158,20 +5215,6 @@ def run(self): self._save_to_microsd(card_id, payload) continue - if selected == 3: - ret = self.run_screen(ToolsTextQRTextEntryScreen, textToEncode=nickname or "", title="Key Nickname") - # Real screens return a dict; mocked run_screen returns an int -- treat - # anything but a dict as "cancelled" and keep the current nickname. - if isinstance(ret, dict): - if "is_back_button" not in ret: - entered = ret["textToEncode"].strip() - nickname = entered or None - payload = seedkeeper_utils.format_satodime_unlock_payload(card_id, secret, nickname) - # Persist the name on the card itself (best effort): it is what a - # later restore matches against when the UID reads blank. - self._write_card_label(nickname or "") - continue - if selected == 2: if exit_label != "Skip Verification": # A verified copy already exists on the MicroSD; nothing to warn about. @@ -5737,16 +5780,49 @@ def run(self): card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) if not card_id: - # init_satochip already re-queried the id; without it a cache lookup would - # miss and show a misleading "No Ownership Key". - self.run_screen( - WarningScreen, - title="Cannot Identify Card", - status_headline=None, - text="Re-present the card\nand try again.", - show_back_button=True, - ) - return Destination(BackStackView) + # init_satochip already re-queried the id. Without it a cache lookup by id + # would miss -- but a key named earlier this session can still be found by + # name, so offer that instead of dead-ending. + while True: + selected = self.run_screen( + WarningScreen, + title="Cannot Identify Card", + status_headline=None, + text="Can't identify card ID\n(Normal for some readers)", + show_back_button=True, + button_data=[ButtonOption("Use Nickname Only"), ButtonOption("Retry")], + ) + if selected == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + if selected == 1: + # Retry: full reconnect + re-identify (init_satochip re-queries the id). + Satochip_Connector = seedkeeper_utils.init_satochip( + self, init_card_filter=["satodime"], require_pin=False + ) + if not Satochip_Connector: + return Destination(BackStackView) + card_id = seedkeeper_utils.satodime_card_id(Satochip_Connector) + if card_id: + break # identified on retry -> fall through to the id lookup + continue # still blank -> show the warning again + # Use Nickname Only: find the key by its name and export that. + nickname = _satodime_prompt_nickname(self) + if not nickname: + continue # cancelled -> back to the warning screen + found = seedkeeper_utils.find_cached_satodime_unlock_by_nickname( + self.controller, nickname + ) + if not found: + self.run_screen( + WarningScreen, + title="No Matching Key", + status_headline=None, + text=f"No key named '{nickname[:16]}'\nis cached this session.", + show_back_button=True, + ) + continue + card_id, _secret = found + break cached_secret = seedkeeper_utils.get_cached_satodime_unlock_secret(self.controller, card_id) if not cached_secret: diff --git a/tests/test_real_screen_flows_satodime_simulated.py b/tests/test_real_screen_flows_satodime_simulated.py index 843b1c354..4fe82330b 100644 --- a/tests/test_real_screen_flows_satodime_simulated.py +++ b/tests/test_real_screen_flows_satodime_simulated.py @@ -954,7 +954,8 @@ def test_nfc_claim_qr_backup_microsd_restore_seal_end_to_end(self, monkeypatch): # serves the rendered frame; the view's real ScanScreen + DecodeQR read # it) -> "Backup Verified" ends the flow. script = ( - [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings + [K.KEY_UP, K.KEY_PRESS] # name prompt -> back (no name) + + [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings + select("Save to MicroSD") # chooser + [K.KEY_PRESS] # ack "Saved" + select("Show QR Code") # chooser again @@ -1015,13 +1016,12 @@ def test_nfc_claim_qr_backup_microsd_restore_seal_end_to_end(self, monkeypatch): except JCardSimUnavailable as exc: pytest.skip(str(exc)) - def test_claim_refuses_to_mint_backup_when_the_card_cannot_be_identified(self, monkeypatch): + def test_claim_aborts_when_unidentified_and_the_user_backs_out(self, monkeypatch): """A connection can complete while CPLC/IIN/CIN come back blank (some readers serve them only intermittently), leaving UID_SHA1 unset or at the empty-hash - sentinel. init_satochip must then re-query with fresh connectors and, still - unidentified, ClaimView aborts BEFORE claiming: a backup keyed to an empty id can - never be restored, and claiming first would emit the one-time secret that could no - longer be backed up.""" + sentinel. init_satochip re-queries with fresh connectors; still unidentified, + ClaimView offers Retry / Use Nickname Only. Backing out must abort BEFORE claiming: + we never mint a one-time secret the user has not opted to back up.""" from real_screen_fixtures import use_microsd try: @@ -1043,16 +1043,51 @@ def counting_cc(*args, **kwargs): monkeypatch.setattr(seedkeeper_utils, "_init_card_connector", counting_cc) view = smartcard_views.ToolsSatodimeClaimView() - recorder = ScreenRecorder(0) # ack "Cannot Identify Card" (no claim screens at all) + recorder = ScreenRecorder(RET_CODE__BACK_BUTTON) # back out of the warning view.run_screen = recorder dest = view.run() assert recorder.titles == ["Cannot Identify Card"] - # initial connect + two reconnect re-queries, all inside init_satochip + # initial connect + two reconnect re-queries, all inside init_satochip (no + # view-level retry was requested -- the user backed out instead) assert len(cc_calls) == seedkeeper_utils.MAX_UID_IDENTIFY_ATTEMPTS assert not (self.controller.Satodime_unlock_secrets or {}) assert dest.View_cls is smartcard_views.BackStackView + def test_claim_proceeds_when_the_card_cannot_be_identified(self, monkeypatch): + """The other exit from the 'Cannot Identify Card' warning: choosing to proceed lets + an unidentified card be claimed anyway. The one-time secret is cached under a blank + id; naming happens in the backup step that follows (its upfront prompt), where it is + also written to the card's own label so the backup can still be matched on restore.""" + from real_screen_fixtures import use_microsd + + try: + ctx = simulated_satodime_raw() + except JCardSimUnavailable as exc: + pytest.skip(str(exc)) + + with ctx: + use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) + monkeypatch.setattr(seedkeeper_utils, "is_usable_uid", lambda uid: False) + + view = smartcard_views.ToolsSatodimeClaimView() + # warning -> proceed (index 0), "Card Unclaimed" -> "Claim Card" (index 0); + # then it forwards to the backup flow with a blank id. + recorder = ScreenRecorder(0, 0) + view.run_screen = recorder + dest = view.run() + + assert recorder.titles == ["Cannot Identify Card", "Card Unclaimed"] + # claimed with a blank id: the one-time secret is cached under "". + secrets = self.controller.Satodime_unlock_secrets or {} + assert list(secrets.keys()) == [""] + (secret,) = list(secrets.values()) + assert len(secret) == 20 and any(secret), "a real one-time secret must be minted" + + assert dest.View_cls is smartcard_views.ToolsSatodimeBackupUnlockView + assert dest.view_args["card_id"] == "" + assert dest.view_args["from_claim"] is True + def test_init_satochip_requeries_until_the_card_identifies_itself(self, monkeypatch): """The reader serves CPLC/IIN/CIN only intermittently: the first connection completes but derives no usable id, and a reconnect gets one. init_satochip must @@ -1226,15 +1261,15 @@ def test_scanning_the_code_back_verifies_the_backup(self, monkeypatch): monkeypatch.setattr(smartcard_views, "_satodime_scan_text", lambda view: payload) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # dire warning, theft caveat, lose-it warning, chooser -> "Show QR Code" (index 0), - # QR, verify menu -> "Scan It Back" (index 0), success - recorder = ScreenRecorder(0, 0, 0, 0, None, 0, 0) + # name prompt (skipped), dire warning, theft caveat, lose-it warning, chooser -> + # "Show QR Code" (index 0), QR, verify menu -> "Scan It Back" (index 0), success + recorder = ScreenRecorder(0, 0, 0, 0, 0, None, 0, 0) view.run_screen = recorder view.run() assert recorder.titles == [ - "Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key", - None, "Verify Backup", "Backup Verified", + "Key Nickname", "Ownership Key", "Not Theft Proof", "If You Lose It", + "Back Up Ownership Key", None, "Verify Backup", "Backup Verified", ] def test_scanning_the_rendered_qr_back_verifies_end_to_end(self, monkeypatch): @@ -1274,7 +1309,8 @@ def test_scanning_the_rendered_qr_back_verifies_end_to_end(self, monkeypatch): assert DecodeQR.extract_qr_data(qr_frame, is_binary=True) == payload.encode("utf-8") script = ( - [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings -> continue + [K.KEY_UP, K.KEY_PRESS] # name prompt -> back (no name) + + [K.KEY_PRESS, K.KEY_PRESS, K.KEY_PRESS] # three intro warnings -> continue + select("Show QR Code") # chooser + [K.KEY_PRESS] # leave the QR screen + select("Scan It Back") # verify menu @@ -1302,9 +1338,10 @@ def test_a_wrong_scan_does_not_count_as_verified(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # chooser -> "Show QR Code", QR, verify menu -> "Scan It Back" (index 0), "No Match", - # QR again, verify menu -> BACK to the chooser, chooser -> "Skip Verification" (index 2), confirm skip - recorder = ScreenRecorder(0, 0, 0, 0, None, 0, 0, None, RET_CODE__BACK_BUTTON, 2, 0) + # name prompt (skipped), chooser -> "Show QR Code", QR, verify menu -> "Scan It Back" + # (index 0), "No Match", QR again, verify menu -> BACK to the chooser, chooser -> + # "Skip Verification" (index 2), confirm skip + recorder = ScreenRecorder(0, 0, 0, 0, 0, None, 0, 0, None, RET_CODE__BACK_BUTTON, 2, 0) view.run_screen = recorder view.run() @@ -1318,7 +1355,8 @@ def test_the_user_is_told_the_code_is_not_theft_protection(self, monkeypatch): use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) # empty: no matching backup on the card self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - recorder = ScreenRecorder(0, 0, 0, 2, 0) # chooser -> "Skip Verification" (index 2), confirm skip + # name prompt (skipped), then chooser -> "Skip Verification" (index 2), confirm skip + recorder = ScreenRecorder(0, 0, 0, 0, 2, 0) view.run_screen = recorder view.run() @@ -1339,16 +1377,17 @@ def test_finalise_claim_shown_when_matching_backup_on_microsd(self, monkeypatch) ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, lose-it warning, chooser -> "Finalise Claim" (index 2); no further screens - recorder = ScreenRecorder(0, 0, 0, 2) + # name prompt (skipped), dire warning, theft caveat, lose-it warning, chooser -> + # "Finalise Claim" (index 2); no further screens + recorder = ScreenRecorder(0, 0, 0, 0, 2) view.run_screen = recorder dest = view.run() - assert recorder.titles == ["Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key"] - menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] - assert menu_buttons == [ - "Show QR Code", "Save to MicroSD", "Finalise Claim", "Name This Key", + assert recorder.titles == [ + "Key Nickname", "Ownership Key", "Not Theft Proof", "If You Lose It", "Back Up Ownership Key", ] + menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] + assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Finalise Claim"] assert dest.View_cls is smartcard_views.BackStackView def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): @@ -1364,12 +1403,12 @@ def test_done_shown_when_matching_backup_on_microsd_reshow(self, monkeypatch): ) view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) # from_claim=False - recorder = ScreenRecorder(0, 0, 0, 2) # chooser -> "Done" (index 2) + recorder = ScreenRecorder(0, 0, 0, 0, 2) # name prompt (skipped), chooser -> "Done" (index 2) view.run_screen = recorder dest = view.run() - menu_buttons = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] - assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Done", "Name This Key"] + menu_buttons = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] + assert menu_buttons == ["Show QR Code", "Save to MicroSD", "Done"] assert dest.View_cls is smartcard_views.BackStackView def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): @@ -1381,15 +1420,15 @@ def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): self._seed_cache() view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID, from_claim=True) - # dire warning, theft caveat, lose-it warning, chooser -> "Save to MicroSD" (index 1), - # "Saved" ack, chooser again -> "Finalise Claim" (index 2) - recorder = ScreenRecorder(0, 0, 0, 1, 0, 2) + # name prompt (skipped), dire warning, theft caveat, lose-it warning, chooser -> + # "Save to MicroSD" (index 1), "Saved" ack, chooser again -> "Finalise Claim" (index 2) + recorder = ScreenRecorder(0, 0, 0, 0, 1, 0, 2) view.run_screen = recorder dest = view.run() - first_menu = [opt.button_label for opt in recorder.calls[3][1]["button_data"]] - second_menu = [opt.button_label for opt in recorder.calls[5][1]["button_data"]] - # the exit button is index 2; "Name This Key" now trails it at index 3 + first_menu = [opt.button_label for opt in recorder.calls[4][1]["button_data"]] + second_menu = [opt.button_label for opt in recorder.calls[6][1]["button_data"]] + # the exit button is index 2 assert first_menu[2] == "Skip Verification" assert second_menu[2] == "Finalise Claim" # The backup file now holds the current key. @@ -1398,9 +1437,9 @@ def test_save_to_microsd_flips_the_exit_button_in_loop(self, monkeypatch): assert dest.View_cls is smartcard_views.BackStackView def test_naming_the_key_writes_the_card_label_and_payload(self, monkeypatch): - """"Name This Key" in the chooser: the nickname goes into the backup payload - and is persisted on the card itself as its label -- the channel a later restore - matches against when the UID reads blank.""" + """The upfront name prompt: the nickname goes into the backup payload, is persisted + on the card itself as its label, and is recorded in the session cache -- together + the channels a later restore matches against when the UID reads blank.""" from real_screen_fixtures import MockSatochipConnector, use_microsd microsd_dir = use_microsd(monkeypatch, Path(tempfile.mkdtemp(prefix="satodime_test_"))) @@ -1410,10 +1449,9 @@ def test_naming_the_key_writes_the_card_label_and_payload(self, monkeypatch): self.controller.Satochip_Connector = connector view = smartcard_views.ToolsSatodimeBackupUnlockView(card_id=self.CARD_ID) - # dire warning, theft caveat, lose-it warning, chooser -> "Name This Key" (index 3), - # text entry returns the nickname, chooser -> "Save to MicroSD" (index 1), ack, - # chooser again -> exit (index 2) - recorder = ScreenRecorder(0, 0, 0, 3, {"textToEncode": "My Card"}, 1, 0, 2) + # name prompt returns the nickname, dire warning, theft caveat, lose-it warning, + # chooser -> "Save to MicroSD" (index 1), ack, chooser again -> exit (index 2) + recorder = ScreenRecorder({"textToEncode": "My Card"}, 0, 0, 0, 1, 0, 2) view.run_screen = recorder dest = view.run() @@ -1425,9 +1463,8 @@ def test_naming_the_key_writes_the_card_label_and_payload(self, monkeypatch): assert seedkeeper_utils.parse_satodime_unlock_payload(saved) == (self.CARD_ID, self.SECRET, "My Card") - # the chooser now shows the name instead of the prompt - second_menu = [opt.button_label for opt in recorder.calls[7][1]["button_data"]] - assert second_menu[-1] == "Key named 'My Card'" + # the name is on file in the session cache for restore-by-name + assert self.controller.Satodime_unlock_nicknames["My Card"] == (self.CARD_ID, self.SECRET) assert dest.View_cls is smartcard_views.BackStackView def test_backup_refuses_when_nothing_is_cached(self):