diff --git a/docs/key-teleport.md b/docs/key-teleport.md index 72799818..28001f5c 100644 --- a/docs/key-teleport.md +++ b/docs/key-teleport.md @@ -13,7 +13,7 @@ NFC, passive websites, and QR/BBQr codes. - Receiver picks an EC keypair, stores it in settings, and publishes the pubkey via a QR/NFC - Sender gets that, pickes own keypair, and does ECDH to arrive at a shared session key -- Sender picks a human-readable secret (12 words) which is independant of anything else (P key) +- Sender picks a human-readable secret which is independant of anything else (P key) - The secret data (perhaps a seed phrase, XPRV, secure note, etc) is AES encryped with P key, then encrypted + MAC added with session key - Data packet is sent to receiver, who can reconstruct the session key via ECDH @@ -40,9 +40,7 @@ NFC, passive websites, and QR/BBQr codes. The first byte encodes what the package contents (under all the encryption). -- `w` - 12/18/24 words - 16/24/32 bytes follow -- `m` - (one byte of length) + (up to 71 bytes) - BIP-32 raw master secret [rare] -- `r` - raw XPRV mode - 64 bytes follow which are the chain code then master privkey +- `s` - 12/18/24 words/raw master/xprv - 16/24/32/64 bytes follow encoded in internal format - `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows - `n` - one or many notes export (JSON array) - `v` - seed vault export (JSON: one secret key but includes includes name, source of key) @@ -58,10 +56,9 @@ Base32 encoded. New type codes for BBQr are defined for the purposes of this application: -- `R` contains `(pubkey)` ... begins the process from receiver; pubkey is base32 of 33 bytes -- `S` contains `(pubkey)(data)` ... data from sender; all base32 encoded, and first 33 bytes - are sender's pubkey -- `E` for PSBT: `(randint)(data)` ... randint (4 bytes) indicates which a randomly +- `R` contains `(pubkey)` ... begins the process from receiver; compressed pubkey is 33 bytes +- `S` contains `(pubkey)(data)` ... data from sender; first 33 bytes are sender's pubkey +- `E` for PSBT: `(randint)(data)` ... randint (4 bytes) indicates which randomly selected derived subkey from pre-shared xpub associated with receiver All the data is encrypted with the exception of the pubkey or randint. Keep in mind @@ -81,8 +78,8 @@ receive the package. The sender's keypair is implied by: .../20250318/(randint) -Because both the sender and receiver have each other's XPUB they can derive -the appropriate pubkeys (and privkey for their side) without communicating anymore +Because both the sender and receiver already have each other's XPUB they can derive +the appropriate pubkeys (and privkey for their side) without communicating more than `randint`. The sending COLDCARD will pick a new random value each time. ## Encryption Details @@ -91,16 +88,22 @@ AES-256-CTR is used exclusively. Session key is picked via ECDH with final key value being the SHA256 over 64 bytes of coordinate X (concat) Y. While ECDH is enough to assure privacy from men in the middle, we -add an additional layer of encryption, using 12 BIP-39 words with -checksum (128 bits). We call this the "paranoid key" internally. +add an additional layer of encryption. We call this the "paranoid key" internally +and in the UX it is called "Teleport Password". + +The user sees a random 8-character password, generated as a random 40-bit value, but +shown in Base32 (8 chars) for the human to enter. We apply PBKDF2-SHA512 with +an interation count of 5000 to stretch that to 512 bits, of which we use half. +The session key is used as the key for the KDF, and the entered value as salt. - ECDH arrives at session key - decrypt (AES-256-CTR) the binary body of message - verify checksum: - final 2 bytes should be `== SHA256(decrypted body[0:-2])[-2:]` - if not, corruption, truncation, or wrong keys -- if that decryption is correct, then prompt user for the paranoid key (12 words) -- concat paranoid key (16 bytes) with first 16 bytes of the session key and run AES-256-CTR again +- if that decryption is correct, then prompt user for the paranoid key (8 chars) +- stretch that value using session key and 5000 iterations of PBKDF2-SHA512 +- use upper 256 bits and run AES-256-CTR again - same checksum of 2 bytes of SHA256 are found inside after decryption Encryption adds 4 bytes of overhead because of these MAC values, @@ -114,23 +117,32 @@ publish a static website directly from an open Github repository. The single-page website contains javascript code which looks at the "hash" part of the incoming URL (`window.location.hash`) and if it meets the requirements, renders a large QR. The QR data must look like -a correctly-ecoded BBQr with one of the type codes above. -(otherwise the website could render any QR, which we don't want to -support). +a correctly-encoded BBQr with one of the 3 type-codes above (`R` `S` or `E`). +Otherwise the website could render any QR, which we don't want to +support. The page will offer "copy to clipboard" features for the data inside the QR as a URL (ie. same URL as shown) and as an image and of course, the COLDCARD Q can scan from the web browser screen itself. -On the COLDCARD side, when NFC is tapped, it will offer a long -URL to this site with the data to be transfered "after the hash". -This is optional since the QR shown on the Q itself, would pass -the same data. +When the BBQr data is larger than comfortable for a single QR, the +website can split into a multi-frame BBQr. The website can +do this without understanding the contents of the BBQr data (all +of which is encrypted). Download options will be provided for +single-frame QR, animated PNG, and "stacked BBQr" (a single tall +PNG with each QR frame stacked). + +On the COLDCARD side, when NFC is tapped, it will offer a long URL +to this site with the data to be transfered "after the hash". This +is optional since the QR can be shown on the Q itself, and would +pass the same data. Since the website is running on Github, Coinkite does not have access to IP addresses or other log details. Because the data for teleport is "after the hash" it is never sent to Github's servers -but remains in the browser only. +but remains in the browser only. All JS resources referenced by the +webpage will have content hashes applied to prevent interference, +and the site will be served over SSL. # UX Details @@ -146,6 +158,4 @@ but remains in the browser only. signed) and the QR is prepared for that receiver. They should get another chance to do the same for the other possible co-signers. -- If the user opts to skip the "paranoid key" then treat it as `bytes([0x5a] * 32)`, - but still do the extra decryption and MAC check. diff --git a/shared/actions.py b/shared/actions.py index b442f5fb..e673ac7e 100644 --- a/shared/actions.py +++ b/shared/actions.py @@ -1292,11 +1292,11 @@ async def verify_backup(*A): # do a limited CRC-check over encrypted file await backups.verify_backup_file(fn) -async def import_extended_key_as_secret(extended_key, ephemeral, meta=None): +async def import_extended_key_as_secret(extended_key, ephemeral, origin=None): try: import seed if ephemeral: - await seed.set_ephemeral_seed_extended_key(extended_key, meta=meta) + await seed.set_ephemeral_seed_extended_key(extended_key, origin=origin) else: await seed.set_seed_extended_key(extended_key) except ValueError: @@ -1360,7 +1360,7 @@ async def import_xprv(_1, _2, item): extended_key = ln break - await import_extended_key_as_secret(extended_key, ephemeral, meta='Imported XPRV') + await import_extended_key_as_secret(extended_key, ephemeral, origin='Imported XPRV') # not reached; will do reset. async def need_clear_seed(*a): diff --git a/shared/backups.py b/shared/backups.py index a8360b5c..dc2173bc 100644 --- a/shared/backups.py +++ b/shared/backups.py @@ -5,7 +5,7 @@ import compat7z, stash, ckcc, chains, gc, sys, bip39, uos, ngu from ubinascii import hexlify as b2a_hex from ubinascii import unhexlify as a2b_hex -from utils import pad_raw_secret +from utils import deserialize_secret from ux import ux_show_story, ux_confirm, ux_dramatic_pause, OK, X, ux_input_text import version, ujson from uio import StringIO @@ -127,7 +127,7 @@ def extract_raw_secret(chain, vals): assert 'raw_secret' in vals rs = vals.pop('raw_secret') - raw = pad_raw_secret(rs) + raw = deserialize_secret(rs) # check we can decode this right (might be different firmare) opmode, bits, node = stash.SecretStash.decode(raw) @@ -277,7 +277,7 @@ async def restore_tmp_from_dict_ll(vals): from seed import set_ephemeral_seed from actions import goto_top_menu - await set_ephemeral_seed(raw, chain, meta="Coldcard Backup") + await set_ephemeral_seed(raw, chain, origin="Coldcard Backup") for k, v in vals.items(): if not k[:8] == "setting.": continue diff --git a/shared/ccc.py b/shared/ccc.py index a0f8abbe..5afb6525 100644 --- a/shared/ccc.py +++ b/shared/ccc.py @@ -4,7 +4,7 @@ # import gc, chains, version, ngu, web2fa, bip39, re from chains import NLOCK_IS_TIME -from utils import swab32, xfp2str, truncate_address, pad_raw_secret, show_single_address +from utils import swab32, xfp2str, truncate_address, deserialize_secret, show_single_address from glob import settings, dis from ux import ux_confirm, ux_show_story, the_ux, OK, ux_dramatic_pause, ux_enter_number, ux_aborted from menu import MenuSystem, MenuItem, start_chooser @@ -45,7 +45,7 @@ class CCCFeature: def get_encoded_secret(cls): # Gets the key C as encoded binary secret, compatible w/ # encodings used in stash. - return pad_raw_secret(settings.get('ccc')['secret']) + return deserialize_secret(settings.get('ccc')['secret']) @classmethod def get_xfp(cls): @@ -369,7 +369,7 @@ be ready to show it as a QR, before proceeding.''' from actions import goto_top_menu enc = CCCFeature.get_encoded_secret() - await set_ephemeral_seed(enc, meta='Key C from CCC') + await set_ephemeral_seed(enc, origin='Key C from CCC') goto_top_menu() @@ -725,10 +725,10 @@ async def gen_or_import(): elif ch == '6': # pick existing from Seed Vault - enc = await SeedVaultChooserMenu.pick(words_only=True) - if not enc: return None - words = SecretStash.decode_words(enc) - await enable_step1(words) + picked = await SeedVaultChooserMenu.pick(words_only=True) + if picked: + words = SecretStash.decode_words(deserialize_secret(picked.encoded)) + await enable_step1(words) return None diff --git a/shared/drv_entro.py b/shared/drv_entro.py index 91ce4cef..f1b6b2f4 100644 --- a/shared/drv_entro.py +++ b/shared/drv_entro.py @@ -263,7 +263,7 @@ async def drv_entro_step2(_1, picked, _2, just_pick=False): xfp_str = xfp2str(settings.get("xfp", 0)) await seed.set_ephemeral_seed( encoded, - meta='BIP85 Derived from [%s], index=%d' % (xfp_str, index) + origin='BIP85 Derived from [%s], index=%d' % (xfp_str, index) ) goto_top_menu() break diff --git a/shared/nfc.py b/shared/nfc.py index 8a3a53c2..1f5ad137 100644 --- a/shared/nfc.py +++ b/shared/nfc.py @@ -665,7 +665,7 @@ class NFCHandler: if winner: try: from seed import set_ephemeral_seed_words - await set_ephemeral_seed_words(winner, meta='NFC Import') + await set_ephemeral_seed_words(winner, origin='NFC Import') except Exception as e: #import sys; sys.print_exception(e) await ux_show_story('Failed to import.\n\n%s\n%s' % (e, problem_file_line(e))) diff --git a/shared/notes.py b/shared/notes.py index 21b6d023..f9d89af6 100644 --- a/shared/notes.py +++ b/shared/notes.py @@ -635,7 +635,8 @@ async def import_from_json(records): was = list(settings.get('notes', [])) was.extend(new) - settings.put('notes', was) + settings.set('notes', was) + settings.set('secnap', True) settings.save() except Exception as e: diff --git a/shared/seed.py b/shared/seed.py index 39db0e53..12f1d708 100644 --- a/shared/seed.py +++ b/shared/seed.py @@ -13,7 +13,8 @@ import ngu, uctypes, bip39, random, version from ucollections import OrderedDict from menu import MenuItem, MenuSystem -from utils import xfp2str, parse_extended_key, swab32, pad_raw_secret, problem_file_line, wipe_if_deltamode +from utils import xfp2str, parse_extended_key, swab32 +from utils import deserialize_secret, problem_file_line, wipe_if_deltamode from uhashlib import sha256 from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, OK, X from ux import PressRelease, ux_input_text, show_qr_code @@ -27,13 +28,22 @@ from nvstore import SettingsObject from files import CardMissingError, needs_microsd, CardSlot from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR from uasyncio import sleep_ms - +from ucollections import namedtuple # seed words lengths we support: 24=>256 bits, and recommended VALID_LENGTHS = (24, 18, 12) # bit flag that means "also include bare prefix as a valid word" _PREFIX_MARKER = const(1<<26) + +# what we store (in JSON as a tuple) for each seed vault key. +# - 'encoded' is hex, and has is trimmed of right side zeros +VaultEntry = namedtuple('VaultEntry', 'xfp encoded label origin') + +def seed_vault_iter(): + # iterate over all seeds in the vault; returns VaultEntry instances. + for tup in settings.master_get("seeds", []): + yield VaultEntry(*tup) def letter_choices(sofar='', depth=0, thres=5): # make a list of word completions based on indicated prefix @@ -413,14 +423,16 @@ async def new_from_dice(nwords): def in_seed_vault(encoded): # Test if indicated secret is in the seed vault already. - seeds = settings.master_get("seeds", []) - if seeds: - ss = SecretStash.storage_serialize(encoded) - if ss in [s[1] for s in seeds]: + hss = None + for rec in seed_vault_iter(): + if not hss: + hss = SecretStash.storage_serialize(encoded) + if hss == rec.encoded: return True + return False -async def add_seed_to_vault(encoded, meta=None): +async def add_seed_to_vault(encoded, origin=None, label=None): if not settings.master_get("seedvault", False): # seed vault disabled @@ -460,10 +472,9 @@ async def add_seed_to_vault(encoded, meta=None): return # Save it into master settings - seeds.append((new_xfp_str, - SecretStash.storage_serialize(encoded), - xfp_ui, - meta)) + rec = VaultEntry(xfp=new_xfp_str, encoded=SecretStash.storage_serialize(encoded), + label=(label or xfp_ui), origin=origin) + seeds.append(tuple(rec)) settings.master_set("seeds", seeds) @@ -472,10 +483,10 @@ async def add_seed_to_vault(encoded, meta=None): return True async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='', - is_restore=False, meta=None): + is_restore=False, origin=None, label=None): # Capture tmp seed into vault, if so enabled, and regardless apply it as new tmp. if not is_restore: - await add_seed_to_vault(encoded, meta=meta) + await add_seed_to_vault(encoded, origin=origin, label=label) dis.fullscreen("Wait...") applied, err_msg = pa.tmp_secret(encoded, chain=chain, bip39pw=bip39pw) @@ -492,11 +503,11 @@ async def set_ephemeral_seed(encoded, chain=None, summarize_ux=True, bip39pw='', return applied -async def set_ephemeral_seed_words(words, meta): +async def set_ephemeral_seed_words(words, origin): dis.progress_bar_show(0.1) encoded = seed_words_to_encoded_secret(words) dis.progress_bar_show(0.5) - await set_ephemeral_seed(encoded, meta=meta) + await set_ephemeral_seed(encoded, origin=origin) goto_top_menu() async def ephemeral_seed_generate_from_dice(nwords): @@ -513,7 +524,7 @@ async def ephemeral_seed_generate_from_dice(nwords): words = await approve_word_list(seed, nwords, ephemeral=True) if words: dis.fullscreen("Applying...") - await set_ephemeral_seed_words(words, meta='Dice') + await set_ephemeral_seed_words(words, origin='Dice') def generate_seed(): # Generate 32 bytes of best-quality high entropy TRNG bytes. @@ -536,7 +547,7 @@ async def make_new_wallet(nwords): async def ephemeral_seed_import(nwords): async def import_done_cb(words): dis.fullscreen("Applying...") - await set_ephemeral_seed_words(words, meta='Imported') + await set_ephemeral_seed_words(words, origin='Imported') if version.has_qwerty: from ux_q1 import seed_word_entry @@ -550,17 +561,17 @@ async def ephemeral_seed_generate(nwords): words = await approve_word_list(seed, nwords, ephemeral=True) if words: dis.fullscreen("Applying...") - await set_ephemeral_seed_words(words, meta="TRNG Words") + await set_ephemeral_seed_words(words, origin="TRNG Words") async def set_seed_extended_key(extended_key): encoded, chain = xprv_to_encoded_secret(extended_key) set_seed_value(encoded=encoded, chain=chain) goto_top_menu(first_time=True) -async def set_ephemeral_seed_extended_key(extended_key, meta=None): +async def set_ephemeral_seed_extended_key(extended_key, origin=None): encoded, chain = xprv_to_encoded_secret(extended_key) dis.fullscreen("Applying...") - await set_ephemeral_seed(encoded=encoded, chain=chain, meta=meta) + await set_ephemeral_seed(encoded=encoded, chain=chain, origin=origin) goto_top_menu() async def approve_word_list(seed, nwords, ephemeral=False): @@ -680,7 +691,7 @@ async def calc_bip39_passphrase(pw, bypass_tmp=False): async def set_bip39_passphrase(pw, bypass_tmp=False, summarize_ux=True): nv, xfp, parent_xfp = await calc_bip39_passphrase(pw, bypass_tmp=bypass_tmp) ret = await set_ephemeral_seed(nv, summarize_ux=summarize_ux, bip39pw=pw, - meta="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp)) + origin="BIP-39 Passphrase on [%s]" % xfp2str(parent_xfp)) dis.draw_status(bip39=int(bool(pw)), xfp=xfp, tmp=1) return ret @@ -821,7 +832,7 @@ class SeedVaultMenu(MenuSystem): from glob import dis dis.fullscreen("Applying...") - xfp, encoded = item.arg + encoded = item.arg # 72 bytes binary await set_ephemeral_seed(encoded, is_restore=True) @@ -833,15 +844,15 @@ class SeedVaultMenu(MenuSystem): esc = "" tmp_val = False - idx, xfp_str, encoded = item.arg + idx, rec, encoded = item.arg current_active = (pa.tmp_value == bytes(encoded)) - msg = "Remove seed from seed vault " + msg = "Remove seed from seed vault" if pa.tmp_value and current_active: tmp_val = True msg += "?\n\n" else: - msg += ("and delete its settings?\n\n" + msg += (" and delete its settings?\n\n" "Press %s to continue, press (1) to " "only remove from seed vault and keep " "encrypted settings for later use.\n\n") % OK @@ -849,7 +860,7 @@ class SeedVaultMenu(MenuSystem): msg += "WARNING: Funds will be lost if wallet is not backed-up elsewhere." - ch = await ux_show_story(title="[" + xfp_str + "]", msg=msg, escape=esc) + ch = await ux_show_story(title="[" + rec.xfp + "]", msg=msg, escape=esc) if ch == "x": return dis.fullscreen("Saving...") @@ -883,13 +894,13 @@ class SeedVaultMenu(MenuSystem): @staticmethod async def _detail(menu, label, item): - xfp_str, encoded, name, meta = item.arg + rec, encoded = item.arg - # - first byte represents type of secret (internal encoding flag) + # - first byte represents type of secret (internal encoding flags) txt = SecretStash.summary(encoded[0]) - detail = "Name:\n%s\n\nMaster XFP:\n%s\n\nOrigin:\n%s\n\nSecret Type:\n%s" \ - % (name, xfp_str, meta, txt) + detail = "Name:\n%s\n\nMaster XFP: %s\nSecret Type: %s\n\nOrigin:\n%s\n\n" \ + % (rec.label, rec.xfp, txt, rec.origin) await ux_show_story(detail) @@ -899,28 +910,28 @@ class SeedVaultMenu(MenuSystem): from glob import dis from ux import ux_input_text - idx, xfp_str = item.arg + idx, old = item.arg - seeds = settings.master_get("seeds", []) - chk_xfp, encoded, old_name, meta = seeds[idx] - assert chk_xfp == xfp_str + #seeds = settings.master_get("seeds", []) + #old = VaultEntry(*seeds[idx]) + assert old.xfp == xfp_str - new_name = await ux_input_text(old_name, confirm_exit=False, max_len=40) + new_label = await ux_input_text(old.label, confirm_exit=False, max_len=40) - if not new_name: + if not new_label: return dis.fullscreen("Saving...") # save it - seeds[idx] = (chk_xfp, encoded, new_name, meta) + seeds[idx] = VaultEntry(old.xfp, old.encoded, new_label, old.origin) # need to load and work on master secrets, will be slow if on tmp seed settings.master_set("seeds", seeds) # update label in sub-menu - menu.items[0].label = new_name - menu.items[0].arg = menu.items[0].arg[0:2] + (new_name,) + menu.items[0].arg[3:] + menu.items[0].label = new_label + menu.items[0].arg = seeds[idx] # .. and name in parent menu too parent = the_ux.parent_of(menu) @@ -950,10 +961,9 @@ class SeedVaultMenu(MenuSystem): seeds = settings.master_get("seeds", []) # Save it into master settings - seeds.append((new_xfp_str, + seeds.append(VaultEntry(new_xfp_str, SecretStash.storage_serialize(pa.tmp_value), - xfp_ui, - "unknown origin")) + xfp_ui, "unknown origin")) settings.master_set("seeds", seeds) @@ -970,7 +980,7 @@ class SeedVaultMenu(MenuSystem): rv = [] add_current_tmp = MenuItem("Add current tmp", f=cls._add_current_tmp) - seeds = settings.master_get("seeds", []) + seeds = list(seed_vault_iter()) if not seeds: rv.append(MenuItem('(none saved yet)')) @@ -981,16 +991,19 @@ class SeedVaultMenu(MenuSystem): wipe_if_deltamode() tmp_in_sv = False - for i, (xfp_str, encoded, name, meta) in enumerate(seeds): + for i, rec in enumerate(seeds): is_active = False - encoded = pad_raw_secret(encoded) + + # de-serialize encoded secret + encoded = deserialize_secret(rec.encoded) if encoded == pa.tmp_value: is_active = tmp_in_sv = True + submenu = [ - MenuItem(name, f=cls._detail, arg=(xfp_str, encoded, name, meta)), - MenuItem('Use This Seed', f=cls._set, arg=(xfp_str, encoded)), - MenuItem('Rename', f=cls._rename, arg=(i, xfp_str)), - MenuItem('Delete', f=cls._remove, arg=(i, xfp_str, encoded)), + MenuItem(rec.label, f=cls._detail, arg=(rec, encoded)), + MenuItem('Use This Seed', f=cls._set, arg=encoded), + MenuItem('Rename', f=cls._rename, arg=(i, rec)), + MenuItem('Delete', f=cls._remove, arg=(i, rec, encoded)), ] if is_active: submenu[1] = MenuItem("Seed In Use") @@ -1001,7 +1014,7 @@ class SeedVaultMenu(MenuSystem): # DO NOT offer any modification api (rename/delete) submenu = submenu[:2] - item = MenuItem('%2d: %s' % (i+1, name), menu=MenuSystem(submenu)) + item = MenuItem('%2d: %s' % (i+1, rec.label), menu=MenuSystem(submenu)) if is_active: item.is_chosen = lambda: True @@ -1027,18 +1040,14 @@ class SeedVaultChooserMenu(MenuSystem): def __init__(self, words_only=False): self.result = None - seeds = settings.master_get("seeds", []) items = [] - - for i, (xfp_str, encoded, name, meta) in enumerate(seeds): - encoded = pad_raw_secret(encoded) - if words_only and not SecretStash.is_words(encoded): + for i, rec in enumerate(seed_vault_iter()): + if words_only and not SecretStash.is_words(deserialize_secret(rec.encoded)): continue - item = MenuItem('%2d: %s' % (i+1, name), arg=encoded, f=self.picked) + item = MenuItem('%2d: %s' % (i+1, rec.label), arg=rec, f=self.picked) items.append(item) - - if not items: + else: items.append(MenuItem("(none suitable)")) super().__init__(items) @@ -1324,7 +1333,7 @@ async def apply_pass_value(new_pp): return await set_ephemeral_seed(nv, summarize_ux=False, bip39pw=new_pp, - meta="BIP-39 Passphrase on [%s]" % parent_xfp_str) + origin="BIP-39 Passphrase on [%s]" % parent_xfp_str) if ch == '1': try: diff --git a/shared/stash.py b/shared/stash.py index 9389677a..d7f4ffeb 100644 --- a/shared/stash.py +++ b/shared/stash.py @@ -167,6 +167,7 @@ class SecretStash: @staticmethod def storage_serialize(secret): # make it a JSON-compatible field + # - converse: utils.deserialize_secret() return B2A(bytes(secret).rstrip(b"\x00")) @staticmethod @@ -422,13 +423,4 @@ class SensitiveValues: self.register(pk) return pk - def encoded_secret(self): - # we do not support master as secret - only extended keys and mnemonics - if self.mode == "xprv": - nv = SecretStash.encode(xprv=self.node) - else: - assert self.mode == "words" - nv = SecretStash.encode(seed_phrase=self.raw) - return nv - # EOF diff --git a/shared/tapsigner.py b/shared/tapsigner.py index 76f9d11b..e8def94c 100644 --- a/shared/tapsigner.py +++ b/shared/tapsigner.py @@ -33,7 +33,7 @@ async def import_tapsigner_backup_file(_1, _2, item): from pincodes import pa assert pa.is_secret_blank() # "must not have secret" - meta = "from " + origin = "from " label = "TAPSIGNER encrypted backup file" choice = await import_export_prompt(label, is_import=True) @@ -69,7 +69,7 @@ async def import_tapsigner_backup_file(_1, _2, item): else: fn = await file_picker(suffix="aes", min_size=100, max_size=160, **choice) if not fn: return - meta += (" (%s)" % fn) + origin += (" (%s)" % fn) try: with CardSlot(**choice) as card: with open(fn, 'rb') as fp: @@ -103,6 +103,6 @@ async def import_tapsigner_backup_file(_1, _2, item): await ux_show_story(title="FAILURE", msg=str(e)) continue - await import_extended_key_as_secret(extended_key, ephemeral, meta=meta) + await import_extended_key_as_secret(extended_key, ephemeral, origin=origin) # EOF diff --git a/shared/teleport.py b/shared/teleport.py index e30f5a4a..b8bffb16 100644 --- a/shared/teleport.py +++ b/shared/teleport.py @@ -3,15 +3,16 @@ # teleport.py - Magically transport extremely sensitive data between the # secure environment of two Q's. # -import utime, uzlib, ngu, aes256ctr, bip39 -from utils import problem_file_line, xor +import sys, uzlib, ngu, aes256ctr, bip39, json, stash +from utils import problem_file_line, B2A, xfp2str, deserialize_secret from ubinascii import unhexlify as a2b_hex from ubinascii import hexlify as b2a_hex from glob import settings -from ux import ux_show_story, ux_confirm, show_qr_code -from ux_q1 import show_bbqr_codes, QRScannerInteraction, seed_word_entry, ux_render_words +from ux import ux_show_story, ux_confirm, show_qr_code, the_ux, ux_dramatic_pause +from ux_q1 import show_bbqr_codes, QRScannerInteraction, ux_input_text from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL -from bbqr import int2base36, b32encode +from bbqr import b32encode, b32decode +from menu import MenuItem, MenuSystem KT_DOMAIN = 'keyteleport.com' @@ -30,8 +31,8 @@ KT_DOMAIN = 'keyteleport.com' def short_bbqr(type_code, data): # Short-circuit basic BBQr encoding here: always Base32, single part: 1 of 1 - #hdr = 'B$' + encoding + type_code + int2base36(num_parts) + int2base36(pkt) - hdr = 'B$2' + type_code + int2base36(1) + int2base36(0) + # XXX generalize + hdr = 'B$2%s0100' % type_code return hdr + b32encode(data) @@ -79,14 +80,15 @@ We will re-use same values as last try, unless you press (R) for new values to b settings.set("ktrx", b2a_hex(kp.privkey())) settings.save() - pubkey = kp.pubkey().to_bytes(False) # compressed format + pubkey = kp.pubkey().to_bytes() # default: compressed format + assert pubkey[0] in { 2, 3} - msg = '''You are starting a teleport of sensitive data from another COLDCARD.\n + msg = '''You are starting a teleport of sensitive data from another COLDCARD. \ It will be double-encrypted with AES-256-CTR using ECDH for one-time key and also \ -an optional passphrase.\n -You must show this QR code to the sender somehow. %s to show now''' % KEY_QR +a password.\n +Show the QR on next screen to the sender somehow. ENTER or %s to show here''' % KEY_QR - await tk_show_payload('R', pubkey, 'Key Teleport: Receive', 'Show to Sender', msg) + await tk_show_payload('R', pubkey, 'Key Teleport: Receive', cta='Show to Sender', msg=msg) async def tk_show_payload(type_code, pubkey, title, cta=None, msg=None): # show the QR and/or NFC @@ -116,41 +118,69 @@ async def tk_show_payload(type_code, pubkey, title, cta=None, msg=None): await nfc_push_kt(qr) elif ch == KEY_QR or ch == 'y': await show_qr_code(qr, is_alnum=True, msg=cta, force_msg=True, allow_nfc=False) - + if not msg: break elif ch == 'x': return +def valid_looking_pubkey(rx_pubkey): + try: + assert rx_pubkey[0] in { 2, 3} + assert len(rx_pubkey) == 33 + assert len(set(rx_pubkey)) > 3 + # check on curve? secp256k1.ecdh_multiply does that ? + return True + except: + # dont waste bytes on error messages for hackers + return False + async def kt_start_send(rx_pubkey): + # a QR was scanned and it held a pubkey # they want to send to this guy, ask them what to send, etc - msg = '''You can now teleport secrets. You can select from seed words, temporary keys, + + if not valid_looking_pubkey(rx_pubkey): return + + msg = '''You can now teleport secrets. You can select from seed words, temporary keys, \ secure notes and passwords. \ -WARNING: \ -The other COLDCARD will have full access to all Bitcoin controlled by these keys! +WARNING: Receiver will have full access to all Bitcoin controlled by these keys! ''' ch = await ux_show_story(msg, title="Key Teleport: Send") # TODO: pick what to send, somehow ... + menu = SecretPickerMenu(rx_pubkey) - body = b'w'+ (b'A'*16) + the_ux.push(menu) + +async def kt_do_send(rx_pubkey, dtype, raw=None, obj=None): + # Example: cleartext = b'w'+ (b'A'*16) + cleartext = dtype.encode() + (raw or json.dumps(obj).encode()) # Pick and show noid key to sender - noid_key = ngu.random.bytes(16) + noid_key, txt = pick_noid_key() - msg = "Share passphrase with receiver, via some different channel:" - msg += ux_render_words(bip39.b2a_words(noid_key).split()) - await ux_show_story(msg, 'Paranoid Key') + msg = "Share this password with the receiver, via some different channel:"\ + "\n\n %s = %s\n\n" % (txt, ' '.join(txt)) + msg += "ENTER to view QR" - # throw away entropy + # all new EC key my_keypair = ngu.secp256k1.keypair() - words = bip39.b2a_words(noid_key).split(' ') + payload = encode_payload(my_keypair, rx_pubkey, noid_key, cleartext) - payload = encode_payload(my_keypair, rx_pubkey, noid_key, body) + await tk_show_payload('S', payload, 'Teleport Password', cta='Show to Receiver', msg=msg) - await tk_show_payload('S', payload, None, 'Show to Receiver') + from flow import goto_top_menu + goto_top_menu() +def pick_noid_key(): + # pick an 40 bit password, shown as base32 + # - on rx, libngu base32 decoder will convert '018' into 'OLB' + # - but a little tempted to removed vowels here? + k = ngu.random.bytes(5) + txt = b32encode(k).upper() + + return k, txt async def kt_decode_rx(is_psbt, payload): # we are getting data back from a sender, decode it. @@ -159,45 +189,62 @@ async def kt_decode_rx(is_psbt, payload): rx_key = settings.get("ktrx") if not rx_key: await ux_show_story("Not expecting any teleports. You need to start over.") + + await kt_start_rx() # help them to start over? idk maybe not. return his_pubkey = payload[0:33] body = payload[33:] pair = ngu.secp256k1.keypair(a2b_hex(rx_key)) + else: randint = payload[0:4] body = payload[4:] # may need to iterate over a few wallets? - ses_key, body = await decode_step1(pair, his_pubkey, body) + ses_key, body = decode_step1(pair, his_pubkey, body) if not ses_key: - await ux_show_story("QR code is damaged or incorrect.\n\n" + body, title="Decode Fail") + # when ECDH fails, it's truncation or wrong RX key (due to sender using old rx key, etc) + await ux_show_story("QR code is damaged, or was sent to a different user. " + "Sender should start again.", title="Teleport Fail") return + from glob import dis while 1: # ask for noid key - words = await seed_word_entry('Paranoid Key', 12, has_checksum=True) - if not words: - noid_key = b'\x5a' * 16 - noid_key = bip39.a2b_words(words) + pw = await ux_input_text('', confirm_exit=False, hex_only=False, max_len=8, + prompt='Teleport Password', min_len=8, b39_complete=False, scan_ok=False, + placeholder='********', funct_keys=None, force_xy=None) - final = decode_step2(ses_key, noid_key, body) - if final is not None: - break + dis.progress_bar_show(0) + try: + assert len(pw) == 8 + noid_key = b32decode(pw) # case insenstive, and smart about confused chars + final = decode_step2(ses_key, noid_key, body) + if final is not None: + break + except: + pass + finally: + dis.progress_bar_show(1) - ch = await ux_show_story("Incorrect Paranoid Key. You can try again or CANCEL to stop.") + ch = await ux_show_story( + "Incorrect Teleport Password. You can try again or CANCEL to stop.") if ch == 'x': return # will ask again - await kt_accept_values(final[0].decode(), final[1:]) + # success w/ decoding. but maybe something goes wrong or they reject a confirm step + # so keep the rx key alive still + + await kt_accept_values(chr(final[0]), final[1:]) async def kt_accept_values(dtype, raw): - # got the secret, decode it more + # We got some secret, decode it more, and save it. ''' -- `w` - 12/18/24 words - 16/24/32 bytes follow -- `m` - (one byte of length) + (up to 71 bytes) - BIP-32 raw master secret [rare] +- `s` - secret, encoded per stash.py +- `m` - (up to 72 bytes?) - BIP-32 raw master secret [rare] - `r` - raw XPRV mode - 64 bytes follow which are the chain code then master privkey - `x` - XPRV mode, full details - 4 bytes (XPRV) + base58 *decoded* binary-XPRV follows - `n` - one or many notes export (JSON array) @@ -205,68 +252,74 @@ async def kt_accept_values(dtype, raw): - `p` - binary PSBT to be signed - `P` - a more-signed binary PSBT being returned back to sender ''' - from stash import SecretStash from chains import current_chain, slip32_deserialize + from flow import has_se_secrets, goto_top_menu enc = None - meta = 'Teleported' + origin = 'Teleported' + label = None + + if dtype == 's': + # words / bip 32 master / xprv, etc + enc = bytearray(72) + enc[0:len(raw)] = raw - if dtype == 'w': - # words. - assert len(raw) in { 16, 24, 32 } - enc = SecretStash.encode(seed_phrase=raw) - elif dtype == 'm': - enc = SecretStash.encode(master_secret=raw) - elif dtype == 'r': - assert len(raw) == 64 - enc = b'\x01' + raw elif dtype == 'x': # it's an XPRV, but in binary.. some extra data we throw away here; sigh - + # XXX no way to send this .. but was thinking of address explorer txt = ngu.codecs.b58_encode(raw) node, ch, _, _ = slip32_deserialize(txt) assert ch.name == chains.current_chain.name, 'wrong chain' - enc = SecretStash.encode(node=node) + enc = stash.SecretStash.encode(node=node) elif dtype in 'pP': # raw PSBT -- bigger from auth import sign_transaction psbt_len = len(raw) + # copy into PSRAM with SFFile(TXN_INPUT_OFFSET, max_size=psbt_len) as out: out.write(raw) - # this will take over UX w/ the signing process + # This will take over UX w/ the signing process sign_transaction(psbt_len, flags=0x0) return elif dtype in 'nv': # all are JSON things - js = loads(raw) + js = json.loads(raw) if dtype == 'v': # one key export from a seed vault - enc = a2b_hex(js[1]) - meta = js[2] + # - watch for incompatibility here if we ever change VaultEntry + from seed import VaultEntry + rec = VaultEntry(*js) + enc = deserialize_secret(rec.encoded) + origin = rec.origin + label = rec.label elif dtype == 'n': - # secure note(s) - from notes import import_from_json + # import secure note(s) + from notes import import_from_json, make_notes_menu, NoteContent + settings.remove_key("ktrx") # force new rx key after this point await import_from_json(dict(coldcard_notes=js)) - await ux_dramatic_pause('Imported.', 3) + await ux_dramatic_pause('Imported.', 2) - # TODO: force them into notes submenu so they see result? + # force them into notes submenu so they can see result right away + # - highlight to last note, which should be the just-added one(s) + goto_top_menu() + nm = await make_notes_menu() + nm.goto_idx(NoteContent.count()-1) + the_ux.push(nm) return else: raise ValueError(dtype) - # key material is arriving; offer to use as main secret or tmp or seed vault + # key material is arriving; offer to use as main secret, or tmp, or seed vault? assert enc - #summary = SecretStash.summary(enc[0]) - from flow import has_se_secrets, goto_top_menu from seed import set_ephemeral_seed, set_seed_value if not has_se_secrets(): @@ -274,50 +327,60 @@ async def kt_accept_values(dtype, raw): set_seed_value(encoded=enc) ok = True else: - ok = await set_ephemeral_seed(enc, meta=meta) + ok = await set_ephemeral_seed(enc, origin=origin, label=label) if ok: + settings.remove_key("ktrx") # force new rx key after this point goto_top_menu() +def noid_stretch(session_key, noid_key): + return ngu.hash.pbkdf2_sha512(session_key, noid_key, 5000)[0:32] def encode_payload(my_keypair, his_pubkey, noid_key, body): # do all the encryption assert len(his_pubkey) == 33 - assert len(noid_key) == 16 + assert len(noid_key) == 5 session_key = my_keypair.ecdh_multiply(his_pubkey) - b1 = aes256ctr.new(session_key).cipher(body) - b1 += ngu.hash.sha256s(b1)[-2:] + # stretch noid key out -- will be slow + pk = noid_stretch(session_key, noid_key) - b2 = aes256ctr.new(noid_key + session_key[16:]).cipher(b1) - b2 += ngu.hash.sha256s(b2)[-2:] + b1 = aes256ctr.new(pk).cipher(body) + b1 += ngu.hash.sha256s(body)[-2:] - return my_keypair.pubkey().to_bytes(True) + b2 + b2 = aes256ctr.new(session_key).cipher(b1) + b2 += ngu.hash.sha256s(b1)[-2:] + + return my_keypair.pubkey().to_bytes() + b2 def decode_step1(my_keypair, his_pubkey, body): - # Do ECDH and get out next layer of encryption + # Do ECDH and remove top layer of encryption try: - assert len(his_pubkey) == 33 + assert valid_looking_pubkey(his_pubkey) assert len(body) >= 10 session_key = my_keypair.ecdh_multiply(his_pubkey) - body = aes256ctr.new(session_key).cipher(payload[:-2]) - chk = sha256s(body)[-2:] - assert chk == payload[-2:], 'first checksum' - except Exception as exc: - ln = problem_file_line(exc) - return None, ln + rv = aes256ctr.new(session_key).cipher(body[:-2]) + chk = ngu.hash.sha256s(rv)[-2:] - return session_key, body + assert chk == body[-2:] # likely means wrong rx key, or truncation + except: + return None, None + + return session_key, rv def decode_step2(session_key, noid_key, body): - tk = noid_key + session_key[16:] - msg = aes256ctr.new(noid_key + session_key[16:]).cipher(body[:-2]) - chk = sha256(msg)[:-2] + # After we have the noid key, can decode true payload + assert len(noid_key) == 5 - return msg if chk == msg[-2:] else None + pk = noid_stretch(session_key, noid_key) + + msg = aes256ctr.new(pk).cipher(body[:-2]) + chk = ngu.hash.sha256s(msg)[-2:] + + return msg if chk == body[-2:] else None async def kt_incoming(type_code, payload): @@ -335,6 +398,100 @@ async def kt_incoming(type_code, payload): # incoming PSBT! return await kt_decode_rx(True, payload) - + else: + raise ValueError(type_code) + +class SecretPickerMenu(MenuSystem): + def __init__(self, rx_pubkey): + self.rx_pubkey = rx_pubkey + + from flow import word_based_seed, is_tmp + from stash import bip39_passphrase + has_notes = bool(settings.get('secnap', False)) + has_ms = bool(settings.get('multisig', False)) + has_sv = bool(settings.get('seedvault', False)) + + # Q-only feature, so menu can be W I D E + # - in increasing order of important / sensitivity! + m = [ + MenuItem('Multisig PSBT for Signing', predicate=has_ms), + MenuItem('Single Note / Password', predicate=has_notes, menu=self.pick_note_submenu), + MenuItem('Export All Notes & Passwords', predicate=has_notes, f=self.picked_note), + ] + + if has_sv: + m.append( MenuItem('From Seed Vault', menu=self.pick_vault_submenu) ) + + if is_tmp(): + # tmp seed, or maybe bip39 is in effect + # - all are the current master secret + msg = 'Temp Secret (words)' if word_based_seed() else ( + 'XPRV from Words+Passphrase' if bip39_passphrase else 'Temp XPRV Secret') + + else: + # real master secret + msg = 'Master Seed Words' if word_based_seed() else 'Master XPRV' + + m.append( MenuItem(msg, f=self.share_master_secret) ) + + super().__init__(m) + + async def pick_vault_submenu(self, *a): + # pick a secret from seed vault + from seed import SeedVaultChooserMenu + rec = await SeedVaultChooserMenu.pick() + if rec: + await kt_do_send(self.rx_pubkey, 'v', obj=list(rec)) + + async def pick_note_submenu(self, *a): + # Make a submenu to select a single note/password + from notes import NoteContent + + rv = [] + for note in NoteContent.get_all(): + rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), f=self.picked_note, arg=note)) + + return rv + + async def picked_note(self, _, _2, item): + # exporting note(s) + from notes import NoteContent + + if item.arg is None: + # export all + body = [n.serialize() for n in NoteContent.get_all()] + else: + # single note/password + body = [item.arg.serialize()] + + await kt_do_send(self.rx_pubkey, 'n', obj=body) + + async def share_master_secret(self, _, _2, item): + # altho menu items look different we are sharing same thing: + # - up to 72 bytes from secure elements + from stash import SensitiveValues, SecretStash, blank_object + + with stash.SensitiveValues(bypass_tmp=False, enforce_delta=True) as sv: + raw = bytearray(sv.secret) + xfp = xfp2str(sv.get_xfp()) + + # rtrim zeros + while raw[-1] == 0: + raw = raw[0:-1] + + summary = SecretStash.summary(raw[0]) + + from pincodes import pa + scale = 'your MASTER secret' if not pa.tmp_value else 'a temporary secret' + + msg = "Sharing %s [%s] (%s)." % (scale, xfp, summary) + msg += "\n\nWARNING: Allows full control over all associated Bitcoin!" + + if not await ux_confirm(msg): + blank_object(raw) + return + + await kt_do_send(self.rx_pubkey, 's', raw=raw) + # EOF diff --git a/shared/trick_pins.py b/shared/trick_pins.py index aea14165..3566e850 100644 --- a/shared/trick_pins.py +++ b/shared/trick_pins.py @@ -776,7 +776,7 @@ normal operation.''') # switch over to new secret! dis.fullscreen("Applying...") - await set_ephemeral_seed(encoded, meta=name) + await set_ephemeral_seed(encoded, origin=name) goto_top_menu() async def countdown_details(self, m, l, item): diff --git a/shared/utils.py b/shared/utils.py index 96bcbb1e..3ba60b38 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -551,10 +551,11 @@ def chunk_writer(fd, body): dis.progress_bar_show(1) -def pad_raw_secret(text_sec_str): +def deserialize_secret(text_sec_str): # Chip can hold 72-bytes as a secret - # every secret has 0th byte as marker - # then secret and padded to zero to AE_SECRET_LEN + # - has 0th byte as marker, secret and zero padding to AE_SECRET_LEN + # - also does hex to binary conversion + # - converse of: SecretStash.storage_serialize() from pincodes import AE_SECRET_LEN raw = bytearray(AE_SECRET_LEN) diff --git a/shared/xor_seed.py b/shared/xor_seed.py index b4f3a323..350355ca 100644 --- a/shared/xor_seed.py +++ b/shared/xor_seed.py @@ -8,11 +8,11 @@ import ngu, bip39, version from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause from ux import show_qr_code, ux_render_words, OK -from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed +from seed import word_quiz, WordNestMenu, set_seed_value, set_ephemeral_seed, seed_vault_iter from glob import settings from menu import MenuSystem, MenuItem from actions import goto_top_menu -from utils import encode_seed_qr, pad_raw_secret, xor +from utils import encode_seed_qr, deserialize_secret, xor from charcodes import KEY_QR from stash import SecretStash, blank_object, SensitiveValues, numwords_to_len, len_to_numwords @@ -203,13 +203,11 @@ async def xor_all_done(data): # )) # for i in enc_parts # ] - await set_ephemeral_seed( - enc, - meta='SeedXOR(%d parts, check: "%s")' % ( - num_parts, chk_word - ) - ) + await set_ephemeral_seed(enc, + origin='SeedXOR(%d parts, check: "%s")' % (num_parts, chk_word)) + goto_top_menu() + break class XORWordNestMenu(WordNestMenu): @@ -284,14 +282,14 @@ or press (2) for 18 words XOR.''' % OK, escape="12") # Add from Seed Vault? # filter only those that are correct length and type from seed vault opt = [] - for i, (xfp_str, hex_str, _, _) in enumerate(settings.master_get("seeds", [])): - raw = pad_raw_secret(hex_str) + for i, rec in seed_vault_iter(): + raw = deserialize_secret(rec.encoded) nw = SecretStash.is_words(raw) if nw and nw == desired_num_words: # it is words, and right length sk = SecretStash.decode_words(raw, bin_mode=True) - opt.append((i, xfp_str, sk)) + opt.append((i, rec.xfp, sk)) blank_object(raw)