# (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
