Skip to content

Commit 8b8809b

Browse files
berlinxrayclaude
andcommitted
feat: add support for importing AES-256-CBC encrypted seed backups via QR
Scan a QR code containing an OpenSSL-compatible AES-256-CBC encrypted seed (base64-encoded, starting with U2FsdGVkX1 / Salted__), decrypt with a user-provided passphrase, validate as BIP-39 or Electrum mnemonic, and import as a seed. - Add pure-Python AES-256-CBC decryptor (no pyaes dependency) with precomputed InvMixColumns lookup tables for Pi Zero performance - Add EncryptedSeedQrDecoder and QRType.SEED__ENCRYPTED for auto- detecting encrypted seed QRs - Add SETTING__ENCRYPTED_SEEDS toggle (advanced, disabled by default) - Add EncryptedSeedPassphraseView, EncryptedSeedDecryptView, and EncryptedSeedDecryptionFailedView for the decrypt/retry UI flow - Support both 10,000 and 100,000 PBKDF2 iterations (auto-detect) - Fix SeedAddPassphraseScreen to preserve custom title for decrypt flow - Add back button support for scan screen - Add 11 tests (AES unit + encrypted QR integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5a91af0 commit 8b8809b

10 files changed

Lines changed: 567 additions & 12 deletions

File tree

src/seedsigner/gui/screens/seed_screens.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,8 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
658658

659659

660660
def __post_init__(self):
661-
self.title = _("BIP-39 Passphrase")
661+
if not self.title or self.title == "Screen Title":
662+
self.title = _("BIP-39 Passphrase")
662663
super().__post_init__()
663664

664665
keys_lower = "abcdefghijklmnopqrstuvwxyz"
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import base64
2+
import hashlib
3+
import struct
4+
5+
6+
class DecryptionError(Exception):
7+
"""Raised for any decryption failure (bad passphrase, corrupt data, padding error, etc.)."""
8+
pass
9+
10+
11+
# ---------------------------------------------------------------------------
12+
# Minimal pure-Python AES-256-CBC decryption (no external dependencies).
13+
#
14+
# Only decryption is implemented — encryption is not needed. Lookup tables
15+
# are precomputed at import time to avoid per-byte GF(2^8) multiplication
16+
# in the hot loop (important on Pi Zero).
17+
# ---------------------------------------------------------------------------
18+
19+
# AES S-box
20+
_SBOX = (
21+
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
22+
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
23+
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
24+
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
25+
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
26+
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
27+
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
28+
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
29+
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
30+
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
31+
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
32+
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
33+
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
34+
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
35+
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
36+
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
37+
)
38+
39+
# Inverse S-box (for decryption)
40+
_INV_SBOX = tuple(_SBOX.index(i) for i in range(256))
41+
42+
# Round constants
43+
_RCON = (0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36)
44+
45+
46+
def _gmul(a, b):
47+
"""Galois field multiplication in GF(2^8)."""
48+
p = 0
49+
for _ in range(8):
50+
if b & 1:
51+
p ^= a
52+
hi = a & 0x80
53+
a = (a << 1) & 0xff
54+
if hi:
55+
a ^= 0x1b
56+
b >>= 1
57+
return p
58+
59+
60+
# Precomputed multiplication tables for InvMixColumns constants.
61+
# Each table maps byte value (0-255) to its product with the constant.
62+
# This replaces per-byte _gmul calls in the hot loop with O(1) lookups.
63+
_MUL9 = tuple(_gmul(i, 0x09) for i in range(256))
64+
_MUL11 = tuple(_gmul(i, 0x0b) for i in range(256))
65+
_MUL13 = tuple(_gmul(i, 0x0d) for i in range(256))
66+
_MUL14 = tuple(_gmul(i, 0x0e) for i in range(256))
67+
68+
69+
def _key_expansion(key: bytes) -> list:
70+
"""Expand 256-bit key into 60 32-bit round key words."""
71+
nk = 8 # AES-256: 8 words in key
72+
nr = 14 # AES-256: 14 rounds
73+
w = list(struct.unpack('>8I', key))
74+
for i in range(nk, 4 * (nr + 1)):
75+
t = w[i - 1]
76+
if i % nk == 0:
77+
# RotWord + SubWord + Rcon
78+
t = ((t << 8) | (t >> 24)) & 0xffffffff
79+
t = (_SBOX[(t >> 24) & 0xff] << 24 |
80+
_SBOX[(t >> 16) & 0xff] << 16 |
81+
_SBOX[(t >> 8) & 0xff] << 8 |
82+
_SBOX[t & 0xff])
83+
t ^= _RCON[i // nk - 1] << 24
84+
elif i % nk == 4:
85+
t = (_SBOX[(t >> 24) & 0xff] << 24 |
86+
_SBOX[(t >> 16) & 0xff] << 16 |
87+
_SBOX[(t >> 8) & 0xff] << 8 |
88+
_SBOX[t & 0xff])
89+
w.append(w[i - nk] ^ t)
90+
return w
91+
92+
93+
def _inv_cipher_block(block: bytes, rk: list) -> bytes:
94+
"""Decrypt one 16-byte AES block (AES-256, 14 rounds)."""
95+
nr = 14
96+
s = list(block)
97+
98+
# AddRoundKey (round nr)
99+
for c in range(4):
100+
w = rk[nr * 4 + c]
101+
s[c * 4 + 0] ^= (w >> 24) & 0xff
102+
s[c * 4 + 1] ^= (w >> 16) & 0xff
103+
s[c * 4 + 2] ^= (w >> 8) & 0xff
104+
s[c * 4 + 3] ^= w & 0xff
105+
106+
for rnd in range(nr - 1, 0, -1):
107+
# InvShiftRows
108+
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]
109+
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]
110+
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]
111+
112+
# InvSubBytes
113+
s = [_INV_SBOX[b] for b in s]
114+
115+
# AddRoundKey
116+
for c in range(4):
117+
w = rk[rnd * 4 + c]
118+
s[c * 4 + 0] ^= (w >> 24) & 0xff
119+
s[c * 4 + 1] ^= (w >> 16) & 0xff
120+
s[c * 4 + 2] ^= (w >> 8) & 0xff
121+
s[c * 4 + 3] ^= w & 0xff
122+
123+
# InvMixColumns (using precomputed lookup tables)
124+
ns = list(s)
125+
for c in range(4):
126+
i = c * 4
127+
a0, a1, a2, a3 = s[i], s[i+1], s[i+2], s[i+3]
128+
ns[i] = _MUL14[a0] ^ _MUL11[a1] ^ _MUL13[a2] ^ _MUL9[a3]
129+
ns[i+1] = _MUL9[a0] ^ _MUL14[a1] ^ _MUL11[a2] ^ _MUL13[a3]
130+
ns[i+2] = _MUL13[a0] ^ _MUL9[a1] ^ _MUL14[a2] ^ _MUL11[a3]
131+
ns[i+3] = _MUL11[a0] ^ _MUL13[a1] ^ _MUL9[a2] ^ _MUL14[a3]
132+
s = ns
133+
134+
# Final round (no InvMixColumns)
135+
# InvShiftRows
136+
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]
137+
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]
138+
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]
139+
140+
# InvSubBytes
141+
s = [_INV_SBOX[b] for b in s]
142+
143+
# AddRoundKey (round 0)
144+
for c in range(4):
145+
w = rk[c]
146+
s[c * 4 + 0] ^= (w >> 24) & 0xff
147+
s[c * 4 + 1] ^= (w >> 16) & 0xff
148+
s[c * 4 + 2] ^= (w >> 8) & 0xff
149+
s[c * 4 + 3] ^= w & 0xff
150+
151+
return bytes(s)
152+
153+
154+
def _aes256_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
155+
"""AES-256-CBC decryption with PKCS#7 unpadding."""
156+
rk = _key_expansion(key)
157+
blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
158+
plaintext = bytearray()
159+
prev = iv
160+
for block in blocks:
161+
decrypted = _inv_cipher_block(block, rk)
162+
plaintext.extend(b ^ p for b, p in zip(decrypted, prev))
163+
prev = block
164+
165+
# PKCS#7 unpadding
166+
if not plaintext:
167+
raise DecryptionError("Empty plaintext")
168+
pad_len = plaintext[-1]
169+
if pad_len < 1 or pad_len > 16:
170+
raise DecryptionError("Invalid PKCS#7 padding")
171+
if plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
172+
raise DecryptionError("Invalid PKCS#7 padding")
173+
return bytes(plaintext[:-pad_len])
174+
175+
176+
# ---------------------------------------------------------------------------
177+
# Public API
178+
# ---------------------------------------------------------------------------
179+
180+
def decrypt_openssl_aes256cbc(data_b64: str, passphrase: str) -> str:
181+
"""
182+
Decrypts OpenSSL-compatible AES-256-CBC data (base64, starts with "U2FsdGVkX1").
183+
Matches: openssl enc -aes-256-cbc -pbkdf2 [-iter N] -base64
184+
185+
The PBKDF2 iteration count is not stored in the ciphertext, so we try
186+
common values: 10 000 (OpenSSL default when -iter is omitted) and
187+
100 000 (commonly recommended). The first one that yields valid
188+
PKCS#7 padding and UTF-8 plaintext wins.
189+
190+
Returns plaintext UTF-8 string (the mnemonic).
191+
Raises DecryptionError on any failure.
192+
"""
193+
try:
194+
# 1. Base64-decode
195+
data = base64.b64decode(data_b64.strip())
196+
197+
# 2. Verify magic header
198+
if len(data) < 16 or data[:8] != b'Salted__':
199+
raise DecryptionError("Invalid OpenSSL magic header (expected 'Salted__')")
200+
201+
# 3. Extract salt + ciphertext
202+
salt = data[8:16]
203+
ciphertext = data[16:]
204+
205+
if len(ciphertext) % 16 != 0:
206+
raise DecryptionError("Ciphertext length not multiple of AES block size (16)")
207+
208+
# 4. Try PBKDF2 with common iteration counts
209+
passphrase_bytes = passphrase.encode('utf-8')
210+
for iterations in (10_000, 100_000):
211+
try:
212+
derived = hashlib.pbkdf2_hmac(
213+
'sha256', passphrase_bytes, salt, iterations, dklen=48
214+
)
215+
plaintext_bytes = _aes256_cbc_decrypt(derived[:32], derived[32:48], ciphertext)
216+
return plaintext_bytes.decode('utf-8')
217+
except (DecryptionError, UnicodeDecodeError):
218+
continue
219+
220+
raise DecryptionError("Decryption failed for all supported iteration counts")
221+
222+
except Exception as e:
223+
if isinstance(e, DecryptionError):
224+
raise
225+
raise DecryptionError(f"Decryption failed: {str(e)}") from e

