From e50a91adcf7358ed69d20d749fcf88a83784c4c9 Mon Sep 17 00:00:00 2001 From: earthdiver Date: Mon, 30 Mar 2026 00:59:44 +0900 Subject: [PATCH 1/4] add ambigous/nested qr support --- src/seedsigner/gui/screens/scan_screens.py | 17 ++ src/seedsigner/models/decode_qr.py | 248 ++++++++++------ src/seedsigner/models/qr_type.py | 1 + src/seedsigner/models/settings_definition.py | 17 ++ src/seedsigner/views/scan_views.py | 291 +++++++++++++++---- tests/test_seedqr_ambiguity.py | 157 ++++++++++ 6 files changed, 592 insertions(+), 139 deletions(-) create mode 100644 tests/test_seedqr_ambiguity.py diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 33ab468ea..50882069f 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -326,6 +326,23 @@ def __post_init__(self): )) +@dataclass +class ScanAmbiguousQRScreen(ButtonListScreen): + message: str = None + + def __post_init__(self): + self.title = _("Select QR Type") + self.show_back_button = False + self.is_bottom_list = True + super().__post_init__() + + self.components.append(TextArea( + text=self.message, + screen_y=self.top_nav.height, + is_text_centered=True, + )) + + @dataclass class ScanTypeEncryptionKeyScreen(BaseTopNavScreen): diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index a6b1bba8e..495d186b1 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -2,6 +2,7 @@ import json import logging import re +from dataclasses import dataclass from datetime import datetime from binascii import a2b_base64, b2a_base64 @@ -22,6 +23,14 @@ logger = logging.getLogger(__name__) +@dataclass +class PayloadAnalysis: + segment: bytes + candidate_types: list[str] + public_data: str | None = None + encrypted_qr: object | None = None + + class DecodeQRStatus(IntEnum): """ @@ -122,6 +131,9 @@ def add_data(self, data): elif self.qr_type == QRType.SEED__ENCRYPTEDQR: self.decoder = EncryptedQrDecoder() + elif self.qr_type == QRType.SEED__AMBIGUOUS_QR: + self.decoder = AmbiguousQrDecoder() + elif self.qr_type == QRType.ENCRYPTION_KEY: self.decoder = EncryptionKeyQrDecoder() @@ -145,7 +157,7 @@ def add_data(self, data): return DecodeQRStatus.INVALID # Process the binary formats first - if self.qr_type in [QRType.SEED__COMPACTSEEDQR, QRType.SEED__ENCRYPTEDQR]: + if self.qr_type in [QRType.SEED__COMPACTSEEDQR, QRType.SEED__ENCRYPTEDQR, QRType.SEED__AMBIGUOUS_QR]: rt = self.decoder.add(data, self.qr_type) if rt == DecodeQRStatus.COMPLETE: self.complete = True @@ -282,6 +294,18 @@ def get_public_data(self): if self.is_encrypted_seedqr: return self.decoder.get_public_data() + def get_ambiguous_segment(self): + if self.is_ambiguous_qr: + return self.decoder.get_segment() + + def get_ambiguous_candidate_types(self): + if self.is_ambiguous_qr: + return self.decoder.get_candidate_types() + + def get_ambiguous_public_data(self): + if self.is_ambiguous_qr: + return self.decoder.get_public_data() + def get_text(self): if self.is_text: @@ -424,6 +448,10 @@ def is_settings(self): def is_encrypted_seedqr(self) -> bool: return self.qr_type == QRType.SEED__ENCRYPTEDQR + @property + def is_ambiguous_qr(self) -> bool: + return self.qr_type == QRType.SEED__AMBIGUOUS_QR + @staticmethod def extract_qr_data(image, is_binary:bool = False) -> str | None: @@ -441,6 +469,98 @@ def extract_qr_data(image, is_binary:bool = False) -> str | None: return barcode.data + @staticmethod + def get_ambiguous_qr_preference() -> str: + from seedsigner.models.settings import Settings + return Settings.get_instance().get_value(SettingsConstants.SETTING__AMBIGUOUS_QR) + + + @staticmethod + def store_encrypted_qr_candidate(encrypted_qr, public_data: str) -> None: + from seedsigner.models.encryptedqr import EncryptedQR + from seedsigner.controller import Controller + + Controller.get_instance().storage2.set_encryptedqr( + EncryptedQR(encrypted_qr=encrypted_qr, public_data=public_data) + ) + + + @staticmethod + def analyze_bytedata_payload(segment: bytes) -> PayloadAnalysis: + candidate_types: list[str] = [] + + if len(segment) in (16, 20, 24, 28, 32): + candidate_types.append(QRType.SEED__COMPACTSEEDQR) + + encrypted_qr, public_data = DecodeQR.parse_encrypted_qr(segment) + if public_data: + candidate_types.append(QRType.SEED__ENCRYPTEDQR) + + try: + text = segment.decode("utf-8").strip() + except Exception: + text = None + + if text: + try: + hdkey = bip32.HDKey.from_string(text) + if hdkey.is_private: + candidate_types.append(QRType.SEED__XPRV) + except Exception: + pass + + return PayloadAnalysis( + segment=segment, + candidate_types=candidate_types, + public_data=public_data, + encrypted_qr=encrypted_qr, + ) + + + @staticmethod + def parse_encrypted_qr(segment: bytes): + from seedsigner.models.encryption import EncryptedQRCode + from seedsigner.helpers.base43 import base43_decode + + encrypted_qr = EncryptedQRCode() + try: + segment_text = segment.decode("utf-8") + data_bytes = base43_decode(segment_text) + public_data = encrypted_qr.public_data(data_bytes) + if public_data: + return encrypted_qr, public_data + except Exception: + pass + + try: + public_data = encrypted_qr.public_data(segment) + if public_data: + return encrypted_qr, public_data + except Exception: + pass + + return None, None + + + @staticmethod + def resolve_payload_type(analysis: PayloadAnalysis) -> str | None: + if not analysis.candidate_types: + return None + + if len(analysis.candidate_types) == 1: + return analysis.candidate_types[0] + + preference = DecodeQR.get_ambiguous_qr_preference() + if preference == SettingsConstants.AMBIGUOUS_QR_COMPACT and QRType.SEED__COMPACTSEEDQR in analysis.candidate_types: + return QRType.SEED__COMPACTSEEDQR + if preference == SettingsConstants.AMBIGUOUS_QR_ENCRYPTED and QRType.SEED__ENCRYPTEDQR in analysis.candidate_types: + return QRType.SEED__ENCRYPTEDQR + if preference == SettingsConstants.AMBIGUOUS_QR_PROMPT: + return QRType.SEED__AMBIGUOUS_QR + + return QRType.SEED__AMBIGUOUS_QR + + @staticmethod def detect_segment_type(s, wordlist_language_code=None): # print("-------------- DecodeQR.detect_segment_type --------------") @@ -571,44 +691,12 @@ def detect_segment_type(s, wordlist_language_code=None): # Couldn't convert back to bytes; shouldn't happen raise Exception("Conversion to bytes failed") - # Byte lengths for CompactSeedQR entropy: - # 32 bytes for 24-word - # 28 bytes for 21-word - # 24 bytes for 18-word - # 20 bytes for 15-word - # 16 bytes for 12-word - if len(s) in (16, 20, 24, 28, 32): - try: - bitstream = "" - for b in s: - bitstream += bin(b).lstrip('0b').zfill(8) - # print(bitstream) - - return QRType.SEED__COMPACTSEEDQR - except Exception as e: - # Couldn't extract byte data; assume it's not a byte format - pass - - else: - from seedsigner.models.encryption import EncryptedQRCode - from seedsigner.helpers.base43 import base43_decode - encrypted_qr = EncryptedQRCode() - public_data = None - try: # Try to decode base43 data - if isinstance(s, bytes): - s = s.decode('utf-8') - data_bytes = base43_decode(s) - public_data = encrypted_qr.public_data(data_bytes) - except: - pass - if not public_data: # Failed to decode and parse base43 - public_data = encrypted_qr.public_data(s) - if public_data: - from seedsigner.models.encryptedqr import EncryptedQR - encryptedqr = EncryptedQR(encrypted_qr=encrypted_qr, public_data=public_data) - from seedsigner.controller import Controller - Controller.get_instance().storage2.set_encryptedqr(encryptedqr) - return QRType.SEED__ENCRYPTEDQR + analysis = DecodeQR.analyze_bytedata_payload(s) + resolved_qr_type = DecodeQR.resolve_payload_type(analysis) + if resolved_qr_type == QRType.SEED__ENCRYPTEDQR and analysis.encrypted_qr: + DecodeQR.store_encrypted_qr_candidate(analysis.encrypted_qr, analysis.public_data) + if resolved_qr_type: + return resolved_qr_type return QRType.INVALID @@ -1115,6 +1203,37 @@ def get_xprv(self): return self.xprv +class AmbiguousQrDecoder(BaseSingleFrameQrDecoder): + def __init__(self): + super().__init__() + self.segment = None + self.candidate_types = [] + self.public_data = None + + def add(self, segment, qr_type=QRType.SEED__AMBIGUOUS_QR): + if qr_type != QRType.SEED__AMBIGUOUS_QR: + return DecodeQRStatus.INVALID + + analysis = DecodeQR.analyze_bytedata_payload(segment) + if len(analysis.candidate_types) < 2: + return DecodeQRStatus.INVALID + + self.segment = segment + self.candidate_types = analysis.candidate_types + self.public_data = analysis.public_data + self.complete = True + self.collected_segments = 1 + return DecodeQRStatus.COMPLETE + + def get_segment(self): + return self.segment + + def get_candidate_types(self): + return self.candidate_types[:] + + def get_public_data(self): + return self.public_data + class SettingsQrDecoder(BaseSingleFrameQrDecoder): """ @@ -1468,13 +1587,12 @@ def get_bip38(self): class EncryptedQrDecoder(BaseSingleFrameQrDecoder): """ - Decodes single frame representing an encypted seed. + Decodes single frame representing an encrypted payload and stores + parsed metadata for subsequent decrypt flow handling. """ def __init__(self): super().__init__() self.public_data = None - self.seed_phrase = [] - self.xprv = None def add(self, segment, qr_type=QRType.SEED__ENCRYPTEDQR, encryption_key=None): @@ -1487,42 +1605,11 @@ def add(self, segment, qr_type=QRType.SEED__ENCRYPTEDQR, encryption_key=None): encrypted_qr = encryptedqr.encrypted_qr self.public_data = encryptedqr.public_data else: - from seedsigner.models.encryption import EncryptedQRCode - from seedsigner.helpers.base43 import base43_decode - encrypted_qr = EncryptedQRCode() - self.public_data = None - try: # Try to decode base43 data - if isinstance(segment, bytes): - segment = segment.decode('utf-8') - data_bytes = base43_decode(segment) - self.public_data = encrypted_qr.public_data(data_bytes) - except: - pass - if not self.public_data: # Failed to decode and parse base43 - self.public_data = encrypted_qr.public_data(segment) - if not self.public_data: + encrypted_qr, self.public_data = DecodeQR.parse_encrypted_qr(segment) + + if not encrypted_qr or not self.public_data: raise Exception("Encrypted QR code is invalid.") - from seedsigner.models.encryptedqr import EncryptedQR - encryptedqr = EncryptedQR(encrypted_qr=encrypted_qr, public_data=self.public_data) - controller.storage2.set_encryptedqr(encryptedqr) - - if encryption_key: - word_bytes = encrypted_qr.decrypt(encryption_key) - if not word_bytes: - return DecodeQRStatus.WRONG_KEY - try: - self.seed_phrase = bip39.mnemonic_from_bytes(word_bytes).split() - self.xprv = None - except Exception: - candidate = word_bytes.decode("utf-8", errors="ignore").strip() - hdkey = bip32.HDKey.from_string(candidate) - if not hdkey.is_private: - return DecodeQRStatus.INVALID - self.seed_phrase = [] - self.xprv = candidate - else: - self.seed_phrase = [] - self.xprv = None + DecodeQR.store_encrypted_qr_candidate(encrypted_qr, self.public_data) self.complete = True self.collected_segments = 1 @@ -1538,13 +1625,6 @@ def get_public_data(self): return self.public_data - def get_seed_phrase(self): - return self.seed_phrase[:] - - def get_xprv(self): - return self.xprv - - class TextQrDecoder(BaseSingleFrameQrDecoder): def __init__(self): diff --git a/src/seedsigner/models/qr_type.py b/src/seedsigner/models/qr_type.py index 89eae68f4..ddc01f73e 100644 --- a/src/seedsigner/models/qr_type.py +++ b/src/seedsigner/models/qr_type.py @@ -13,6 +13,7 @@ class QRType: SEED__MNEMONIC = "seed__mnemonic" SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic" SEED__ENCRYPTEDQR = "seed__encryptedqr" + SEED__AMBIGUOUS_QR = "seed__ambiguous_qr" SEED__SLIP39 = "seed__slip39" SEED__XPRV = "seed__xprv" diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 17d1237f6..5690321a8 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -465,6 +465,7 @@ def map_network_to_embit(cls, network) -> str: SETTING__PARTNER_LOGOS = "partner_logos" SETTING__PLAINTEXTQR = "plaintextqr" SETTING__ENCRYPTED_QR = "encrypted_qr" + SETTING__AMBIGUOUS_QR = "ambiguous_qr_preference" SETTING__ENCRYPTION_MODE = "version" SETTING__ENCRYPTION_ITER = "pbkdf2_iterations" SETTING__WIF_KEYS = "wif_keys" @@ -559,6 +560,14 @@ def map_network_to_embit(cls, network) -> str: ENCRYPTION_MODE_CBCV1 = "AES-CBC v1" ENCRYPTION_MODE = ENCRYPTION_MODE_GCM ENCRYPTION_ITERATIONS = 10 + AMBIGUOUS_QR_PROMPT = "prompt" + AMBIGUOUS_QR_COMPACT = "compactseedqr" + AMBIGUOUS_QR_ENCRYPTED = "encryptedseedqr" + ALL_AMBIGUOUS_QR_OPTIONS = [ + (AMBIGUOUS_QR_COMPACT, _mft("Prefer CompactSeedQR")), + (AMBIGUOUS_QR_ENCRYPTED, _mft("Prefer EncryptedQR")), + (AMBIGUOUS_QR_PROMPT, _mft("Ask each time")), + ] ALL_ENCRYPTION_MODES = [ ENCRYPTION_MODE_ECB, ENCRYPTION_MODE_CBC, @@ -943,6 +952,14 @@ class SettingsDefinition: visibility=SettingsConstants.VISIBILITY__ADVANCED, default_value=SettingsConstants.ENCRYPTION_ITERATIONS), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, + attr_name=SettingsConstants.SETTING__AMBIGUOUS_QR, + display_name=_mft("Ambiguous QR"), + type=SettingsConstants.TYPE__SELECT_1, + visibility=SettingsConstants.VISIBILITY__ADVANCED, + selection_options=SettingsConstants.ALL_AMBIGUOUS_QR_OPTIONS, + default_value=SettingsConstants.AMBIGUOUS_QR_COMPACT), + SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__WIF_KEYS, display_name="WIF keys", diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index c421cad54..a65416a00 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -6,7 +6,12 @@ #from embit.descriptor import Descriptor from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen -from seedsigner.gui.screens.scan_screens import ScanEncryptedQRScreen, ScanTypeEncryptionKeyScreen, ScanReviewEncryptionKeyScreen +from seedsigner.gui.screens.scan_screens import ( + ScanAmbiguousQRScreen, + ScanEncryptedQRScreen, + ScanTypeEncryptionKeyScreen, + ScanReviewEncryptionKeyScreen, +) from seedsigner.gui.screens import LargeIconStatusScreen from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus from seedsigner.models.seed import Seed, AezeedSeed, XprvSeed, InvalidSeedException @@ -23,6 +28,55 @@ logger = logging.getLogger(__name__) +def finalize_mnemonic_seed( + controller, + settings, + seed_mnemonic: list[str], + seed_type: str, + wordlist_language_code: str, + skip_current_view: bool = False, +) -> Destination: + # Found a valid mnemonic seed! All new seeds should be considered + # pending (might set a passphrase, SeedXOR, etc) until finalized. + if seed_type == "aezeed": + seed = AezeedSeed(mnemonic=seed_mnemonic) + controller.storage.set_pending_seed(seed) + if seed.seed_bytes is None: + from seedsigner.views.seed_views import SeedAezeedPassphraseModeView + return Destination(SeedAezeedPassphraseModeView) + else: + from seedsigner.models.seed import Seed + controller.storage.set_pending_seed( + Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code) + ) + if settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: + from seedsigner.views.seed_views import SeedAddPassphraseView + return Destination(SeedAddPassphraseView) + + from .seed_views import SeedFinalizeView + return Destination(SeedFinalizeView, skip_current_view=skip_current_view) + + +def finalize_xprv_seed(controller, candidate: str, skip_current_view: bool = False) -> Destination: + from embit import bip32 + + try: + hdkey = bip32.HDKey.from_string(candidate) + except Exception: + return Destination(ScanInvalidQRTypeView) + + if not hdkey.is_private: + return Destination(ScanInvalidQRTypeView) + + try: + controller.storage.set_pending_seed(XprvSeed(candidate)) + except InvalidSeedException: + return Destination(ScanInvalidQRTypeView) + + from .seed_views import SeedFinalizeView + return Destination(SeedFinalizeView, skip_current_view=skip_current_view) + + class ScanView(View): """ @@ -51,6 +105,18 @@ def __init__(self): def is_valid_qr_type(self): return True + def _finalize_mnemonic_seed(self, seed_mnemonic: list[str], seed_type: str) -> Destination: + return finalize_mnemonic_seed( + controller=self.controller, + settings=self.settings, + seed_mnemonic=seed_mnemonic, + seed_type=seed_type, + wordlist_language_code=self.wordlist_language_code, + ) + + def _finalize_xprv_seed(self, xprv: str) -> Destination: + return finalize_xprv_seed(controller=self.controller, candidate=xprv) + def run(self): from seedsigner.gui.screens.scan_screens import ScanScreen @@ -105,25 +171,7 @@ def run(self): return Destination(BackStackView) seed_type = "aezeed" if button_data[selected] == TYPE_AEZEED else "bip39" - # Found a valid mnemonic seed! All new seeds should be considered - # pending (might set a passphrase, SeedXOR, etc) until finalized. - from seedsigner.models.seed import Seed - from .seed_views import SeedFinalizeView - if seed_type == "aezeed": - seed = AezeedSeed(mnemonic=seed_mnemonic) - self.controller.storage.set_pending_seed(seed) - if seed.seed_bytes is None: - from seedsigner.views.seed_views import SeedAezeedPassphraseModeView - return Destination(SeedAezeedPassphraseModeView) - else: - self.controller.storage.set_pending_seed( - Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code) - ) - if seed_type != "aezeed" and self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: - from seedsigner.views.seed_views import SeedAddPassphraseView - return Destination(SeedAddPassphraseView) - else: - return Destination(SeedFinalizeView) + return self._finalize_mnemonic_seed(seed_mnemonic, seed_type) elif self.decoder.is_slip39_share: share = self.decoder.get_slip39_share() @@ -135,16 +183,18 @@ def run(self): return Destination(SeedSlip39MoreSharesView) elif self.decoder.is_xprv: - from .seed_views import SeedFinalizeView + return self._finalize_xprv_seed(self.decoder.get_xprv()) - try: - self.controller.storage.set_pending_seed( - XprvSeed(self.decoder.get_xprv()) - ) - except InvalidSeedException: - return Destination(ScanInvalidQRTypeView) - - return Destination(SeedFinalizeView) + elif self.decoder.is_ambiguous_qr: + return Destination( + ScanAmbiguousQRPromptView, + view_args=dict( + segment=self.decoder.get_ambiguous_segment(), + candidate_types=self.decoder.get_ambiguous_candidate_types(), + public_data=self.decoder.get_ambiguous_public_data(), + ), + skip_current_view=True, + ) elif self.decoder.is_psbt: from seedsigner.views.psbt_views import PSBTSelectSeedView @@ -331,7 +381,7 @@ class ScanSeedQRView(ScanView): @property def is_valid_qr_type(self): - return self.decoder.is_seed or self.decoder.is_encrypted_seedqr or self.decoder.is_xprv + return self.decoder.is_seed or self.decoder.is_encrypted_seedqr or self.decoder.is_xprv or self.decoder.is_ambiguous_qr class ScanSlip39ShareQRView(ScanView): @@ -468,6 +518,81 @@ def run(self): +class ScanAmbiguousQRPromptView(View): + COMPACT = ButtonOption("CompactSeedQR") + ENCRYPTED = ButtonOption("EncryptedQR") + XPRV = ButtonOption("xprv") + CANCEL = ButtonOption("Cancel") + + def __init__(self, segment: bytes | str, candidate_types: list[str], public_data: str = None): + super().__init__() + self.segment = segment + self.candidate_types = candidate_types + self.public_data = public_data + self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) + + def run(self): + from seedsigner.models.qr_type import QRType + + button_data = [] + if QRType.SEED__COMPACTSEEDQR in self.candidate_types: + button_data.append(self.COMPACT) + if QRType.SEED__ENCRYPTEDQR in self.candidate_types: + button_data.append(self.ENCRYPTED) + if QRType.SEED__XPRV in self.candidate_types: + button_data.append(self.XPRV) + button_data.append(self.CANCEL) + + selected_menu_num = self.run_screen( + ScanAmbiguousQRScreen, + message=_("Data matches multiple\nQR types."), + button_data=button_data, + ) + + selected_option = button_data[selected_menu_num] + + if selected_option == self.ENCRYPTED: + analysis = DecodeQR.analyze_bytedata_payload(self.segment) + if analysis.encrypted_qr and analysis.public_data: + DECRYPT = ButtonOption("Decrypt") + CANCEL = ButtonOption("Cancel") + button_data = [DECRYPT, CANCEL] + + selected_menu_num = self.run_screen( + ScanEncryptedQRScreen, + public_data=analysis.public_data, + button_data=button_data, + ) + + if button_data[selected_menu_num] == DECRYPT: + DecodeQR.store_encrypted_qr_candidate(analysis.encrypted_qr, analysis.public_data) + return Destination(ScanEncryptedQREncryptionKeyView, skip_current_view=True) + + elif button_data[selected_menu_num] == CANCEL: + return Destination(MainMenuView) + + return Destination(ScanInvalidQRTypeView) + + if selected_option == self.COMPACT: + from embit import bip39 + return finalize_mnemonic_seed( + controller=self.controller, + settings=self.settings, + seed_mnemonic=bip39.mnemonic_from_bytes(self.segment).split(), + seed_type="bip39", + wordlist_language_code=self.wordlist_language_code, + ) + + if selected_option == self.XPRV: + return finalize_xprv_seed( + controller=self.controller, + candidate=self.segment.decode("utf-8").strip(), + ) + + + return Destination(MainMenuView) + + class ScanEncryptedQREncryptionKeyView(View): def run(self): TYPE = ButtonOption("Type encryption key") @@ -656,48 +781,104 @@ def __init__(self, encryption_key: str, encrypted_data: bytes = None): self.encrypted_data: bytes = encrypted_data self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) + def _route_decrypted_payload(self, word_bytes: bytes) -> Destination: + from seedsigner.models.qr_type import QRType + + analysis = DecodeQR.analyze_bytedata_payload(word_bytes) + resolved_qr_type = DecodeQR.resolve_payload_type(analysis) + + if resolved_qr_type == QRType.SEED__COMPACTSEEDQR: + from embit import bip39 + return finalize_mnemonic_seed( + controller=self.controller, + settings=self.settings, + seed_mnemonic=bip39.mnemonic_from_bytes(word_bytes).split(), + seed_type="bip39", + wordlist_language_code=self.wordlist_language_code, + skip_current_view=True, + ) + + if resolved_qr_type == QRType.SEED__XPRV: + candidate = word_bytes.decode("utf-8").strip() + return finalize_xprv_seed( + controller=self.controller, + candidate=candidate, + skip_current_view=True, + ) + + if resolved_qr_type == QRType.SEED__ENCRYPTEDQR: + if analysis.encrypted_qr and analysis.public_data: + DECRYPT = ButtonOption("Decrypt") + CANCEL = ButtonOption("Cancel") + button_data = [DECRYPT, CANCEL] + + selected_menu_num = self.run_screen( + ScanEncryptedQRScreen, + public_data=analysis.public_data, + button_data=button_data, + ) + + if button_data[selected_menu_num] == DECRYPT: + DecodeQR.store_encrypted_qr_candidate(analysis.encrypted_qr, analysis.public_data) + return Destination(ScanEncryptedQREncryptionKeyView, skip_current_view=True) + + elif button_data[selected_menu_num] == CANCEL: + return Destination(MainMenuView) + + return Destination(ScanInvalidQRTypeView) + + + if resolved_qr_type == QRType.SEED__AMBIGUOUS_QR: + return Destination( + ScanAmbiguousQRPromptView, + view_args=dict( + segment=word_bytes, + candidate_types=analysis.candidate_types, + public_data=analysis.public_data, + ), + skip_current_view=True, + ) + + return Destination(ScanInvalidQRTypeView) + def run(self): from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Processing...") self.loading_screen.start() + status = DecodeQRStatus.INVALID + word_bytes = None + encryptedqr = None try: from seedsigner.models.decode_qr import EncryptedQrDecoder from seedsigner.models.qr_type import QRType decoder = EncryptedQrDecoder() - status = decoder.add(self.encrypted_data, qr_type=QRType.SEED__ENCRYPTEDQR, encryption_key=self.encryption_key) + status = decoder.add(self.encrypted_data, qr_type=QRType.SEED__ENCRYPTEDQR) + if status == DecodeQRStatus.COMPLETE: + encryptedqr = self.controller.storage2.encryptedqr + if encryptedqr: + word_bytes = encryptedqr.encrypted_qr.decrypt(self.encryption_key) finally: self.loading_screen.stop() if status == DecodeQRStatus.COMPLETE: - self.controller.storage2.clear_encryptedqr() - xprv = decoder.get_xprv() - if xprv: - self.controller.storage.set_pending_seed(XprvSeed(xprv)) - from .seed_views import SeedFinalizeView - return Destination(SeedFinalizeView, skip_current_view=True) - else: - self.controller.storage.set_pending_seed( - Seed(mnemonic=decoder.get_seed_phrase(), wordlist_language_code=self.wordlist_language_code) - ) - if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: - from seedsigner.views.seed_views import SeedAddPassphraseView - return Destination(SeedAddPassphraseView, skip_current_view=True) - else: - from .seed_views import SeedFinalizeView - return Destination(SeedFinalizeView, skip_current_view=True) + if not encryptedqr: + return Destination(ScanInvalidQRTypeView) + if not word_bytes: + WarningScreen( + title="Error", + show_back_button=False, + status_headline="decryption failure", + text="Review your encryption key.", + ).display() + return Destination(BackStackView) - elif status == DecodeQRStatus.WRONG_KEY: - WarningScreen( - title="Error", - show_back_button=False, - status_headline="decryption failure", - text="Review your encryption key.", - ).display() - return Destination(BackStackView) + self.controller.storage2.clear_encryptedqr() + return self._route_decrypted_payload(word_bytes) else: + self.controller.storage2.clear_encryptedqr() WarningScreen( title="Error", show_back_button=False, diff --git a/tests/test_seedqr_ambiguity.py b/tests/test_seedqr_ambiguity.py new file mode 100644 index 000000000..d4ea87972 --- /dev/null +++ b/tests/test_seedqr_ambiguity.py @@ -0,0 +1,157 @@ +from base import BaseTest, FlowTest +from seedsigner.models.decode_qr import DecodeQR, PayloadAnalysis +from seedsigner.models.qr_type import QRType +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.views import scan_views +from seedsigner.views.view import Destination + + +AMBIGUOUS_SEGMENT = bytes([0] * 32) +ENCRYPTED_PUBLIC_DATA = "Encrypted QR Code:\nID: test" + + +class FakeEncryptedQR: + def decrypt(self, _encryption_key: str): + return AMBIGUOUS_SEGMENT + + +def make_ambiguous_analysis(segment: bytes) -> PayloadAnalysis: + return PayloadAnalysis( + segment=segment, + candidate_types=[QRType.SEED__COMPACTSEEDQR, QRType.SEED__ENCRYPTEDQR], + public_data=ENCRYPTED_PUBLIC_DATA, + encrypted_qr=FakeEncryptedQR(), + ) + + +class TestSeedQRAmbiguityDetection(BaseTest): + def test_detect_segment_type_prefers_compact_when_setting_is_compact(self, monkeypatch): + self.settings.set_value( + SettingsConstants.SETTING__AMBIGUOUS_QR, + SettingsConstants.AMBIGUOUS_QR_COMPACT, + save=False, + ) + monkeypatch.setattr( + DecodeQR, + "parse_encrypted_qr", + staticmethod(lambda _segment: (FakeEncryptedQR(), ENCRYPTED_PUBLIC_DATA)), + ) + + decoder = DecodeQR() + + assert ( + decoder.detect_segment_type( + AMBIGUOUS_SEGMENT, + wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH, + ) + == QRType.SEED__COMPACTSEEDQR + ) + + def test_detect_segment_type_returns_ambiguous_when_setting_is_prompt(self, monkeypatch): + self.settings.set_value( + SettingsConstants.SETTING__AMBIGUOUS_QR, + SettingsConstants.AMBIGUOUS_QR_PROMPT, + save=False, + ) + monkeypatch.setattr( + DecodeQR, + "parse_encrypted_qr", + staticmethod(lambda _segment: (FakeEncryptedQR(), ENCRYPTED_PUBLIC_DATA)), + ) + + decoder = DecodeQR() + + assert ( + decoder.detect_segment_type( + AMBIGUOUS_SEGMENT, + wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH, + ) + == QRType.SEED__AMBIGUOUS_QR + ) + + def test_detect_segment_type_prefers_encrypted_and_stores_candidate(self, monkeypatch): + self.settings.set_value( + SettingsConstants.SETTING__AMBIGUOUS_QR, + SettingsConstants.AMBIGUOUS_QR_ENCRYPTED, + save=False, + ) + fake_encrypted_qr = FakeEncryptedQR() + monkeypatch.setattr( + DecodeQR, + "parse_encrypted_qr", + staticmethod(lambda _segment: (fake_encrypted_qr, ENCRYPTED_PUBLIC_DATA)), + ) + + decoder = DecodeQR() + qr_type = decoder.detect_segment_type( + AMBIGUOUS_SEGMENT, + wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH, + ) + + assert qr_type == QRType.SEED__ENCRYPTEDQR + stored = self.controller.storage2.encryptedqr + assert stored is not None + assert stored.encrypted_qr is fake_encrypted_qr + assert stored.public_data == ENCRYPTED_PUBLIC_DATA + + +class TestSeedQRAmbiguityFlows(FlowTest): + def test_ambiguous_prompt_encrypted_choice_routes_to_encrypted_key_view(self, monkeypatch): + self.settings.set_value( + SettingsConstants.SETTING__AMBIGUOUS_QR, + SettingsConstants.AMBIGUOUS_QR_PROMPT, + save=False, + ) + fake_encrypted_qr = FakeEncryptedQR() + monkeypatch.setattr( + DecodeQR, + "analyze_bytedata_payload", + staticmethod( + lambda segment: PayloadAnalysis( + segment=segment, + candidate_types=[QRType.SEED__COMPACTSEEDQR, QRType.SEED__ENCRYPTEDQR], + public_data=ENCRYPTED_PUBLIC_DATA, + encrypted_qr=fake_encrypted_qr, + ) + ), + ) + + view = scan_views.ScanAmbiguousQRPromptView( + segment=AMBIGUOUS_SEGMENT, + candidate_types=[QRType.SEED__COMPACTSEEDQR, QRType.SEED__ENCRYPTEDQR], + public_data=ENCRYPTED_PUBLIC_DATA, + ) + view.run_screen = lambda *args, **kwargs: 1 if kwargs.get("public_data") is None else 0 + + destination = view.run() + + assert isinstance(destination, Destination) + assert destination.View_cls == scan_views.ScanEncryptedQREncryptionKeyView + stored = self.controller.storage2.encryptedqr + assert stored is not None + assert stored.encrypted_qr is fake_encrypted_qr + assert stored.public_data == ENCRYPTED_PUBLIC_DATA + + def test_decrypt_route_returns_prompt_for_nested_ambiguous_payload(self, monkeypatch): + self.settings.set_value( + SettingsConstants.SETTING__AMBIGUOUS_QR, + SettingsConstants.AMBIGUOUS_QR_PROMPT, + save=False, + ) + monkeypatch.setattr( + DecodeQR, + "analyze_bytedata_payload", + staticmethod(make_ambiguous_analysis), + ) + + view = scan_views.ScanDecryptEncryptedQRView(encryption_key="outer key", encrypted_data=b"unused") + destination = view._route_decrypted_payload(AMBIGUOUS_SEGMENT) + + assert isinstance(destination, Destination) + assert destination.View_cls == scan_views.ScanAmbiguousQRPromptView + assert destination.view_args["segment"] == AMBIGUOUS_SEGMENT + assert destination.view_args["candidate_types"] == [ + QRType.SEED__COMPACTSEEDQR, + QRType.SEED__ENCRYPTEDQR, + ] + assert destination.view_args["public_data"] == ENCRYPTED_PUBLIC_DATA From ff10f257cbd19ddd1c4c58e3078f0855497858ec Mon Sep 17 00:00:00 2001 From: earthdiver Date: Mon, 30 Mar 2026 01:16:08 +0900 Subject: [PATCH 2/4] hide legacy AES v1 modes from encryption-mode selection --- src/seedsigner/models/settings_definition.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 5690321a8..3b0989ce0 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -573,8 +573,6 @@ def map_network_to_embit(cls, network) -> str: ENCRYPTION_MODE_CBC, ENCRYPTION_MODE_CTR, ENCRYPTION_MODE_GCM, - ENCRYPTION_MODE_ECBV1, - ENCRYPTION_MODE_CBCV1, ] ALL_SEED_WORD_LENGTHS = [ From 90329bdea678bcfb1ff7c3cfae21cf8c46d1e77f Mon Sep 17 00:00:00 2001 From: earthdiver Date: Mon, 30 Mar 2026 22:23:31 +0900 Subject: [PATCH 3/4] Handle decrypted text from encrypted QR codes --- src/seedsigner/gui/screens/tools_screens.py | 6 ++-- src/seedsigner/views/scan_views.py | 35 +++++++++++++++++++++ tests/test_seedqr_ambiguity.py | 22 +++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py index 037e746fb..9817c6bf2 100644 --- a/src/seedsigner/gui/screens/tools_screens.py +++ b/src/seedsigner/gui/screens/tools_screens.py @@ -1136,6 +1136,8 @@ def _run(self): class ToolsTextQRReviewTextScreen(ButtonListScreen): textToEncode: str = None title: str = None + max_lines: int = 5 + visible_space: bool = True def __post_init__(self): # Customize defaults @@ -1143,7 +1145,7 @@ def __post_init__(self): super().__post_init__() - if " " in self.textToEncode: + if self.visible_space and " " in self.textToEncode: self.textToEncode = self.textToEncode.replace(" ", "\u2589") review_font_name = ( @@ -1155,7 +1157,7 @@ def __post_init__(self): max_font_size = GUIConstants.get_top_nav_title_font_size() + 8 min_font_size = GUIConstants.get_top_nav_title_font_size() - 4 font_size = max_font_size - max_lines = 5 + max_lines = self.max_lines max_chars_per_line = -1 found_solution = False for font_size in range(max_font_size, min_font_size-1, -2): diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index a65416a00..162423cd3 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -839,6 +839,18 @@ def _route_decrypted_payload(self, word_bytes: bytes) -> Destination: skip_current_view=True, ) + try: + decoded_text = word_bytes.decode("utf-8") + except UnicodeDecodeError: + decoded_text = None + + if decoded_text: + return Destination( + ScanDecryptedTextView, + view_args=dict(text=decoded_text), + skip_current_view=True, + ) + return Destination(ScanInvalidQRTypeView) @@ -905,3 +917,26 @@ def run(self): ) return Destination(MainMenuView, clear_history=True) + + +class ScanDecryptedTextView(View): + def __init__(self, text: str): + super().__init__() + self.text = text + + def run(self): + from seedsigner.gui.screens.tools_screens import ToolsTextQRReviewTextScreen + + DONE = ButtonOption("Done") + + self.run_screen( + ToolsTextQRReviewTextScreen, + title=_("Decrypted Text"), + textToEncode=self.text, + max_lines=8, + visible_space=False, + button_data=[DONE], + show_back_button=False, + ) + + return Destination(MainMenuView, clear_history=True) diff --git a/tests/test_seedqr_ambiguity.py b/tests/test_seedqr_ambiguity.py index d4ea87972..e08a9c352 100644 --- a/tests/test_seedqr_ambiguity.py +++ b/tests/test_seedqr_ambiguity.py @@ -155,3 +155,25 @@ def test_decrypt_route_returns_prompt_for_nested_ambiguous_payload(self, monkeyp QRType.SEED__ENCRYPTEDQR, ] assert destination.view_args["public_data"] == ENCRYPTED_PUBLIC_DATA + + def test_decrypt_route_shows_text_when_payload_is_not_known_qr_type(self, monkeypatch): + monkeypatch.setattr( + DecodeQR, + "analyze_bytedata_payload", + staticmethod( + lambda segment: PayloadAnalysis( + segment=segment, + candidate_types=[], + public_data=None, + encrypted_qr=None, + ) + ), + ) + monkeypatch.setattr(DecodeQR, "resolve_payload_type", staticmethod(lambda _analysis: None)) + + view = scan_views.ScanDecryptEncryptedQRView(encryption_key="outer key", encrypted_data=b"unused") + destination = view._route_decrypted_payload(b"UTF-8 TEXT") + + assert isinstance(destination, Destination) + assert destination.View_cls == scan_views.ScanDecryptedTextView + assert destination.view_args["text"] == "UTF-8 TEXT" From be789248ff155cd0c8709260eed780097826c0ee Mon Sep 17 00:00:00 2001 From: earthdiver Date: Wed, 8 Apr 2026 21:41:53 +0900 Subject: [PATCH 4/4] Set default ambiguous QR handling to Ask each time --- src/seedsigner/models/settings_definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 3b0989ce0..663746645 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -956,7 +956,7 @@ class SettingsDefinition: type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_AMBIGUOUS_QR_OPTIONS, - default_value=SettingsConstants.AMBIGUOUS_QR_COMPACT), + default_value=SettingsConstants.AMBIGUOUS_QR_PROMPT), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__WIF_KEYS,