Compare commits

...

6 Commits

Author SHA1 Message Date
Peter D. Gray
57f3ae86f8
more thoughts 2023-02-08 11:12:47 -05:00
Peter D. Gray
24ce083aae
block sd 2fa feature when ephemeral 2023-02-08 10:51:55 -05:00
Peter D. Gray
f4a03d0fd9
Docs for sd2fa feature 2023-02-08 10:44:56 -05:00
Peter D. Gray
e128161ada
comments 2023-02-08 10:25:07 -05:00
Peter D. Gray
324e2d29c3
group changes a bit 2023-02-08 10:10:03 -05:00
Peter D. Gray
07d31cc68d
MicroSD as 2FA feature 2023-02-08 10:03:49 -05:00
8 changed files with 360 additions and 22 deletions

96
docs/microsd-2fa.md Normal file
View File

@ -0,0 +1,96 @@
# MicroSD as a Second Factor for Login
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.
## How it Works
To "enroll" a card, a small encrypted file is written to the card.
During login, after the correct (true) PIN is entered, we use
the master secret to construct an AES key which is used to decrypt
the file found on the card. If the file is JSON and contains a nonce,
we check that in our list of acceptable cards.
The AES key includes the master secret and also a hash of the
unique serial number of the card, retrieved using low-level
protocols. This prevents moving the file to another card.
To allow the same card to unlock multiple Coldcards, we write the
file using a filename derived from the serial number of the Coldcard
(hashed). Thus there could be a number of 2FA-enabling files on a
single card.
The file name starts with a dot, and has extension `.2fa`. Your
tools may or may not hide it from you based on Unix filename
conventions. Reformating the card will certainly remove this file,
so keep that in mind when managing your "special" cards.
## Menu Settings
See menu in: `Settings -> Login Settings -> MicroSD 2FA`
The option is enabled only once the main secret is picked. It cannot
be used with ephemeral seeds, as that secret will not be in effect
during boot time.
The menu initially contains only "Add Card". Once one or more
cards are enabled (and the feature is activated), addition
options appear: "Check Card" and "Remove Card #N" for each
enrolled card.
"Check Card" validates the card inserted and indicates if it would
be accepted or not.
Use "Remove Card #N" is remove cards from the system. When the last
card is removed, the feature is disabled and no card will be required
for login.
## During Login
After the PIN is entered, and if it is the true PIN (or the main
code thinks it is, in Delta Mode or Duress Wallet cases) the main
settings are read. After this point, if there are one or more card
enrolled, then the check is performed. If the slot is empty or
the card fails the check, a fast wipe of the seed is done and shown
on screen. The memory is wipe and system stops. You must power cycle
to continue.
## Tricky Thinking
Because settings are encrypted by the master seed, if you have a
duress wallet, it could have required cards set as well. Generally,
we do not see a good use for this, and assume that typically only
the "true" PIN will have required cards associated with it. Remember
any Trick PIN can wipe the seed directly.
In Delta Mode, the usual card policy is in effect. However, if you
are relying on this 2FA feature to wipe the seed in a case of duress,
there doesn't seem to be any need for Delta Mode.
## Duress Defenses
We recommend simply keeping no card in your Coldcard once activating
this feature. Your attacker, or yourself under duress, will login
normally and trigger this defense without you taking any explicit
action.
If you were being forced to prepare a PSBT under duress, you can
choose which SD card to use (so pick a normal one, which isn't
enrolled) and you may also have a chance to clear your card of the
special file. Either way would be an opportunity to ensure the
automatic wipe occurs, even as you comply and provide the PIN code.
Your enrolled SD cards can also be stored at another location away
from your Coldcard. This could be a bank safety deposit box, since
it contains no sensitive data.
If you are closely surveilled when logging and using your Coldcard,
the PIN code might already be known to your attacker. However, there
is no indication on the screen during a normal (successful) login
that this feature is in effect, so they would not know if the SD
card was inserted by chance or necessity.

View File

