secure notes and passwords

This commit is contained in:
Peter D. Gray 2024-01-11 16:07:42 -05:00
parent 7ac6915202
commit 17d0e7d345
No known key found for this signature in database
GPG Key ID: A2DCD558C2BE5D7C
10 changed files with 457 additions and 35 deletions

View File

@ -216,6 +216,10 @@ def restore_from_dict_ll(vals):
# saving into settings
continue
if k == 'notes' and not version.has_qwerty:
# Secure notes only supported on keyboard-equiped units
continue
settings.set(k, vals[key])
# write out

View File

@ -38,16 +38,18 @@ KEY_SYMBOL = '\x02'
KEY_DELETE = '\x08' # ^H = backspace
KEY_CLEAR = '\x15' # ^U = clear entry
# function keys, filling gaps, running out of space!
KEY_F1 = '\x0f'
KEY_F2 = '\x12'
KEY_F3 = '\x13'
KEY_F4 = '\x14'
KEY_F5 = '\x16'
KEY_F6 = '\x17'
# save memory on Mk4
if has_qwerty:
# function keys, filling gaps, running out of space!
KEY_F1 = '\x0f'
KEY_F2 = '\x12'
KEY_F3 = '\x13'
KEY_F4 = '\x14'
KEY_F5 = '\x16'
KEY_F6 = '\x17'
KEYS_FUNCTION = KEY_F1 + KEY_F2 + KEY_F3 + KEY_F4 + KEY_F5 + KEY_F6
NUM_ROWS = const(6)
NUM_COLS = const(10)

View File

