diff --git a/common/protob/check.py b/common/protob/check.py index 9c64682166..ddef718c90 100755 --- a/common/protob/check.py +++ b/common/protob/check.py @@ -17,6 +17,8 @@ for fn in sorted(glob(os.path.join(MYDIR, "messages-*.proto"))): continue if prefix == "Nem": prefix = "NEM" + elif prefix == "Webauthn": + prefix = "WebAuthn" for line in f: line = line.strip().split(" ") if line[0] not in ["enum", "message"]: diff --git a/common/protob/messages-webauthn.proto b/common/protob/messages-webauthn.proto new file mode 100644 index 0000000000..1f2fcdd3d8 --- /dev/null +++ b/common/protob/messages-webauthn.proto @@ -0,0 +1,56 @@ +syntax = "proto2"; +package hw.trezor.messages.webauthn; + +// Sugar for easier handling in Java +option java_package = "com.satoshilabs.trezor.lib.protobuf"; +option java_outer_classname = "TrezorMessageWebAuthn"; + +/** + * Request: List resident credentials + * @start + * @next WebAuthnCredentials + * @next Failure + */ +message WebAuthnListResidentCredentials { +} + +/** + * Request: Add resident credential + * @start + * @next Success + * @next Failure + */ +message WebAuthnAddResidentCredential { + optional bytes credential_id = 1; +} + +/** + * Request: Remove resident credential + * @start + * @next Success + * @next Failure + */ +message WebAuthnRemoveResidentCredential { + optional uint32 index = 1; +} + + +/** + * Response: Resident credential list + * @start + * @next end + */ +message WebAuthnCredentials { + repeated WebAuthnCredential credentials = 1; + message WebAuthnCredential { + optional uint32 index = 1; + optional bytes id = 2; + optional string rp_id = 3; + optional string rp_name = 4; + optional bytes user_id = 5; + optional string user_name = 6; + optional string user_display_name = 7; + optional uint32 creation_time = 8; + optional bool hmac_secret = 9; + } +} diff --git a/common/protob/messages.proto b/common/protob/messages.proto index 86b205f0b9..0ad3c5daee 100644 --- a/common/protob/messages.proto +++ b/common/protob/messages.proto @@ -237,4 +237,10 @@ enum MessageType { MessageType_BinanceOrderMsg = 707 [(wire_in) = true]; MessageType_BinanceCancelMsg = 708 [(wire_in) = true]; MessageType_BinanceSignedTx = 709 [(wire_out) = true]; + + // WebAuthn + MessageType_WebAuthnListResidentCredentials = 800 [(wire_in) = true]; + MessageType_WebAuthnCredentials = 801 [(wire_out) = true]; + MessageType_WebAuthnAddResidentCredential = 802 [(wire_in) = true]; + MessageType_WebAuthnRemoveResidentCredential = 803 [(wire_in) = true]; } diff --git a/core/src/apps/common/storage/webauthn.py b/core/src/apps/common/storage/webauthn.py index 5c25f533fa..faf2d35852 100644 --- a/core/src/apps/common/storage/webauthn.py +++ b/core/src/apps/common/storage/webauthn.py @@ -10,37 +10,48 @@ _RESIDENT_CREDENTIAL_START_KEY = const(1) _MAX_RESIDENT_CREDENTIALS = const(16) -def get_resident_credentials(rp_id_hash: Optional[bytes]) -> List[Credential]: +def get_resident_credentials(rp_id_hash: Optional[bytes] = None) -> List[Credential]: creds = [] # type: List[Credential] - for i in range( - _RESIDENT_CREDENTIAL_START_KEY, - _RESIDENT_CREDENTIAL_START_KEY + _MAX_RESIDENT_CREDENTIALS, - ): - stored_cred_data = common._get(common._APP_FIDO2, i) - if stored_cred_data is None: - continue - - stored_rp_id_hash = stored_cred_data[:32] - stored_cred_id = stored_cred_data[32:] - - if rp_id_hash is not None and rp_id_hash != stored_rp_id_hash: - # Stored credential is not for this RP ID. - continue - - stored_cred = Fido2Credential.from_cred_id(stored_cred_id, stored_rp_id_hash) - if stored_cred is not None: - creds.append(stored_cred) - + for i in range(_MAX_RESIDENT_CREDENTIALS): + cred = get_resident_credential(i, rp_id_hash) + if cred is not None: + creds.append(cred) return creds +def get_resident_credential( + index: int, rp_id_hash: Optional[bytes] = None +) -> Optional[Credential]: + if not (0 <= index < _MAX_RESIDENT_CREDENTIALS): + return None + + stored_cred_data = common.get( + common.APP_FIDO2, index + _RESIDENT_CREDENTIAL_START_KEY + ) + if stored_cred_data is None: + return None + + stored_rp_id_hash = stored_cred_data[:32] + stored_cred_id = stored_cred_data[32:] + + if rp_id_hash is not None and rp_id_hash != stored_rp_id_hash: + # Stored credential is not for this RP ID. + return None + + stored_cred = Fido2Credential.from_cred_id(stored_cred_id, stored_rp_id_hash) + if stored_cred is None: + return None + + stored_cred.index = index + return stored_cred + + def store_resident_credential(cred: Fido2Credential) -> bool: slot = None - for i in range( - _RESIDENT_CREDENTIAL_START_KEY, - _RESIDENT_CREDENTIAL_START_KEY + _MAX_RESIDENT_CREDENTIALS, - ): - stored_cred_data = common._get(common._APP_FIDO2, i) + for i in range(_MAX_RESIDENT_CREDENTIALS): + stored_cred_data = common.get( + common.APP_FIDO2, i + _RESIDENT_CREDENTIAL_START_KEY + ) if stored_cred_data is None: if slot is None: slot = i @@ -66,13 +77,21 @@ def store_resident_credential(cred: Fido2Credential) -> bool: if slot is None: return False - common._set(common._APP_FIDO2, slot, cred.rp_id_hash + cred.id) + common.set( + common.APP_FIDO2, + slot + _RESIDENT_CREDENTIAL_START_KEY, + cred.rp_id_hash + cred.id, + ) return True def erase_resident_credentials() -> None: - for i in range( - _RESIDENT_CREDENTIAL_START_KEY, - _RESIDENT_CREDENTIAL_START_KEY + _MAX_RESIDENT_CREDENTIALS, - ): - common._delete(common._APP_FIDO2, i) + for i in range(_MAX_RESIDENT_CREDENTIALS): + common.delete(common.APP_FIDO2, i + _RESIDENT_CREDENTIAL_START_KEY) + + +def erase_resident_credential(index: int) -> bool: + if not (0 <= index < _MAX_RESIDENT_CREDENTIALS): + return False + common.delete(common.APP_FIDO2, index + _RESIDENT_CREDENTIAL_START_KEY) + return True diff --git a/core/src/apps/webauthn/__init__.py b/core/src/apps/webauthn/__init__.py index 94ad16b505..71a7efff0c 100644 --- a/core/src/apps/webauthn/__init__.py +++ b/core/src/apps/webauthn/__init__.py @@ -3,11 +3,12 @@ import ustruct import utime from micropython import const -from trezor import config, io, log, loop, ui, utils, workflow +from trezor import config, io, log, loop, ui, utils, wire, workflow from trezor.crypto import aes, der, hashlib, hmac, random from trezor.crypto.curve import nist256p1 +from trezor.messages import MessageType from trezor.ui.confirm import CONFIRMED, Confirm, ConfirmPageable, Pageable -from trezor.ui.text import Text, text_center_trim_left, text_center_trim_right +from trezor.ui.text import Text from apps.common import cbor, storage from apps.common.storage.webauthn import ( @@ -15,6 +16,7 @@ from apps.common.storage.webauthn import ( get_resident_credentials, store_resident_credential, ) +from apps.webauthn.confirm import ConfirmContent, ConfirmInfo from apps.webauthn.credential import Credential, Fido2Credential, U2fCredential if __debug__: @@ -464,6 +466,19 @@ def send_cmd_sync(cmd: Cmd, iface: io.HID) -> None: def boot(iface: io.HID) -> None: + wire.add( + MessageType.WebAuthnListResidentCredentials, + __name__, + "list_resident_credentials", + ) + wire.add( + MessageType.WebAuthnAddResidentCredential, __name__, "add_resident_credential" + ) + wire.add( + MessageType.WebAuthnRemoveResidentCredential, + __name__, + "remove_resident_credential", + ) loop.schedule(handle_reports(iface)) @@ -526,64 +541,6 @@ async def confirm(*args: Any, **kwargs: Any) -> bool: return await dialog is CONFIRMED -class ConfirmInfo: - def __init__(self) -> None: - self.app_icon = None # type: Optional[bytes] - - def get_header(self) -> Optional[str]: - return None - - def app_name(self) -> str: - raise NotImplementedError - - def account_name(self) -> Optional[str]: - return None - - def load_icon(self, rp_id_hash: bytes) -> None: - from trezor import res - from apps.webauthn import knownapps - - try: - namepart = knownapps.knownapps[rp_id_hash].lower().replace(" ", "_") - icon = res.load("apps/webauthn/res/icon_%s.toif" % namepart) - except Exception as e: - icon = res.load("apps/webauthn/res/icon_webauthn.toif") - if __debug__: - log.exception(__name__, e) - self.app_icon = icon - - -class ConfirmContent(ui.Component): - def __init__(self, info: ConfirmInfo) -> None: - self.info = info - self.repaint = True - - def on_render(self) -> None: - if self.repaint: - header = self.info.get_header() - - if header is None or self.info.app_icon is None: - return - ui.header(header, ui.ICON_DEFAULT, ui.GREEN, ui.BG, ui.GREEN) - ui.display.image((ui.WIDTH - 64) // 2, 48, self.info.app_icon) - - app_name = self.info.app_name() - account_name = self.info.account_name() - - # Dummy requests usually have some text as both app_name and account_name, - # in that case show the text only once. - if account_name is not None: - if app_name != account_name: - text_center_trim_left(ui.WIDTH // 2, 140, app_name) - text_center_trim_right(ui.WIDTH // 2, 172, account_name) - else: - text_center_trim_right(ui.WIDTH // 2, 156, account_name) - else: - text_center_trim_left(ui.WIDTH // 2, 156, app_name) - - self.repaint = False - - class State: def __init__(self, cid: int, iface: io.HID) -> None: self.cid = cid diff --git a/core/src/apps/webauthn/add_resident_credential.py b/core/src/apps/webauthn/add_resident_credential.py new file mode 100644 index 0000000000..9d9f11ba2c --- /dev/null +++ b/core/src/apps/webauthn/add_resident_credential.py @@ -0,0 +1,54 @@ +from trezor import ui, wire +from trezor.messages.Success import Success +from trezor.messages.WebAuthnAddResidentCredential import WebAuthnAddResidentCredential +from trezor.ui.text import Text + +from apps.common.confirm import require_confirm +from apps.common.storage.webauthn import store_resident_credential +from apps.webauthn.confirm import ConfirmContent, ConfirmInfo +from apps.webauthn.credential import Fido2Credential + +if False: + from typing import Optional + + +class ConfirmAddCredential(ConfirmInfo): + def __init__(self, cred: Fido2Credential): + self._cred = cred + self.load_icon(cred.rp_id_hash) + + def get_header(self) -> str: + return "Import credential" + + def app_name(self) -> str: + return self._cred.app_name() + + def account_name(self) -> Optional[str]: + return self._cred.account_name() + + +async def add_resident_credential( + ctx: wire.Context, msg: WebAuthnAddResidentCredential +) -> Success: + if not msg.credential_id: + raise wire.ProcessError("Missing credential ID parameter.") + + cred = Fido2Credential.from_cred_id(msg.credential_id, None) + if cred is None: + text = Text("Import credential", ui.ICON_WRONG, ui.RED) + text.normal( + "The credential you are", + "trying to import does", + "not belong to this", + "authenticator.", + ) + await require_confirm(ctx, text, confirm=None) + raise wire.ActionCancelled("Cancelled") + + content = ConfirmContent(ConfirmAddCredential(cred)) + await require_confirm(ctx, content) + + if store_resident_credential(cred): + return Success(message="Credential added") + else: + raise wire.ProcessError("Internal credential storage is full.") diff --git a/core/src/apps/webauthn/confirm.py b/core/src/apps/webauthn/confirm.py new file mode 100644 index 0000000000..6cb0ae08a8 --- /dev/null +++ b/core/src/apps/webauthn/confirm.py @@ -0,0 +1,62 @@ +from trezor import log, ui +from trezor.ui.text import text_center_trim_left, text_center_trim_right + +if False: + from typing import Optional + + +class ConfirmInfo: + def __init__(self) -> None: + self.app_icon = None # type: Optional[bytes] + + def get_header(self) -> str: + raise NotImplementedError + + def app_name(self) -> str: + raise NotImplementedError + + def account_name(self) -> Optional[str]: + return None + + def load_icon(self, rp_id_hash: bytes) -> None: + from trezor import res + from apps.webauthn import knownapps + + try: + namepart = knownapps.knownapps[rp_id_hash].lower().replace(" ", "_") + icon = res.load("apps/webauthn/res/icon_%s.toif" % namepart) + except Exception as e: + icon = res.load("apps/webauthn/res/icon_webauthn.toif") + if __debug__: + log.exception(__name__, e) + self.app_icon = icon + + +class ConfirmContent(ui.Component): + def __init__(self, info: ConfirmInfo) -> None: + self.info = info + self.repaint = True + + def on_render(self) -> None: + if self.repaint: + header = self.info.get_header() + ui.header(header, ui.ICON_DEFAULT, ui.GREEN, ui.BG, ui.GREEN) + + if self.info.app_icon is not None: + ui.display.image((ui.WIDTH - 64) // 2, 48, self.info.app_icon) + + app_name = self.info.app_name() + account_name = self.info.account_name() + + # Dummy requests usually have some text as both app_name and account_name, + # in that case show the text only once. + if account_name is not None: + if app_name != account_name: + text_center_trim_left(ui.WIDTH // 2, 140, app_name) + text_center_trim_right(ui.WIDTH // 2, 172, account_name) + else: + text_center_trim_right(ui.WIDTH // 2, 156, account_name) + else: + text_center_trim_left(ui.WIDTH // 2, 156, app_name) + + self.repaint = False diff --git a/core/src/apps/webauthn/credential.py b/core/src/apps/webauthn/credential.py index c1862ab249..dd48b500c3 100644 --- a/core/src/apps/webauthn/credential.py +++ b/core/src/apps/webauthn/credential.py @@ -30,6 +30,7 @@ _U2F_KEY_PATH = const(0x80553246) class Credential: def __init__(self) -> None: + self.index = None # type Optional[int] self.id = b"" # type: bytes self.rp_id = "" # type: str self.rp_id_hash = b"" # type: bytes @@ -104,7 +105,9 @@ class Fido2Credential(Credential): self.id = _CRED_ID_VERSION + iv + ciphertext + tag @staticmethod - def from_cred_id(cred_id: bytes, rp_id_hash: bytes) -> Optional["Fido2Credential"]: + def from_cred_id( + cred_id: bytes, rp_id_hash: Optional[bytes] + ) -> Optional["Fido2Credential"]: if len(cred_id) < _CRED_ID_MIN_LENGTH or cred_id[0:4] != _CRED_ID_VERSION: return None @@ -114,6 +117,16 @@ class Fido2Credential(Credential): iv = cred_id[4:16] ciphertext = cred_id[16:-16] tag = cred_id[-16:] + + if rp_id_hash is None: + ctx = chacha20poly1305(key, iv) + data = ctx.decrypt(ciphertext) + try: + rp_id = cbor.decode(data)[_CRED_ID_RP_ID] + except Exception: + return None + rp_id_hash = hashlib.sha256(rp_id).digest() + ctx = chacha20poly1305(key, iv) ctx.auth(rp_id_hash) data = ctx.decrypt(ciphertext) diff --git a/core/src/apps/webauthn/list_resident_credentials.py b/core/src/apps/webauthn/list_resident_credentials.py new file mode 100644 index 0000000000..4d7b0020a5 --- /dev/null +++ b/core/src/apps/webauthn/list_resident_credentials.py @@ -0,0 +1,38 @@ +from trezor import wire +from trezor.messages.WebAuthnCredential import WebAuthnCredential +from trezor.messages.WebAuthnCredentials import WebAuthnCredentials +from trezor.messages.WebAuthnListResidentCredentials import ( + WebAuthnListResidentCredentials, +) +from trezor.ui.text import Text + +from apps.common.confirm import require_confirm +from apps.common.storage.webauthn import get_resident_credentials + + +async def list_resident_credentials( + ctx: wire.Context, msg: WebAuthnListResidentCredentials +) -> WebAuthnCredentials: + text = Text("List credentials") + text.normal( + "Do you want to export", + "information about the", + "resident credentials", + "stored on this device?", + ) + await require_confirm(ctx, text) + creds = [ + WebAuthnCredential( + index=cred.index, + id=cred.id, + rp_id=cred.rp_id, + rp_name=cred.rp_name, + user_id=cred.user_id, + user_name=cred.user_name, + user_display_name=cred.user_display_name, + creation_time=cred._creation_time, + hmac_secret=cred.hmac_secret, + ) + for cred in get_resident_credentials() + ] + return WebAuthnCredentials(creds) diff --git a/core/src/apps/webauthn/remove_resident_credential.py b/core/src/apps/webauthn/remove_resident_credential.py new file mode 100644 index 0000000000..0e41d6dbe3 --- /dev/null +++ b/core/src/apps/webauthn/remove_resident_credential.py @@ -0,0 +1,48 @@ +from trezor import wire +from trezor.messages.Success import Success +from trezor.messages.WebAuthnRemoveResidentCredential import ( + WebAuthnRemoveResidentCredential, +) + +from apps.common.confirm import require_confirm +from apps.common.storage.webauthn import ( + erase_resident_credential, + get_resident_credential, +) +from apps.webauthn.confirm import ConfirmContent, ConfirmInfo +from apps.webauthn.credential import Fido2Credential + +if False: + from typing import Optional + + +class ConfirmRemoveCredential(ConfirmInfo): + def __init__(self, cred: Fido2Credential): + self._cred = cred + self.load_icon(cred.rp_id_hash) + + def get_header(self) -> str: + return "Remove credential" + + def app_name(self) -> str: + return self._cred.app_name() + + def account_name(self) -> Optional[str]: + return self._cred.account_name() + + +async def remove_resident_credential( + ctx: wire.Context, msg: WebAuthnRemoveResidentCredential +) -> Success: + if msg.index is None: + raise wire.ProcessError("Missing credential index parameter.") + + cred = get_resident_credential(msg.index) + if cred is None: + raise wire.ProcessError("Invalid credential index.") + + content = ConfirmContent(ConfirmRemoveCredential(cred)) + await require_confirm(ctx, content) + + erase_resident_credential(msg.index) + return Success(message="Credential removed") diff --git a/core/src/trezor/messages/MessageType.py b/core/src/trezor/messages/MessageType.py index 2727531728..a4fb1eb4ac 100644 --- a/core/src/trezor/messages/MessageType.py +++ b/core/src/trezor/messages/MessageType.py @@ -180,3 +180,7 @@ if not utils.BITCOIN_ONLY: BinanceOrderMsg = 707 BinanceCancelMsg = 708 BinanceSignedTx = 709 + WebAuthnListResidentCredentials = 800 + WebAuthnCredentials = 801 + WebAuthnAddResidentCredential = 802 + WebAuthnRemoveResidentCredential = 803 diff --git a/core/src/trezor/messages/WebAuthnAddResidentCredential.py b/core/src/trezor/messages/WebAuthnAddResidentCredential.py new file mode 100644 index 0000000000..f95f352e3d --- /dev/null +++ b/core/src/trezor/messages/WebAuthnAddResidentCredential.py @@ -0,0 +1,26 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnAddResidentCredential(p.MessageType): + MESSAGE_WIRE_TYPE = 802 + + def __init__( + self, + credential_id: bytes = None, + ) -> None: + self.credential_id = credential_id + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('credential_id', p.BytesType, 0), + } diff --git a/core/src/trezor/messages/WebAuthnCredential.py b/core/src/trezor/messages/WebAuthnCredential.py new file mode 100644 index 0000000000..a47d2b44a7 --- /dev/null +++ b/core/src/trezor/messages/WebAuthnCredential.py @@ -0,0 +1,49 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnCredential(p.MessageType): + + def __init__( + self, + index: int = None, + id: bytes = None, + rp_id: str = None, + rp_name: str = None, + user_id: bytes = None, + user_name: str = None, + user_display_name: str = None, + creation_time: int = None, + hmac_secret: bool = None, + ) -> None: + self.index = index + self.id = id + self.rp_id = rp_id + self.rp_name = rp_name + self.user_id = user_id + self.user_name = user_name + self.user_display_name = user_display_name + self.creation_time = creation_time + self.hmac_secret = hmac_secret + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('index', p.UVarintType, 0), + 2: ('id', p.BytesType, 0), + 3: ('rp_id', p.UnicodeType, 0), + 4: ('rp_name', p.UnicodeType, 0), + 5: ('user_id', p.BytesType, 0), + 6: ('user_name', p.UnicodeType, 0), + 7: ('user_display_name', p.UnicodeType, 0), + 8: ('creation_time', p.UVarintType, 0), + 9: ('hmac_secret', p.BoolType, 0), + } diff --git a/core/src/trezor/messages/WebAuthnCredentials.py b/core/src/trezor/messages/WebAuthnCredentials.py new file mode 100644 index 0000000000..6659f98b00 --- /dev/null +++ b/core/src/trezor/messages/WebAuthnCredentials.py @@ -0,0 +1,28 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +from .WebAuthnCredential import WebAuthnCredential + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnCredentials(p.MessageType): + MESSAGE_WIRE_TYPE = 801 + + def __init__( + self, + credentials: List[WebAuthnCredential] = None, + ) -> None: + self.credentials = credentials if credentials is not None else [] + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('credentials', WebAuthnCredential, p.FLAG_REPEATED), + } diff --git a/core/src/trezor/messages/WebAuthnListResidentCredentials.py b/core/src/trezor/messages/WebAuthnListResidentCredentials.py new file mode 100644 index 0000000000..e88ad3976b --- /dev/null +++ b/core/src/trezor/messages/WebAuthnListResidentCredentials.py @@ -0,0 +1,14 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnListResidentCredentials(p.MessageType): + MESSAGE_WIRE_TYPE = 800 diff --git a/core/src/trezor/messages/WebAuthnRemoveResidentCredential.py b/core/src/trezor/messages/WebAuthnRemoveResidentCredential.py new file mode 100644 index 0000000000..956d8956e4 --- /dev/null +++ b/core/src/trezor/messages/WebAuthnRemoveResidentCredential.py @@ -0,0 +1,26 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnRemoveResidentCredential(p.MessageType): + MESSAGE_WIRE_TYPE = 803 + + def __init__( + self, + index: int = None, + ) -> None: + self.index = index + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('index', p.UVarintType, 0), + } diff --git a/legacy/firmware/protob/Makefile b/legacy/firmware/protob/Makefile index 4904f96486..1608b327ad 100644 --- a/legacy/firmware/protob/Makefile +++ b/legacy/firmware/protob/Makefile @@ -2,7 +2,7 @@ ifneq ($(V),1) Q := @ endif -SKIPPED_MESSAGES := Binance Cardano DebugMonero Eos Monero Ontology Ripple Tezos +SKIPPED_MESSAGES := Binance Cardano DebugMonero Eos Monero Ontology Ripple Tezos WebAuthn ifeq ($(BITCOIN_ONLY), 1) SKIPPED_MESSAGES += Ethereum Lisk NEM Stellar diff --git a/python/docs/OPTIONS.rst b/python/docs/OPTIONS.rst index 45d2299fce..54f2a388a7 100644 --- a/python/docs/OPTIONS.rst +++ b/python/docs/OPTIONS.rst @@ -82,4 +82,7 @@ Use the following command to see all options: tezos-sign-tx Sign Tezos transaction. verify-message Verify message. version Show version of trezorctl/trezorlib. + webauthn-add-credential Add the credential with the given ID as a resident credential. + webauthn-list-credentials List all resident credentials on the device. + webauthn-remove-credential Remove the resident credential at the given index. wipe-device Reset device to factory defaults and remove all private data. diff --git a/python/src/trezorlib/cli/trezorctl.py b/python/src/trezorlib/cli/trezorctl.py index ac50001dbd..a78893d3e8 100755 --- a/python/src/trezorlib/cli/trezorctl.py +++ b/python/src/trezorlib/cli/trezorctl.py @@ -54,6 +54,7 @@ from trezorlib import ( tezos, tools, ui, + webauthn, ) from trezorlib.client import TrezorClient from trezorlib.transport import enumerate_devices, get_transport @@ -1929,6 +1930,58 @@ def binance_sign_tx(connect, address, file): return binance.sign_tx(client, address_n, json.load(file)) +# +# WebAuthn functions +# + + +@cli.command(help="List all resident credentials on the device.") +@click.pass_obj +def webauthn_list_credentials(connect): + creds = webauthn.list_credentials(connect()) + for cred in creds: + click.echo("") + click.echo("WebAuthn credential at index {}:".format(cred.index)) + if cred.rp_id is not None: + click.echo(" Relying party ID: {}".format(cred.rp_id)) + if cred.rp_name is not None: + click.echo(" Relying party name: {}".format(cred.rp_name)) + if cred.user_id is not None: + click.echo(" User ID: {}".format(cred.user_id.hex())) + if cred.user_name is not None: + click.echo(" User name: {}".format(cred.user_name)) + if cred.user_display_name is not None: + click.echo(" User display name: {}".format(cred.user_display_name)) + if cred.creation_time is not None: + click.echo(" Creation time: {}".format(cred.creation_time)) + if cred.hmac_secret is not None: + click.echo(" hmac-secret enabled: {}".format(cred.hmac_secret)) + click.echo(" Credential ID: {}".format(cred.id.hex())) + + if not creds: + click.echo("There are no resident credentials stored on the device.") + + +@cli.command() +@click.argument("hex_credential_id") +@click.pass_obj +def webauthn_add_credential(connect, hex_credential_id): + """Add the credential with the given ID as a resident credential. + + HEX_CREDENTIAL_ID is the credential ID as a hexadecimal string. + """ + return webauthn.add_credential(connect(), bytes.fromhex(hex_credential_id)) + + +@cli.command(help="Remove the resident credential at the given index.") +@click.option( + "-i", "--index", required=True, type=click.IntRange(0, 15), help="Credential index." +) +@click.pass_obj +def webauthn_remove_credential(connect, index): + return webauthn.remove_credential(connect(), index) + + # # Main # diff --git a/python/src/trezorlib/messages/MessageType.py b/python/src/trezorlib/messages/MessageType.py index d38c70b53c..fab7070f0b 100644 --- a/python/src/trezorlib/messages/MessageType.py +++ b/python/src/trezorlib/messages/MessageType.py @@ -177,3 +177,7 @@ BinanceTransferMsg = 706 BinanceOrderMsg = 707 BinanceCancelMsg = 708 BinanceSignedTx = 709 +WebAuthnListResidentCredentials = 800 +WebAuthnCredentials = 801 +WebAuthnAddResidentCredential = 802 +WebAuthnRemoveResidentCredential = 803 diff --git a/python/src/trezorlib/messages/WebAuthnAddResidentCredential.py b/python/src/trezorlib/messages/WebAuthnAddResidentCredential.py new file mode 100644 index 0000000000..efa17baba4 --- /dev/null +++ b/python/src/trezorlib/messages/WebAuthnAddResidentCredential.py @@ -0,0 +1,26 @@ +# Automatically generated by pb2py +# fmt: off +from .. import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnAddResidentCredential(p.MessageType): + MESSAGE_WIRE_TYPE = 802 + + def __init__( + self, + credential_id: bytes = None, + ) -> None: + self.credential_id = credential_id + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('credential_id', p.BytesType, 0), + } diff --git a/python/src/trezorlib/messages/WebAuthnCredential.py b/python/src/trezorlib/messages/WebAuthnCredential.py new file mode 100644 index 0000000000..05f5091b80 --- /dev/null +++ b/python/src/trezorlib/messages/WebAuthnCredential.py @@ -0,0 +1,49 @@ +# Automatically generated by pb2py +# fmt: off +from .. import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnCredential(p.MessageType): + + def __init__( + self, + index: int = None, + id: bytes = None, + rp_id: str = None, + rp_name: str = None, + user_id: bytes = None, + user_name: str = None, + user_display_name: str = None, + creation_time: int = None, + hmac_secret: bool = None, + ) -> None: + self.index = index + self.id = id + self.rp_id = rp_id + self.rp_name = rp_name + self.user_id = user_id + self.user_name = user_name + self.user_display_name = user_display_name + self.creation_time = creation_time + self.hmac_secret = hmac_secret + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('index', p.UVarintType, 0), + 2: ('id', p.BytesType, 0), + 3: ('rp_id', p.UnicodeType, 0), + 4: ('rp_name', p.UnicodeType, 0), + 5: ('user_id', p.BytesType, 0), + 6: ('user_name', p.UnicodeType, 0), + 7: ('user_display_name', p.UnicodeType, 0), + 8: ('creation_time', p.UVarintType, 0), + 9: ('hmac_secret', p.BoolType, 0), + } diff --git a/python/src/trezorlib/messages/WebAuthnCredentials.py b/python/src/trezorlib/messages/WebAuthnCredentials.py new file mode 100644 index 0000000000..46a5d99244 --- /dev/null +++ b/python/src/trezorlib/messages/WebAuthnCredentials.py @@ -0,0 +1,28 @@ +# Automatically generated by pb2py +# fmt: off +from .. import protobuf as p + +from .WebAuthnCredential import WebAuthnCredential + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnCredentials(p.MessageType): + MESSAGE_WIRE_TYPE = 801 + + def __init__( + self, + credentials: List[WebAuthnCredential] = None, + ) -> None: + self.credentials = credentials if credentials is not None else [] + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('credentials', WebAuthnCredential, p.FLAG_REPEATED), + } diff --git a/python/src/trezorlib/messages/WebAuthnListResidentCredentials.py b/python/src/trezorlib/messages/WebAuthnListResidentCredentials.py new file mode 100644 index 0000000000..404ebed9ca --- /dev/null +++ b/python/src/trezorlib/messages/WebAuthnListResidentCredentials.py @@ -0,0 +1,14 @@ +# Automatically generated by pb2py +# fmt: off +from .. import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnListResidentCredentials(p.MessageType): + MESSAGE_WIRE_TYPE = 800 diff --git a/python/src/trezorlib/messages/WebAuthnRemoveResidentCredential.py b/python/src/trezorlib/messages/WebAuthnRemoveResidentCredential.py new file mode 100644 index 0000000000..e9484e01c3 --- /dev/null +++ b/python/src/trezorlib/messages/WebAuthnRemoveResidentCredential.py @@ -0,0 +1,26 @@ +# Automatically generated by pb2py +# fmt: off +from .. import protobuf as p + +if __debug__: + try: + from typing import Dict, List, Optional + from typing_extensions import Literal # noqa: F401 + except ImportError: + Dict, List, Optional = None, None, None # type: ignore + + +class WebAuthnRemoveResidentCredential(p.MessageType): + MESSAGE_WIRE_TYPE = 803 + + def __init__( + self, + index: int = None, + ) -> None: + self.index = index + + @classmethod + def get_fields(cls) -> Dict: + return { + 1: ('index', p.UVarintType, 0), + } diff --git a/python/src/trezorlib/messages/__init__.py b/python/src/trezorlib/messages/__init__.py index 8415b1cb1d..1310b657b0 100644 --- a/python/src/trezorlib/messages/__init__.py +++ b/python/src/trezorlib/messages/__init__.py @@ -247,6 +247,11 @@ from .TxRequest import TxRequest from .TxRequestDetailsType import TxRequestDetailsType from .TxRequestSerializedType import TxRequestSerializedType from .VerifyMessage import VerifyMessage +from .WebAuthnAddResidentCredential import WebAuthnAddResidentCredential +from .WebAuthnCredential import WebAuthnCredential +from .WebAuthnCredentials import WebAuthnCredentials +from .WebAuthnListResidentCredentials import WebAuthnListResidentCredentials +from .WebAuthnRemoveResidentCredential import WebAuthnRemoveResidentCredential from .WipeDevice import WipeDevice from .WordAck import WordAck from .WordRequest import WordRequest diff --git a/python/src/trezorlib/webauthn.py b/python/src/trezorlib/webauthn.py new file mode 100644 index 0000000000..3ca88d1ae1 --- /dev/null +++ b/python/src/trezorlib/webauthn.py @@ -0,0 +1,34 @@ +# This file is part of the Trezor project. +# +# Copyright (C) 2019 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + + +from . import messages as proto +from .tools import expect + + +@expect(proto.WebAuthnCredentials, field="credentials") +def list_credentials(client): + return client.call(proto.WebAuthnListResidentCredentials()) + + +@expect(proto.Success, field="message") +def add_credential(client, credential_id): + return client.call(proto.WebAuthnAddResidentCredential(credential_id)) + + +@expect(proto.Success, field="message") +def remove_credential(client, index): + return client.call(proto.WebAuthnRemoveResidentCredential(index)) diff --git a/tests/device_tests/test_msg_webauthn.py b/tests/device_tests/test_msg_webauthn.py new file mode 100644 index 0000000000..4ba0b6eccd --- /dev/null +++ b/tests/device_tests/test_msg_webauthn.py @@ -0,0 +1,193 @@ +# This file is part of the Trezor project. +# +# Copyright (C) 2012-2019 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + +import pytest + +from trezorlib import webauthn +from trezorlib.exceptions import Cancelled, TrezorFailure + +from ..common import MNEMONIC12 + +CRED1 = bytes.fromhex( + "f1d00200f8221312f7898e31ea5ec30409527c2b0bde0b9dfdd7eaab4424173f" + "bf75ab67627fff60974460d903d7d96bb9e974c169a01b2c38cf2305da304169" + "d4e28f59053a2564bebb3eb3f06c2182f1ea4a2f7cebd8f92a930a76f3b45334" + "1e3f3285a575a54bcba9cf8a088dbfe24e8e691a5926160174e03aa941828f49" + "e42b47804d" +) + +CRED2 = bytes.fromhex( + "f1d00200eb3b566f4ea0a219552b2efd2c76e1ffc2e641d3bf91ec92d47a4ed4" + "d78cf42845248c4e982a503618bac0cecfb0fa91fa10821df1efe1d59ac8314e" + "b57eb7f32a1a605f91e8692daf1a679b55ab1acadfded5e0c7fd1365e2801759" + "bd3a4450dd5589586ab072da79" +) + +CRED3 = bytes.fromhex( + "f1d00200ebee50034eb7affb555602eed0812b63d158b57a4188523ad064a719" + "febf477c52cfcc7ded8d7a7a83af52287ed1ecee9f74f62b7e55ad8e814c062e" + "009bb3b3391dfec79dc93053b0279eca7207358a0962865da55668b2509de773" + "8c819dbeead9997778319ac1f1c7318fd6" +) + +CREDS = [ + bytes.fromhex( + "f1d0020029a297837485bf2b43f2a8cc53b759a03201cf6902cf25794a375214" + "aea1357cee1e2fa9188e8fb74e5b5501767ca740cd1f0c745bb72afd" + ), + bytes.fromhex( + "f1d00200ce4e44a4d5076b7d3037ca039894738183f18b0ef5edfa84b59ba4e9" + "2e9ce5fe02ddd6cd397c459636dfb45af740d268bd67610578581cc1" + ), + bytes.fromhex( + "f1d00200776ac8476ac5a621c135e9ab3d5c5c1d836843eddad88f94ff044989" + "cc941f5971bd3df1a3008e12ad16a11753cdfe113d023784a29bbbe0" + ), + bytes.fromhex( + "f1d00200f4bf428bc3ea21a64691bc1cfb3ae14d4ed29621777856ea81b8936e" + "51293fb8b073ab1c03fe7016b01f9e2bcac796f3c3c33515ffbf88c2" + ), + bytes.fromhex( + "f1d0020055e4d0a8b06951564f71dd601287929b396013d1b1cfd1ab237a6e1d" + "b53b7f562465ed53b3fc8ba7f0b5e05498fd13badfaac358694e76f2" + ), + bytes.fromhex( + "f1d00200ea2b8789416aa55dac3e8446da76a9fba3f52722329bf4820480faf1" + "ed35f2eb8577a0e3bbcecd6177d1a4c21faafc3411281ebbc2a8f100" + ), + bytes.fromhex( + "f1d0020043e37bb7c62fd11b6d446da96741123b38ab9123d695537357373970" + "8d0e7aaff1ed90306da2779c23fde88c68cd37171c871af4f6c6cc08" + ), + bytes.fromhex( + "f1d00200309ced39cf016b1ae284cd63e48310dd73e14f5f3af681fcfd84e121" + "6cbab4b1d00f505445b839bca1909521e4ba06209fd161bb98eb2b7d" + ), + bytes.fromhex( + "f1d00200c19e3a3e2ce982419b52487e84ceb42a92bbda1c029b1bb3e832ffa7" + "0321c22edfb6163ee5ec2be03b1b291f451667a6020a720c41653745" + ), + bytes.fromhex( + "f1d0020046ce52d1ed50a900687d6ba20863cc9c0cd6ee9fb72129a0f63eb598" + "dcd3cd79c449d251240e2098f4b29e4cfa28ab7b45b77f045589312d" + ), + bytes.fromhex( + "f1d002004f92099262dbedc059237e3aff412204131dad9cbad98147322b00ed" + "988cd7f7b2ea2f34b0388b3efa1246477d058e4d94773a38355bc2e7" + ), + bytes.fromhex( + "f1d00200ac93867d1bfbe6a6be75d943354f280e32fafce204bcee65db097666" + "e805b80d38f4f3094f334fb310d4f5cc80ccef603fdd6ba320b4eb73" + ), + bytes.fromhex( + "f1d002006d5d6efbe81fe81927029727409d0f242a4da827947ec55e118cd65c" + "e6f0d1ae4c7ac578f3682806b5e0e5bfaaf7d0416960ece3fc219516" + ), + bytes.fromhex( + "f1d00200e231eba4d9875231644ff1e38c83be7ce3508401b6184320a2ea3dc2" + "6092f807aba192c6fc5e7286dfc0e5ccc4738d6d8c8a1a440140b47a" + ), + bytes.fromhex( + "f1d002008841311e477753cbfa4b21779d4c04e7c5532f956f2c6995b99e1392" + "1143b64b4099c98b4b1c012ef06c1bfa673f192fec193f05cf26c0cc" + ), +] + + +@pytest.mark.skip_t1 +@pytest.mark.altcoin +class TestMsgWebAuthn: + @pytest.mark.setup_client(mnemonic=MNEMONIC12) + def test_add_remove(self, client): + # Remove index 0 should fail. + with pytest.raises(TrezorFailure): + webauthn.remove_credential(client, 0) + + # List should be empty. + assert webauthn.list_credentials(client) == [] + + # Add valid credential #1. + webauthn.add_credential(client, CRED1) + + # Check that the credential was added and parameters are correct. + creds = webauthn.list_credentials(client) + assert len(creds) == 1 + assert creds[0].rp_id == "example.com" + assert creds[0].rp_name == "Example" + assert creds[0].user_id == bytes.fromhex( + "3082019330820138A0030201023082019330820138A003020102308201933082" + ) + assert creds[0].user_name == "johnpsmith@example.com" + assert creds[0].user_display_name == "John P. Smith" + assert creds[0].creation_time == 3 + assert creds[0].hmac_secret is True + + # Add valid credential #2, which has same rpId and userId as credential #1. + webauthn.add_credential(client, CRED2) + + # Check that the credential #2 replaced credential #1 and parameters are correct. + creds = webauthn.list_credentials(client) + assert len(creds) == 1 + assert creds[0].rp_id == "example.com" + assert creds[0].rp_name is None + assert creds[0].user_id == bytes.fromhex( + "3082019330820138A0030201023082019330820138A003020102308201933082" + ) + assert creds[0].user_name == "johnpsmith@example.com" + assert creds[0].user_display_name is None + assert creds[0].creation_time == 2 + assert creds[0].hmac_secret is True + + # Adding an invalid credential should appear as if user cancelled. + with pytest.raises(Cancelled): + webauthn.add_credential(client, CRED1[:-2]) + + # Check that the credential was not added. + creds = webauthn.list_credentials(client) + assert len(creds) == 1 + + # Add valid credential, which has same userId as #2, but different rpId. + webauthn.add_credential(client, CRED3) + + # Check that the credential was added. + creds = webauthn.list_credentials(client) + assert len(creds) == 2 + + # Fill up with 14 more valid credentials. + for cred in CREDS[:14]: + webauthn.add_credential(client, cred) + + # Adding one more valid credential to full storage should fail. + with pytest.raises(TrezorFailure): + webauthn.add_credential(client, CREDS[14]) + + # Remove index 16 should fail. + with pytest.raises(TrezorFailure): + webauthn.remove_credential(client, 16) + + # Remove index 2. + webauthn.remove_credential(client, 2) + + # Check that the credential was removed. + creds = webauthn.list_credentials(client) + assert len(creds) == 15 + + # Adding another valid credential should succeed now. + webauthn.add_credential(client, CREDS[14]) + + # Check that the credential was added. + creds = webauthn.list_credentials(client) + assert len(creds) == 16 diff --git a/tools/build_protobuf b/tools/build_protobuf index 31ba7808ec..04bca9e268 100755 --- a/tools/build_protobuf +++ b/tools/build_protobuf @@ -20,6 +20,7 @@ CORE_PROTOBUF_SOURCES="\ $PROTOB/messages-ripple.proto \ $PROTOB/messages-stellar.proto \ $PROTOB/messages-tezos.proto \ + $PROTOB/messages-webauthn.proto \ " PYTHON_PROTOBUF_SOURCES=$PROTOB/*.proto @@ -72,7 +73,7 @@ do_rebuild() { sed -i "3ifrom trezor import utils\n" "$DESTDIR"/Capability.py sed -i "3ifrom trezor import utils\n" "$DESTDIR"/MessageType.py sed -i "/^EthereumGetPublicKey/iif not utils.BITCOIN_ONLY:" "$DESTDIR"/MessageType.py - for altcoin in Ethereum NEM Lisk Tezos Stellar Cardano Ripple Monero DebugMonero Eos Binance; do + for altcoin in Ethereum NEM Lisk Tezos Stellar Cardano Ripple Monero DebugMonero Eos Binance WebAuthn; do sed -i "s:^$altcoin: $altcoin:" "$DESTDIR"/Capability.py sed -i "s:^$altcoin: $altcoin:" "$DESTDIR"/MessageType.py done