MicroSD as 2FA feature

This commit is contained in:
Peter D. Gray 2023-02-08 10:03:49 -05:00
parent c0b106a0ca
commit 07d31cc68d
No known key found for this signature in database
GPG Key ID: F0E6CC6AFC16CF7B
6 changed files with 244 additions and 9 deletions

View File

@ -1,5 +1,8 @@
## 5.1.0 - 2023-02-08
- New Feature: "MicroSD card as Second Factor". Specially marked MicroSD card must be
already inserted when (true) PIN is entered, or else seed is wiped. Add, remove and check
cards in menu: Settings -> Login Settings -> MicroSD 2FA
- New Feature: Single signature wallet generic descriptor export
`Advanced -> Export Wallet -> Descriptor`. Both new format with internal/external
in one descriptor `<0;1>` and standard with two descriptors are supported.

View File

@ -4,7 +4,7 @@
#
# Every function here is called directly by a menu item. They should all be async.
#
import ckcc, pyb, version, uasyncio
import ckcc, pyb, version, uasyncio, sys
from ux import ux_show_story, the_ux, ux_confirm, ux_dramatic_pause, ux_aborted, ux_enter_bip32_index, ux_input_text
from utils import imported, pretty_short_delay, problem_file_line, import_prompt_builder
from uasyncio import sleep_ms
@ -855,7 +855,7 @@ async def start_login_sequence():
except BaseException as exc:
# Robustness: any logic errors/bugs in above will brick the Coldcard
# even for legit owner, since they can't login. Try to recover, when it's
# even for legit owner, since they can't login. To try to recover, when it's
# safe to do so. Remember the bootrom checks PIN on every access to
# the secret, so "letting" them past this point is harmless if they don't know
# the true pin.
@ -863,7 +863,6 @@ async def start_login_sequence():
raise
print("Bug recovery!")
import sys
sys.print_exception(exc)
# Successful login...
@ -879,6 +878,14 @@ async def start_login_sequence():
except:
pass
# Maybe insist on the "right" microSD being already installed?
try:
from pwsave import MicroSD2FA
MicroSD2FA.enforce_policy()
except BaseException as exc:
# robustness: keep going!
sys.print_exception(exc)
# implement idle timeout now that we are logged-in
from imptask import IMPT
IMPT.start_task('idle', idle_logout())
@ -2088,5 +2095,16 @@ async def change_which_chain(name):
# no secrets yet, not an error
pass
async def microsd_2fa(*a):
# Feature: enforce special MicroSD being inserted at login time (a 2FA)
from pwsave import MicroSD2FA
if not settings.get('sd2fa'):
ch = await ux_show_story('''When enabled, this feature requires a specially prepared MicroSD card to be inserted during login process. After correct PIN is provided, if card slot is empty or unknown card present, the seed is wiped.''')
if ch != 'y':
return
return MicroSD2FA.menu()
# EOF

View File

@ -114,6 +114,7 @@ LoginPrefsMenu = [
MenuItem('Scramble Keypad', f=pick_scramble),
MenuItem('Kill Key', f=pick_killkey, predicate=lambda: version.has_se2),
MenuItem('Login Countdown', chooser=countdown_chooser),
MenuItem('MicroSD 2FA', menu=microsd_2fa, predicate=lambda: version.has_se2 and has_secrets()),
MenuItem('Test Login Now', f=login_now, arg=1),
]

View File

@ -55,6 +55,7 @@ from glob import PSRAM
# emu = (bool) if set, enables the USB Keyboard emulation (BIP-85 password entry)
# wa = (bool) if set, enables menu wraparound
# hsmcmd = (bool) if set, enables all user management and hsm-only USB commands
# sd2fa = (list of strings): track which SD card is needed for login
# Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug)
# nick = optional nickname for this coldcard (personalization)

View File

