Files
seedpgp-web/REFERENCE/encryption_ui.py
LC mac aa06c9ae27 feat: fix CompactSeedQR binary QR code scanning with jsQR library
- Replace BarcodeDetector with jsQR for raw binary byte access
- BarcodeDetector forced UTF-8 decoding which corrupted binary data
- jsQR's binaryData property preserves raw bytes without text conversion
- Fix regex bug: use single backslash \x00 instead of \x00 for binary detection
- Add debug logging for scan data inspection
- QR generation already worked (Krux-compatible), only scanning was broken

Resolves binary QR code scanning for 12/24-word CompactSeedQR format.
Tested with Krux device - full bidirectional compatibility confirmed.
2026-02-07 04:22:56 +08:00

716 lines
25 KiB
Python

# The MIT License (MIT)
# Copyright (c) 2021-2024 Krux contributors
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import time
from embit import bip39
from binascii import hexlify
from ..display import DEFAULT_PADDING, FONT_HEIGHT, BOTTOM_PROMPT_LINE
from ..krux_settings import t, Settings
from ..encryption import QR_CODE_ITER_MULTIPLE
from krux import kef
from ..themes import theme
from . import (
Page,
Menu,
MENU_CONTINUE,
ESC_KEY,
LETTERS,
UPPERCASE_LETTERS,
NUM_SPECIAL_1,
NUM_SPECIAL_2,
DIGITS,
)
# Override constants for KEF envelope operations
OVERRIDE_ITERATIONS = 1
OVERRIDE_VERSION = 2
OVERRIDE_MODE = 3
OVERRIDE_LABEL = 4
ENCRYPTION_KEY_MAX_LEN = 200
def decrypt_kef(ctx, data):
"""finds kef-envelope and returns data fully decrypted, else ValueError"""
from binascii import unhexlify
from krux.baseconv import base_decode, hint_encodings
# nothing to decrypt or declined raises ValueError here,
# so callers can `except ValueError: pass`, then treat original data.
# If user decides to decrypt and fails with wrong key, then
# `KeyError("Failed to decrypt")` raised by `KEFEnvelope.unseal_ui()`
# will bubble up to caller.
err = "Not decrypted" # intentionally vague
# if data is str, assume encoded, look for kef envelope
kef_envelope = None
if isinstance(data, str):
encodings = hint_encodings(data)
for encoding in encodings:
as_bytes = None
if encoding in ("hex", "HEX"):
try:
as_bytes = unhexlify(data)
except:
continue
elif encoding == 32:
try:
as_bytes = base_decode(data, 32)
except:
continue
elif encoding == 64:
try:
as_bytes = base_decode(data, 64)
except:
continue
elif encoding == 43:
try:
as_bytes = base_decode(data, 43)
except:
continue
if as_bytes:
kef_envelope = KEFEnvelope(ctx)
if kef_envelope.parse(as_bytes):
break
kef_envelope = None
del as_bytes
# kef_envelope may already be parsed, else do so or fail early
if kef_envelope is None:
if not isinstance(data, bytes):
raise ValueError(err)
kef_envelope = KEFEnvelope(ctx)
if not kef_envelope.parse(data):
raise ValueError(err)
# unpack as many kef_envelopes as there may be
while True:
data = kef_envelope.unseal_ui()
if data is None:
# fail if not unsealed
raise ValueError(err)
# we may have unsealed another envelope
kef_envelope = KEFEnvelope(ctx)
if not kef_envelope.parse(data):
return data
raise ValueError(err)
def prompt_for_text_update(
ctx,
dflt_value,
dflt_prompt=None,
dflt_affirm=True,
prompt_highlight_prefix="",
title=None,
keypads=None,
esc_prompt=False,
):
"""Clears screen, prompts question, allows for keypad input"""
if dflt_value:
if dflt_prompt:
dflt_prompt += " " + dflt_value
else:
dflt_prompt = t("Use current value?") + " " + dflt_value
ctx.display.clear()
if dflt_value and dflt_prompt:
ctx.display.draw_centered_text(
dflt_prompt, highlight_prefix=prompt_highlight_prefix
)
dflt_answer = Page(ctx).prompt("", BOTTOM_PROMPT_LINE)
if dflt_affirm == dflt_answer:
return dflt_value
if not isinstance(keypads, list) or keypads is None:
keypads = [LETTERS, UPPERCASE_LETTERS, NUM_SPECIAL_1, NUM_SPECIAL_2]
value = Page(ctx).capture_from_keypad(
title, keypads, starting_buffer=dflt_value, esc_prompt=esc_prompt
)
if isinstance(value, str):
return value
return dflt_value
class KEFEnvelope(Page):
"""UI to handle KEF-Encryption-Format Envelopes"""
def __init__(self, ctx):
super().__init__(ctx, None)
self.ctx = ctx
self.__key = None
self.__iv = None
self.label = None
self.iterations = Settings().encryption.pbkdf2_iterations
max_delta = self.iterations // 10
self.iterations += int(time.ticks_ms()) % max_delta
self.mode_name = Settings().encryption.version
self.mode = kef.MODE_NUMBERS[self.mode_name]
self.iv_len = kef.MODE_IVS.get(self.mode, 0)
self.version = None
self.version_name = None
self.ciphertext = None
def parse(self, kef_envelope):
"""parses envelope, from kef.wrap()"""
if self.ciphertext is not None:
raise ValueError("KEF Envelope already parsed")
try:
self.label, self.version, self.iterations, self.ciphertext = kef.unwrap(
kef_envelope
)
except:
return False
self.version_name = kef.VERSIONS[self.version]["name"]
self.mode = kef.VERSIONS[self.version]["mode"]
self.mode_name = [k for k, v in kef.MODE_NUMBERS.items() if v == self.mode][0]
return True
def input_key_ui(self, creating=True):
"""calls ui to gather master key"""
ui = EncryptionKey(self.ctx)
self.__key = ui.encryption_key(creating)
return bool(self.__key)
def input_mode_ui(self):
"""implements ui to allow user to select KEF mode-of-operation"""
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Use default Mode?") + " " + self.mode_name, highlight_prefix="?"
)
if self.prompt("", BOTTOM_PROMPT_LINE):
return True
menu_items = [(k, v) for k, v in kef.MODE_NUMBERS.items() if v is not None]
idx, _ = Menu(
self.ctx, [(x[0], lambda: None) for x in menu_items], back_label=None
).run_loop()
self.mode_name, self.mode = menu_items[idx]
self.iv_len = kef.MODE_IVS.get(self.mode, 0)
return True
def input_version_ui(self):
"""implements ui to allow user to select KEF version"""
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Use default Mode?") + " " + self.mode_name, highlight_prefix="?"
)
if self.prompt("", BOTTOM_PROMPT_LINE):
return True
menu_items = [
(v["name"], k)
for k, v in sorted(kef.VERSIONS.items())
if isinstance(v, dict) and v["mode"] is not None
]
idx, _ = Menu(
self.ctx, [(x[0], lambda: None) for x in menu_items], back_label=None
).run_loop()
self.version = [v for i, (_, v) in enumerate(menu_items) if i == idx][0]
self.version_name = kef.VERSIONS[self.version]["name"]
self.mode = kef.VERSIONS[self.version]["mode"]
self.mode_name = [k for k, v in kef.MODE_NUMBERS.items() if v == self.mode][0]
self.iv_len = kef.MODE_IVS.get(self.mode, 0)
return True
def input_iterations_ui(self):
"""implements ui to allow user to set key-stretch iterations"""
curr_value = str(self.iterations)
dflt_prompt = t("Use default PBKDF2 iter.?")
title = t("PBKDF2 iter.") + ": 10K - 510K"
keypads = [DIGITS]
iterations = prompt_for_text_update(
self.ctx, curr_value, dflt_prompt, True, "?", title, keypads
)
if QR_CODE_ITER_MULTIPLE <= int(iterations) <= 550000:
self.iterations = int(iterations)
return True
return None
def input_label_ui(
self,
dflt_label="",
dflt_prompt="",
dflt_affirm=True,
title=t("Visible Label"),
keypads=None,
):
"""implements ui to allow user to set a KEF label"""
if dflt_label and not dflt_prompt:
dflt_prompt = t("Update KEF ID?")
dflt_affirm = False
self.label = prompt_for_text_update(
self.ctx, dflt_label, dflt_prompt, dflt_affirm, "?", title, keypads
)
return True
def input_iv_ui(self):
"""implements ui to allow user to gather entropy from camera for iv"""
if self.iv_len > 0:
error_txt = t("Failed gathering camera entropy")
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Additional entropy from camera required for %s") % self.mode_name
)
if not self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE):
self.flash_error(error_txt)
self.__iv = None
return None
from .capture_entropy import CameraEntropy
camera_entropy = CameraEntropy(self.ctx)
entropy = camera_entropy.capture(show_entropy_details=False)
if entropy is None:
self.flash_error(error_txt)
self.__iv = None
return None
self.__iv = entropy[: self.iv_len]
return True
self.__iv = None
return True
def public_info_ui(self, kef_envelope=None, prompt_decrypt=False):
"""implements ui to allow user to see public exterior of KEF envelope"""
if kef_envelope:
self.parse(kef_envelope)
elif not self.ciphertext:
raise ValueError("KEF Envelope not yet parsed")
try:
displayable_label = self.label.decode()
except:
displayable_label = "0x" + hexlify(self.label).decode()
public_info = "\n".join(
[
t("KEF Encrypted") + " (" + str(len(self.ciphertext)) + " B)",
self.fit_to_line(displayable_label, t("ID") + ": "),
t("Version") + ": " + self.version_name,
t("PBKDF2 iter.") + ": " + str(self.iterations),
]
)
self.ctx.display.clear()
if prompt_decrypt:
return self.prompt(
public_info + "\n\n" + t("Decrypt?"), self.ctx.display.height() // 2
)
self.ctx.display.draw_hcentered_text(public_info)
self.ctx.input.wait_for_button()
return True
def seal_ui(
self,
plaintext,
overrides=None,
dflt_label_prompt="",
dflt_label_affirm=True,
):
"""implements ui to allow user to seal plaintext inside a KEF envelope"""
if not isinstance(overrides, list):
overrides = []
if self.ciphertext:
raise ValueError("KEF Envelope already sealed")
if not (self.__key or self.input_key_ui()):
return None
if overrides:
if OVERRIDE_ITERATIONS in overrides and not self.input_iterations_ui():
return None
if OVERRIDE_VERSION in overrides and not self.input_version_ui():
return None
if OVERRIDE_MODE in overrides and not self.input_mode_ui():
return None
if self.iv_len:
if not (self.__iv or self.input_iv_ui()):
return None
if OVERRIDE_LABEL in overrides or not self.label:
self.input_label_ui(self.label, dflt_label_prompt, dflt_label_affirm)
if self.version is None:
self.version = kef.suggest_versions(plaintext, self.mode_name)[0]
self.version_name = kef.VERSIONS[self.version]["name"]
self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))
cipher = kef.Cipher(self.__key, self.label, self.iterations)
self.ciphertext = cipher.encrypt(plaintext, self.version, self.__iv)
self.__key = None
self.__iv = None
return kef.wrap(self.label, self.version, self.iterations, self.ciphertext)
def unseal_ui(self, kef_envelope=None, prompt_decrypt=True, display_plain=False):
"""implements ui to allow user to unseal a plaintext from a sealed KEF envelope"""
if kef_envelope:
if not self.parse(kef_envelope):
return None
if not self.ciphertext:
raise ValueError("KEF Envelope not yet parsed")
if prompt_decrypt:
if not self.public_info_ui(prompt_decrypt=prompt_decrypt):
return None
if not (self.__key or self.input_key_ui(creating=False)):
return None
self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))
cipher = kef.Cipher(self.__key, self.label, self.iterations)
plaintext = cipher.decrypt(self.ciphertext, self.version)
self.__key = None
if plaintext is None:
raise KeyError("Failed to decrypt")
if display_plain:
self.ctx.display.clear()
try:
self.ctx.display.draw_centered_text(plaintext.decode())
except:
self.ctx.display.draw_centered_text("0x" + hexlify(plaintext).decode())
self.ctx.input.wait_for_button()
return plaintext
class EncryptionKey(Page):
"""UI to capture an encryption key"""
def __init__(self, ctx):
super().__init__(ctx, None)
self.ctx = ctx
def key_strength(self, key_string):
"""Check the strength of a key."""
if isinstance(key_string, bytes):
key_string = hexlify(key_string).decode()
if len(key_string) < 8:
return t("Weak")
has_upper = has_lower = has_digit = has_special = False
for c in key_string:
if "a" <= c <= "z":
has_lower = True
elif "A" <= c <= "Z":
has_upper = True
elif "0" <= c <= "9":
has_digit = True
else:
has_special = True
# small optimization: stop if all found
if has_upper and has_lower and has_digit and has_special:
break
# Count how many character types are present
score = sum([has_upper, has_lower, has_digit, has_special])
# Add length score to score
key_len = len(key_string)
if key_len >= 12:
score += 1
if key_len >= 16:
score += 1
if key_len >= 20:
score += 1
if key_len >= 40:
score += 1
set_len = len(set(key_string))
if set_len < 6:
score -= 1
if set_len < 3:
score -= 1
# Determine key strength
if score >= 4:
return t("Strong")
if score >= 3:
return t("Medium")
return t("Weak")
def encryption_key(self, creating=False):
"""Loads and returns an encryption key from keypad or QR code"""
submenu = Menu(
self.ctx,
[
(t("Type Key"), self.load_key),
(t("Scan Key QR Code"), self.load_qr_encryption_key),
],
back_label=None,
)
_, key = submenu.run_loop()
try:
# encryption key may have been encrypted
decrypted = decrypt_kef(self.ctx, key)
try:
# no assumed decodings except for utf8
decrypted = decrypted.decode()
except:
pass
key = decrypted if decrypted else key
except KeyError:
self.flash_error(t("Failed to decrypt"))
return None
except ValueError:
# ValueError=not KEF or declined to decrypt
pass
while True:
if key in (None, "", b"", ESC_KEY, MENU_CONTINUE):
self.flash_error(t("Failed to load"))
return None
self.ctx.display.clear()
offset_y = DEFAULT_PADDING
displayable = key if isinstance(key, str) else "0x" + hexlify(key).decode()
key_lines = self.ctx.display.draw_hcentered_text(
"{} ({}): {}".format(t("Key"), len(key), displayable),
offset_y,
highlight_prefix=":",
)
if creating:
strength = self.key_strength(key)
offset_y += (key_lines + 1) * FONT_HEIGHT
color = theme.error_color if strength == t("Weak") else theme.fg_color
self.ctx.display.draw_hcentered_text(
"{}: {}".format(t("Strength"), strength),
offset_y,
color,
highlight_prefix=":",
)
if self.prompt(t("Proceed?"), BOTTOM_PROMPT_LINE):
return key
# user did not confirm to proceed
if not isinstance(key, str):
return None
key = self.load_key(key)
def load_key(self, data=""):
"""Loads and returns a key from keypad"""
if not isinstance(data, str):
raise TypeError("load_key() expected str")
data = self.capture_from_keypad(
t("Key"),
[LETTERS, UPPERCASE_LETTERS, NUM_SPECIAL_1, NUM_SPECIAL_2],
starting_buffer=data,
)
if len(str(data)) > ENCRYPTION_KEY_MAX_LEN:
raise ValueError("Maximum length exceeded (%s)" % ENCRYPTION_KEY_MAX_LEN)
return data
def load_qr_encryption_key(self):
"""Loads and returns a key from a QR code"""
from .qr_capture import QRCodeCapture
qr_capture = QRCodeCapture(self.ctx)
data, _ = qr_capture.qr_capture_loop()
if data is None:
return None
if len(data) > ENCRYPTION_KEY_MAX_LEN:
raise ValueError("Maximum length exceeded (%s)" % ENCRYPTION_KEY_MAX_LEN)
return data
class EncryptMnemonic(Page):
"""UI with mnemonic encryption output options"""
def __init__(self, ctx):
super().__init__(ctx, None)
self.ctx = ctx
self.mode_name = Settings().encryption.version
def _encrypt_mnemonic_with_label(self):
"""Helper method to encrypt mnemonic with label selection."""
kef_envelope = KEFEnvelope(self.ctx)
default_label = self.ctx.wallet.key.fingerprint_hex_str()
kef_envelope.label = default_label
mnemonic_bytes = bip39.mnemonic_to_bytes(self.ctx.wallet.key.mnemonic)
encrypted_data = kef_envelope.seal_ui(
mnemonic_bytes,
overrides=[OVERRIDE_LABEL],
dflt_label_prompt=t("Use fingerprint as ID?"),
dflt_label_affirm=True,
)
if encrypted_data is None:
return None, None
mnemonic_id = kef_envelope.label
return encrypted_data, mnemonic_id
def encrypt_menu(self):
"""Menu with mnemonic encryption output options"""
encrypt_outputs_menu = [
(t("Store on Flash"), self.store_mnemonic_on_memory),
(
t("Store on SD Card"),
(
None
if not self.has_sd_card()
else lambda: self.store_mnemonic_on_memory(True)
),
),
(t("Encrypted QR Code"), self.encrypted_qr_code),
]
submenu = Menu(self.ctx, encrypt_outputs_menu)
_, _ = submenu.run_loop()
return MENU_CONTINUE
def store_mnemonic_on_memory(self, sd_card=False):
"""Save encrypted mnemonic on flash or sd_card"""
from ..encryption import MnemonicStorage
encrypted_data, mnemonic_id = self._encrypt_mnemonic_with_label()
if encrypted_data is None:
return
mnemonic_storage = MnemonicStorage()
if mnemonic_id in mnemonic_storage.list_mnemonics(sd_card):
self.flash_error(
t("ID already exists") + "\n" + t("Encrypted mnemonic was not stored")
)
del mnemonic_storage
return
if mnemonic_storage.store_encrypted_kef(mnemonic_id, encrypted_data, sd_card):
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Encrypted mnemonic stored with ID:") + " " + mnemonic_id,
highlight_prefix=":",
)
else:
self.ctx.display.clear()
self.ctx.display.draw_centered_text(
t("Failed to store mnemonic"), theme.error_color
)
self.ctx.input.wait_for_button()
del mnemonic_storage
def encrypted_qr_code(self):
"""Exports an encryprted mnemonic QR code"""
encrypted_data, mnemonic_id = self._encrypt_mnemonic_with_label()
if encrypted_data is None:
return
from .qr_view import SeedQRView
from ..baseconv import base_encode
# All currently offered versions should encode to base43
qr_data = base_encode(encrypted_data, 43)
seed_qr_view = SeedQRView(self.ctx, data=qr_data, title=mnemonic_id)
seed_qr_view.display_qr(allow_export=True)
class LoadEncryptedMnemonic(Page):
"""UI to load encrypted mnemonics stored on flash and Sd card"""
def __init__(self, ctx):
super().__init__(ctx, None)
self.ctx = ctx
def load_from_storage(self, remove_opt=False):
"""Lists all encrypted mnemonics stored is flash and SD card"""
from ..encryption import MnemonicStorage
from ..settings import THIN_SPACE
mnemonic_ids_menu = []
mnemonic_storage = MnemonicStorage()
mnemonics = mnemonic_storage.list_mnemonics()
sd_mnemonics = mnemonic_storage.list_mnemonics(sd_card=True)
del mnemonic_storage
for mnemonic_id in sorted(mnemonics):
mnemonic_ids_menu.append(
(
mnemonic_id + " (flash)",
lambda m_id=mnemonic_id: (
self._remove_encrypted_mnemonic(m_id)
if remove_opt
else self._load_encrypted_mnemonic(m_id)
),
)
)
for mnemonic_id in sorted(sd_mnemonics):
mnemonic_ids_menu.append(
(
mnemonic_id + " (SD" + THIN_SPACE + "card)",
lambda m_id=mnemonic_id: (
self._remove_encrypted_mnemonic(m_id, sd_card=True)
if remove_opt
else self._load_encrypted_mnemonic(m_id, sd_card=True)
),
)
)
submenu = Menu(self.ctx, mnemonic_ids_menu)
index, status = submenu.run_loop()
if index == submenu.back_index:
return MENU_CONTINUE
return status
def _load_encrypted_mnemonic(self, mnemonic_id, sd_card=False):
"""Uses encryption module to load and decrypt a mnemonic"""
from ..encryption import MnemonicStorage
error_txt = t("Failed to decrypt")
key_capture = EncryptionKey(self.ctx)
key = key_capture.encryption_key()
if key in (None, "", ESC_KEY):
self.flash_error(t("Key was not provided"))
return MENU_CONTINUE
self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))
mnemonic_storage = MnemonicStorage()
try:
words = mnemonic_storage.decrypt(key, mnemonic_id, sd_card).split()
except:
self.flash_error(error_txt)
return MENU_CONTINUE
if len(words) not in (12, 24):
self.flash_error(error_txt)
return MENU_CONTINUE
del mnemonic_storage
return words
def _remove_encrypted_mnemonic(self, mnemonic_id, sd_card=False):
"""Deletes a mnemonic"""
from ..encryption import MnemonicStorage
mnemonic_storage = MnemonicStorage()
self.ctx.display.clear()
if self.prompt(t("Remove %s?") % mnemonic_id, self.ctx.display.height() // 2):
mnemonic_storage.del_mnemonic(mnemonic_id, sd_card)
message = t("%s removed.") % mnemonic_id
message += "\n\n"
if sd_card:
message += t(
"Fully erase your SD card in another device to ensure data is unrecoverable"
)
else:
message += t("To ensure data is unrecoverable use Wipe Device feature")
self.ctx.display.clear()
self.ctx.display.draw_centered_text(message)
self.ctx.input.wait_for_button()
del mnemonic_storage