# (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 tmp.title = url_decode(got[15:]).split('?', 1)[0] elif got.startswith('otpauth-migration://offline'): # see 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 '', self.password or '') 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 '' 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