Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/seedsigner/gui/screens/seed_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,8 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):


def __post_init__(self):
self.title = _("BIP-39 Passphrase")
if not self.title or self.title == "Screen Title":
self.title = _("BIP-39 Passphrase")
super().__post_init__()

keys_lower = "abcdefghijklmnopqrstuvwxyz"
Expand Down
225 changes: 225 additions & 0 deletions src/seedsigner/helpers/aes_decrypt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import base64
import hashlib
import struct


class DecryptionError(Exception):
"""Raised for any decryption failure (bad passphrase, corrupt data, padding error, etc.)."""
pass


# ---------------------------------------------------------------------------
# Minimal pure-Python AES-256-CBC decryption (no external dependencies).
#
# Only decryption is implemented — encryption is not needed. Lookup tables
# are precomputed at import time to avoid per-byte GF(2^8) multiplication
# in the hot loop (important on Pi Zero).
# ---------------------------------------------------------------------------

# AES S-box
_SBOX = (
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
)

# Inverse S-box (for decryption)
_INV_SBOX = tuple(_SBOX.index(i) for i in range(256))

# Round constants
_RCON = (0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36)


def _gmul(a, b):
"""Galois field multiplication in GF(2^8)."""
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xff
if hi:
a ^= 0x1b
b >>= 1
return p


# Precomputed multiplication tables for InvMixColumns constants.
# Each table maps byte value (0-255) to its product with the constant.
# This replaces per-byte _gmul calls in the hot loop with O(1) lookups.
_MUL9 = tuple(_gmul(i, 0x09) for i in range(256))
_MUL11 = tuple(_gmul(i, 0x0b) for i in range(256))
_MUL13 = tuple(_gmul(i, 0x0d) for i in range(256))
_MUL14 = tuple(_gmul(i, 0x0e) for i in range(256))


