standalone encrypted backups for Secure Notes & Passwords
What changed, and why it matters
This commit adds a new feature to the COLDCARD firmware: encrypted backups for Secure Notes & Passwords. Previously, exporting notes and passwords to a microSD card produced an unencrypted JSON file. Now the device can encrypt that export using the same 7z/AES backup encryption used for full wallet backups. The change also refactors the existing backup password code so it can be reused for both full-device and notes-only backups. A small, unrelated fix in message signing changes a logical AND to an OR when deciding whether tab/newline characters are allowed in signed text. There is no vendor disclosure of a security vulnerability, and the commit is framed as a feature addition.
Review the refactored backup encryption helpers for correct password handling and ensure the new .json inner-extension check does not weaken backup file validation. Specifically verify that the auth.py change from `is_json and allow_tab_nl` to `is_json or allow_tab_nl` is intentional and does not introduce unintended character acceptance in message signing. Treat this commit as a feature patch rather than an emergency security fix unless additional context emerges.
Security signals we found
New encryption feature for previously cleartext notes/password exports
Refactoring of backup password and 7z encryption code into reusable helpers
Addition of inner_ext parameter to backup decryption to support .json payloads
Logic change in message-signing text validation (AND to OR)
No vendor security disclosure or CVE references present in commit materials
Evidence from the diff
The patch introduces standalone encrypted exports/imports for the Secure Notes & Passwords feature. Key changes: (1) shared/backups.py is refactored to expose pick_backup_password(), bkpw_workflow(), and encrypt_7z_data(), and check_and_decrypt() now accepts an inner_ext argument so it can validate .json inner files as well as .txt. (2) shared/notes.py’s start_export() now calls the backup password workflow, optionally encrypts the JSON with encrypt_7z_data(), and writes a .7z file; start_import() now detects .7z files, prompts for custom string or 12-word password, decrypts via check_and_decrypt(), and imports the JSON. (3) shared/auth.py changes validate_text_for_signing(…, allow_tab_nl=is_json or allow_tab_nl) from an AND, which broadens allowed characters for JSON message signing. (4) Tests are updated to cover encrypted and cleartext export/import paths, wrong-password handling, and rejection of seed backups imported as notes.
Changed components
shared/backups.pyshared/notes.pyshared/auth.pytesting/test_notes.pytesting/conftest.pyInspect captured patch +467 / −124
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 4142101..2507573 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -60,6 +60,7 @@ This lists the new changes that have not yet been published in a normal release.
- New Feature: Secure Notes & Passwords UX groups
- New Feature: Apply Secure Note text, or Secure Note password as BIP-39 passphrase
+- New Feature: Standalone encrypted backups for Secure Notes & Passwords
- Bugfix: Teleporting a multisig PSBT file (without signing it first) sent stale data instead of the selected file
- Bugfix: Fix export UX message after teleport PSBT import & sign
- Bugfix: BIP-21 QR `amount` rendered with wrong decimal scaling on the Payment Address screen (e.g. `amount=1.1` was shown as `1.00000001 BTC`)
diff --git a/shared/auth.py b/shared/auth.py
index 6618324..d9074fb 100644
--- a/shared/auth.py
+++ b/shared/auth.py
@@ -141,7 +141,7 @@ class ApproveMessageSign(UserAuthorizedAction):
text, subpath, addr_fmt, is_json = parse_msg_sign_request(msg_sign_request)
self.text = validate_text_for_signing(
- text, allow_tab_nl=is_json and allow_tab_nl
+ text, allow_tab_nl=is_json or allow_tab_nl
)
self.subpath = cleanup_deriv_path(subpath)
self.addr_fmt = chains.parse_addr_fmt_str(addr_fmt)
diff --git a/shared/backups.py b/shared/backups.py
index 4f508cd..6fb722d 100644
--- a/shared/backups.py
+++ b/shared/backups.py
@@ -329,31 +329,42 @@ async def restore_from_dict(vals, raw):
reset()
-async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
- from stash import bip39_passphrase
-
- pwd = None
- skip_quiz = False
- bypass_tmp = False
+async def pick_backup_password(write_sflash=False, secret_opt=False, what="money for free"):
+ # Pick a password: like bip39 but no checksum word
+ #
+ b = bytearray(32)
+ while 1:
+ ckcc.rng_bytes(b)
+ # b2a_words(32 bytes) gives 24 BIP39 words. Keep the leading 12 by dropping the tail,
+ # which includes checksum bits; this is a wordlist password, not a valid BIP39 mnemonic.
+ # * keep pwd as a string for the encryption/settings paths
+ # * use rsplit to avoid split+join churn (2x slower)
+ assert num_pw_words == 12
+ pwd = bip39.b2a_words(b).rsplit(' ', num_pw_words)[0]
+
+ ch = await seed.show_words(
+ prompt="Record this (%d word) backup file password:\n" % num_pw_words,
+ words=pwd.split(" "), escape='6',
+ extra="Press (6) for cleartext backup. " if secret_opt else ""
+ )
+
+ if (ch == "6") and not write_sflash:
+ # Secret feature: plaintext mode
+ # - only safe for people living in faraday cages inside locked vaults.
+ if await ux_confirm("The file will **NOT** be encrypted and anyone who finds"
+ " the file will get all of your %s!" % what):
+ pwd = []
+ break
+ continue
- if bip39_passphrase and pa.tmp_value:
- # this is a BIP39 password ephemeral wallet
- msg = ("BIP39 passphrase is in effect. Backup ignores passphrases "
- "and produces backup of main seed. Press %s to back-up main wallet,"
- " press (2) to back-up BIP39 passphrase wallet "
- "(extended private key created via seed + pass)" % OK)
- ch = await ux_show_story(msg, escape="2")
- if ch == "x": return
- if ch == "y":
- bypass_tmp = True
+ break
- elif pa.tmp_value:
- if not await ux_confirm("A temporary seed is in effect, "
- "so backup will be of that seed."):
- return
+ return pwd, ch == "x"
- # first check if bkpw already defined on tmp seed settings
+async def bkpw_workflow():
stored_pwd = None
+ skip_quiz = False
+
master_pwd = settings.master_get("bkpw", None)
if pa.tmp_value:
stored_pwd = settings.get('bkpw', None)
@@ -373,36 +384,58 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
sensitive=True)
if ch == 'y':
- pwd = stored_pwd # string, not list
skip_quiz = True
- if not pwd:
- # Pick a password: like bip39 but no checksum word
- #
- b = bytearray(32)
- while 1:
- ckcc.rng_bytes(b)
- pwd = bip39.b2a_words(b).rsplit(' ', num_pw_words)[0]
-
- ch = await seed.show_words(
- prompt="Record this (%d word) backup file password:\n" % num_pw_words,
- words=pwd.split(" "), escape='6'
- )
-
- if (ch == '6') and not write_sflash:
- # Secret feature: plaintext mode
- # - only safe for people living in faraday cages inside locked vaults.
- if await ux_confirm("The file will **NOT** be encrypted and "
- "anyone who finds the file will get all of your money for free!"):
- pwd = []
- fname_pattern = 'backup.txt'
- break
- continue
-
- if ch == 'x':
- return
+ return stored_pwd, skip_quiz
- break
+
+def encrypt_7z_data(password, body, ext="txt"):
+ from glob import dis
+
+ zz = compat7z.Builder(password=password, progress_fcn=dis.progress_bar_show)
+ zz.add_data(body)
+
+ # pick random filename, but ending in 'ext'
+ word = bip39.wordlist_en[ngu.random.uniform(2048)]
+ num = ngu.random.uniform(1000)
+ fname = '%s%d.%s' % (word, num, ext)
+
+ hdr, footer = zz.save(fname)
+ return zz, hdr, footer
+
+
+async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
+ from stash import bip39_passphrase
+
+ pwd = None
+ bypass_tmp = False
+
+ if bip39_passphrase and pa.tmp_value:
+ # this is a BIP39 password ephemeral wallet
+ msg = ("BIP39 passphrase is in effect. Backup ignores passphrases "
+ "and produces backup of main seed. Press %s to back-up main wallet,"
+ " press (2) to back-up BIP39 passphrase wallet "
+ "(extended private key created via seed + pass)" % OK)
+ ch = await ux_show_story(msg, escape="2")
+ if ch == "x": return
+ if ch == "y":
+ bypass_tmp = True
+
+ elif pa.tmp_value:
+ if not await ux_confirm("A temporary seed is in effect, "
+ "so backup will be of that seed."):
+ return
+
+ # first check if bkpw already defined on tmp seed settings
+ stored_pwd, skip_quiz = await bkpw_workflow()
+ if skip_quiz:
+ pwd = stored_pwd
+
+ if not pwd:
+ pwd, abort = await pick_backup_password(write_sflash=write_sflash)
+ if abort: return
+ if not pwd:
+ fname_pattern = 'backup.txt'
if pwd and not skip_quiz:
# quiz them, but be nice and do a shorter test.
@@ -416,10 +449,6 @@ async def make_complete_backup(fname_pattern='backup.7z', write_sflash=False):
if ch == '1':
settings.set('bkpw', pwd) # if on tmp save to tmp, do not update master
settings.save()
- # stop droping bkpw just because someone decided to use differrent password
- # elif stored_words:
- # settings.remove_key('bkpw')
- # settings.save()
return await write_complete_backup(pwd, fname_pattern, write_sflash=write_sflash,
bypass_tmp=bypass_tmp)
@@ -440,15 +469,7 @@ async def write_complete_backup(pwd, fname_pattern, write_sflash=False,
# NOTE: Takes a few seconds to do the key-streching, but little actual
# time to do the encryption.
- zz = compat7z.Builder(password=pwd, progress_fcn=dis.progress_bar_show)
- zz.add_data(body)
-
- # pick random filename, but ending in .txt
- word = bip39.wordlist_en[ngu.random.uniform(2048)]
- num = ngu.random.uniform(1000)
- fname = '%s%d.txt' % (word, num)
-
- hdr, footer = zz.save(fname)
+ zz, hdr, footer = encrypt_7z_data(pwd, body)
del body
@@ -610,7 +631,7 @@ async def restore_complete(fname_or_fd, temporary=False, words=True, usb=False):
await done(pwd)
-def check_and_decrypt(fd, password):
+def check_and_decrypt(fd, password, inner_ext=".txt"):
try:
compat7z.check_file_headers(fd)
except Exception as e:
@@ -625,11 +646,15 @@ def check_and_decrypt(fd, password):
progress_fcn=dis.progress_bar_show)
# simple quick sanity checks
- assert fname.endswith('.txt') # was == 'ckcc-backup.txt'
- assert contents[0:1] == b'#' and contents[-1:] == b'\n'
+ assert fname.endswith(inner_ext), "not %s" % inner_ext
+ if inner_ext == ".txt":
+ assert contents[0:1] == b'#' and contents[-1:] == b'\n',"malformed"
return contents
- except Exception as e:
+ except AssertionError as e:
+ raise RuntimeError('Invalid backup file: %s'% str(e))
+
+ except Exception:
# assume everything here is "password wrong" errors
raise RuntimeError('Unable to decrypt backup file. Incorrect password?'
'\n\nTried:\n\n' + password)
diff --git a/shared/notes.py b/shared/notes.py
index 5b46d1e..d33d888 100644
--- a/shared/notes.py
+++ b/shared/notes.py
@@ -2,10 +2,10 @@
#
# notes.py - Store some short notes, securely.
#
-import ngu, bip39
+import ngu, bip39, ujson
from menu import MenuItem, MenuSystem, ShortcutItem
from ux import ux_show_story, ux_dramatic_pause, ux_confirm, the_ux
-from ux import ux_input_text, show_qr_code, import_export_prompt
+from ux import ux_input_text, show_qr_code, import_export_prompt, OK
from ux_q1 import QRScannerInteraction
from actions import goto_top_menu
from glob import settings, dis
@@ -751,40 +751,69 @@ class NoteContent(NoteContentBase):
async def start_export(notes):
# Save out notes/passwords
- from glob import NFC
+ import seed
from msgsign import write_sig_file
- import ujson as json
from ux_q1 import show_bbqr_codes
+ from backups import encrypt_7z_data, bkpw_workflow, pick_backup_password
singular = (len(notes) == 1)
item = notes[0].type_label if singular else 'all notes & passwords'
+
choice = await import_export_prompt(item, title="Data Export", no_nfc=True,
- footnotes="WARNING: No encryption happens here."
- " Your secrets will be cleartext.")
+ footnotes="WARNING: QR exports are NOT encrypted!")
if choice == KEY_CANCEL:
return
# render it
- data = json.dumps(dict(coldcard_notes=[i.serialize() for i in notes]))
+ data = ujson.dumps(dict(coldcard_notes=[i.serialize() for i in notes]))
if choice == KEY_QR:
# Always do BBRq.
await show_bbqr_codes('J', data, 'Notes & Passwords Export')
return
+ pwd = None
+ stored_pwd, skip_quiz = await bkpw_workflow()
+ if skip_quiz:
+ pwd = stored_pwd
+
+ if not pwd:
+ pwd, abort = await pick_backup_password(secret_opt=True, what="notes & passwords")
+ if abort: return
+
+ if pwd and not skip_quiz:
+ # quiz them, but be nice and do a shorter test.
+ words = pwd.split(" ")
+ ch = await seed.word_quiz(words, limited=(len(words) // 3))
+ if ch == 'x': return
+
# ideally, we'd use the title to make a filename, but meh...
fname_pattern = 'cc-notes.json' if not singular else ('cc-%s.json' % notes[0].type_label)
+ zz = None
+ if pwd:
+ dis.fullscreen("Encrypting...")
+ fname_pattern = fname_pattern.replace(".json", ".7z")
+ zz, hdr, footer = encrypt_7z_data(pwd, data.encode(), "json")
+
try:
with CardSlot(**choice) as card:
fname, nice = card.pick_filename(fname_pattern)
- with open(fname, 'w+') as fp:
- fp.write(data)
+ with open(fname, 'wb' if zz else 'w+') as fp:
+ if zz:
+ fp.write(hdr)
+ fp.write(zz.body)
+ fp.write(footer)
+ else:
+ fp.write(data)
- h = ngu.hash.sha256s(data)
- sig_nice = write_sig_file([(h, fname)])
+ sig_nice = None
+ if not zz:
+ # only unencrypted produces a signature file
+ h = ngu.hash.sha256s(data)
+ sig_nice = write_sig_file([(h, fname)])
except CardMissingError:
await needs_microsd()
@@ -793,9 +822,9 @@ async def start_export(notes):
await ux_show_story('Failed to write!\n\n'+str(e))
return
- msg = 'Export file written:\n\n%s\n\nSignature file written:\n\n%s' % (
- nice, sig_nice
- )
+ msg = '%sxport file written:\n\n%s' % ("Encrypted e" if zz else "E", nice)
+ if sig_nice:
+ msg += "\n\nSignature file written:\n\n%s" % sig_nice
await ux_show_story(msg)
@@ -803,38 +832,88 @@ async def import_from_other(menu, *a):
# Suck in a bunch of notes/passwords. Has to be coming from a Coldcard
# - but it's also just simple JSON
from actions import file_picker
- import json
+ from backups import bkpw_min_len, check_and_decrypt
choice = await import_export_prompt('secure notes and/or passwords', no_nfc=True,
- is_import=True, title='Data Import')
+ is_import=True, title='Data Import')
if choice == KEY_CANCEL:
return
elif choice == KEY_QR:
# Always do BBRq.
- zz = QRScannerInteraction()
- records = await zz.scan_json('Scan BBQr from other COLDCARD.')
+ qr = QRScannerInteraction()
+ records = await qr.scan_json('Scan BBQr from other COLDCARD.')
if records is None: return
+ ok = await import_from_json(records)
+ if not ok: return
else:
- def contains_json(fname):
+ def suitable(fname):
+ if fname.endswith('.7z'): return True # encrypted
if not fname.endswith('.json'): return False
try:
- obj = json.load(open(fname, 'rt'))
+ obj = ujson.load(open(fname, 'rt'))
assert 'coldcard_notes' in obj
return True
except: pass
- fn = await file_picker(min_size=8, max_size=100000, taster=contains_json, **choice)
+ fn = await file_picker(min_size=8, max_size=100000, taster=suitable, **choice)
if not fn: return
- with CardSlot(readonly=True, **choice) as card:
- records = json.load(open(fn, 'rt'))
+ if fn.endswith('.7z'): # encrypted version
+ import seed, version
+ from backups import num_pw_words
+
+ ch = await ux_show_story("Press (1) if your password is custom string, press %s for"
+ " 12 word password." % OK, title="Custom PWD?",
+ escape="1")
+ if ch == "x": return
+ custom_pwd = (ch == "1")
+
+ # need password
+ async def enc_done(words):
+ # remove all pw-picking from menu stack
+ seed.WordNestMenu.pop_all()
+ password = ' '.join(words)
+
+ try:
+ with CardSlot(readonly=True, **choice):
+ with open(fn, "rb") as fd:
+ contents = check_and_decrypt(fd, password, inner_ext=".json")
+ ok = await import_from_json(ujson.loads(contents))
+ if not ok: return False
+
+ except CardMissingError:
+ await needs_microsd()
+ return False
+ except Exception as e:
+ await ux_show_story(str(e), title='FAILED')
+ return False
- # We have some JSON, parsed now.
- ok = await import_from_json(records)
- if not ok: return
+ return True
+ if custom_pwd:
+ ipw = await ux_input_text("", prompt="Your Backup Password",
+ min_len=bkpw_min_len, max_len=128)
+ if not ipw: return
+ if not await enc_done([ipw]): return
+
+ else:
+ from ux_q1 import seed_word_entry
+ words = await seed_word_entry('Enter Password:', num_pw_words,
+ has_checksum=False)
+ if not words: return
+ if not await enc_done(words): return
+
+ else:
+ with CardSlot(readonly=True, **choice) as card:
+ with open(fn, 'rt') as f:
+ records = ujson.loads(f.read())
+
+ # We have some JSON, parsed now.
+ ok = await import_from_json(records)
+ if not ok: return
+
await ux_dramatic_pause('Saved.', 3)
menu.update_contents()
diff --git a/testing/conftest.py b/testing/conftest.py
index b249485..1f06595 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -2257,12 +2257,13 @@ def verify_backup_file(goto_home, pick_menu_item, cap_story, need_keypress):
@pytest.fixture
-def check_and_decrypt_backup(microsd_path):
- def doit(fn, passphrase):
+def check_and_decrypt_backup(request, microsd_path):
+ def doit(fn, passphrase, vdisk=False, notes=False):
# List contents using unix tools
- pn = microsd_path(fn)
+ path_f = request.getfixturevalue('virtdisk_path') if vdisk else microsd_path
+ pn = path_f(fn)
out = check_output(['7z', 'l', pn], encoding='utf8')
- xfname, = re.findall('[a-z0-9]{4,30}.txt', out)
+ xfname, = re.findall('[a-z0-9]{4,30}.%s' % ("json" if notes else "txt"), out)
print(f"Filename inside 7z: {xfname}")
assert xfname in out
assert 'Method = 7zAES' in out
diff --git a/testing/test_notes.py b/testing/test_notes.py
index df61060..af721c6 100644
--- a/testing/test_notes.py
+++ b/testing/test_notes.py
@@ -168,7 +168,6 @@ def build_note(goto_notes, pick_menu_item, enter_text, cap_menu, cap_story,
assert 'to save note to SD' in story
assert 'to show QR' in story
assert 'WARNING' in story
- assert 'will be cleartext' in story
need_keypress(KEY_QR)
file_type, data = readback_bbqr()
@@ -460,30 +459,117 @@ def test_password_change_title(build_password, change_password):
change_password(id_title="old_title", new_title="new_title")
-def test_top_export(goto_notes, pick_menu_item, cap_story, need_keypress, settings_get,
- readback_bbqr, need_some_notes):
+@pytest.fixture
+def backup_notes(goto_notes, pick_menu_item, cap_story, need_keypress, readback_bbqr, virtdisk_path,
+ microsd_path, seed_story_to_words, press_select, pass_word_quiz, garbage_collector,
+ check_and_decrypt_backup):
- notes = settings_get('notes', [])
- if not len(notes):
- notes = need_some_notes()
+ def doit(way, encrypted=False, bkpw=None):
+ pth = words = None
+ goto_notes()
+ pick_menu_item('Export All')
- goto_notes()
- pick_menu_item('Export All')
+ title, story = cap_story()
+ assert 'Export' in title
+ assert 'to SD Card' in story
+ assert 'to show QR' in story
+ assert 'WARNING' in story
+ assert "QR exports are NOT encrypted!" in story
- title, story = cap_story()
- assert 'Export' in title
- assert 'to SD Card' in story
- assert 'to show QR' in story
- assert 'WARNING' in story
- assert 'will be cleartext' in story
+ if way == "qr":
+ need_keypress(KEY_QR)
+ file_type, data = readback_bbqr()
+ assert file_type == 'J'
- need_keypress(KEY_QR)
- file_type, data = readback_bbqr()
- assert file_type == 'J'
+ else:
+ if way == "vdisk":
+ if "(2) to save to Virtual Disk" not in story:
+ raise pytest.skip("vdisk disabled")
+ need_keypress("2")
+ path_f = virtdisk_path
+ else:
+ need_keypress("1")
+ path_f = microsd_path
+
+ if encrypted:
+ time.sleep(.1)
+ title, story = cap_story()
+ if bkpw:
+ assert "Use same backup file password as last time?" in story
+ assert f"{bkpw[0]}...{bkpw[-1]}" in story
+ press_select()
+ words = [bkpw]
+
+ else:
+ assert 'Record this (12 word)' in story
+ assert 'password:' in story
+ assert "Press (6) for cleartext backup" in story
+
+ words = seed_story_to_words(story)
+ count, title, body = pass_word_quiz(words)
+ assert count >= 4
+ assert len(words) == 12
+
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "Encrypted export file written" in story
+ fname = story.split("\n\n")[-1]
+ pth = path_f(fname)
+ data = check_and_decrypt_backup(fname, words, vdisk=(way == "vdisk"), notes=True)
+
+ else:
+ # unencrypted export
+ need_keypress("6")
+ time.sleep(.1)
+ title, story = cap_story()
+ assert "file will **NOT** be encrypted" in story
+ assert "anyone who finds the file will get all of your notes & passwords" in story
+ press_select()
+
+ time.sleep(.1)
+ title, story = cap_story()
+ split_story = story.split("\n\n")
+ pth = path_f(split_story[1])
+ garbage_collector.append(path_f(split_story[-1]))
+ with open(pth, "r") as f:
+ data = f.read()
+
+ return data, pth, words
+
+ return doit
+
+
+@pytest.mark.parametrize('way', ["qr", "sd", "vdisk"])
+@pytest.mark.parametrize('encrypted', [True, False, "x"*32])
+def test_top_export(way, encrypted, settings_set, settings_remove, need_some_passwords, press_select,
+ need_some_notes, backup_notes, garbage_collector):
+
+ if encrypted and (way == "qr"):
+ raise pytest.skip("QR export is not encrypted")
+
+
+ if isinstance(encrypted, str):
+ bkpw = encrypted
+ encrypted = True
+ settings_set('bkpw', bkpw)
+ else:
+ bkpw = None
+ settings_remove('bkpw')
+
+ #clear
+ settings_set('notes', [])
+ need_some_notes()
+ notes = need_some_passwords()
+
+ data, path, _ = backup_notes(way, encrypted, bkpw)
+ if path:
+ garbage_collector.append(path)
+
+ press_select()
obj = json.loads(data)
assert obj.keys() == {'coldcard_notes'}
assert obj['coldcard_notes'] == notes
- need_keypress(KEY_ENTER)
+
def test_sort_by_title(goto_notes, pick_menu_item, cap_story, need_keypress, settings_get,
settings_set, build_note, cap_menu, build_password):
@@ -674,10 +760,31 @@ def test_old_records_without_group(settings_set, settings_get, goto_notes, cap_m
assert settings_get('notes')[0].get('group', '') == ''
-def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get,
- settings_set, scan_a_qr, need_some_notes):
+@pytest.mark.parametrize('way', ["qr", "sd", "vdisk"])
+@pytest.mark.parametrize('encrypted', [True, False, "x"*32])
+def test_top_import(way, encrypted, goto_notes, cap_menu, cap_story, need_keypress, settings_get,
+ settings_set, scan_a_qr, need_some_notes, backup_notes, need_some_passwords,
+ garbage_collector, settings_remove, pick_menu_item, press_select,
+ word_menu_entry, enter_complex):
+
+ if encrypted and (way == "qr"):
+ raise pytest.skip("QR import is not encrypted")
+
# make some
- notes = need_some_notes()
+ need_some_notes()
+ notes = need_some_passwords()
+
+ if isinstance(encrypted, str):
+ bkpw = encrypted
+ encrypted = True
+ settings_set('bkpw', bkpw)
+ else:
+ bkpw = None
+ settings_remove('bkpw')
+
+ data, path, words = backup_notes(way, encrypted, bkpw)
+ if path:
+ garbage_collector.append(path)
# wipe them
settings_set('notes', [])
@@ -689,17 +796,40 @@ def test_top_import(goto_notes, cap_menu, cap_story, need_keypress, settings_get
assert 'to scan QR' in story
assert 'WARNING' not in story
- jj = json.dumps(dict(coldcard_notes=notes))
+ if way == "qr":
+ need_keypress(KEY_QR)
+ _, parts = split_qrs(data, 'J', max_version=20)
+ random.shuffle(parts)
- need_keypress(KEY_QR)
+ for p in parts:
+ scan_a_qr(p)
- _, parts = split_qrs(jj, 'J', max_version=20)
- random.shuffle(parts)
+ time.sleep(.5) # decompression time in some cases
- for p in parts:
- scan_a_qr(p)
+ else:
+ if way == "vdisk":
+ if "(2) to import from Virtual Disk" not in story:
+ raise pytest.skip("vdisk disabled")
+ need_keypress("2")
+ else:
+ need_keypress("1")
+
+ fname = os.path.basename(path)
+ pick_menu_item(fname)
+ if encrypted:
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Custom PWD?"
+ assert "Press (1) if your password is custom string" in story
+ assert "press ENTER for 12 word password" in story
+ if bkpw:
+ need_keypress("1")
+ enter_complex(bkpw, b39pass=False)
+ else:
+ press_select()
+ # looking at word entry right now
+ word_menu_entry(words, has_checksum=False)
- time.sleep(.5) # decompression time in some cases
m = cap_menu()
for _ in range(3):
if "1:" in m[0]:
@@ -740,6 +870,113 @@ def test_top_import_u_typed_json(goto_notes, cap_menu, cap_story, need_keypress,
goto_notes()
+@pytest.mark.parametrize('bkpw', [True, False])
+def test_top_import_wrong_pw(bkpw, goto_notes, cap_menu, cap_story, need_keypress,
+ settings_set, need_some_notes, backup_notes, press_select,
+ garbage_collector, settings_remove, pick_menu_item,
+ word_menu_entry, enter_complex, need_some_passwords):
+
+ # make some
+ need_some_notes()
+ need_some_passwords()
+
+ if bkpw:
+ bkpw = 32*"g"
+ settings_set('bkpw', bkpw)
+ else:
+ settings_remove('bkpw')
+ bkpw = None
+
+ data, path, words = backup_notes("sd", True, bkpw)
+ if path:
+ garbage_collector.append(path)
+
+ # wipe them
+ settings_set('notes', [])
+
+ goto_notes('Import')
+ title, story = cap_story()
+ assert 'Import' in title
+ assert 'from SD Card' in story
+ assert 'to scan QR' in story
+ assert 'WARNING' not in story
+
+ need_keypress("1")
+ fname = os.path.basename(path)
+ pick_menu_item(fname)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Custom PWD?"
+ assert "Press (1) if your password is custom string" in story
+ assert "press ENTER for 12 word password" in story
+
+ # provide wrong password
+ if bkpw:
+ invalid_pwd = 32*"H"
+ need_keypress("1")
+ enter_complex(invalid_pwd, b39pass=False)
+ else:
+ invalid_pwd = 12 * ["abandon"]
+ press_select()
+ # looking at word entry right now
+ word_menu_entry(invalid_pwd, has_checksum=False)
+
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "FAILED"
+ assert "Unable to decrypt backup file. Incorrect password?" in story
+ if isinstance(invalid_pwd, list):
+ invalid_pwd = " ".join(invalid_pwd)
+ assert invalid_pwd in story
+
+ press_select()
+ assert len(cap_menu()) == 4 # nothing has been added
+
+
+def test_top_import_seed_backup_fails(goto_notes, cap_menu, cap_story, need_keypress,
+ settings_set, backup_system, press_select,
+ garbage_collector, microsd_path, pick_menu_item,
+ word_menu_entry, press_cancel, goto_home):
+ goto_home()
+ settings_set('notes', [])
+
+ words = backup_system()
+ time.sleep(.1)
+ title, story = cap_story()
+ assert 'written:' in story
+
+ fname = [ln.strip() for ln in story.split('\n') if ln.strip().endswith('.7z')][0]
+ garbage_collector.append(microsd_path(fname))
+
+ press_cancel()
+ time.sleep(.1)
+
+ goto_notes('Import')
+ title, story = cap_story()
+ assert 'Import' in title
+ assert 'from SD Card' in story
+ assert 'to scan QR' in story
+ assert 'WARNING' not in story
+
+ need_keypress("1")
+ pick_menu_item(fname)
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "Custom PWD?"
+ assert "Press (1) if your password is custom string" in story
+ assert "press ENTER for 12 word password" in story
+
+ press_select()
+ word_menu_entry(words, has_checksum=False)
+
+ time.sleep(.1)
+ title, story = cap_story()
+ assert title == "FAILED"
+
+ press_select()
+ assert len(cap_menu()) == 4 # nothing has been added
+
+
@pytest.mark.parametrize('qr,title', [
('otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30',
'ACME Co:john.doe@email.com'),
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.