@ -47,6 +47,7 @@ still backed-up.''')
if not await ux_confirm(msg):
return
# XXX changes in this order will break lots of stuff
choices = [ '12 words', '18 words', '24 words', 'WIF (privkey)',
'XPRV (BIP-32)', '32-bytes hex', '64-bytes hex', 'Passwords']
@ -112,7 +113,11 @@ def bip85_pwd(secret):
secret_b64 = b2a_base64(secret).decode().strip()
return secret_b64[:BIP85_PWD_LEN]
async def drv_entro_step2(_1, picked, _2):
async def pick_bip85_password():
# ask for index and then return the pw (see notes.py)
return await drv_entro_step2(None, 7, None, just_pick=True)
async def drv_entro_step2(_1, picked, _2, just_pick=False):
from glob import dis
from files import CardSlot, CardMissingError, needs_microsd
@ -127,6 +132,9 @@ async def drv_entro_step2(_1, picked, _2):
dis.fullscreen("Working...")
new_secret, width, s_mode, path = bip85_derive(picked, index)
if just_pick:
return bip85_pwd(new_secret)
# Reveal to user!
encoded = None
chain = chains.current_chain()

View File

@ -34,14 +34,16 @@ else:
hsm_feature = lambda: False
make_users_menu = lambda: []
# Battery related items
# Q related items
if version.has_battery:
from battery import battery_idle_timeout_chooser, brightness_chooser
from q1 import scan_and_bag
from notes import make_notes_menu
else:
battery_idle_timeout_chooser = None
brightness_chooser = None
scan_and_bag = None
make_notes_menu = None
#
@ -298,6 +300,8 @@ AdvancedNormalMenu = [
MenuItem('Export Wallet', predicate=has_secrets, menu=WalletExportMenu), # also inside FileMgmt
MenuItem("Upgrade Firmware", menu=UpgradeMenu, predicate=is_not_tmp),
MenuItem("File Management", menu=FileMgmtMenu),
NonDefaultMenuItem('Secure Notes & Passwords', 'notes', menu=make_notes_menu,
predicate=lambda: version.has_qwerty),
MenuItem('Derive Seed B85', f=drv_entro_start),
MenuItem("View Identity", f=view_ident),
MenuItem("Temporary Seed", menu=make_ephemeral_seed_menu),
@ -361,6 +365,8 @@ NormalSystem = [
menu=NFCToolsMenu, shortcut=KEY_NFC),
MenuItem('Start HSM Mode', f=start_hsm_menu_item, predicate=hsm_policy_available),
MenuItem("Address Explorer", f=address_explore, shortcut='x'),
MenuItem('Secure Notes & Passwords', menu=make_notes_menu, shortcut='n',
predicate=lambda: (settings.get("notes", False) != False)),
MenuItem('Type Passwords', f=password_entry, shortcut='t',
predicate=lambda: settings.get("emu", False) and has_secrets()),
MenuItem('Seed Vault', menu=make_seed_vault_menu, shortcut='v',

View File

@ -16,6 +16,7 @@ freeze_as_mpy('', [
'trick_pins.py',
'ux_q1.py',
'battery.py',
'notes.py',
], opt=0)
# Optimize data-like files, since no need to debug them.

View File

@ -122,7 +122,7 @@ class NonDefaultMenuItem(MenuItem):
from glob import settings
s = settings
return bool(s.get(self.nvkey, self.def_value))
return (s.get(self.nvkey, self.def_value) != self.def_value)
class ToggleMenuItem(MenuItem):
@ -412,7 +412,5 @@ class MenuSystem:
if self.items[n].label[0].upper() == key.upper():
self.goto_idx(n)
break
else:
print("Unused menu key: %s=0x%02x" % (key, ord(key)))
# EOF

380
shared/notes.py Normal file
View File

@ -0,0 +1,380 @@
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# notes.py - Store some short notes, securely.
#
import ngu, bip39
from menu import MenuItem, MenuSystem
from ux import ux_show_story, the_ux, ux_dramatic_pause, ux_confirm, the_ux
from ux import PressRelease, ux_input_numbers, ux_input_text, show_qr_code
from actions import goto_top_menu
from glob import settings, dis
from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR, KEY_ENTER, KEY_CANCEL, KEY_CLEAR
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
ONE_LINE = CHARS_W-2
async def make_notes_menu(*a):
if settings.get('notes', False) == False:
# Explain feature, and then enable if interested. Drop them into menu.
ch = await ux_show_story('''\
Enable this feature to store short text notes and passwords inside the Coldcard.
The notes are encrypted along with your other settings and will be backed-up with them.
Press ENTER to enable and get started otherwise CANCEL.''',
title="Secure Notes")
if ch != 'y':
return
# mark as enabled (altho empty)
settings.set('notes', [])
# need to correct top menu now, so this choice is there.
return NotesMenu(NotesMenu.construct())
async def get_a_password(old_value):
# Get a (new) password as a string.
# - does some fun generation as well.
from seed import generate_seed
from drv_entro import bip85_pwd, pick_bip85_password
from random import randbelow, shuffle
async def _pick_12(was):
# 128 bits
seed = generate_seed()[0:16]
return bip39.b2a_words(seed)
async def _pick_24(was):
# 256 bits
seed = generate_seed()
return ' '.join(w[0:4] for w in bip39.b2a_words(seed).split())
async def _pick_dense(was):
# 126 bits, 21 chars ... base64 but no symbols
seed = generate_seed() + generate_seed()
return bip85_pwd(seed).replace('+', 'P').replace('/', 's')
async def _do_dumb(was):
# mixed case, number and symbol for bullshit site rules
# entropy: 11+11 + (3.8*3) + 16 = 49 bits
rv = ''
for n in range(2):
w = bip39.wordlist_en[randbelow(2048)]
rv += w[0].upper() + w[1:]
s = list('!@#$%^&*-=|+~?') # opinionated
shuffle(s)
rv += ''.join(s[0:3])
rv += '%04d' % randbelow(100000)
return rv
async def _bip85(was):
s = dis.save_state()
rv = await pick_bip85_password()
dis.restore_state(s)
return rv
def _toggle_case(was):
# undocumented
return was.upper() if was[0].islower() else was.lower()
fmsg = (KEY_F1 + ' 12 ' + KEY_F2 + ' 24 word '
+ KEY_F3 + KEY_F4 + ' random '
+ KEY_F5 + 'B85')
handlers = {KEY_F1: _pick_12, KEY_F2: _pick_24, KEY_F3: _pick_dense,
KEY_F4: _do_dumb, KEY_F6: _toggle_case, KEY_F5: _bip85}
return await ux_input_text(old_value, confirm_exit=True, max_len=128, scan_ok=True,
b39_complete=True, prompt='Password', placeholder='(optional)',
funct_keys=(fmsg, handlers))
class NotesMenu(MenuSystem):
@classmethod
def construct(cls):
# Dynamic menu with user-defined names of notes shown
news = [ MenuItem('New Note', f=cls.new_note, arg='n'),
MenuItem('New Password', f=cls.new_note, arg='p') ]
if not NoteContent.count():
rv = news + [ MenuItem('Disable Feature', f=cls.disable_notes) ]
else:
rv = []
for note in NoteContent.get_all():
rv.append(MenuItem('%d: %s' % (note.idx+1, note.title), menu=note.make_menu))
rv.extend(news)
rv.append(MenuItem('Import from File', f=None))
return rv
def update_contents(self):
# Reconstruct the list of notes on this dynamic menu, because
# we added or changed them and are showing that same menu again.
tmp = self.construct()
self.replace_items(tmp)
@classmethod
async def disable_notes(cls, *a):
# they don't want feature anymore; already checked no notes in effect
# - no need for confirm, they aren't loosing anything
settings.remove_key('notes')
settings.save()
from actions import goto_top_menu
goto_top_menu()
@classmethod
async def new_note(cls, menu, _, item):
# Create a new note. Wizard style
tmp = PasswordContent() if item.arg == 'p' else NoteContent()
await tmp.edit(menu, _, item)
class NoteContentBase:
def __init__(self, json={}, idx=-1):
# no args will make a blank record, else we are deserializing json
for fld in self.flds:
setattr(self, fld, json.get(fld, ''))
self.idx = idx
def serialize(self):
return {fld:getattr(self, fld, '') for fld in self.flds}
@classmethod
def get_all(cls):
# list of all notes/passwords
rv = []
for idx, j in enumerate(settings.get('notes', [])):
rv.append(PasswordContent(j, idx) if 'user' in j else NoteContent(j, idx))
return rv
@classmethod
def count(cls):
# how many do we have?
return len(settings.get('notes', []))
async def delete(self, *a):
# Remove note
ok = await ux_confirm("Everything about this note/password will be lost.")
if not ok:
await ux_dramatic_pause('Aborted.', 3)
return
was = list(settings.get('notes', []))
assert self.idx >= 0
assert self.idx < len(was)
del was[self.idx]
settings.put('notes', was)
settings.save()
# go to (updated) parent menu
the_ux.pop()
m = the_ux.top_of_stack()
m.update_contents()
await ux_dramatic_pause('Deleted.', 3)
async def export(self, *a):
pass
async def _save_ux(self, menu):
is_new = self.save()
if not is_new:
# change our own menu (only one thing: title line)
menu.items[0].label = '"%s"' % self.title
# update parent
parent = the_ux.parent_of(menu)
parent.update_contents()
else:
menu.update_contents()
await ux_dramatic_pause('Saved.', 3)
def save(self):
was = list(settings.get('notes', []))
if self.idx == -1:
was.append(self.serialize())
self.idx = len(was)-1
is_new = True
else:
was[self.idx] = self.serialize()
is_new = False
settings.put('notes', was)
settings.save()
return is_new
class PasswordContent(NoteContentBase):
# "Passwords" have a few more fields and are more structured
flds = ['title', 'user', 'password', 'site', 'misc' ]
async def make_menu(self, *a):
return [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Password', f=self.view_pw),
MenuItem('Change Password', f=self.change_pw),
MenuItem('Send Password', f=self.send_pw),
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
async def view(self, *a):
pl = len(self.password)
m = 'Site: %s''' % self.site
m = 'User: %s''' % self.user
m += '\nPassword: (%d chars long)' % pl
if self.misc:
m += '\nNotes:\n' + self.misc
await ux_show_story(m, title=self.title)
async def change_pw(self, *a):
# change password
npw = await get_a_password(self.password)
if npw == self.password: return
msg = 'Old Password:\n%s\n\nNew Password:\n%s' % (
self.password or '<EMPTY>', npw or '<EMPTY>')
ch = await ux_show_story(msg, title='Confirm Change')
if ch == 'y':
self.password = npw
self.save()
await ux_dramatic_pause('Saved.', 3)
else:
await ux_dramatic_pause('Aborted.', 3)
async def view_pw(self, *a):
msg = self.password or '<EMPTY>'
msg += '\n\nPress (1) to change, (6) to send over USB.'
ch = await ux_show_story(msg, title='Password', escape='16')
if ch == '1':
await self.change_pw()
elif ch == '6':
await self.send_pw()
async def send_pw(self, *a):
pass
async def edit(self, menu, _, item):
# Edit, also used for add new
title = await ux_input_text(self.title, confirm_exit=False, max_len=ONE_LINE,
prompt='Title', placeholder='(required for menu)')
if not title:
return
# blank is OK for all other values
user = await ux_input_text(self.site, confirm_exit=True, max_len=ONE_LINE, scan_ok=True,
prompt='Username', placeholder='(optional)')
if self.idx == -1:
# prompt for password only on new records.
self.password = await get_a_password(self.password)
site = await ux_input_text(self.site, confirm_exit=True, max_len=ONE_LINE, scan_ok=True,
prompt='Website', placeholder='(optional)')
misc = await ux_input_text(self.misc, confirm_exit=True, max_len=None, scan_ok=True,
prompt='More Notes', placeholder='(optional)')
if self.idx != -1:
# confirm changes, don't for new records
chgs = []
if self.title != title:
chgs.append('Title')
if self.site != site:
chgs.append('Site Name')
if self.user != user:
chgs.append('Username')
if self.misc != misc:
chgs.append('Other Notes')
if not chgs:
await ux_dramatic_pause('No changes.', 3)
return
ok = await ux_confirm("Save changes?\n- " + ('\n - '.join(chgs)))
if not ok:
return
self.title = title
self.site = site
self.misc = misc
self.user = user
await self._save_ux(menu)
class NoteContent(NoteContentBase):
# Pure "notes" have just a title and free-form text
flds = ['title', 'misc' ]
async def make_menu(self, *a):
# details and actions for this Note
# details and actions for this Note
return [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Notes', f=self.view),
#MenuItem('Send Password', f=self.send_pw),
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
]
async def view(self, *a):
await ux_show_story(self.misc, title=self.title)
async def edit(self, menu, _, item):
# Edit, also used for add new
title = await ux_input_text(self.title, confirm_exit=False, max_len=CHARS_W-2,
prompt='Title', placeholder='(required for menu)')
if not title:
return
# blank is OK for all other values
misc = await ux_input_text(self.misc, confirm_exit=True, max_len=None, scan_ok=True,
prompt='Your Notes', placeholder='(freeform text)')
if self.idx != -1:
# confirm changes, don't for new records
chgs = []
if self.title != title:
chgs.append('Title')
if self.misc != misc:
chgs.append('Note Text')
if not chgs:
await ux_dramatic_pause('No changes.', 3)
return
ok = await ux_confirm("Save changes?\n- " + ('\n - '.join(chgs)))
if not ok:
return
self.title = title
self.misc = misc
await self._save_ux(menu)
# EOF