def _key_expansion(key: bytes) -> list:
"""Expand 256-bit key into 60 32-bit round key words."""
nk = 8 # AES-256: 8 words in key
nr = 14 # AES-256: 14 rounds
w = list(struct.unpack('>8I', key))
for i in range(nk, 4 * (nr + 1)):
t = w[i - 1]
if i % nk == 0:
# RotWord + SubWord + Rcon
t = ((t << 8) | (t >> 24)) & 0xffffffff
t = (_SBOX[(t >> 24) & 0xff] << 24 |
_SBOX[(t >> 16) & 0xff] << 16 |
_SBOX[(t >> 8) & 0xff] << 8 |
_SBOX[t & 0xff])
t ^= _RCON[i // nk - 1] << 24
elif i % nk == 4:
t = (_SBOX[(t >> 24) & 0xff] << 24 |
_SBOX[(t >> 16) & 0xff] << 16 |
_SBOX[(t >> 8) & 0xff] << 8 |
_SBOX[t & 0xff])
w.append(w[i - nk] ^ t)
return w


def _inv_cipher_block(block: bytes, rk: list) -> bytes:
"""Decrypt one 16-byte AES block (AES-256, 14 rounds)."""
nr = 14
s = list(block)

# AddRoundKey (round nr)
for c in range(4):
w = rk[nr * 4 + c]
s[c * 4 + 0] ^= (w >> 24) & 0xff
s[c * 4 + 1] ^= (w >> 16) & 0xff
s[c * 4 + 2] ^= (w >> 8) & 0xff
s[c * 4 + 3] ^= w & 0xff

for rnd in range(nr - 1, 0, -1):
# InvShiftRows
s[0*4+1], s[1*4+1], s[2*4+1], s[3*4+1] = s[3*4+1], s[0*4+1], s[1*4+1], s[2*4+1]
s[0*4+2], s[1*4+2], s[2*4+2], s[3*4+2] = s[2*4+2], s[3*4+2], s[0*4+2], s[1*4+2]
s[0*4+3], s[1*4+3], s[2*4+3], s[3*4+3] = s[1*4+3], s[2*4+3], s[3*4+3], s[0*4+3]

# InvSubBytes
s = [_INV_SBOX[b] for b in s]

# AddRoundKey
for c in range(4):
w = rk[rnd * 4 + c]
s[c * 4 + 0] ^= (w >> 24) & 0xff
s[c * 4 + 1] ^= (w >> 16) & 0xff
s[c * 4 + 2] ^= (w >> 8) & 0xff
s[c * 4 + 3] ^= w & 0xff

# InvMixColumns (using precomputed lookup tables)
ns = list(s)
for c in range(4):
i = c * 4
a0, a1, a2, a3 = s[i], s[i+1], s[i+2], s[i+3]
ns[i] = _MUL14[a0] ^ _MUL11[a1] ^ _MUL13[a2] ^ _MUL9[a3]
ns[i+1] = _MUL9[a0] ^ _MUL14[a1] ^ _MUL11[a2] ^ _MUL13[a3]
ns[i+2] = _MUL13[a0] ^ _MUL9[a1] ^ _MUL14[a2] ^ _MUL11[a3]
ns[i+3] = _MUL11[a0] ^ _MUL13[a1] ^ _MUL9[a2] ^ _MUL14[a3]
s = ns

# Final round (no InvMixColumns)
# InvShiftRows
s[0*4+1], s[1*4+1], s[2*4+1], s[3*4+1] = s[3*4+1], s[0*4+1], s[1*4+1], s[2*4+1]
s[0*4+2], s[1*4+2], s[2*4+2], s[3*4+2] = s[2*4+2], s[3*4+2], s[0*4+2], s[1*4+2]
s[0*4+3], s[1*4+3], s[2*4+3], s[3*4+3] = s[1*4+3], s[2*4+3], s[3*4+3], s[0*4+3]

# InvSubBytes
s = [_INV_SBOX[b] for b in s]

# AddRoundKey (round 0)
for c in range(4):
w = rk[c]
s[c * 4 + 0] ^= (w >> 24) & 0xff
s[c * 4 + 1] ^= (w >> 16) & 0xff
s[c * 4 + 2] ^= (w >> 8) & 0xff
s[c * 4 + 3] ^= w & 0xff

return bytes(s)


def _aes256_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
"""AES-256-CBC decryption with PKCS#7 unpadding."""
rk = _key_expansion(key)
blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
plaintext = bytearray()
prev = iv
for block in blocks:
decrypted = _inv_cipher_block(block, rk)
plaintext.extend(b ^ p for b, p in zip(decrypted, prev))
prev = block

# PKCS#7 unpadding
if not plaintext:
raise DecryptionError("Empty plaintext")
pad_len = plaintext[-1]
if pad_len < 1 or pad_len > 16:
raise DecryptionError("Invalid PKCS#7 padding")
if plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
raise DecryptionError("Invalid PKCS#7 padding")
return bytes(plaintext[:-pad_len])


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def decrypt_openssl_aes256cbc(data_b64: str, passphrase: str) -> str:
"""
Decrypts OpenSSL-compatible AES-256-CBC data (base64, starts with "U2FsdGVkX1").
Matches: openssl enc -aes-256-cbc -pbkdf2 [-iter N] -base64

The PBKDF2 iteration count is not stored in the ciphertext, so we try
common values: 10 000 (OpenSSL default when -iter is omitted) and
100 000 (commonly recommended). The first one that yields valid
PKCS#7 padding and UTF-8 plaintext wins.

Returns plaintext UTF-8 string (the mnemonic).
Raises DecryptionError on any failure.
"""
try:
# 1. Base64-decode
data = base64.b64decode(data_b64.strip())

# 2. Verify magic header
if len(data) < 16 or data[:8] != b'Salted__':
raise DecryptionError("Invalid OpenSSL magic header (expected 'Salted__')")

# 3. Extract salt + ciphertext
salt = data[8:16]
ciphertext = data[16:]

if len(ciphertext) % 16 != 0:
raise DecryptionError("Ciphertext length not multiple of AES block size (16)")

# 4. Try PBKDF2 with common iteration counts
passphrase_bytes = passphrase.encode('utf-8')
for iterations in (10_000, 100_000):
try:
derived = hashlib.pbkdf2_hmac(
'sha256', passphrase_bytes, salt, iterations, dklen=48
)
plaintext_bytes = _aes256_cbc_decrypt(derived[:32], derived[32:48], ciphertext)
return plaintext_bytes.decode('utf-8')
except (DecryptionError, UnicodeDecodeError):
continue

raise DecryptionError("Decryption failed for all supported iteration counts")

except Exception as e:
if isinstance(e, DecryptionError):
raise
raise DecryptionError(f"Decryption failed: {str(e)}") from e
34 changes: 32 additions & 2 deletions src/seedsigner/models/decode_qr.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ def add_data(self, data):
self.decoder = BBQRPsbtQrDecoder() # BBQr Decoder

elif self.qr_type in [QRType.SEED__SEEDQR, QRType.SEED__COMPACTSEEDQR, QRType.SEED__MNEMONIC, QRType.SEED__FOUR_LETTER_MNEMONIC, QRType.SEED__UR2]:
self.decoder = SeedQrDecoder(wordlist_language_code=self.wordlist_language_code)
self.decoder = SeedQrDecoder(wordlist_language_code=self.wordlist_language_code)

elif self.qr_type == QRType.SEED__ENCRYPTED:
self.decoder = EncryptedSeedQrDecoder()

elif self.qr_type == QRType.SETTINGS:
self.decoder = SettingsQrDecoder() # Settings config
Expand Down Expand Up @@ -254,7 +257,6 @@ def get_percent_complete(self, weight_mixed_frames: bool = False) -> int:
def is_complete(self) -> bool:
return self.complete


@property
def is_invalid(self) -> bool:
return self.qr_type == QRType.INVALID
Expand All @@ -281,6 +283,14 @@ def is_seed(self):
QRType.SEED__FOUR_LETTER_MNEMONIC,
]