@ -1,22 +1,28 @@
## 5.1.0 - 2023-02-08 ## 5.1.0 - 2023-02-XX
- 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 - New Feature: Single signature wallet generic descriptor export
`Advanced -> Export Wallet -> Descriptor`. Both new format with internal/external `Advanced -> Export Wallet -> Descriptor`. Both new format with internal/external
in one descriptor `<0;1>` and standard with two descriptors are supported. in one descriptor `<0;1>` and standard with two descriptors are supported.
- Enhancement: Add ability to import multisig wallet via Virtual Disk. - Address Explorer:
- Enhancement: Add ability to import extended private key via Virtual Disk and via NFC. - Enhancement: Application-specific derivation paths in `Address Explorer -> Applications`
- Enhancement: Import seed in compact/truncated form (just 3-4 letters of each seed word). - Bugfix: Change value was ignored when generating addresses file
- Enhancement: Application-specific address/derivation paths in `Address Explorer -> Applications` - Import Enhancements:
- Enhancement: Samourai POST-MIX and PRE-MIX descriptor export options added to `Export Wallet` - Add import multisig wallet via Virtual Disk
- Enhancement: Lily Wallet export option added to `Export Wallet` - Add import extended private key via Virtual Disk and via NFC
- Enhancement: Add ability to export all supported wallets via NFC (instead of SD card only) - Import seed in compact/truncated form (just 3-4 letters of each seed word)
- Export Enhancements:
- Samourai POST-MIX and PRE-MIX descriptor export options added
- Lily Wallet added
- Ability to export all supported wallets via NFC (instead of SD card only)
- Change electrum export file name from 'new-wallet.json' to 'new-electrum.json'
- Allow export of Wasabi skeleton for Bitcoin Regtest.
- Enhancement: During seed generation from dice rolls, enforce at least 50 rolls - Enhancement: During seed generation from dice rolls, enforce at least 50 rolls
for 12 word seeds, and 99 rolls for 24 word seeds. Statistical distribution check for 12 word seeds, and 99 rolls for 24 word seeds. Statistical distribution check
added to prevent users from generating low-entropy seeds by rolling same value repeatedly. added to prevent users from generating low-entropy seeds by rolling same value repeatedly.
- Enhancement: Change electrum export file name from 'new-wallet.json' to 'new-electrum.json'
- Bugfix: Change value was ignored when generating addresses file from address explorer.
- Bugfix: Offer import/export from/to Virtual Disk in UI even if SD Card is inserted. - Bugfix: Offer import/export from/to Virtual Disk in UI even if SD Card is inserted.
- Bugfix: Allow export of Wasabi skeleton for Bitcoin Regtest.
- Docs: Add `docs/rolls12.py` script for verifying dice rolls math for 12 word seeds. - Docs: Add `docs/rolls12.py` script for verifying dice rolls math for 12 word seeds.
## 5.0.7 - 2022-10-05 ## 5.0.7 - 2022-10-05

View File

@ -4,7 +4,7 @@
# #
# Every function here is called directly by a menu item. They should all be async. # 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 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 utils import imported, pretty_short_delay, problem_file_line, import_prompt_builder
from uasyncio import sleep_ms from uasyncio import sleep_ms
@ -855,7 +855,7 @@ async def start_login_sequence():
except BaseException as exc: except BaseException as exc:
# Robustness: any logic errors/bugs in above will brick the Coldcard # 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 # 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 secret, so "letting" them past this point is harmless if they don't know
# the true pin. # the true pin.
@ -863,7 +863,6 @@ async def start_login_sequence():
raise raise
print("Bug recovery!") print("Bug recovery!")
import sys
sys.print_exception(exc) sys.print_exception(exc)
# Successful login... # Successful login...
@ -879,6 +878,14 @@ async def start_login_sequence():
except: except:
pass 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 # implement idle timeout now that we are logged-in
from imptask import IMPT from imptask import IMPT
IMPT.start_task('idle', idle_logout()) IMPT.start_task('idle', idle_logout())
@ -2088,5 +2095,16 @@ async def change_which_chain(name):
# no secrets yet, not an error # no secrets yet, not an error
pass 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 # EOF

View File