src/seedsigner/models/decode_qr.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ def add_data(self, data):
8080
self.decoder = BBQRPsbtQrDecoder() # BBQr Decoder
8181

8282
elif self.qr_type in [QRType.SEED__SEEDQR, QRType.SEED__COMPACTSEEDQR, QRType.SEED__MNEMONIC, QRType.SEED__FOUR_LETTER_MNEMONIC, QRType.SEED__UR2]:
83-
self.decoder = SeedQrDecoder(wordlist_language_code=self.wordlist_language_code)
83+
self.decoder = SeedQrDecoder(wordlist_language_code=self.wordlist_language_code)
84+
85+
elif self.qr_type == QRType.SEED__ENCRYPTED:
86+
self.decoder = EncryptedSeedQrDecoder()
8487

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

257-
258260
@property
259261
def is_invalid(self) -> bool:
260262
return self.qr_type == QRType.INVALID
@@ -281,6 +283,14 @@ def is_seed(self):
281283
QRType.SEED__FOUR_LETTER_MNEMONIC,
282284
]
283285

286+
@property
287+
def is_encrypted_seed(self):
288+
return self.qr_type == QRType.SEED__ENCRYPTED
289+
290+
def get_encrypted_seed_data(self):
291+
if self.is_encrypted_seed:
292+
return self.decoder.get_encrypted_data()
293+
return None
284294

