Seedvault refactor, more on KT

This commit is contained in:
Peter D. Gray 2025-03-20 11:28:38 -04:00 committed by doc-hex
parent 0aa0fc4500
commit ec64a9aa38
14 changed files with 378 additions and 210 deletions

View File

@ -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.

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)))

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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)