@property
def is_encrypted_seed(self):
return self.qr_type == QRType.SEED__ENCRYPTED

def get_encrypted_seed_data(self):
if self.is_encrypted_seed:
return self.decoder.get_encrypted_data()
return None

@property
def is_json(self):
Expand Down Expand Up @@ -381,6 +391,10 @@ def detect_segment_type(s, wordlist_language_code=None):

elif "sortedmulti" in s:
return QRType.WALLET__GENERIC

# Encrypted seed QR (OpenSSL AES-256-CBC base64)
if s.startswith("U2FsdGVkX1"):
return QRType.SEED__ENCRYPTED

# Seed
if re.search(r'\d{48,96}', s):
Expand Down Expand Up @@ -925,7 +939,23 @@ def is_12_or_24_word_phrase(self):
return True
return False

class EncryptedSeedQrDecoder(BaseSingleFrameQrDecoder):
"""Decodes a single frame containing a base64-encoded OpenSSL AES-256-CBC encrypted mnemonic."""

def __init__(self):
super().__init__()
self.encrypted_data = None

def add(self, segment, qr_type=QRType.SEED__ENCRYPTED):
self.encrypted_data = segment.strip()
self.complete = True
self.collected_segments = 1
return DecodeQRStatus.COMPLETE

def get_encrypted_data(self) -> str:
if self.complete:
return self.encrypted_data
return None

class SettingsQrDecoder(BaseSingleFrameQrDecoder):
"""
Expand Down
4 changes: 2 additions & 2 deletions src/seedsigner/models/qr_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class QRType:
SEED__UR2 = "seed__ur2"
SEED__MNEMONIC = "seed__mnemonic"
SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic"

SEED__ENCRYPTED = "seed__encrypted"
SETTINGS = "settings"

XPUB = "xpub"
Expand All @@ -32,4 +32,4 @@ class QRType:
ACCOUNT__UR = "account__ur"
BYTES__UR = "bytes__ur"

INVALID = "invalid"
INVALID = "invalid"
11 changes: 10 additions & 1 deletion src/seedsigner/models/settings_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ def map_network_to_embit(cls, network) -> str:
SETTING__COMPACT_SEEDQR = "compact_seedqr"
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
SETTING__ELECTRUM_SEEDS = "electrum_seeds"
SETTING__ENCRYPTED_SEEDS = "encrypted_seeds"
SETTING__MESSAGE_SIGNING = "message_signing"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
Expand Down Expand Up @@ -672,7 +673,15 @@ class SettingsDefinition:
help_text=_mft("Native Segwit only"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),


SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__ENCRYPTED_SEEDS,
abbreviated_name="enc_seeds",
display_name=_mft("Encrypted seeds"),
help_text=_mft("Import AES-256-CBC encrypted seed QRs"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),

SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MICROSD_TOAST_TIMER,
display_name=_mft("MicroSD notification duration"),
Expand Down
17 changes: 14 additions & 3 deletions src/seedsigner/views/scan_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from seedsigner.helpers.l10n import mark_for_translation as _mft
from seedsigner.models.settings import SettingsConstants
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.gui.screens.screen import ButtonOption, RET_CODE__BACK_BUTTON

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,7 +43,7 @@ def run(self):
from seedsigner.gui.screens.scan_screens import ScanScreen

# Start the live preview and background QR reading
self.run_screen(
scan_results = self.run_screen(
ScanScreen,
instructions_text=self.instructions_text,
decoder=self.decoder
Expand All @@ -53,6 +53,9 @@ def run(self):
# doesn't immediately engage when we leave here.
self.controller.reset_screensaver_timeout()

if scan_results == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)

# Handle the results
if self.decoder.is_complete:
if not self.is_valid_qr_type:
Expand Down Expand Up @@ -88,7 +91,15 @@ def run(self):
return Destination(SeedAddPassphraseView)
else:
return Destination(SeedFinalizeView)


elif self.decoder.is_encrypted_seed:
from .seed_views import EncryptedSeedPassphraseView
if self.settings.get_value(SettingsConstants.SETTING__ENCRYPTED_SEEDS) == SettingsConstants.OPTION__DISABLED:
from .view import OptionDisabledView
return Destination(OptionDisabledView, view_args=dict(settings_attr=SettingsConstants.SETTING__ENCRYPTED_SEEDS))
encrypted_data = self.decoder.get_encrypted_seed_data()
return Destination(EncryptedSeedPassphraseView, view_args=dict(encrypted_data=encrypted_data))

elif self.decoder.is_psbt:
from seedsigner.views.psbt_views import PSBTSelectSeedView
psbt = self.decoder.get_psbt()
Expand Down
Loading
Loading