@ -2,8 +2,9 @@
#
# pwsave.py - Save bip39 passphrases into encrypted file on MicroSD (if desired)
#
import sys, stash, ujson, os, ngu
import sys, stash, ujson, os, ngu, pyb
from files import CardSlot, CardMissingError, needs_microsd
from ux import ux_dramatic_pause, ux_confirm, ux_show_story
class PassphraseSaver:
# Encrypts BIP-39 passphrase very carefully, and appends
@ -16,9 +17,10 @@ class PassphraseSaver:
# - some very minor obscurity, but we aren't relying on that.
return card.get_sd_root() + '/.tmp.tmp'
def _calc_key(self, card):
def _calc_key(self, card, force=False):
# calculate the key to be used.
if getattr(self, 'key', None): return
if not force and getattr(self, 'key', None):
return
try:
salt = card.get_id_hash()
@ -35,16 +37,20 @@ class PassphraseSaver:
decrypt = ngu.aes.CTR(self.key)
try:
msg = open(self.filename(card), 'rb').read()
fname = self.filename(card)
msg = open(fname, 'rb').read()
txt = decrypt.cipher(msg)
return ujson.loads(txt)
except OSError:
#print('missing? ' + fname)
return []
except:
return []
async def append(self, xfp, bip39pw):
# encrypt and save; always appends.
from ux import ux_dramatic_pause
from glob import dis
while 1:
@ -79,7 +85,6 @@ class PassphraseSaver:
from actions import goto_top_menu
from ux import ux_show_story
from seed import set_bip39_passphrase
import pyb
# Very quick check for card not present case.
if not pyb.SDCard().present():
@ -143,5 +148,211 @@ class PassphraseSaver:
return MenuSystem((MenuItem(label or '(empty)', f=doit, arg=pw) for pw, label in zip(pws, parts)))
#
# Support for using MicroSD as second factor to the login PIN.
#
class MicroSD2FA(PassphraseSaver):
def filename(self, card):
# Construct actual filename to use.
# - want to support same card authorizing multiple CC, so cant be fixed filename
# - dont want to search tho, so should be deterministic
# - serial number of CC is nearly public but hmac anyway
# - if this file was written from a trick pin situation, it would have
# correct filename but contents would not decrypt since AES key is based off seed
import version
from utils import B2A
k = ngu.hash.sha256s(version.serial_number())
h = ngu.hmac.hmac_sha256(k, b'silly?')
return card.get_sd_root() + '/.%s.2fa' % B2A(h[0:8])
@classmethod
def get_nonces(cls):
# this is the only setting: list of nonce values we have saved to various cards
from glob import settings
return settings.get('sd2fa') or []
def read_card(self):
# Read the data, if any, and if decrypted correctly
# Read file, decrypt and make a menu to show; OR return None
# if any error hit.
try:
with CardSlot() as card:
self._calc_key(card, force=True)
if not self.key: return None
data = self._read(card)
if not data: return None
except CardMissingError:
# late fail
return None
return data
@classmethod
def enforce_policy(cls):
# If feature enabled, and if so check authorized card is inserted right now.
nonces = cls.get_nonces()
if not nonces:
# feature not in use, no problem
return
try:
ok = cls.authorized_card_present(nonces)
assert ok == True
except:
# die. wrong
import callgate
callgate.fast_wipe(silent=False)
# proceed w/o any notice
return
@classmethod
def authorized_card_present(cls, nonces):
# Check if good card present
if not pyb.SDCard().present():
# no card present, so nope
return False
s = cls()
got = s.read_card()
if not got:
# garbage seen, missing file, etc => fail
#print('2fa file decrypt fail')
return False
#print(repr(got))
#print(repr(nonces))
# check it is in the list of authorized cards
return (got['nonce'] in nonces)
async def enroll(self):
# Write little file, update our settings to allow this card to auth.
from utils import B2A
from glob import dis, settings
nonce = B2A(ngu.random.bytes(8))
v = list(self.get_nonces())
# encrypt and save; always appends.
dis.fullscreen('Saving...')
try:
with CardSlot() as card:
self._calc_key(card, force=True)
data = dict(nonce=nonce)
encrypt = ngu.aes.CTR(self.key)
msg = encrypt.cipher(ujson.dumps(data))
with open(self.filename(card), 'wb') as fd:
fd.write(msg)
# update setting as well
v.append(nonce)
settings.set('sd2fa', v)
settings.save()
await ux_dramatic_pause("Saved.", 1)
return
except CardMissingError:
return await needs_microsd()
async def remove(self, nonce):
# remove indicated nonce from records
# - delete file if present and found, but ok if missing
from glob import dis, settings
v = self.get_nonces()
assert nonce in v, 'missing card nonce'
v2 = [i for i in v if i != nonce]
if not v2:
settings.remove_key('sd2fa')
else:
settings.set('sd2fa', v2)
settings.save()
try:
with CardSlot() as card:
fn = self.filename(card)
os.remove(fn)
except:
pass
@classmethod
def menu(cls):
# menu contents needed for current state
from menu import MenuItem
existing = cls.get_nonces()
menu = []
menu.append(MenuItem("Add Card", f=cls.menu_enroll, arg=len(existing)))
if existing:
menu.append(MenuItem("Check Card", f=cls.menu_check_card))
for n, card_nonce in enumerate(existing):
menu.append(MenuItem("Remove Card #%d" % (n+1), f=cls.menu_edit, arg=card_nonce))
return menu
@classmethod
async def menu_check_card(cls, *a):
ok = cls.authorized_card_present(cls.get_nonces())
if not ok:
await ux_show_story("This card would NOT be accepted during login.", title="FAIL")
else:
await ux_show_story("This card is enrolled and would be accepted during login.", title="PASS")
@classmethod
async def menu_enroll(cls, menu, label, item):
from files import _is_ejected
count = item.arg
if _is_ejected():
return await needs_microsd()
# careful: if they re-enrolled same card twice, confusion will result
if count:
ok = cls.authorized_card_present(cls.get_nonces())
if ok:
await ux_show_story("Need a different MicroSD card. "
"This card would already be accepted.")
return
ctx = 'this card or one of the others' if count >= 1 else 'it'
ok = await ux_confirm("Add this card to authorized set? Going forward %s must be present during login process or the seed will be wiped!" % ctx)
await cls().enroll()
menu.replace_items(cls.menu())
@classmethod
async def menu_edit(cls, menu, label, item):
# only allowing delete for now... could show details or something
ok = await ux_confirm("Remove this card from authorized set?")
if not ok:
return
# delete magic file if we can, but more importantly our nonce
await cls().remove(item.arg)
menu.replace_items(cls.menu())
# EOF

View File

@ -16,6 +16,7 @@ from glob import settings
# accepting strings and strings, returning bytes when decoding, str when encoding (ie. correct)
b32encode = ngu.codecs.b32_encode
b32decode = ngu.codecs.b32_decode
hmac_sha256 = ngu.hmac.hmac_sha256
# to keep menus and such to a reasonable size