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.
Files changed (33) hide show
  1. tba_protocol_webauthn/__init__.py +131 -0
  2. tba_protocol_webauthn/attestation/__init__.py +8 -0
  3. tba_protocol_webauthn/attestation/adapters.py +51 -0
  4. tba_protocol_webauthn/attestation/registry.py +59 -0
  5. tba_protocol_webauthn/bindings.py +46 -0
  6. tba_protocol_webauthn/ceremonies/__init__.py +21 -0
  7. tba_protocol_webauthn/ceremonies/authentication.py +169 -0
  8. tba_protocol_webauthn/ceremonies/registration.py +220 -0
  9. tba_protocol_webauthn/claims.py +5 -0
  10. tba_protocol_webauthn/codecs/__init__.py +23 -0
  11. tba_protocol_webauthn/codecs/attestation_object.py +55 -0
  12. tba_protocol_webauthn/codecs/authenticator_data.py +120 -0
  13. tba_protocol_webauthn/codecs/client_data.py +107 -0
  14. tba_protocol_webauthn/codecs/credential_public_key.py +10 -0
  15. tba_protocol_webauthn/compatibility.py +11 -0
  16. tba_protocol_webauthn/configuration.py +33 -0
  17. tba_protocol_webauthn/errors.py +29 -0
  18. tba_protocol_webauthn/extensions/__init__.py +17 -0
  19. tba_protocol_webauthn/extensions/registry.py +68 -0
  20. tba_protocol_webauthn/features.py +41 -0
  21. tba_protocol_webauthn/migrations.py +23 -0
  22. tba_protocol_webauthn/protocol.py +40 -0
  23. tba_protocol_webauthn/py.typed +0 -0
  24. tba_protocol_webauthn/related_origins.py +21 -0
  25. tba_protocol_webauthn/schemas.py +29 -0
  26. tba_protocol_webauthn/versions.py +54 -0
  27. tba_protocol_webauthn/wire_schemas/__init__.py +27 -0
  28. tba_protocol_webauthn/wire_schemas/common.py +113 -0
  29. tba_protocol_webauthn/wire_schemas/level2.py +29 -0
  30. tba_protocol_webauthn/wire_schemas/level3.py +29 -0
  31. tba_protocol_webauthn-0.4.0.dev2.dist-info/METADATA +27 -0
  32. tba_protocol_webauthn-0.4.0.dev2.dist-info/RECORD +33 -0
  33. 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,5 @@
1
+ """WebAuthn defines ceremonies and credential data, not an identity-claims set."""
2
+
3
+ CLAIM_NAMES: tuple[str, ...] = ()
4
+
5
+ __all__ = ["CLAIM_NAMES"]
@@ -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,10 @@
1
+ """WebAuthn credential public-key codec."""
2
+
3
+ from tba_concrete_cose import decode_cose_key
4
+
5
+
6
+ def decode_credential_public_key(encoded: bytes):
7
+ return decode_cose_key(encoded)
8
+
9
+
10
+ __all__ = ["decode_credential_public_key"]
@@ -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
+ ]