tba-protocol-webauthn 0.4.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tba_protocol_webauthn/__init__.py +131 -0
- tba_protocol_webauthn/attestation/__init__.py +8 -0
- tba_protocol_webauthn/attestation/adapters.py +51 -0
- tba_protocol_webauthn/attestation/registry.py +59 -0
- tba_protocol_webauthn/bindings.py +46 -0
- tba_protocol_webauthn/ceremonies/__init__.py +21 -0
- tba_protocol_webauthn/ceremonies/authentication.py +169 -0
- tba_protocol_webauthn/ceremonies/registration.py +220 -0
- tba_protocol_webauthn/claims.py +5 -0
- tba_protocol_webauthn/codecs/__init__.py +23 -0
- tba_protocol_webauthn/codecs/attestation_object.py +55 -0
- tba_protocol_webauthn/codecs/authenticator_data.py +120 -0
- tba_protocol_webauthn/codecs/client_data.py +107 -0
- tba_protocol_webauthn/codecs/credential_public_key.py +10 -0
- tba_protocol_webauthn/compatibility.py +11 -0
- tba_protocol_webauthn/configuration.py +33 -0
- tba_protocol_webauthn/errors.py +29 -0
- tba_protocol_webauthn/extensions/__init__.py +17 -0
- tba_protocol_webauthn/extensions/registry.py +68 -0
- tba_protocol_webauthn/features.py +41 -0
- tba_protocol_webauthn/migrations.py +23 -0
- tba_protocol_webauthn/protocol.py +40 -0
- tba_protocol_webauthn/py.typed +0 -0
- tba_protocol_webauthn/related_origins.py +21 -0
- tba_protocol_webauthn/schemas.py +29 -0
- tba_protocol_webauthn/versions.py +54 -0
- tba_protocol_webauthn/wire_schemas/__init__.py +27 -0
- tba_protocol_webauthn/wire_schemas/common.py +113 -0
- tba_protocol_webauthn/wire_schemas/level2.py +29 -0
- tba_protocol_webauthn/wire_schemas/level3.py +29 -0
- tba_protocol_webauthn-0.4.0.dev2.dist-info/METADATA +27 -0
- tba_protocol_webauthn-0.4.0.dev2.dist-info/RECORD +33 -0
- tba_protocol_webauthn-0.4.0.dev2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""WebAuthn Level 2 registration ceremony processing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from tba_contracts_identity.public_key_authentication import (
|
|
11
|
+
AttestationTrustResult,
|
|
12
|
+
AttestationType,
|
|
13
|
+
CredentialBinding,
|
|
14
|
+
PublicKeyCredentialSource,
|
|
15
|
+
VerifiedCredentialRegistration,
|
|
16
|
+
)
|
|
17
|
+
from tba_concrete_cose import decode_cose_key
|
|
18
|
+
from tba_provider_cose_cryptography import verify_detached_signature
|
|
19
|
+
|
|
20
|
+
from ..codecs import decode_attestation_object, parse_client_data
|
|
21
|
+
from ..attestation import AttestationVerificationInput, AttestationVerifierRegistry
|
|
22
|
+
from ..errors import RegistrationVerificationError
|
|
23
|
+
from ..schemas import (
|
|
24
|
+
AuthenticatorSelectionCriteria,
|
|
25
|
+
PublicKeyCredential,
|
|
26
|
+
PublicKeyCredentialCreationOptions,
|
|
27
|
+
PublicKeyCredentialDescriptor,
|
|
28
|
+
PublicKeyCredentialParameters,
|
|
29
|
+
PublicKeyCredentialRpEntity,
|
|
30
|
+
PublicKeyCredentialUserEntity,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _identifier(value: bytes) -> str:
|
|
35
|
+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class RegistrationExpectation:
|
|
40
|
+
ceremony_id: str
|
|
41
|
+
tenant_id: str
|
|
42
|
+
principal_id: str
|
|
43
|
+
rp_id: str
|
|
44
|
+
origin: str
|
|
45
|
+
challenge: bytes
|
|
46
|
+
user_handle: bytes
|
|
47
|
+
allowed_algorithms: tuple[int, ...] = (-7, -257)
|
|
48
|
+
user_verification: str = "preferred"
|
|
49
|
+
attestation_verifiers: AttestationVerifierRegistry | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_creation_options(
|
|
53
|
+
*,
|
|
54
|
+
rp_id: str,
|
|
55
|
+
rp_name: str,
|
|
56
|
+
user_handle: bytes,
|
|
57
|
+
user_name: str,
|
|
58
|
+
user_display_name: str,
|
|
59
|
+
algorithms: tuple[int, ...] = (-7, -257),
|
|
60
|
+
excluded_credential_ids: tuple[bytes, ...] = (),
|
|
61
|
+
user_verification: str = "preferred",
|
|
62
|
+
resident_key: str = "preferred",
|
|
63
|
+
attestation: str = "none",
|
|
64
|
+
timeout_ms: int = 300_000,
|
|
65
|
+
challenge: bytes | None = None,
|
|
66
|
+
) -> PublicKeyCredentialCreationOptions:
|
|
67
|
+
if not algorithms:
|
|
68
|
+
raise ValueError("at least one credential algorithm is required")
|
|
69
|
+
return PublicKeyCredentialCreationOptions(
|
|
70
|
+
challenge=challenge or secrets.token_bytes(32),
|
|
71
|
+
rp=PublicKeyCredentialRpEntity(id=rp_id, name=rp_name),
|
|
72
|
+
user=PublicKeyCredentialUserEntity(
|
|
73
|
+
id=user_handle, name=user_name, display_name=user_display_name
|
|
74
|
+
),
|
|
75
|
+
pub_key_cred_params=tuple(
|
|
76
|
+
PublicKeyCredentialParameters(alg=value) for value in algorithms
|
|
77
|
+
),
|
|
78
|
+
timeout=timeout_ms,
|
|
79
|
+
exclude_credentials=tuple(
|
|
80
|
+
PublicKeyCredentialDescriptor(id=value) for value in excluded_credential_ids
|
|
81
|
+
),
|
|
82
|
+
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
83
|
+
resident_key=resident_key,
|
|
84
|
+
require_resident_key=resident_key == "required",
|
|
85
|
+
user_verification=user_verification,
|
|
86
|
+
),
|
|
87
|
+
attestation=attestation,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def verify_registration_response(
|
|
92
|
+
credential: PublicKeyCredential,
|
|
93
|
+
*,
|
|
94
|
+
expected: RegistrationExpectation,
|
|
95
|
+
) -> VerifiedCredentialRegistration:
|
|
96
|
+
try:
|
|
97
|
+
if credential.type != "public-key" or credential.raw_id == b"":
|
|
98
|
+
raise RegistrationVerificationError(
|
|
99
|
+
"registration credential type or identifier is invalid"
|
|
100
|
+
)
|
|
101
|
+
response = credential.response
|
|
102
|
+
if not hasattr(response, "attestation_object"):
|
|
103
|
+
raise RegistrationVerificationError(
|
|
104
|
+
"registration requires an attestation response"
|
|
105
|
+
)
|
|
106
|
+
client = parse_client_data(
|
|
107
|
+
response.client_data_json,
|
|
108
|
+
expected_type="webauthn.create",
|
|
109
|
+
expected_challenge=expected.challenge,
|
|
110
|
+
expected_origin=expected.origin,
|
|
111
|
+
)
|
|
112
|
+
if client.value.cross_origin:
|
|
113
|
+
raise RegistrationVerificationError(
|
|
114
|
+
"cross-origin registration is not enabled"
|
|
115
|
+
)
|
|
116
|
+
attestation = decode_attestation_object(response.attestation_object)
|
|
117
|
+
auth = attestation.authenticator_data
|
|
118
|
+
if not auth.matches_rp_id_hash(
|
|
119
|
+
hashlib.sha256(expected.rp_id.encode("utf-8")).digest()
|
|
120
|
+
):
|
|
121
|
+
raise RegistrationVerificationError(
|
|
122
|
+
"relying-party identifier hash does not match"
|
|
123
|
+
)
|
|
124
|
+
if not auth.user_present:
|
|
125
|
+
raise RegistrationVerificationError(
|
|
126
|
+
"authenticator did not report user presence"
|
|
127
|
+
)
|
|
128
|
+
if expected.user_verification == "required" and not auth.user_verified:
|
|
129
|
+
raise RegistrationVerificationError(
|
|
130
|
+
"authenticator did not report user verification"
|
|
131
|
+
)
|
|
132
|
+
data = auth.attested_credential_data
|
|
133
|
+
if data is None:
|
|
134
|
+
raise RegistrationVerificationError(
|
|
135
|
+
"registration omitted attested credential data"
|
|
136
|
+
)
|
|
137
|
+
if not secrets.compare_digest(data.credential_id, credential.raw_id):
|
|
138
|
+
raise RegistrationVerificationError(
|
|
139
|
+
"credential identifier does not match authenticator data"
|
|
140
|
+
)
|
|
141
|
+
cose_key = decode_cose_key(data.credential_public_key)
|
|
142
|
+
algorithm = cose_key.get(3)
|
|
143
|
+
if (
|
|
144
|
+
not isinstance(algorithm, int)
|
|
145
|
+
or algorithm not in expected.allowed_algorithms
|
|
146
|
+
):
|
|
147
|
+
raise RegistrationVerificationError("credential algorithm is not allowed")
|
|
148
|
+
if attestation.format == "none":
|
|
149
|
+
if attestation.statement:
|
|
150
|
+
raise RegistrationVerificationError(
|
|
151
|
+
"none attestation must have an empty statement"
|
|
152
|
+
)
|
|
153
|
+
trust = AttestationTrustResult(
|
|
154
|
+
False, AttestationType.NONE, reason="attestation not requested"
|
|
155
|
+
)
|
|
156
|
+
elif attestation.format == "packed" and "x5c" not in attestation.statement:
|
|
157
|
+
statement_algorithm = attestation.statement.get("alg")
|
|
158
|
+
signature = attestation.statement.get("sig")
|
|
159
|
+
if statement_algorithm != algorithm or not isinstance(signature, bytes):
|
|
160
|
+
raise RegistrationVerificationError(
|
|
161
|
+
"packed self-attestation statement is invalid"
|
|
162
|
+
)
|
|
163
|
+
if not verify_detached_signature(
|
|
164
|
+
public_key=data.credential_public_key,
|
|
165
|
+
algorithm=algorithm,
|
|
166
|
+
message=auth.raw + client.digest,
|
|
167
|
+
signature=signature,
|
|
168
|
+
):
|
|
169
|
+
raise RegistrationVerificationError(
|
|
170
|
+
"packed self-attestation signature is invalid"
|
|
171
|
+
)
|
|
172
|
+
trust = AttestationTrustResult(
|
|
173
|
+
False, AttestationType.SELF, reason="self attestation"
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
if expected.attestation_verifiers is None:
|
|
177
|
+
raise RegistrationVerificationError(
|
|
178
|
+
f"attestation format requires a configured provider: {attestation.format}"
|
|
179
|
+
)
|
|
180
|
+
trust = expected.attestation_verifiers.verify(
|
|
181
|
+
AttestationVerificationInput(
|
|
182
|
+
format=attestation.format,
|
|
183
|
+
statement=attestation.statement,
|
|
184
|
+
authenticator_data=auth.raw,
|
|
185
|
+
client_data_hash=client.digest,
|
|
186
|
+
rp_id_hash=auth.rp_id_hash,
|
|
187
|
+
credential_id=data.credential_id,
|
|
188
|
+
credential_public_key=data.credential_public_key,
|
|
189
|
+
aaguid=data.aaguid,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
source = PublicKeyCredentialSource(
|
|
193
|
+
credential_id=_identifier(credential.raw_id),
|
|
194
|
+
external_id=credential.raw_id,
|
|
195
|
+
public_key=data.credential_public_key,
|
|
196
|
+
algorithm=algorithm,
|
|
197
|
+
binding=CredentialBinding(
|
|
198
|
+
tenant_id=expected.tenant_id,
|
|
199
|
+
rp_id=expected.rp_id,
|
|
200
|
+
principal_id=expected.principal_id,
|
|
201
|
+
user_handle=expected.user_handle,
|
|
202
|
+
),
|
|
203
|
+
sign_count=auth.sign_count,
|
|
204
|
+
transports=tuple(response.transports),
|
|
205
|
+
discoverable=False,
|
|
206
|
+
backup_eligible=bool(auth.flags & 0x08),
|
|
207
|
+
backup_state=bool(auth.flags & 0x10),
|
|
208
|
+
)
|
|
209
|
+
return VerifiedCredentialRegistration(expected.ceremony_id, source, trust)
|
|
210
|
+
except RegistrationVerificationError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
raise RegistrationVerificationError(str(exc)) from exc
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = [
|
|
217
|
+
"RegistrationExpectation",
|
|
218
|
+
"build_creation_options",
|
|
219
|
+
"verify_registration_response",
|
|
220
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Binary codecs used by WebAuthn ceremonies."""
|
|
2
|
+
|
|
3
|
+
from .attestation_object import AttestationObject, decode_attestation_object
|
|
4
|
+
from .authenticator_data import (
|
|
5
|
+
AttestedCredentialData,
|
|
6
|
+
AuthenticatorData,
|
|
7
|
+
AuthenticatorDataFlags,
|
|
8
|
+
decode_authenticator_data,
|
|
9
|
+
)
|
|
10
|
+
from .client_data import ParsedClientData, parse_client_data
|
|
11
|
+
from .credential_public_key import decode_credential_public_key
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AttestationObject",
|
|
15
|
+
"AttestedCredentialData",
|
|
16
|
+
"AuthenticatorData",
|
|
17
|
+
"AuthenticatorDataFlags",
|
|
18
|
+
"ParsedClientData",
|
|
19
|
+
"decode_attestation_object",
|
|
20
|
+
"decode_authenticator_data",
|
|
21
|
+
"decode_credential_public_key",
|
|
22
|
+
"parse_client_data",
|
|
23
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Decoder for WebAuthn attestation objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
import cbor2
|
|
9
|
+
|
|
10
|
+
from ..errors import WebAuthnVerificationError
|
|
11
|
+
from .authenticator_data import AuthenticatorData, decode_authenticator_data
|
|
12
|
+
|
|
13
|
+
MAX_ATTESTATION_OBJECT_BYTES = 131_072
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class AttestationObject:
|
|
18
|
+
format: str
|
|
19
|
+
authenticator_data: AuthenticatorData
|
|
20
|
+
statement: Mapping[object, object]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decode_attestation_object(encoded: bytes) -> AttestationObject:
|
|
24
|
+
if (
|
|
25
|
+
not isinstance(encoded, bytes)
|
|
26
|
+
or not encoded
|
|
27
|
+
or len(encoded) > MAX_ATTESTATION_OBJECT_BYTES
|
|
28
|
+
):
|
|
29
|
+
raise WebAuthnVerificationError(
|
|
30
|
+
"attestation object is empty or exceeds the supported size"
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
value = cbor2.loads(encoded)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
raise WebAuthnVerificationError("attestation object is not valid CBOR") from exc
|
|
36
|
+
if not isinstance(value, dict) or set(value) != {"fmt", "authData", "attStmt"}:
|
|
37
|
+
raise WebAuthnVerificationError(
|
|
38
|
+
"attestation object must contain fmt, authData, and attStmt"
|
|
39
|
+
)
|
|
40
|
+
if not isinstance(value["fmt"], str) or not isinstance(value["authData"], bytes):
|
|
41
|
+
raise WebAuthnVerificationError("attestation object fields have invalid types")
|
|
42
|
+
if not isinstance(value["attStmt"], dict):
|
|
43
|
+
raise WebAuthnVerificationError("attestation statement must be a CBOR map")
|
|
44
|
+
return AttestationObject(
|
|
45
|
+
format=value["fmt"],
|
|
46
|
+
authenticator_data=decode_authenticator_data(value["authData"]),
|
|
47
|
+
statement=value["attStmt"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"MAX_ATTESTATION_OBJECT_BYTES",
|
|
53
|
+
"AttestationObject",
|
|
54
|
+
"decode_attestation_object",
|
|
55
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Strict decoder for the WebAuthn authenticator data byte sequence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import IntFlag
|
|
8
|
+
from hmac import compare_digest
|
|
9
|
+
|
|
10
|
+
import cbor2
|
|
11
|
+
|
|
12
|
+
from ..errors import WebAuthnVerificationError
|
|
13
|
+
|
|
14
|
+
MAX_AUTHENTICATOR_DATA_BYTES = 65_536
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticatorDataFlags(IntFlag):
|
|
18
|
+
USER_PRESENT = 0x01
|
|
19
|
+
USER_VERIFIED = 0x04
|
|
20
|
+
BACKUP_ELIGIBLE = 0x08
|
|
21
|
+
BACKUP_STATE = 0x10
|
|
22
|
+
ATTESTED_CREDENTIAL_DATA = 0x40
|
|
23
|
+
EXTENSION_DATA = 0x80
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AttestedCredentialData:
|
|
28
|
+
aaguid: bytes
|
|
29
|
+
credential_id: bytes
|
|
30
|
+
credential_public_key: bytes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class AuthenticatorData:
|
|
35
|
+
raw: bytes
|
|
36
|
+
rp_id_hash: bytes
|
|
37
|
+
flags: AuthenticatorDataFlags
|
|
38
|
+
sign_count: int
|
|
39
|
+
attested_credential_data: AttestedCredentialData | None = None
|
|
40
|
+
extensions: object | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def user_present(self) -> bool:
|
|
44
|
+
return bool(self.flags & AuthenticatorDataFlags.USER_PRESENT)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def user_verified(self) -> bool:
|
|
48
|
+
return bool(self.flags & AuthenticatorDataFlags.USER_VERIFIED)
|
|
49
|
+
|
|
50
|
+
def matches_rp_id_hash(self, expected: bytes) -> bool:
|
|
51
|
+
return len(expected) == 32 and compare_digest(self.rp_id_hash, expected)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_one(data: bytes, offset: int) -> tuple[object, int]:
|
|
55
|
+
stream = io.BytesIO(data[offset:])
|
|
56
|
+
try:
|
|
57
|
+
value = cbor2.CBORDecoder(stream).decode()
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise WebAuthnVerificationError(
|
|
60
|
+
"authenticator data contains invalid CBOR"
|
|
61
|
+
) from exc
|
|
62
|
+
return value, offset + stream.tell()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def decode_authenticator_data(raw: bytes) -> AuthenticatorData:
|
|
66
|
+
if (
|
|
67
|
+
not isinstance(raw, bytes)
|
|
68
|
+
or len(raw) < 37
|
|
69
|
+
or len(raw) > MAX_AUTHENTICATOR_DATA_BYTES
|
|
70
|
+
):
|
|
71
|
+
raise WebAuthnVerificationError("authenticator data has an invalid size")
|
|
72
|
+
flags = AuthenticatorDataFlags(raw[32])
|
|
73
|
+
if bool(flags & AuthenticatorDataFlags.BACKUP_STATE) and not bool(
|
|
74
|
+
flags & AuthenticatorDataFlags.BACKUP_ELIGIBLE
|
|
75
|
+
):
|
|
76
|
+
raise WebAuthnVerificationError(
|
|
77
|
+
"backup state cannot be set for an ineligible credential"
|
|
78
|
+
)
|
|
79
|
+
offset = 37
|
|
80
|
+
attested = None
|
|
81
|
+
if flags & AuthenticatorDataFlags.ATTESTED_CREDENTIAL_DATA:
|
|
82
|
+
if len(raw) < offset + 18:
|
|
83
|
+
raise WebAuthnVerificationError("attested credential data is truncated")
|
|
84
|
+
aaguid = raw[offset : offset + 16]
|
|
85
|
+
credential_id_length = int.from_bytes(raw[offset + 16 : offset + 18], "big")
|
|
86
|
+
offset += 18
|
|
87
|
+
if credential_id_length == 0 or len(raw) < offset + credential_id_length:
|
|
88
|
+
raise WebAuthnVerificationError("attested credential identifier is invalid")
|
|
89
|
+
credential_id = raw[offset : offset + credential_id_length]
|
|
90
|
+
offset += credential_id_length
|
|
91
|
+
_, key_end = _decode_one(raw, offset)
|
|
92
|
+
encoded_key = raw[offset:key_end]
|
|
93
|
+
attested = AttestedCredentialData(aaguid, credential_id, encoded_key)
|
|
94
|
+
offset = key_end
|
|
95
|
+
extensions = None
|
|
96
|
+
if flags & AuthenticatorDataFlags.EXTENSION_DATA:
|
|
97
|
+
extensions, offset = _decode_one(raw, offset)
|
|
98
|
+
if not isinstance(extensions, dict):
|
|
99
|
+
raise WebAuthnVerificationError(
|
|
100
|
+
"authenticator extensions must be a CBOR map"
|
|
101
|
+
)
|
|
102
|
+
if offset != len(raw):
|
|
103
|
+
raise WebAuthnVerificationError("authenticator data contains trailing bytes")
|
|
104
|
+
return AuthenticatorData(
|
|
105
|
+
raw=raw,
|
|
106
|
+
rp_id_hash=raw[:32],
|
|
107
|
+
flags=flags,
|
|
108
|
+
sign_count=int.from_bytes(raw[33:37], "big"),
|
|
109
|
+
attested_credential_data=attested,
|
|
110
|
+
extensions=extensions,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"MAX_AUTHENTICATOR_DATA_BYTES",
|
|
116
|
+
"AttestedCredentialData",
|
|
117
|
+
"AuthenticatorData",
|
|
118
|
+
"AuthenticatorDataFlags",
|
|
119
|
+
"decode_authenticator_data",
|
|
120
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CollectedClientData parsing and ceremony binding checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from hmac import compare_digest
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from ..errors import WebAuthnVerificationError
|
|
13
|
+
from ..schemas import CollectedClientData
|
|
14
|
+
|
|
15
|
+
MAX_CLIENT_DATA_BYTES = 16_384
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _b64url_decode(value: str) -> bytes:
|
|
19
|
+
if not isinstance(value, str) or not value:
|
|
20
|
+
raise WebAuthnVerificationError(
|
|
21
|
+
"client challenge must be a non-empty base64url string"
|
|
22
|
+
)
|
|
23
|
+
try:
|
|
24
|
+
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
raise WebAuthnVerificationError(
|
|
27
|
+
"client challenge is not valid base64url"
|
|
28
|
+
) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validated_origin(value: object) -> str:
|
|
32
|
+
if not isinstance(value, str):
|
|
33
|
+
raise WebAuthnVerificationError("client origin must be a string")
|
|
34
|
+
parsed = urlsplit(value)
|
|
35
|
+
if (
|
|
36
|
+
parsed.scheme not in {"https", "http"}
|
|
37
|
+
or not parsed.hostname
|
|
38
|
+
or parsed.path not in {"", "/"}
|
|
39
|
+
):
|
|
40
|
+
raise WebAuthnVerificationError("client origin is not a valid web origin")
|
|
41
|
+
if parsed.scheme == "http" and parsed.hostname not in {
|
|
42
|
+
"localhost",
|
|
43
|
+
"127.0.0.1",
|
|
44
|
+
"::1",
|
|
45
|
+
}:
|
|
46
|
+
raise WebAuthnVerificationError("WebAuthn requires HTTPS outside localhost")
|
|
47
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ParsedClientData:
|
|
52
|
+
value: CollectedClientData
|
|
53
|
+
raw_json: bytes
|
|
54
|
+
digest: bytes
|
|
55
|
+
challenge: bytes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_client_data(
|
|
59
|
+
raw_json: bytes,
|
|
60
|
+
*,
|
|
61
|
+
expected_type: str,
|
|
62
|
+
expected_challenge: bytes,
|
|
63
|
+
expected_origin: str,
|
|
64
|
+
) -> ParsedClientData:
|
|
65
|
+
if (
|
|
66
|
+
not isinstance(raw_json, bytes)
|
|
67
|
+
or not raw_json
|
|
68
|
+
or len(raw_json) > MAX_CLIENT_DATA_BYTES
|
|
69
|
+
):
|
|
70
|
+
raise WebAuthnVerificationError(
|
|
71
|
+
"clientDataJSON is empty or exceeds the supported size"
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
payload = json.loads(raw_json)
|
|
75
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
76
|
+
raise WebAuthnVerificationError(
|
|
77
|
+
"clientDataJSON is not valid UTF-8 JSON"
|
|
78
|
+
) from exc
|
|
79
|
+
if not isinstance(payload, dict):
|
|
80
|
+
raise WebAuthnVerificationError("clientDataJSON must contain an object")
|
|
81
|
+
if payload.get("type") != expected_type:
|
|
82
|
+
raise WebAuthnVerificationError("client data ceremony type does not match")
|
|
83
|
+
challenge = _b64url_decode(payload.get("challenge"))
|
|
84
|
+
if not compare_digest(challenge, expected_challenge):
|
|
85
|
+
raise WebAuthnVerificationError("client data challenge does not match")
|
|
86
|
+
origin = _validated_origin(payload.get("origin"))
|
|
87
|
+
if origin != _validated_origin(expected_origin):
|
|
88
|
+
raise WebAuthnVerificationError("client data origin is not allowed")
|
|
89
|
+
cross_origin = payload.get("crossOrigin", False)
|
|
90
|
+
if not isinstance(cross_origin, bool):
|
|
91
|
+
raise WebAuthnVerificationError("crossOrigin must be boolean")
|
|
92
|
+
top_origin = payload.get("topOrigin")
|
|
93
|
+
if top_origin is not None and not isinstance(top_origin, str):
|
|
94
|
+
raise WebAuthnVerificationError("topOrigin must be a string")
|
|
95
|
+
value = CollectedClientData(
|
|
96
|
+
type=expected_type,
|
|
97
|
+
challenge=payload["challenge"],
|
|
98
|
+
origin=origin,
|
|
99
|
+
cross_origin=cross_origin,
|
|
100
|
+
top_origin=top_origin,
|
|
101
|
+
)
|
|
102
|
+
return ParsedClientData(
|
|
103
|
+
value, raw_json, hashlib.sha256(raw_json).digest(), challenge
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["MAX_CLIENT_DATA_BYTES", "ParsedClientData", "parse_client_data"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .features import features_for
|
|
2
|
+
from .versions import WebAuthnVersion
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def compatible_features(
|
|
6
|
+
source: WebAuthnVersion | str, target: WebAuthnVersion | str
|
|
7
|
+
) -> frozenset:
|
|
8
|
+
return features_for(source).intersection(features_for(target))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["compatible_features"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from .versions import WebAuthnVersion
|
|
4
|
+
from .features import features_for
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class WebAuthnConfiguration:
|
|
9
|
+
version: str = WebAuthnVersion.LEVEL_2
|
|
10
|
+
allowed_algorithms: tuple[int, ...] = (-7, -257)
|
|
11
|
+
user_verification: str = "preferred"
|
|
12
|
+
resident_key: str = "preferred"
|
|
13
|
+
attestation: str = "none"
|
|
14
|
+
timeout_ms: int = 300_000
|
|
15
|
+
enabled_features: frozenset[str] = frozenset()
|
|
16
|
+
related_origins: tuple[str, ...] = ()
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if not self.allowed_algorithms:
|
|
20
|
+
raise ValueError("at least one WebAuthn COSE algorithm is required")
|
|
21
|
+
if self.timeout_ms <= 0:
|
|
22
|
+
raise ValueError("WebAuthn timeout must be positive")
|
|
23
|
+
unsupported = self.enabled_features.difference(features_for(self.version))
|
|
24
|
+
if unsupported:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"features are unavailable in {self.version}: {sorted(unsupported)}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def feature_enabled(self, feature: str) -> bool:
|
|
30
|
+
return feature in self.enabled_features
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["WebAuthnConfiguration"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class WebAuthnProtocolError(ValueError):
|
|
2
|
+
"""Base WebAuthn parsing and verification failure."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WebAuthnVerificationError(WebAuthnProtocolError):
|
|
6
|
+
"""A supplied WebAuthn artifact failed a binding or verification rule."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientDataError(WebAuthnProtocolError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticatorDataError(WebAuthnProtocolError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttestationObjectError(WebAuthnProtocolError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RegistrationVerificationError(WebAuthnProtocolError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthenticationVerificationError(WebAuthnProtocolError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [name for name in globals() if name.endswith("Error")]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .registry import (
|
|
2
|
+
ExtensionRegistry,
|
|
3
|
+
validate_appid,
|
|
4
|
+
validate_cred_props,
|
|
5
|
+
validate_credential_protection,
|
|
6
|
+
validate_large_blob,
|
|
7
|
+
validate_payment,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ExtensionRegistry",
|
|
12
|
+
"validate_appid",
|
|
13
|
+
"validate_cred_props",
|
|
14
|
+
"validate_credential_protection",
|
|
15
|
+
"validate_large_blob",
|
|
16
|
+
"validate_payment",
|
|
17
|
+
]
|