diff --git a/core/src/apps/webauthn/credential.py b/core/src/apps/webauthn/credential.py new file mode 100644 index 0000000000..5e74c6c0c9 --- /dev/null +++ b/core/src/apps/webauthn/credential.py @@ -0,0 +1,196 @@ +import ustruct +from micropython import const +from ubinascii import hexlify + +from trezor import log, utils +from trezor.crypto import bip32, chacha20poly1305, hashlib, hmac, random + +from apps.common import HARDENED, cbor, seed, storage + +if False: + from typing import Optional + +# Credential ID values +_CRED_ID_VERSION = b"\xf1\xd0\x02\x00" +_CRED_ID_MIN_LENGTH = const(33) + +# Credential ID keys +_CRED_ID_RP_ID = const(0x01) +_CRED_ID_RP_NAME = const(0x02) +_CRED_ID_USER_ID = const(0x03) +_CRED_ID_USER_NAME = const(0x04) +_CRED_ID_USER_DISPLAY_NAME = const(0x05) +_CRED_ID_CREATION_TIME = const(0x06) +_CRED_ID_HMAC_SECRET = const(0x07) + + +class Credential: + def __init__(self) -> None: + self.id = b"" # type: bytes + self.rp_id = "" # type: str + self.rp_id_hash = b"" # type: bytes + self.user_id = None # type: Optional[bytes] + + def app_name(self) -> str: + return "" + + def account_name(self) -> Optional[str]: + return None + + def private_key(self) -> bytes: + return b"" + + def hmac_secret_key(self) -> Optional[bytes]: + return None + + @staticmethod + def from_bytes(data: bytes, rp_id_hash: bytes) -> Optional["Credential"]: + cred = Fido2Credential.from_cred_id( + data, rp_id_hash + ) # type: Optional[Credential] + if cred is None: + cred = U2fCredential.from_key_handle(data, rp_id_hash) + return cred + + +# SLIP-0022: FIDO2 credential ID format for HD wallets +class Fido2Credential(Credential): + def __init__(self) -> None: + super().__init__() + self.rp_name = None # type: Optional[str] + self.user_name = None # type: Optional[str] + self.user_display_name = None # type: Optional[str] + self._creation_time = 0 # type: int + self.hmac_secret = False # type: bool + + def __lt__(self, other: Credential) -> bool: + # Sort FIDO2 credentials newest first amongst each other. + if isinstance(other, Fido2Credential): + return self._creation_time > other._creation_time + + # Sort FIDO2 credentials before U2F credentials. + return True + + def generate_id(self) -> None: + self._creation_time = storage.device.next_u2f_counter() or 0 + + data = cbor.encode( + { + key: value + for key, value in ( + (_CRED_ID_RP_ID, self.rp_id), + (_CRED_ID_RP_NAME, self.rp_name), + (_CRED_ID_USER_ID, self.user_id), + (_CRED_ID_USER_NAME, self.user_name), + (_CRED_ID_USER_DISPLAY_NAME, self.user_display_name), + (_CRED_ID_CREATION_TIME, self._creation_time), + (_CRED_ID_HMAC_SECRET, self.hmac_secret), + ) + if value + } + ) + key = seed.derive_slip21_node_without_passphrase( + [b"SLIP-0022", _CRED_ID_VERSION, b"Encryption key"] + ).key() + iv = random.bytes(12) + ctx = chacha20poly1305(key, iv) + ctx.auth(self.rp_id_hash) + ciphertext = ctx.encrypt(data) + tag = ctx.finish() + self.id = _CRED_ID_VERSION + iv + ciphertext + tag + + @staticmethod + def from_cred_id(cred_id: bytes, rp_id_hash: bytes) -> Optional["Fido2Credential"]: + if len(cred_id) < _CRED_ID_MIN_LENGTH or cred_id[0:4] != _CRED_ID_VERSION: + return None + + key = seed.derive_slip21_node_without_passphrase( + [b"SLIP-0022", cred_id[0:4], b"Encryption key"] + ).key() + iv = cred_id[4:16] + ciphertext = cred_id[16:-16] + tag = cred_id[-16:] + ctx = chacha20poly1305(key, iv) + ctx.auth(rp_id_hash) + data = ctx.decrypt(ciphertext) + if not utils.consteq(ctx.finish(), tag): + return None + + try: + data = cbor.decode(data) + except Exception: + return None + + if not isinstance(data, dict): + return None + + cred = Fido2Credential() + cred.rp_id = data.get(_CRED_ID_RP_ID, None) + cred.rp_id_hash = rp_id_hash + cred.rp_name = data.get(_CRED_ID_RP_NAME, None) + cred.user_id = data.get(_CRED_ID_USER_ID, None) + cred.user_name = data.get(_CRED_ID_USER_NAME, None) + cred.user_display_name = data.get(_CRED_ID_USER_DISPLAY_NAME, None) + cred._creation_time = data.get(_CRED_ID_CREATION_TIME, 0) + cred.hmac_secret = data.get(_CRED_ID_HMAC_SECRET, False) + cred.id = cred_id + + if ( + not cred.check_required_fields() + or not cred.check_data_types() + or hashlib.sha256(cred.rp_id).digest() != rp_id_hash + ): + return None + + return cred + + def check_required_fields(self) -> bool: + return ( + self.rp_id is not None + and self.user_id is not None + and self._creation_time is not None + ) + + def check_data_types(self) -> bool: + return ( + isinstance(self.rp_id, str) + and isinstance(self.rp_name, (str, type(None))) + and isinstance(self.user_id, (bytes, bytearray)) + and isinstance(self.user_name, (str, type(None))) + and isinstance(self.user_display_name, (str, type(None))) + and isinstance(self.hmac_secret, bool) + and isinstance(self._creation_time, (int, type(None))) + and isinstance(self.id, (bytes, bytearray)) + ) + + def app_name(self) -> str: + return self.rp_id + + def account_name(self) -> Optional[str]: + if self.user_name: + return self.user_name + elif self.user_display_name: + return self.user_display_name + elif self.user_id: + return hexlify(self.user_id).decode() + else: + return None + + def private_key(self) -> bytes: + path = [HARDENED | 10022, HARDENED | int.from_bytes(self.id[:4], "big")] + [ + HARDENED | i for i in ustruct.unpack(">4L", self.id[-16:]) + ] + node = seed.derive_node_without_passphrase(path, "nist256p1") + return node.private_key() + + def hmac_secret_key(self) -> Optional[bytes]: + # Returns the symmetric key for the hmac-secret extension also known as CredRandom. + + if not self.hmac_secret: + return None + + node = seed.derive_slip21_node_without_passphrase( + [b"SLIP-0022", self.id[0:4], b"hmac-secret", self.id] + ) + + return node.key()