python-fido2/fido2/webauthn.py

568 lines
18 KiB
Python

# 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,
_CamelCaseDataObject,
)
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
class Aaguid(bytes):
def __init__(self, data):
if len(data) != 16:
raise ValueError("AAGUID must be 16 bytes")
def __bool__(self):
return self != Aaguid.NONE
def __str__(self):
h = self.hex()
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
def __repr__(self):
return f"AAGUID({str(self)})"
@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)
@dataclass(init=False, frozen=True)
class AttestedCredentialData(bytes):
aaguid: Aaguid
credential_id: bytes
public_key: CoseKey
def __init__(self, _):
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")
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
@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)
)
@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
@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))
@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.
"""
@unique
class FLAG(IntFlag):
"""Authenticator data flags
See https://www.w3.org/TR/webauthn/#sec-authenticator-data for details
"""
USER_PRESENT = 0x01
USER_VERIFIED = 0x04
ATTESTED = 0x40
EXTENSION_DATA = 0x80
rp_id_hash: bytes
flags: AuthenticatorData.FLAG
counter: int
credential_data: Optional[AttestedCredentialData]
extensions: Optional[Mapping]
def __init__(self, _):
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.ATTESTED:
credential_data, rest = AttestedCredentialData.unpack_from(rest)
else:
credential_data = None
object.__setattr__(self, "credential_data", credential_data)
if self.flags & AuthenticatorData.FLAG.EXTENSION_DATA:
extensions, rest = cbor.decode_from(rest)
else:
extensions = None
object.__setattr__(self, "extensions", extensions)
if rest:
raise ValueError("Wrong length")
def __str__(self): # Override default implementation from bytes.
return repr(self)
@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"")
)
def is_user_present(self) -> bool:
"""Return true if the User Present flag is set.
:return: True if User Present is set, False otherwise.
:rtype: bool
"""
return bool(self.flags & AuthenticatorData.FLAG.USER_PRESENT)
def is_user_verified(self) -> bool:
"""Return true if the User Verified flag is set.
:return: True if User Verified is set, False otherwise.
:rtype: bool
"""
return bool(self.flags & AuthenticatorData.FLAG.USER_VERIFIED)
def is_attested(self) -> bool:
"""Return true if the Attested credential data flag is set.
:return: True if Attested credential data is set, False otherwise.
:rtype: bool
"""
return bool(self.flags & AuthenticatorData.FLAG.ATTESTED)
def has_extension_data(self) -> bool:
"""Return true if the Extenstion data flag is set.
:return: True if Extenstion data is set, False otherwise.
:rtype: bool
"""
return bool(self.flags & AuthenticatorData.FLAG.EXTENSION_DATA)
@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, _):
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"])
def __str__(self): # Override default implementation from bytes.
return repr(self)
@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})
)
@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.ATTESTED | AuthenticatorData.FLAG.USER_PRESENT,
0,
AttestedCredentialData.from_ctap1(
registration.key_handle, registration.public_key
),
),
{"x5c": [registration.certificate], "sig": registration.signature},
)
@dataclass(init=False, frozen=True)
class CollectedClientData(bytes):
@unique
class TYPE(str, Enum):
CREATE = "webauthn.create"
GET = "webauthn.get"
type: str
challenge: bytes
origin: str
cross_origin: bool = False
def __init__(self, *args):
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))
@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,
"cross_origin": cross_origin,
**kwargs,
},
separators=(",", ":"),
).encode()
)
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.
"""
@unique
class AttestationConveyancePreference(_StringEnum):
NONE = "none"
INDIRECT = "indirect"
DIRECT = "direct"
ENTERPRISE = "enterprise"
@unique
class UserVerificationRequirement(_StringEnum):
REQUIRED = "required"
PREFERRED = "preferred"
DISCOURAGED = "discouraged"
@unique
class ResidentKeyRequirement(_StringEnum):
REQUIRED = "required"
PREFERRED = "preferred"
DISCOURAGED = "discouraged"
@unique
class AuthenticatorAttachment(_StringEnum):
PLATFORM = "platform"
CROSS_PLATFORM = "cross-platform"
@unique
class AuthenticatorTransport(_StringEnum):
USB = "usb"
NFC = "nfc"
BLE = "ble"
INTERNAL = "internal"
@unique
class PublicKeyCredentialType(_StringEnum):
PUBLIC_KEY = "public-key"
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialRpEntity(_CamelCaseDataObject):
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
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialUserEntity(_CamelCaseDataObject):
name: str
id: bytes
display_name: Optional[str] = None
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialParameters(_CamelCaseDataObject):
type: PublicKeyCredentialType
alg: int
@classmethod
def _deserialize_list(cls, value):
if value is None:
return None
items = [cls.from_dict(e) for e in value]
return [e for e in items if e.type is not None]
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialDescriptor(_CamelCaseDataObject):
type: PublicKeyCredentialType
id: bytes
transports: Optional[Sequence[AuthenticatorTransport]] = None
@classmethod
def _deserialize_list(cls, value):
if value is None:
return None
items = [cls.from_dict(e) for e in value]
return [e for e in items if e.type is not None]
@dataclass(eq=False, frozen=True)
class AuthenticatorSelectionCriteria(_CamelCaseDataObject):
authenticator_attachment: Optional[AuthenticatorAttachment] = None
resident_key: Optional[ResidentKeyRequirement] = None
user_verification: Optional[UserVerificationRequirement] = None
require_resident_key: Optional[bool] = False
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,
)
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialCreationOptions(_CamelCaseDataObject):
rp: PublicKeyCredentialRpEntity
user: PublicKeyCredentialUserEntity
challenge: bytes
pub_key_cred_params: Sequence[PublicKeyCredentialParameters] = field(
metadata=dict(deserialize=PublicKeyCredentialParameters._deserialize_list),
)
timeout: Optional[int] = None
exclude_credentials: Optional[Sequence[PublicKeyCredentialDescriptor]] = field(
default=None,
metadata=dict(deserialize=PublicKeyCredentialDescriptor._deserialize_list),
)
authenticator_selection: Optional[AuthenticatorSelectionCriteria] = None
attestation: Optional[AttestationConveyancePreference] = None
extensions: Optional[Mapping[str, Any]] = None
@dataclass(eq=False, frozen=True)
class PublicKeyCredentialRequestOptions(_CamelCaseDataObject):
challenge: bytes
timeout: Optional[int] = None
rp_id: Optional[str] = None
allow_credentials: Optional[Sequence[PublicKeyCredentialDescriptor]] = field(
default=None,
metadata={"deserialize": PublicKeyCredentialDescriptor._deserialize_list},
)
user_verification: Optional[UserVerificationRequirement] = None
extensions: Optional[Mapping[str, Any]] = None
@dataclass(eq=False, frozen=True)
class AuthenticatorAttestationResponse(_CamelCaseDataObject):
client_data: bytes
attestation_object: AttestationObject
extension_results: Optional[Mapping[str, Any]] = None
@dataclass(eq=False, frozen=True)
class AuthenticatorAssertionResponse(_CamelCaseDataObject):
client_data: bytes
authenticator_data: AuthenticatorData
signature: bytes
user_handle: bytes
credential_id: bytes
extension_results: Optional[Mapping[str, Any]] = None