View File

@ -56,6 +56,7 @@ from version import is_devmode
# seedvault = (bool) opt-in enable seed vault feature
# seeds = list of stored secrets for seedvault feature
# bright = (int:0-255) LCD brightness when on battery
# notes = (complex) Secure notes held for user
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)

View File

@ -1175,7 +1175,7 @@ class PassphraseMenu(MenuSystem):
# let them control each character
global pp_sofar
pw = await ux_input_text(pp_sofar, prompt="Your BIP-39 Passphrase",
b39_complete=True, max_len=100)
b39_complete=True, scan_ok=True, max_len=100)
if pw is not None:
pp_sofar = pw
self.check_length()

View File

@ -128,38 +128,35 @@ async def ux_input_numbers(val, validate_func):
pass
async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=True):
prompt='Enter value', min_len=0, b39_complete=False, scan_ok=False,
placeholder=None, funct_keys=None):
# Get a text string.
# - Should allow full unicode, NKDN
# - but our font is mostly just ascii
# - no control chars allowed either
# - TODO: press QR -> do scan and use that text
# - press QR -> do scan and use that text
# - funct_keys => CTA msg, and map of Fn key to async-function which takes and returns new text
# - TODO: regex validation for derviation paths?
# - TODO: arrowing around
from glob import dis
from ux import ux_show_story
MAX_LINES = 7
value = value or ''
dis.clear()
if b39_complete:
dis.text(None, -2, KEY_TAB + " to auto-complete. " + KEY_QR + " to scan.")
dis.text(None, -1, "CANCEL or ENTER when done.")
# TODO:
# - left/right to edit in middle
# - multi line support
# - add prompt text?
# map from what they entered, to allowed char. None if not allowed char
# - can case fold if desired
ch_remap = lambda ch: ch if ' ' <= ch < chr(127) else None
if hex_only:
ch_remap = lambda ch: ch.lower() if ch in '0123456789abcdefABCDEF' else None
line_len = CHARS_W-2
y = 2
if max_len <= CHARS_W-2:
if max_len is None:
# whatever the max is we can support
num_lines = MAX_LINES
max_len = num_lines * line_len
elif max_len <= line_len:
# single-line or perhaps shorter value
line_len = max_len
num_lines = 1
@ -170,15 +167,35 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
num_lines = 4
else:
# multi-line mode: just do a box for most of screen
num_lines = 6
line_len = CHARS_W-2
num_lines, runt = divmod(max_len, line_len)
if runt:
num_lines += 1
assert num_lines <= MAX_LINES, num_lines # too big to fit w/o scroll
dis.clear()
if funct_keys:
msg, funct_keys = funct_keys
dis.text(None, -2, msg, dark=True)
if b39_complete or scan_ok:
msg = []
if b39_complete:
msg.append(KEY_TAB + " to auto-complete.")
if scan_ok:
msg.append(KEY_QR + " to scan.")
dis.text(None, -1, ' '.join(msg), dark=True)
elif num_lines <= 2:
# show this dumb CTA only if screen mostly blank
dis.text(None, -1, "CANCEL or ENTER when done.", dark=True)
dis.text(None, y-2, prompt)
x = dis.draw_box(None, y-1, line_len, num_lines)
x = dis.draw_box(None, y-1, line_len, num_lines, dark=True)
# NOTE:
# - x,y here are top left of entry area
# - not allow cursor movement, always appending to end
# - does not allow cursor movement, always appending to end
# no key-repeat on certain keys
err_msg = last_err = None
@ -199,6 +216,8 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
if not value:
bx = 0
n = 0
if placeholder:
dis.text(x, y, placeholder, dark=True)
else:
for n, ln_pos in enumerate(range(0, len(value), line_len)):
ln = value[ln_pos:ln_pos+line_len]
@ -206,9 +225,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
bx = len(ln)
# decide cursor appearance
cur = CursorSpec(x+bx, y+n, CURSOR_SOLID)
cur = CursorSpec(x+bx, y+n, CURSOR_OUTLINE)
if cur.x >= x+line_len:
# outline mode if on final possible location
# if on final possible location, adjust over top of final char
cur = CursorSpec(x+line_len-1, y+n, CURSOR_OUTLINE)
dis.show(cursor=cur)
@ -301,6 +320,9 @@ async def ux_input_text(value, confirm_exit=True, hex_only=False, max_len=100,
else:
err_msg = 'Need more letters.'
elif funct_keys and (ch in funct_keys):
# replace w/ function output ... might do a transform, or not
value = await funct_keys[ch](value)
else:
ch = ch_remap(ch)
if ch is not None: