firmware/shared/notes.py

603 lines
20 KiB
Python

# (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, ShortcutItem
from ux import ux_show_story, ux_dramatic_pause, ux_confirm, the_ux
from ux import ux_input_text, show_qr_code, import_export_prompt
from ux_q1 import QRScannerInteraction
from actions import goto_top_menu
from glob import settings, dis
from files import CardMissingError, needs_microsd, CardSlot
from charcodes import KEY_QR, KEY_NFC, KEY_CANCEL
from charcodes import KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6
from lcd_display import CHARS_W
from utils import problem_file_line, url_decode
# title, username and such are limited that they fit on the one line both in
# text entry (W-2) and also in menu display (W-3)
# - but W-3 is not centered .. so just lose some extra chars on right side if too long in menu
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.
goto_top_menu()
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
async def _toggle_case(was):
# undocumented, not very useful
if not was: return ''
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=False, 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'),
ShortcutItem(KEY_QR, f=cls.quick_create)]
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('Export All', f=cls.export_all))
rv.append(MenuItem('Import', f=import_from_other))
return rv
@classmethod
async def export_all(cls, *a):
await start_export(NoteContent.get_all())
@classmethod
async def quick_create(cls, menu, _, item):
# using QR, created a Note (never a password) with auto-generated title.
# - we are auto-detecting some common QR formats here but only to get a title
tmp = NoteContent()
tmp.title = 'Scanned'
zz = QRScannerInteraction()
got = await zz.scan_text('Scan any QR or Barcode for text.')
if not got or len(got) < 5: return
# aways save it, and attempt to guess a nice name for it too
tmp.misc = got
if got.startswith('otpauth://totp/'):
# see <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
tmp.title = url_decode(got[15:]).split('?', 1)[0]
elif got.startswith('otpauth-migration://offline'):
# see <https://github.com/qistoph/otp_export>
tmp.title = 'Google Auth'
elif '://' in got[0:20]:
# might be a URL, try to get the domain name as title
try:
tmp.title = (got.split('://', 1)[1].split('/', 1)[0])[0:32]
except:
tmp.title = 'Scanned URL'
await tmp._save_ux(menu)
await cls.drill_to(menu, tmp)
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()
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()
didit = await tmp.edit(menu, _, item)
if didit:
await cls.drill_to(menu, tmp)
@classmethod
async def drill_to(cls, menu, item):
# make it so looks like we drilled down into the new note
menu.goto_idx(item.idx)
m = MenuSystem(await item.make_menu())
the_ux.push(m)
class NoteContentBase:
def __init__(self, json={}, idx=-1):
# no args will make a blank record, else we are deserializing json
# - called only by subclasses
for fld in self.flds:
setattr(self, fld, json.get(fld, ''))
self.idx = idx
@classmethod
def constructor(cls, j, idx):
# create correct class based on JSON content
return PasswordContent(j, idx) if 'user' in j else NoteContent(j, idx)
def serialize(self):
return {fld:getattr(self, fld, '') for fld in self.flds}
to_json = serialize
@classmethod
def get_all(cls):
# list of all notes/passwords
rv = []
for idx, j in enumerate(settings.get('notes', [])):
rv.append(cls.constructor(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 share_nfc(self, menu, _, item):
# share something via NFC -- if small enough and enabled
from glob import NFC
if not NFC: return
v = getattr(self, item.arg)
if len(v) < 8000: # see MAX_NFC_SIZE
await NFC.share_text(v)
async def _save_ux(self, menu):
is_new = self.save()
if not is_new:
# change our own menu contents
menu.replace_items(await self.make_menu())
# 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
async def export(self, *a):
# single export
await start_export([self])
class PasswordContent(NoteContentBase):
# "Passwords" have a few more fields and are more structured
flds = ['title', 'user', 'password', 'site', 'misc' ]
type_label = 'password'
async def make_menu(self, *a):
rv = [MenuItem('"%s"' % self.title, f=self.view)]
if self.user:
rv.append(MenuItem('%s' % self.user, f=self.view))
if self.site:
rv.append(MenuItem('%s' % self.site, f=self.view))
#if self.misc: rv.append(MenuItem('↳ (notes)', f=self.view))
return rv + [
MenuItem('View Password', f=self.view_pw),
MenuItem('Send Password', f=self.send_pw, predicate=lambda: settings.get('du', True)),
MenuItem('Export', f=self.export),
MenuItem('Edit Metadata', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Change Password', f=self.change_pw),
ShortcutItem(KEY_QR, f=self.view_qr),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='password'),
]
async def view(self, *a):
pl = len(self.password)
m = ''
if self.user:
m += 'User: %s\n' % self.user
m += 'Password: (%d chars)\n' % pl
if self.site:
m += 'Site: %s\n' % self.site
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
if npw is None: return
msg = 'New Password:\n%s\n\nOld Password:\n%s' % (
npw or '<EMPTY>', self.password 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>'
ch = await ux_show_story(msg, title=self.title, escape=KEY_QR, hint_icons=KEY_QR)
if ch == KEY_QR:
await self.view_qr()
async def send_pw(self, *a):
# use USB to send it -- weak at present
from drv_entro import single_send_keystrokes
from usb import EmulatedKeyboard
if not EmulatedKeyboard.can_type(self.password):
return await ux_show_story("Sorry, your password contains a character that "
"we cannot type at this time.")
await single_send_keystrokes(self.password)
async def view_qr(self, *a):
# full screen QR
await show_qr_code(self.password, msg=self.title)
async def edit(self, menu, _, item):
# Edit, also used for add new
title = await ux_input_text(self.title, max_len=ONE_LINE, confirm_exit=False,
prompt='Title', placeholder='(required for menu)')
if not title:
return None
# blank is OK for all other values
user = await ux_input_text(self.user, max_len=ONE_LINE, scan_ok=True, confirm_exit=False,
prompt='Username', placeholder='(optional)')
if user is None:
user = self.user
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, max_len=ONE_LINE, scan_ok=True, confirm_exit=False,
prompt='Website', placeholder='(optional)')
if site is None:
site = self.site
misc = await ux_input_text(self.misc, max_len=None, scan_ok=True, confirm_exit=False,
prompt='More Notes', placeholder='(optional)')
if misc is None:
misc = self.misc
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 None
self.title = title
self.user = user
self.site = site
self.misc = misc
await self._save_ux(menu)
return self
class NoteContent(NoteContentBase):
# Pure "notes" have just a title and free-form text
flds = ['title', 'misc']
type_label = 'note'
async def make_menu(self, *a):
# Details and actions for this Note
return [
MenuItem('"%s"' % self.title, f=self.view),
MenuItem('View Note', f=self.view),
MenuItem('Edit', f=self.edit),
MenuItem('Delete', f=self.delete),
MenuItem('Export', f=self.export),
ShortcutItem(KEY_QR, f=self.view_qr),
ShortcutItem(KEY_NFC, f=self.share_nfc, arg='misc'),
]
async def view(self, *a):
ch = await ux_show_story(self.misc, title=self.title, escape=KEY_QR, hint_icons=KEY_QR)
if ch == KEY_QR:
await self.view_qr()
async def view_qr(self, *a):
# full screen QR
try:
await show_qr_code(self.misc, msg=self.title)
except Exception as exc:
# - not all data can be a QR (non-text, binary, zeros)
# - might be too big for single QR
# - may be a RuntimeError(n) where n is line number inside uqr
await ux_show_story("Unable to display as QR.\n\nError: "+str(exc))
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
misc = await ux_input_text(self.misc, confirm_exit=False,
max_len=None, scan_ok=True,
prompt='Your Notes', placeholder='(freeform text)')
if misc is None:
misc = self.misc
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:
await ux_dramatic_pause('Not saved. Change aborted.', 3)
return
self.title = title
self.misc = misc
await self._save_ux(menu)
return self
async def start_export(notes):
# Save out notes/passwords
from glob import NFC
from auth import write_sig_file
import ujson as json
from ux_q1 import show_bbqr_codes
singular = (len(notes) == 1)
item = notes[0].type_label if singular else 'all notes & passwords'
choice = await import_export_prompt(item, is_import=False, title="Data Export", no_nfc=True,
footnotes="\n\nWARNING: No encryption happens here. "
"Your secrets will be cleartext.")
if choice == KEY_CANCEL:
return
# render it
data = json.dumps(dict(coldcard_notes=[i.serialize() for i in notes]))
if choice == KEY_QR:
# Always do BBRq.
await show_bbqr_codes('J', data, 'Notes & Passwords Export')
return
# ideally, we'd use the title to make a filename, but meh...
fname_pattern = 'cc-notes.json' if not singular else ('cc-%s.json' % notes[0].type_label)
try:
with CardSlot(**choice) as card:
fname, nice = card.pick_filename(fname_pattern)
with open(fname, 'w+') as fp:
fp.write(data)
h = ngu.hash.sha256s(data)
sig_nice = write_sig_file([(h, fname)])
except CardMissingError:
await needs_microsd()
return
except Exception as e:
await ux_show_story('Failed to write!\n\n\n'+str(e))
return
msg = 'Export file written:\n\n%s\n\nSignature file written:\n\n%s' % (
nice, sig_nice
)
await ux_show_story(msg)
async def import_from_other(menu, *a):
# Suck in a bunch of notes/passwords. Has to be coming from a Coldcard
# - but it's also just simple JSON
from actions import file_picker
import json
choice = await import_export_prompt('secure notes and/or passwords', no_nfc=True,
is_import=True, title='Data Import')
if choice == KEY_CANCEL:
return
elif choice == KEY_QR:
# Always do BBRq.
zz = QRScannerInteraction()
records = await zz.scan_json('Scan BBQr from other COLDCARD.')
if records is None: return
else:
def contains_json(fname):
if not fname.endswith('.json'): return False
print(fname)
try:
obj = json.load(open(fname, 'rt'))
assert 'coldcard_notes' in obj
return True
except Exception as exc:
import sys; sys.print_exception(exc)
pass
fn = await file_picker(min_size=8, max_size=100000, taster=contains_json, **choice)
if not fn: return
with CardSlot(readonly=True, **choice) as card:
records = json.load(open(fn, 'rt'))
# We have some JSON, parsed now.
# - should dedup, but we aren't
try:
assert 'coldcard_notes' in records, 'Incorrect format'
# de-and-re-serialize each one (just in case? backwards compat?)
new = [NoteContentBase.constructor(rec, -1).serialize()
for rec in records['coldcard_notes']]
was = list(settings.get('notes', []))
was.extend(new)
settings.put('notes', was)
settings.save()
except Exception as e:
await ux_show_story(title="Failure", msg=str(e) + '\n\n' + problem_file_line(e))
await ux_dramatic_pause('Saved.', 3)
menu.update_contents()
# EOF