Source code for fido2.webauthn

# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
#   Redistribution and use in source and binary forms, with or
#   without modification, are permitted provided that the following
#   conditions are met:
#
#    1. Redistributions of source code must retain the above copyright
#       notice, this list of conditions and the following disclaimer.
#    2. Redistributions in binary form must reproduce the above
#       copyright notice, this list of conditions and the following
#       disclaimer in the documentation and/or other materials provided
#       with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

from __future__ import annotations

from . import cbor
from .cose import CoseKey, ES256
from .utils import (
    sha256,
    websafe_decode,
    websafe_encode,
    ByteBuffer,
    _JsonDataObject,
)
from .features import webauthn_json_mapping
from enum import Enum, EnumMeta, unique, IntFlag
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional, Sequence, Tuple, Union, cast
import struct
import json

"""
Data classes based on the W3C WebAuthn specification (https://www.w3.org/TR/webauthn/).

See the specification for a description and details on their usage.
"""

# Binary types


[docs] class Aaguid(bytes): def __init__(self, data: bytes): if len(self) != 16: raise ValueError("AAGUID must be 16 bytes")
[docs] def __bool__(self): return self != Aaguid.NONE
[docs] def __str__(self): h = self.hex() return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
[docs] def __repr__(self): return f"AAGUID({str(self)})"
[docs] @classmethod def parse(cls, value: str) -> Aaguid: return cls.fromhex(value.replace("-", ""))
NONE: Aaguid
# Special instance of AAGUID used when there is no AAGUID Aaguid.NONE = Aaguid(b"\0" * 16)
[docs] @dataclass(init=False, frozen=True) class AttestedCredentialData(bytes): aaguid: Aaguid credential_id: bytes public_key: CoseKey def __init__(self, _: bytes): super().__init__() parsed = AttestedCredentialData._parse(self) object.__setattr__(self, "aaguid", parsed[0]) object.__setattr__(self, "credential_id", parsed[1]) object.__setattr__(self, "public_key", parsed[2]) if parsed[3]: raise ValueError("Wrong length")
[docs] def __str__(self): # Override default implementation from bytes. return repr(self)
@staticmethod def _parse(data: bytes) -> Tuple[bytes, bytes, CoseKey, bytes]: """Parse the components of an AttestedCredentialData from a binary string, and return them. :param data: A binary string containing an attested credential data. :return: AAGUID, credential ID, public key, and remaining data. """ reader = ByteBuffer(data) aaguid = Aaguid(reader.read(16)) cred_id = reader.read(reader.unpack(">H")) pub_key, rest = cbor.decode_from(reader.read()) return aaguid, cred_id, CoseKey.parse(pub_key), rest
[docs] @classmethod def create( cls, aaguid: bytes, credential_id: bytes, public_key: CoseKey ) -> AttestedCredentialData: """Create an AttestedCredentialData by providing its components. :param aaguid: The AAGUID of the authenticator. :param credential_id: The binary ID of the credential. :param public_key: A COSE formatted public key. :return: The attested credential data. """ return cls( aaguid + struct.pack(">H", len(credential_id)) + credential_id + cbor.encode(public_key) )
[docs] @classmethod def unpack_from(cls, data: bytes) -> Tuple[AttestedCredentialData, bytes]: """Unpack an AttestedCredentialData from a byte string, returning it and any remaining data. :param data: A binary string containing an attested credential data. :return: The parsed AttestedCredentialData, and any remaining data from the input. """ aaguid, cred_id, pub_key, rest = cls._parse(data) return cls.create(aaguid, cred_id, pub_key), rest
[docs] @classmethod def from_ctap1(cls, key_handle: bytes, public_key: bytes) -> AttestedCredentialData: """Create an AttestatedCredentialData from a CTAP1 RegistrationData instance. :param key_handle: The CTAP1 credential key_handle. :type key_handle: bytes :param public_key: The CTAP1 65 byte public key. :type public_key: bytes :return: The credential data, using an all-zero AAGUID. :rtype: AttestedCredentialData """ return cls.create(Aaguid.NONE, key_handle, ES256.from_ctap1(public_key))
[docs] @dataclass(init=False, frozen=True) class AuthenticatorData(bytes): """Binary encoding of the authenticator data. :param _: The binary representation of the authenticator data. :ivar rp_id_hash: SHA256 hash of the RP ID. :ivar flags: The flags of the authenticator data, see AuthenticatorData.FLAG. :ivar counter: The signature counter of the authenticator. :ivar credential_data: Attested credential data, if available. :ivar extensions: Authenticator extensions, if available. """
[docs] class FLAG(IntFlag): """Authenticator data flags See https://www.w3.org/TR/webauthn/#sec-authenticator-data for details """ # Names used in WebAuthn UP = 0x01 UV = 0x04 BE = 0x08 BS = 0x10 AT = 0x40 ED = 0x80 # Aliases (for historical purposes) USER_PRESENT = 0x01 USER_VERIFIED = 0x04 BACKUP_ELIGIBILITY = 0x08 BACKUP_STATE = 0x10 ATTESTED = 0x40 EXTENSION_DATA = 0x80
rp_id_hash: bytes flags: AuthenticatorData.FLAG counter: int credential_data: Optional[AttestedCredentialData] extensions: Optional[Mapping] def __init__(self, _: bytes): super().__init__() reader = ByteBuffer(self) object.__setattr__(self, "rp_id_hash", reader.read(32)) object.__setattr__(self, "flags", reader.unpack("B")) object.__setattr__(self, "counter", reader.unpack(">I")) rest = reader.read() if self.flags & AuthenticatorData.FLAG.AT: credential_data, rest = AttestedCredentialData.unpack_from(rest) else: credential_data = None object.__setattr__(self, "credential_data", credential_data) if self.flags & AuthenticatorData.FLAG.ED: extensions, rest = cbor.decode_from(rest) else: extensions = None object.__setattr__(self, "extensions", extensions) if rest: raise ValueError("Wrong length")
[docs] def __str__(self): # Override default implementation from bytes. return repr(self)
[docs] @classmethod def create( cls, rp_id_hash: bytes, flags: AuthenticatorData.FLAG, counter: int, credential_data: bytes = b"", extensions: Optional[Mapping] = None, ): """Create an AuthenticatorData instance. :param rp_id_hash: SHA256 hash of the RP ID. :param flags: Flags of the AuthenticatorData. :param counter: Signature counter of the authenticator data. :param credential_data: Authenticated credential data (only if attested credential data flag is set). :param extensions: Authenticator extensions (only if ED flag is set). :return: The authenticator data. """ return cls( rp_id_hash + struct.pack(">BI", flags, counter) + credential_data + (cbor.encode(extensions) if extensions is not None else b"") )
[docs] def is_user_present(self) -> bool: """Return true if the User Present flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.UP)
[docs] def is_user_verified(self) -> bool: """Return true if the User Verified flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.UV)
[docs] def is_backup_eligible(self) -> bool: """Return true if the Backup Eligibility flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.BE)
[docs] def is_backed_up(self) -> bool: """Return true if the Backup State flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.BS)
[docs] def is_attested(self) -> bool: """Return true if the Attested credential data flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.AT)
[docs] def has_extension_data(self) -> bool: """Return true if the Extenstion data flag is set.""" return bool(self.flags & AuthenticatorData.FLAG.ED)
[docs] @dataclass(init=False, frozen=True) class AttestationObject(bytes): # , Mapping[str, Any]): """Binary CBOR encoded attestation object. :param _: The binary representation of the attestation object. :ivar fmt: The type of attestation used. :ivar auth_data: The attested authenticator data. :ivar att_statement: The attestation statement. """ fmt: str auth_data: AuthenticatorData att_stmt: Mapping[str, Any] def __init__(self, _: bytes): super().__init__() data = cast(Mapping[str, Any], cbor.decode(bytes(self))) object.__setattr__(self, "fmt", data["fmt"]) object.__setattr__(self, "auth_data", AuthenticatorData(data["authData"])) object.__setattr__(self, "att_stmt", data["attStmt"])
[docs] def __str__(self): # Override default implementation from bytes. return repr(self)
[docs] @classmethod def create( cls, fmt: str, auth_data: AuthenticatorData, att_stmt: Mapping[str, Any] ) -> AttestationObject: return cls( cbor.encode({"fmt": fmt, "authData": auth_data, "attStmt": att_stmt}) )
[docs] @classmethod def from_ctap1(cls, app_param: bytes, registration) -> AttestationObject: """Create an AttestationObject from a CTAP1 RegistrationData instance. :param app_param: SHA256 hash of the RP ID used for the CTAP1 request. :type app_param: bytes :param registration: The CTAP1 registration data. :type registration: RegistrationData :return: The attestation object, using the "fido-u2f" format. :rtype: AttestationObject """ return cls.create( "fido-u2f", AuthenticatorData.create( app_param, AuthenticatorData.FLAG.AT | AuthenticatorData.FLAG.UP, 0, AttestedCredentialData.from_ctap1( registration.key_handle, registration.public_key ), ), {"x5c": [registration.certificate], "sig": registration.signature}, )
[docs] @dataclass(init=False, frozen=True) class CollectedClientData(bytes):
[docs] @unique class TYPE(str, Enum): CREATE = "webauthn.create" GET = "webauthn.get"
type: str challenge: bytes origin: str cross_origin: bool = False def __init__(self, _: bytes): super().__init__() data = json.loads(self.decode()) object.__setattr__(self, "type", data["type"]) object.__setattr__(self, "challenge", websafe_decode(data["challenge"])) object.__setattr__(self, "origin", data["origin"]) object.__setattr__(self, "cross_origin", data.get("crossOrigin", False))
[docs] @classmethod def create( cls, type: str, challenge: Union[bytes, str], origin: str, cross_origin: bool = False, **kwargs, ) -> CollectedClientData: if isinstance(challenge, bytes): encoded_challenge = websafe_encode(challenge) else: encoded_challenge = challenge return cls( json.dumps( { "type": type, "challenge": encoded_challenge, "origin": origin, "crossOrigin": cross_origin, **kwargs, }, separators=(",", ":"), ).encode() )
[docs] def __str__(self): # Override default implementation from bytes. return repr(self)
@property def b64(self) -> str: return websafe_encode(self) @property def hash(self) -> bytes: return sha256(self)
class _StringEnumMeta(EnumMeta): def _get_value(cls, value): return None def __call__(cls, value, *args, **kwargs): try: return super().__call__(value, *args, **kwargs) except ValueError: return cls._get_value(value) class _StringEnum(str, Enum, metaclass=_StringEnumMeta): """Enum of strings for WebAuthn types. Unrecognized values are treated as missing. """
[docs] @unique class AttestationConveyancePreference(_StringEnum): NONE = "none" INDIRECT = "indirect" DIRECT = "direct" ENTERPRISE = "enterprise"
[docs] @unique class UserVerificationRequirement(_StringEnum): REQUIRED = "required" PREFERRED = "preferred" DISCOURAGED = "discouraged"
[docs] @unique class ResidentKeyRequirement(_StringEnum): REQUIRED = "required" PREFERRED = "preferred" DISCOURAGED = "discouraged"
[docs] @unique class AuthenticatorAttachment(_StringEnum): PLATFORM = "platform" CROSS_PLATFORM = "cross-platform"
[docs] @unique class AuthenticatorTransport(_StringEnum): USB = "usb" NFC = "nfc" BLE = "ble" HYBRID = "hybrid" INTERNAL = "internal"
[docs] @unique class PublicKeyCredentialType(_StringEnum): PUBLIC_KEY = "public-key"
class _WebAuthnDataObject(_JsonDataObject): def __getitem__(self, key): if webauthn_json_mapping.enabled: return super().__getitem__(key) return super(_JsonDataObject, self).__getitem__(key) @classmethod def _parse_value(cls, t, value): if webauthn_json_mapping.enabled: return super()._parse_value(t, value) return super(_JsonDataObject, cls)._parse_value(t, value) @classmethod def from_dict(cls, data): webauthn_json_mapping.warn() return super().from_dict(data) def _as_cbor(data: _WebAuthnDataObject) -> Mapping[str, Any]: return {k: super(_JsonDataObject, data).__getitem__(k) for k in data}
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialRpEntity(_WebAuthnDataObject): name: str id: Optional[str] = None @property def id_hash(self) -> Optional[bytes]: """Return SHA256 hash of the identifier.""" return sha256(self.id.encode("utf8")) if self.id else None
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialUserEntity(_WebAuthnDataObject): name: str id: bytes display_name: Optional[str] = None
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialParameters(_WebAuthnDataObject): type: PublicKeyCredentialType alg: int
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialDescriptor(_WebAuthnDataObject): type: PublicKeyCredentialType id: bytes transports: Optional[Sequence[AuthenticatorTransport]] = None
[docs] @dataclass(eq=False, frozen=True) class AuthenticatorSelectionCriteria(_WebAuthnDataObject): authenticator_attachment: Optional[AuthenticatorAttachment] = None resident_key: Optional[ResidentKeyRequirement] = None user_verification: Optional[UserVerificationRequirement] = None require_resident_key: Optional[bool] = False
[docs] def __post_init__(self): super().__post_init__() if self.resident_key is None: object.__setattr__( self, "resident_key", ( ResidentKeyRequirement.REQUIRED if self.require_resident_key else ResidentKeyRequirement.DISCOURAGED ), ) object.__setattr__( self, "require_resident_key", self.resident_key == ResidentKeyRequirement.REQUIRED, )
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialCreationOptions(_WebAuthnDataObject): rp: PublicKeyCredentialRpEntity user: PublicKeyCredentialUserEntity challenge: bytes pub_key_cred_params: Sequence[PublicKeyCredentialParameters] timeout: Optional[int] = None exclude_credentials: Optional[Sequence[PublicKeyCredentialDescriptor]] = None authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None attestation: Optional[AttestationConveyancePreference] = None extensions: Optional[Mapping[str, Any]] = None
[docs] @dataclass(eq=False, frozen=True) class PublicKeyCredentialRequestOptions(_WebAuthnDataObject): challenge: bytes timeout: Optional[int] = None rp_id: Optional[str] = None allow_credentials: Optional[Sequence[PublicKeyCredentialDescriptor]] = None user_verification: Optional[UserVerificationRequirement] = None extensions: Optional[Mapping[str, Any]] = None
# TODO 2.0: Move extension results to RegistrationResponse, remove methods
[docs] @dataclass(eq=False, frozen=True) class AuthenticatorAttestationResponse(_WebAuthnDataObject): client_data: CollectedClientData = field(metadata=dict(name="clientDataJSON")) attestation_object: AttestationObject extension_results: Optional[Mapping[str, Any]] = None
[docs] def __getitem__(self, key): if key == "clientData" and not webauthn_json_mapping.enabled: return self.client_data return super().__getitem__(key)
[docs] @classmethod def from_dict(cls, data): if data is not None and not webauthn_json_mapping.enabled: value = dict(data) value["clientDataJSON"] = value.pop("clientData", None) data = value return super().from_dict(data)
@classmethod def _parse_value(cls, t, value): if t == Optional[Mapping[str, Any]]: # Don't convert extension_results return value return super()._parse_value(t, value)
# TODO 2.0: Move extension results to AuthenticationResponse, remove methods
[docs] @dataclass(eq=False, frozen=True) class AuthenticatorAssertionResponse(_WebAuthnDataObject): client_data: CollectedClientData = field(metadata=dict(name="clientDataJSON")) authenticator_data: AuthenticatorData signature: bytes user_handle: Optional[bytes] = None credential_id: Optional[bytes] = None extension_results: Optional[Mapping[str, Any]] = None
[docs] def __getitem__(self, key): if key == "clientData" and not webauthn_json_mapping.enabled: return self.client_data return super().__getitem__(key)
[docs] @classmethod def from_dict(cls, data): if data is not None and not webauthn_json_mapping.enabled: value = dict(data) value["clientDataJSON"] = value.pop("clientData", None) data = value return super().from_dict(data)
@classmethod def _parse_value(cls, t, value): if t == Optional[Mapping[str, Any]]: # Don't convert extension_results return value return super()._parse_value(t, value)
# TODO 2.0: Re-align against WebAuthn spec and use in client
[docs] @dataclass(eq=False, frozen=True) class RegistrationResponse(_WebAuthnDataObject): id: bytes response: AuthenticatorAttestationResponse authenticator_attachment: Optional[AuthenticatorAttachment] = None client_extension_results: Optional[AuthenticationExtensionsClientOutputs] = None type: Optional[PublicKeyCredentialType] = None
[docs] def __post_init__(self): webauthn_json_mapping.require() super().__post_init__()
# TODO 2.0: Re-align against WebAuthn spec and use in client
[docs] @dataclass(eq=False, frozen=True) class AuthenticationResponse(_WebAuthnDataObject): id: bytes response: AuthenticatorAssertionResponse authenticator_attachment: Optional[AuthenticatorAttachment] = None client_extension_results: Optional[AuthenticationExtensionsClientOutputs] = None type: Optional[PublicKeyCredentialType] = None
[docs] def __post_init__(self): webauthn_json_mapping.require() super().__post_init__()
[docs] @dataclass(eq=False, frozen=True) class CredentialCreationOptions(_WebAuthnDataObject): public_key: PublicKeyCredentialCreationOptions
[docs] @dataclass(eq=False, frozen=True) class CredentialRequestOptions(_WebAuthnDataObject): public_key: PublicKeyCredentialRequestOptions
[docs] class AuthenticationExtensionsClientOutputs(Mapping[str, Any]): """Holds extension output from a call to MakeCredential or GetAssertion. When accessed as a dict, all bytes values will be serialized to base64url encoding, capable of being serialized to JSON. When accessed using attributes, richer types will instead be returned. """ def __init__(self, outputs: Mapping[str, Any]): self._members = {k: v for k, v in outputs.items() if v is not None}
[docs] def __iter__(self): return iter(self._members)
[docs] def __len__(self): return len(self._members)
[docs] def __getitem__(self, key): value = self._members[key] if isinstance(value, bytes): return websafe_encode(value) elif isinstance(value, Mapping) and not isinstance(value, dict): return dict(value) return value
[docs] def __getattr__(self, key): parts = key.split("_") name = parts[0] + "".join(p.title() for p in parts[1:]) return self._members.get(name)
[docs] def __repr__(self): return repr(dict(self))