@ -87,6 +87,10 @@ def nfc_enabled():
def vdisk_enabled(): def vdisk_enabled():
return bool(settings.get('vidsk', 0)) return bool(settings.get('vidsk', 0))
def se2_and_real_secret():
from pincodes import pa
return version.has_se2 and (not pa.is_secret_blank()) and (not pa.tmp_value)
HWTogglesMenu = [ HWTogglesMenu = [
ToggleMenuItem('USB Port', 'du', ['Default On', 'Disable USB'], invert=True, ToggleMenuItem('USB Port', 'du', ['Default On', 'Disable USB'], invert=True,
@ -114,6 +118,7 @@ LoginPrefsMenu = [
MenuItem('Scramble Keypad', f=pick_scramble), MenuItem('Scramble Keypad', f=pick_scramble),
MenuItem('Kill Key', f=pick_killkey, predicate=lambda: version.has_se2), MenuItem('Kill Key', f=pick_killkey, predicate=lambda: version.has_se2),
MenuItem('Login Countdown', chooser=countdown_chooser), MenuItem('Login Countdown', chooser=countdown_chooser),
MenuItem('MicroSD 2FA', menu=microsd_2fa, predicate=se2_and_real_secret),
MenuItem('Test Login Now', f=login_now, arg=1), 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) # emu = (bool) if set, enables the USB Keyboard emulation (BIP-85 password entry)
# wa = (bool) if set, enables menu wraparound # wa = (bool) if set, enables menu wraparound
# hsmcmd = (bool) if set, enables all user management and hsm-only USB commands # 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 # Stored w/ key=00 for access before login
# _skip_pin = hard code a PIN value (dangerous, only for debug) # _skip_pin = hard code a PIN value (dangerous, only for debug)
# nick = optional nickname for this coldcard (personalization) # 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) # 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 files import CardSlot, CardMissingError, needs_microsd
from ux import ux_dramatic_pause, ux_confirm, ux_show_story
class PassphraseSaver: class PassphraseSaver:
# Encrypts BIP-39 passphrase very carefully, and appends # 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. # - some very minor obscurity, but we aren't relying on that.
return card.get_sd_root() + '/.tmp.tmp' 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. # calculate the key to be used.
if getattr(self, 'key', None): return if not force and getattr(self, 'key', None):
return
try: try:
salt = card.get_id_hash() salt = card.get_id_hash()
@ -35,16 +37,20 @@ class PassphraseSaver:
decrypt = ngu.aes.CTR(self.key) decrypt = ngu.aes.CTR(self.key)
try: try:
msg = open(self.filename(card), 'rb').read() fname = self.filename(card)
msg = open(fname, 'rb').read()
txt = decrypt.cipher(msg) txt = decrypt.cipher(msg)
return ujson.loads(txt) return ujson.loads(txt)
except OSError:
#print('missing? ' + fname)
return []
except: except:
return [] return []
async def append(self, xfp, bip39pw): async def append(self, xfp, bip39pw):
# encrypt and save; always appends. # encrypt and save; always appends.
from ux import ux_dramatic_pause
from glob import dis from glob import dis
while 1: while 1:
@ -79,7 +85,6 @@ class PassphraseSaver:
from actions import goto_top_menu from actions import goto_top_menu
from ux import ux_show_story from ux import ux_show_story
from seed import set_bip39_passphrase from seed import set_bip39_passphrase
import pyb
# Very quick check for card not present case. # Very quick check for card not present case.
if not pyb.SDCard().present(): 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))) 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 # EOF

View File

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

View File

@ -122,8 +122,8 @@ cs = files.CardSlot().__enter__(); \
p=PassphraseSaver(); p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()''') p=PassphraseSaver(); p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()''')
assert len(key) == 64 assert len(key) == 64
#assert key == '234af2aa2ab43af83667dfc6e11d08223e0f486ef34539b41a045dd9eb3ea664'
# recalc what it should be
from pycoin.key.BIP32Node import BIP32Node from pycoin.key.BIP32Node import BIP32Node
from pycoin.encoding import from_bytes_32, to_bytes_32 from pycoin.encoding import from_bytes_32, to_bytes_32
from hashlib import sha256 from hashlib import sha256
@ -141,7 +141,7 @@ p=PassphraseSaver(); p._calc_key(cs); RV.write(b2a_hex(p.key)); cs.__exit__()'''
assert expect == key assert expect == key
# check that key works for decrypt / that the file was actually encrypted # check that key works for decrypt and that the file was actually encrypted
with open(SIM_FNAME, 'rb') as fd: with open(SIM_FNAME, 'rb') as fd:
raw = fd.read() raw = fd.read()