285295
@property
286296
def is_json(self):
@@ -381,6 +391,10 @@ def detect_segment_type(s, wordlist_language_code=None):
381391

382392
elif "sortedmulti" in s:
383393
return QRType.WALLET__GENERIC
394+
395+
# Encrypted seed QR (OpenSSL AES-256-CBC base64)
396+
if s.startswith("U2FsdGVkX1"):
397+
return QRType.SEED__ENCRYPTED
384398

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

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

945+
def __init__(self):
946+
super().__init__()
947+
self.encrypted_data = None
948+
949+
def add(self, segment, qr_type=QRType.SEED__ENCRYPTED):
950+
self.encrypted_data = segment.strip()
951+
self.complete = True
952+
self.collected_segments = 1
953+
return DecodeQRStatus.COMPLETE
954+
955+
def get_encrypted_data(self) -> str:
956+
if self.complete:
957+
return self.encrypted_data
958+
return None
929959

930960
class SettingsQrDecoder(BaseSingleFrameQrDecoder):
931961
"""

src/seedsigner/models/qr_type.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class QRType:
1313
SEED__UR2 = "seed__ur2"
1414
SEED__MNEMONIC = "seed__mnemonic"
1515
SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic"
16-
16+
SEED__ENCRYPTED = "seed__encrypted"
1717
SETTINGS = "settings"
1818

1919
XPUB = "xpub"
@@ -32,4 +32,4 @@ class QRType:
3232
ACCOUNT__UR = "account__ur"
3333
BYTES__UR = "bytes__ur"
3434

35-
INVALID = "invalid"
35+
INVALID = "invalid"

src/seedsigner/models/settings_definition.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ def map_network_to_embit(cls, network) -> str:
345345
SETTING__COMPACT_SEEDQR = "compact_seedqr"
346346
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
347347
SETTING__ELECTRUM_SEEDS = "electrum_seeds"
348+
SETTING__ENCRYPTED_SEEDS = "encrypted_seeds"
348349
SETTING__MESSAGE_SIGNING = "message_signing"
349350
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
350351
SETTING__DIRE_WARNINGS = "dire_warnings"
@@ -672,7 +673,15 @@ class SettingsDefinition:
672673
help_text=_mft("Native Segwit only"),
673674
visibility=SettingsConstants.VISIBILITY__ADVANCED,
674675
default_value=SettingsConstants.OPTION__DISABLED),
675-
676+
677+
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
678+
attr_name=SettingsConstants.SETTING__ENCRYPTED_SEEDS,
679+
abbreviated_name="enc_seeds",
680+
display_name=_mft("Encrypted seeds"),
681+
help_text=_mft("Import AES-256-CBC encrypted seed QRs"),
682+
visibility=SettingsConstants.VISIBILITY__ADVANCED,
683+
default_value=SettingsConstants.OPTION__DISABLED),
684+
676685
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
677686
attr_name=SettingsConstants.SETTING__MICROSD_TOAST_TIMER,
678687
display_name=_mft("MicroSD notification duration"),

src/seedsigner/views/scan_views.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from seedsigner.helpers.l10n import mark_for_translation as _mft
66
from seedsigner.models.settings import SettingsConstants
77
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination
8-
from seedsigner.gui.screens.screen import ButtonOption
8+
from seedsigner.gui.screens.screen import ButtonOption, RET_CODE__BACK_BUTTON
99

1010
logger = logging.getLogger(__name__)
1111

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

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

56+
if scan_results == RET_CODE__BACK_BUTTON:
57+
return Destination(BackStackView)
58+
5659
# Handle the results
5760
if self.decoder.is_complete:
5861
if not self.is_valid_qr_type:
@@ -88,7 +91,15 @@ def run(self):
8891
return Destination(SeedAddPassphraseView)
8992
else:
9093
return Destination(SeedFinalizeView)
91-
94+
95+
elif self.decoder.is_encrypted_seed:
96+
from .seed_views import EncryptedSeedPassphraseView
97+
if self.settings.get_value(SettingsConstants.SETTING__ENCRYPTED_SEEDS) == SettingsConstants.OPTION__DISABLED:
98+
from .view import OptionDisabledView
99+
return Destination(OptionDisabledView, view_args=dict(settings_attr=SettingsConstants.SETTING__ENCRYPTED_SEEDS))
100+
encrypted_data = self.decoder.get_encrypted_seed_data()
101+
return Destination(EncryptedSeedPassphraseView, view_args=dict(encrypted_data=encrypted_data))
102+
92103
elif self.decoder.is_psbt:
93104
from seedsigner.views.psbt_views import PSBTSelectSeedView
94105
psbt = self.decoder.get_psbt()

0 commit comments

Comments
 (0)