Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/seedsigner/gui/screens/scan_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions src/seedsigner/gui/screens/tools_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -1136,14 +1136,16 @@ 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
self.is_bottom_list = True

super().__post_init__()

if " " in self.textToEncode:
if self.visible_space and " " in self.textToEncode:
self.textToEncode = self.textToEncode.replace(" ", "\u2589")

review_font_name = (
Expand All @@ -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):
Expand Down
248 changes: 164 additions & 84 deletions src/seedsigner/models/decode_qr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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 --------------")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/seedsigner/models/qr_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading