327 lines
10 KiB
Python
327 lines
10 KiB
Python
# (c) Copyright 2018 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
|
#
|
|
# users.py
|
|
#
|
|
# Users, passwords and management of same. Primarily for HSM feature.
|
|
#
|
|
|
|
import ustruct, ngu
|
|
from public_constants import USER_AUTH_TOTP, USER_AUTH_HOTP, USER_AUTH_HMAC, USER_AUTH_SHOW_QR
|
|
from public_constants import MAX_USERNAME_LEN, PBKDF2_ITER_COUNT
|
|
from menu import MenuSystem, MenuItem
|
|
from ucollections import namedtuple
|
|
from ux import ux_dramatic_pause, ux_show_story, ux_confirm
|
|
from nvstore 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
|
|
MAX_NUMBER_USERS = const(30)
|
|
|
|
def calc_hotp(secret, counter):
|
|
'''
|
|
Get HMAC-based one-time password on the basis of given secret and
|
|
interval number (counter).
|
|
|
|
[RFC4226](https://tools.ietf.org/html/rfc4226)
|
|
|
|
>>> get_hotp(b'abcdefghij', counter=1)
|
|
765705
|
|
>>> get_hotp(b'abcdefghij', counter=2)
|
|
816065
|
|
'''
|
|
|
|
assert len(secret) >= 10
|
|
assert counter >= 0
|
|
|
|
msg = ustruct.pack('>Q', counter)
|
|
|
|
md = ngu.hmac.hmac_sha1(secret, msg)
|
|
|
|
o = md[19] & 15
|
|
token = ustruct.unpack('>I', md[o:o + 4])[0] & 0x7fffffff
|
|
|
|
# return lowest 6 digits
|
|
return '%06d' % (token % 1000000)
|
|
|
|
def calc_hmac_key(text_password):
|
|
# Calculate a 32-byte key based on user's text password, PBKDF2_ITER_COUNT,
|
|
# and device serial number as salt.
|
|
# - before v4, this was pbkdf2_sha256
|
|
import version
|
|
|
|
salt = ngu.hash.sha256s(b'pepper' + version.serial_number().encode())
|
|
pw = ngu.hash.pbkdf2_sha512(text_password, salt, PBKDF2_ITER_COUNT)
|
|
|
|
return pw[0:32]
|
|
|
|
def calc_local_pincode(psbt_sha, hmac_secret):
|
|
# Given a b64 encoded secret (shared from CC over USB) and the PSBT
|
|
# being authorized, cook up 6 digits for local PIN code
|
|
from ubinascii import a2b_base64
|
|
key = a2b_base64(hmac_secret)
|
|
assert len(psbt_sha) == 32
|
|
digest = hmac_sha256(key, psbt_sha)
|
|
|
|
num = ustruct.unpack('>I', digest[-4:])[0] & 0x7fffffff
|
|
return '%06d' % (num % 1000000)
|
|
|
|
|
|
# settings key
|
|
KEY = 'usr'
|
|
|
|
# - storing: [authmode, base32(secret), last_counter] in a map keyed by username
|
|
UserInfo = namedtuple('UserInfo', 'auth_mode secret last_counter')
|
|
|
|
class Users:
|
|
'''Track users and thier TOTP secrets or hashed passwords'''
|
|
|
|
@classmethod
|
|
def get(cls):
|
|
rv = settings.get(KEY)
|
|
return rv or dict()
|
|
|
|
@classmethod
|
|
def lookup(cls, username):
|
|
# find by username, and return details.
|
|
rv = cls.get().get(username, None)
|
|
return UserInfo(*rv) if rv else None
|
|
|
|
@classmethod
|
|
def update_counter(cls, username, cnt):
|
|
t = cls.get()
|
|
assert username in t
|
|
t[username][2] = cnt
|
|
settings.changed()
|
|
|
|
@classmethod
|
|
def valid_username(cls, username):
|
|
return bool(cls.get().get(username, False))
|
|
|
|
@classmethod
|
|
def list(cls):
|
|
return list(sorted(cls.get().keys()))
|
|
|
|
@classmethod
|
|
def create(cls, username, auth_mode, secret):
|
|
# create new user:
|
|
# - username must be unique
|
|
# - if secret is empty, we pick it and return choice
|
|
# - show QR of secret (for TOTP/HOTP) if
|
|
qr_mode = bool(auth_mode & USER_AUTH_SHOW_QR)
|
|
if qr_mode:
|
|
auth_mode &= ~USER_AUTH_SHOW_QR
|
|
assert not secret
|
|
|
|
assert auth_mode in {USER_AUTH_TOTP, USER_AUTH_HOTP, USER_AUTH_HMAC}
|
|
|
|
# validate username;
|
|
assert 1 < len(username) <= MAX_USERNAME_LEN, 'badlen'
|
|
assert username[0] != '_', 'reserved'
|
|
|
|
# We don't care if it exists, because then it's an update?
|
|
# - but can safely let them reset the counter/totp level??
|
|
# - not sure, so force them to delete on-device first
|
|
# - this check does not allow brute-force search for names (because
|
|
existing = cls.lookup(username)
|
|
assert not existing, "exists"
|
|
|
|
if not secret:
|
|
secret, picked = cls.pick_secret(auth_mode)
|
|
else:
|
|
picked = ''
|
|
if auth_mode == USER_AUTH_HMAC:
|
|
assert len(secret) == 32
|
|
else:
|
|
assert len(secret) in {10, 20}
|
|
|
|
# save
|
|
u = cls.get()
|
|
assert len(u) < MAX_NUMBER_USERS, 'too many'
|
|
u[username] = [auth_mode, b32encode(secret), 0]
|
|
settings.put(KEY, u)
|
|
|
|
if qr_mode:
|
|
# can only show up to 42 chars, and secret is 16, required overhead is 23 => 39 min
|
|
# - can't fit any meta data, like username or our serial # in there
|
|
# - HOTP not compliant because 'counter=0' not included (works in FreeOTP)
|
|
from ux import QRDisplay, abort_and_push, the_ux
|
|
|
|
if auth_mode == USER_AUTH_HMAC:
|
|
qr = picked
|
|
else:
|
|
qr = 'otpauth://{m}otp/CC?secret={s}'.format(s=picked,
|
|
m=('t' if auth_mode==USER_AUTH_TOTP else 'h'))
|
|
|
|
o = QRDisplay([qr], False, sidebar=(picked, 4))
|
|
abort_and_push(o)
|
|
|
|
picked = ''
|
|
|
|
return picked
|
|
|
|
@classmethod
|
|
def delete(cls, username):
|
|
# remove a user. simple. no checking
|
|
u = cls.get()
|
|
u.pop(username, None)
|
|
settings.put(KEY, u)
|
|
|
|
@classmethod
|
|
def pick_secret(cls, auth_mode):
|
|
# always 10 bytes for no reason => 80 bits of entropy
|
|
# return binary secret, and encoded value for new user to see
|
|
import ckcc
|
|
b = bytearray(10)
|
|
ckcc.rng_bytes(b)
|
|
picked = b32encode(b)
|
|
|
|
if auth_mode == USER_AUTH_HMAC:
|
|
picked = picked.lower()
|
|
b = calc_hmac_key(picked.encode('ascii'))
|
|
|
|
return b, picked
|
|
|
|
@classmethod
|
|
def auth_okay(cls, username, token, totp_time=None, psbt_hash=None):
|
|
# check a password/totp
|
|
# - where a hash of a PSBT is needed, we use zero; if unknown
|
|
# - return empty string if ok, else problem string
|
|
# - Important SIDE-EFFECT: updates last-counter/totp timestamp if successful
|
|
|
|
u = cls.lookup(username)
|
|
if not u:
|
|
return 'unknown user'
|
|
|
|
auth_mode, secret, last_counter = u
|
|
secret = b32decode(secret)
|
|
|
|
if auth_mode == USER_AUTH_HMAC:
|
|
expect = hmac_sha256(secret, psbt_hash or bytes(32))
|
|
if expect != token:
|
|
return 'mismatch'
|
|
|
|
if last_counter == 0:
|
|
# using this as marker that they have successfully used the code once
|
|
cls.update_counter(username, 1)
|
|
|
|
return ''
|
|
|
|
if len(token) != 6:
|
|
return 'expect otp'
|
|
|
|
if auth_mode == USER_AUTH_HOTP:
|
|
# totp_time provided is ignored; use own counter; but perhaps
|
|
# they fumbled a bit and wasted a few codes, so give forward leeway
|
|
candidates = [last_counter+i for i in range(1, 10)]
|
|
|
|
if not last_counter:
|
|
candidates.append(0)
|
|
else:
|
|
# time based: try back a few slots, but only if not already used up
|
|
if totp_time < 52622505:
|
|
# above is time when I wrote the code, so must be after that
|
|
return 'range'
|
|
|
|
if totp_time <= last_counter:
|
|
return 'replay'
|
|
|
|
candidates = [(totp_time-i) for i in range(0, 3)
|
|
if (totp_time-i) > last_counter]
|
|
if not candidates:
|
|
return 'replay'
|
|
|
|
for c in candidates:
|
|
expect = calc_hotp(secret, c).encode('ascii')
|
|
|
|
#print('expect=%r got=%r cnt=%d last=%d' % (expect, token, c, last_counter))
|
|
|
|
if expect == token:
|
|
# success, need to update last counter level seen (especially for HOTP,
|
|
# but also to resist replay for TOTP)
|
|
cls.update_counter(username, c)
|
|
return ''
|
|
|
|
return 'mismatch'
|
|
|
|
##
|
|
## Menu Stuff
|
|
##
|
|
|
|
class UsersMenu(MenuSystem):
|
|
|
|
@classmethod
|
|
def construct(cls):
|
|
# Dynamic menu with user-defined user names
|
|
from actions import import_multisig
|
|
|
|
async def no_users_yet(*a):
|
|
# action for 'no wallets yet' menu item
|
|
await ux_show_story("You don't have any user accounts defined yet. USB is used to define new users, and their associated secrets.")
|
|
|
|
users = Users.list()
|
|
if not users:
|
|
rv = [MenuItem('(no users yet)', f=no_users_yet)]
|
|
else:
|
|
rv = [MenuItem('%d user%s:' % (len(users), 's' if len(users) != 1 else ''))]
|
|
for u in users:
|
|
rv.append(MenuItem('"%s"' % u, menu=make_user_sub_menu, arg=u))
|
|
|
|
# other static items?
|
|
#rv.append(MenuItem('', f=import_multisig))
|
|
|
|
return rv
|
|
|
|
def update_contents(self):
|
|
# Reconstruct the list of users on this dynamic menu, because
|
|
# we added or changed them and are showing that same menu again.
|
|
tmp = self.construct()
|
|
self.replace_items(tmp)
|
|
|
|
|
|
async def make_users_menu(*a):
|
|
# list of all users, and maybe high-level settings/actions
|
|
rv = UsersMenu.construct()
|
|
return UsersMenu(rv)
|
|
|
|
async def make_user_sub_menu(menu, label, item):
|
|
# details, actions on single multisig wallet
|
|
user = item.arg
|
|
|
|
async def delete_user(menu, label, item):
|
|
if not await ux_confirm('Delete user:\n %s\n' % item.arg):
|
|
return
|
|
|
|
Users.delete(item.arg)
|
|
await ux_dramatic_pause('Deleted.', 3)
|
|
|
|
from ux import the_ux
|
|
the_ux.pop()
|
|
m = the_ux.top_of_stack()
|
|
m.update_contents()
|
|
|
|
# get details: not much
|
|
info = Users.lookup(user)
|
|
if not info:
|
|
return
|
|
|
|
if info.auth_mode == USER_AUTH_TOTP:
|
|
dets = "TOTP: " + ('unused' if not info.last_counter else 'active')
|
|
elif info.auth_mode == USER_AUTH_HOTP:
|
|
dets = "HOTP: count=%d" % info.last_counter
|
|
elif info.auth_mode == USER_AUTH_HMAC:
|
|
dets = "Password: " + ('unused' if not info.last_counter else 'active')
|
|
|
|
rv = [
|
|
MenuItem('"%s"' % user), # does nothing, it's a title
|
|
MenuItem(dets),
|
|
MenuItem('Delete User', f=delete_user, arg=user),
|
|
]
|
|
|
|
return rv
|
|
|
|
# EOF
|