authweave-workload 7.3.0__tar.gz → 7.3.2__tar.gz

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 (29) hide show
  1. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/PKG-INFO +10 -6
  2. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/README.md +6 -2
  3. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/__init__.py +1 -1
  4. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/models.py +2 -2
  5. authweave_workload-7.3.2/authweave_workload/mtls.py +368 -0
  6. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/pyproject.toml +3 -3
  7. authweave_workload-7.3.0/authweave_workload/mtls.py +0 -167
  8. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/.gitignore +0 -0
  9. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/LICENSE +0 -0
  10. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/authorization_details.py +0 -0
  11. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/delegation.py +0 -0
  12. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/dpop.py +0 -0
  13. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/events.py +0 -0
  14. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/__init__.py +0 -0
  15. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/litestar.py +0 -0
  16. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/introspection.py +0 -0
  17. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/jwks.py +0 -0
  18. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/jwt.py +0 -0
  19. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/lifecycle.py +0 -0
  20. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/migrations/0001_postgresql.sql +0 -0
  21. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/provider.py +0 -0
  22. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/py.typed +0 -0
  23. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/rate_limit.py +0 -0
  24. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/redis_store.py +0 -0
  25. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/spiffe.py +0 -0
  26. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/spiffe_snapshot.py +0 -0
  27. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/sqlalchemy.py +0 -0
  28. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/stores.py +0 -0
  29. {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/token_exchange.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authweave-workload
3
- Version: 7.3.0
3
+ Version: 7.3.2
4
4
  Summary: Framework-neutral sender-constrained workload authentication
5
5
  Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
6
6
  Project-URL: documentation, https://zylvext.github.io/litestar-auth/
@@ -20,12 +20,12 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Security
21
21
  Classifier: Topic :: Software Development :: Libraries
22
22
  Requires-Python: <3.15.0,>=3.12.0
23
- Requires-Dist: authweave-core==7.3.0
23
+ Requires-Dist: authweave-core==7.3.2
24
24
  Provides-Extra: all
25
25
  Requires-Dist: anyio<5.0,>=4.14.2; extra == 'all'
26
26
  Requires-Dist: cryptography<51.0,>=50.0.0; extra == 'all'
27
27
  Requires-Dist: httpx<1.0,>=0.28.1; extra == 'all'
28
- Requires-Dist: litestar-auth==7.3.0; extra == 'all'
28
+ Requires-Dist: litestar-auth==7.3.2; extra == 'all'
29
29
  Requires-Dist: litestar<3.0,>=2.24.0; extra == 'all'
30
30
  Requires-Dist: pyjwt<3.0,>=2.13.0; extra == 'all'
31
31
  Requires-Dist: redis<9.0,>=8.1.0; extra == 'all'
@@ -47,7 +47,7 @@ Requires-Dist: cryptography<51.0,>=50.0.0; extra == 'jwt'
47
47
  Requires-Dist: httpx<1.0,>=0.28.1; extra == 'jwt'
48
48
  Requires-Dist: pyjwt<3.0,>=2.13.0; extra == 'jwt'
49
49
  Provides-Extra: litestar
50
- Requires-Dist: litestar-auth==7.3.0; extra == 'litestar'
50
+ Requires-Dist: litestar-auth==7.3.2; extra == 'litestar'
51
51
  Requires-Dist: litestar<3.0,>=2.24.0; extra == 'litestar'
52
52
  Requires-Dist: sqlalchemy<3.0,>=2.0.51; extra == 'litestar'
53
53
  Provides-Extra: mtls
@@ -94,8 +94,12 @@ The payment profile and application guard are documented in
94
94
 
95
95
  Lifecycle mutations require a verified actor, a correlation ID, and an application recorder that
96
96
  writes the security event in the same transaction. X.509 registration accepts only opaque
97
- `CertificateMetadata` returned by `validate_public_certificate`; it cannot be built from unchecked
98
- fields.
97
+ `CertificateMetadata` returned by an AuthWeave validator; it cannot be built from unchecked fields.
98
+ Use `validate_public_certificate` when the application owns the PKIX trust anchors. For a managed CA
99
+ that intentionally does not export its root, use `validate_attested_certificate` with a fresh
100
+ `ProviderCertificateAttestation` constructed only from the pinned provider's authenticated control
101
+ plane response. That path binds the leaf profile, fingerprint, dates, active status, provider, and
102
+ application trust-anchor identity without claiming local PKIX validation.
99
103
  Custom stores raise `StoreOwnerStateConflictError` when concurrent owner revalidation rejects a
100
104
  principal or credential write; the lifecycle service maps that typed conflict to `LifecycleConflictError`.
101
105
 
@@ -26,8 +26,12 @@ The payment profile and application guard are documented in
26
26
 
27
27
  Lifecycle mutations require a verified actor, a correlation ID, and an application recorder that
28
28
  writes the security event in the same transaction. X.509 registration accepts only opaque
29
- `CertificateMetadata` returned by `validate_public_certificate`; it cannot be built from unchecked
30
- fields.
29
+ `CertificateMetadata` returned by an AuthWeave validator; it cannot be built from unchecked fields.
30
+ Use `validate_public_certificate` when the application owns the PKIX trust anchors. For a managed CA
31
+ that intentionally does not export its root, use `validate_attested_certificate` with a fresh
32
+ `ProviderCertificateAttestation` constructed only from the pinned provider's authenticated control
33
+ plane response. That path binds the leaf profile, fingerprint, dates, active status, provider, and
34
+ application trust-anchor identity without claiming local PKIX validation.
31
35
  Custom stores raise `StoreOwnerStateConflictError` when concurrent owner revalidation rejects a
32
36
  principal or credential write; the lifecycle service maps that typed conflict to `LifecycleConflictError`.
33
37
 
@@ -34,7 +34,7 @@ from authweave_workload.stores import (
34
34
  WorkloadStore,
35
35
  )
36
36
 
37
- __version__ = "7.3.0"
37
+ __version__ = "7.3.2"
38
38
 
39
39
  __all__ = (
40
40
  "PAYMENT_AUTHORIZATION_TYPE",
@@ -199,7 +199,7 @@ class WorkloadPage[PAGE_ITEM]:
199
199
 
200
200
  @dataclass(frozen=True, slots=True, init=False)
201
201
  class CertificateMetadata:
202
- """Opaque public-certificate facts produced by the X.509 validator."""
202
+ """Opaque public-certificate facts produced by an AuthWeave validator."""
203
203
 
204
204
  thumbprint: str
205
205
  trust_anchor: str
@@ -211,7 +211,7 @@ class CertificateMetadata:
211
211
 
212
212
  def __new__(cls) -> Self:
213
213
  """Prevent construction outside the certificate validator."""
214
- msg = "CertificateMetadata is created by validate_public_certificate()"
214
+ msg = "CertificateMetadata is created by AuthWeave certificate validators"
215
215
  raise TypeError(msg)
216
216
 
217
217
  def __post_init__(self) -> None:
@@ -0,0 +1,368 @@
1
+ """X.509 public-certificate validation for workload registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from authweave_workload.models import CertificateMetadata, _certificate_metadata
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterable
17
+
18
+
19
+ class CertificateValidationError(ValueError):
20
+ """Raised when public certificate material fails the release security profile."""
21
+
22
+
23
+ _IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
24
+ _THUMBPRINT_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$")
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class CertificateValidationPolicy:
29
+ """Configured trust material for one registration environment."""
30
+
31
+ trust_anchor: str
32
+ trust_anchor_certificates: tuple[bytes, ...]
33
+ revocation_checked_at: datetime
34
+ revoked_serial_numbers: frozenset[int] = frozenset()
35
+ maximum_revocation_age: timedelta = timedelta(minutes=15)
36
+ maximum_certificate_lifetime: timedelta = timedelta(days=90)
37
+
38
+ def __post_init__(self) -> None:
39
+ """Require explicit trust material.
40
+
41
+ Raises:
42
+ ValueError: If no trust anchor certificate is configured.
43
+ """
44
+ if not self.trust_anchor_certificates:
45
+ msg = "at least one trust anchor certificate is required"
46
+ raise ValueError(msg)
47
+ if self.revocation_checked_at.utcoffset() is None:
48
+ msg = "revocation_checked_at must be timezone-aware"
49
+ raise ValueError(msg)
50
+ if self.maximum_revocation_age <= timedelta(0):
51
+ msg = "maximum_revocation_age must be positive"
52
+ raise ValueError(msg)
53
+ if self.maximum_certificate_lifetime <= timedelta(0):
54
+ msg = "maximum_certificate_lifetime must be positive"
55
+ raise ValueError(msg)
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ProviderCertificateAttestation:
60
+ """Fresh public-certificate facts from an authenticated provider control plane.
61
+
62
+ Applications must construct this object only from a successful, authenticated response from
63
+ the pinned certificate provider. It is evidence of provider issuance, not a PKIX path.
64
+ """
65
+
66
+ provider: str
67
+ trust_anchor: str
68
+ certificate_thumbprint: str
69
+ not_before: datetime
70
+ not_after: datetime
71
+ attested_at: datetime
72
+ status: str
73
+
74
+ def __post_init__(self) -> None:
75
+ """Reject invalid provider evidence.
76
+
77
+ Raises:
78
+ ValueError: If evidence is unbounded, inactive, or temporally invalid.
79
+ """
80
+ _validate_identifier(self.provider, name="provider")
81
+ _validate_identifier(self.trust_anchor, name="trust_anchor")
82
+ if _THUMBPRINT_PATTERN.fullmatch(self.certificate_thumbprint) is None:
83
+ msg = "certificate_thumbprint must be an unpadded base64url SHA-256 digest"
84
+ raise ValueError(msg)
85
+ for name in ("not_before", "not_after", "attested_at"):
86
+ if getattr(self, name).utcoffset() is None:
87
+ msg = f"{name} must be timezone-aware"
88
+ raise ValueError(msg)
89
+ if self.not_before >= self.not_after:
90
+ msg = "not_before must be earlier than not_after"
91
+ raise ValueError(msg)
92
+ if self.status != "active":
93
+ msg = "provider certificate status must be active"
94
+ raise ValueError(msg)
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class AttestedCertificateValidationPolicy:
99
+ """Local policy for one authenticated external certificate provider."""
100
+
101
+ provider: str
102
+ trust_anchor: str
103
+ maximum_attestation_age: timedelta = timedelta(minutes=5)
104
+ maximum_certificate_lifetime: timedelta = timedelta(days=90)
105
+ maximum_clock_skew: timedelta = timedelta(minutes=5)
106
+
107
+ def __post_init__(self) -> None:
108
+ """Require a pinned provider boundary.
109
+
110
+ Raises:
111
+ ValueError: If an identifier or bounded policy interval is invalid.
112
+ """
113
+ _validate_identifier(self.provider, name="provider")
114
+ _validate_identifier(self.trust_anchor, name="trust_anchor")
115
+ if self.maximum_attestation_age <= timedelta(0):
116
+ msg = "maximum_attestation_age must be positive"
117
+ raise ValueError(msg)
118
+ if self.maximum_certificate_lifetime <= timedelta(0):
119
+ msg = "maximum_certificate_lifetime must be positive"
120
+ raise ValueError(msg)
121
+ if self.maximum_clock_skew < timedelta(0):
122
+ msg = "maximum_clock_skew cannot be negative"
123
+ raise ValueError(msg)
124
+
125
+
126
+ def validate_public_certificate(
127
+ certificate: bytes,
128
+ *,
129
+ policy: CertificateValidationPolicy,
130
+ intermediates: Iterable[bytes] = (),
131
+ verification_time: datetime | None = None,
132
+ ) -> CertificateMetadata:
133
+ """Validate a CA-issued client certificate and return only safe public facts.
134
+
135
+ Returns:
136
+ Canonical certificate metadata. Certificate bytes are not retained.
137
+
138
+ Raises:
139
+ CertificateValidationError: If parsing, path validation, key policy, usage,
140
+ validity, or revocation checks fail.
141
+ ImportError: If the ``mtls`` extra is not installed.
142
+ """
143
+ if b"PRIVATE KEY" in certificate:
144
+ raise CertificateValidationError("private key material is not accepted")
145
+ crypto = _load_cryptography()
146
+ x509 = crypto["x509"]
147
+ try:
148
+ leaf = _load_certificate(x509, certificate)
149
+ anchors = [_load_certificate(x509, value) for value in policy.trust_anchor_certificates]
150
+ untrusted = [_load_certificate(x509, value) for value in intermediates]
151
+ now = datetime.now(UTC) if verification_time is None else verification_time
152
+ if now.utcoffset() is None:
153
+ raise CertificateValidationError("verification_time must be timezone-aware")
154
+ if policy.revocation_checked_at > now or now - policy.revocation_checked_at > policy.maximum_revocation_age:
155
+ raise CertificateValidationError("certificate revocation information is stale")
156
+ verifier = crypto["PolicyBuilder"]().store(crypto["Store"](anchors)).time(now).build_client_verifier()
157
+ verifier.verify(leaf, untrusted)
158
+ _validate_leaf_profile(leaf, crypto)
159
+ if leaf.not_valid_after_utc - leaf.not_valid_before_utc > policy.maximum_certificate_lifetime:
160
+ raise CertificateValidationError("certificate lifetime exceeds policy")
161
+ except CertificateValidationError:
162
+ raise
163
+ except (TypeError, ValueError, crypto["VerificationError"]) as exc:
164
+ raise CertificateValidationError("certificate validation failed") from exc
165
+ if leaf.serial_number in policy.revoked_serial_numbers:
166
+ raise CertificateValidationError("certificate is revoked")
167
+ return _metadata_from_leaf(leaf, trust_anchor=policy.trust_anchor, encoding=crypto["Encoding"])
168
+
169
+
170
+ def validate_attested_certificate(
171
+ certificate: bytes,
172
+ *,
173
+ attestation: ProviderCertificateAttestation,
174
+ policy: AttestedCertificateValidationPolicy,
175
+ verification_time: datetime | None = None,
176
+ ) -> CertificateMetadata:
177
+ """Validate a leaf against fresh evidence from a pinned external provider.
178
+
179
+ This path is for managed certificate authorities whose authenticated control plane returns the
180
+ issued leaf and its digest but intentionally does not export the CA certificate. It validates
181
+ the leaf profile and reconciles it with that provider evidence; it does not perform or claim
182
+ local PKIX path validation.
183
+
184
+ Returns:
185
+ Canonical certificate metadata. Certificate and attestation bytes are not retained.
186
+
187
+ Raises:
188
+ CertificateValidationError: If parsing, provider binding, freshness, profile, validity,
189
+ fingerprint, or attested dates fail.
190
+ ImportError: If the ``mtls`` extra is not installed.
191
+ """
192
+ if b"PRIVATE KEY" in certificate:
193
+ raise CertificateValidationError("private key material is not accepted")
194
+ crypto = _load_cryptography()
195
+ x509 = crypto["x509"]
196
+ try:
197
+ leaf = _load_single_certificate(x509, certificate)
198
+ now = datetime.now(UTC) if verification_time is None else verification_time
199
+ if now.utcoffset() is None:
200
+ raise CertificateValidationError("verification_time must be timezone-aware")
201
+ _validate_provider_attestation(attestation, policy=policy, verification_time=now)
202
+ _validate_attested_leaf(
203
+ leaf,
204
+ attestation=attestation,
205
+ policy=policy,
206
+ verification_time=now,
207
+ crypto=crypto,
208
+ )
209
+ except CertificateValidationError:
210
+ raise
211
+ except (TypeError, ValueError) as exc:
212
+ raise CertificateValidationError("certificate validation failed") from exc
213
+ return _metadata_from_leaf(leaf, trust_anchor=policy.trust_anchor, encoding=crypto["Encoding"])
214
+
215
+
216
+ def _load_cryptography() -> dict[str, Any]:
217
+ try:
218
+ from cryptography import x509
219
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
220
+ from cryptography.hazmat.primitives.serialization import Encoding
221
+ from cryptography.x509 import ExtendedKeyUsageOID
222
+ from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
223
+ except ImportError as exc:
224
+ msg = "X.509 validation requires the 'authweave-workload[mtls]' extra."
225
+ raise ImportError(msg) from exc
226
+ return {
227
+ "Encoding": Encoding,
228
+ "ExtendedKeyUsageOID": ExtendedKeyUsageOID,
229
+ "PolicyBuilder": PolicyBuilder,
230
+ "Store": Store,
231
+ "VerificationError": VerificationError,
232
+ "ec": ec,
233
+ "rsa": rsa,
234
+ "x509": x509,
235
+ }
236
+
237
+
238
+ def _load_certificate(x509: Any, value: bytes) -> Any:
239
+ try:
240
+ if b"-----BEGIN CERTIFICATE-----" in value:
241
+ return x509.load_pem_x509_certificate(value)
242
+ return x509.load_der_x509_certificate(value)
243
+ except ValueError as exc:
244
+ raise CertificateValidationError("certificate encoding is invalid") from exc
245
+
246
+
247
+ def _load_single_certificate(x509: Any, value: bytes) -> Any:
248
+ if b"-----BEGIN CERTIFICATE-----" not in value:
249
+ return _load_certificate(x509, value)
250
+ try:
251
+ certificates = x509.load_pem_x509_certificates(value)
252
+ except ValueError as exc:
253
+ raise CertificateValidationError("certificate encoding is invalid") from exc
254
+ if len(certificates) != 1:
255
+ raise CertificateValidationError("exactly one public certificate is required")
256
+ return certificates[0]
257
+
258
+
259
+ def _validate_leaf_profile(certificate: Any, crypto: dict[str, Any]) -> None:
260
+ x509 = crypto["x509"]
261
+ public_key = certificate.public_key()
262
+ if isinstance(public_key, crypto["rsa"].RSAPublicKey):
263
+ if public_key.key_size < 3072:
264
+ raise CertificateValidationError("RSA certificate keys must be at least 3072 bits")
265
+ elif isinstance(public_key, crypto["ec"].EllipticCurvePublicKey):
266
+ if public_key.curve.name not in {"secp256r1", "secp384r1"}:
267
+ raise CertificateValidationError("ECDSA certificate keys must use P-256 or P-384")
268
+ else:
269
+ raise CertificateValidationError("certificate public key type is not supported")
270
+
271
+ signature_hash = certificate.signature_hash_algorithm
272
+ if signature_hash is None or signature_hash.name.lower() in {"md5", "sha1"}:
273
+ raise CertificateValidationError("certificate signature hash is not supported")
274
+ try:
275
+ extended_key_usage = certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
276
+ except x509.ExtensionNotFound as exc:
277
+ raise CertificateValidationError("client certificate must include clientAuth EKU") from exc
278
+ try:
279
+ key_usage = certificate.extensions.get_extension_for_class(x509.KeyUsage).value
280
+ except x509.ExtensionNotFound:
281
+ key_usage = None
282
+ if key_usage is not None and not key_usage.digital_signature:
283
+ raise CertificateValidationError("client certificate must permit digital signatures")
284
+ if crypto["ExtendedKeyUsageOID"].CLIENT_AUTH not in extended_key_usage:
285
+ raise CertificateValidationError("client certificate must include clientAuth EKU")
286
+
287
+
288
+ def _validate_attested_leaf_profile(certificate: Any, crypto: dict[str, Any]) -> None:
289
+ _validate_leaf_profile(certificate, crypto)
290
+ x509 = crypto["x509"]
291
+ try:
292
+ basic_constraints = certificate.extensions.get_extension_for_class(x509.BasicConstraints)
293
+ except x509.ExtensionNotFound as exc:
294
+ raise CertificateValidationError("client certificate must include BasicConstraints") from exc
295
+ if not basic_constraints.critical or basic_constraints.value.ca:
296
+ raise CertificateValidationError("client certificate must be a critical CA=false end entity")
297
+
298
+
299
+ def _validate_provider_attestation(
300
+ attestation: ProviderCertificateAttestation,
301
+ *,
302
+ policy: AttestedCertificateValidationPolicy,
303
+ verification_time: datetime,
304
+ ) -> None:
305
+ if attestation.provider != policy.provider or attestation.trust_anchor != policy.trust_anchor:
306
+ raise CertificateValidationError("certificate provider attestation is not trusted")
307
+ if attestation.attested_at > verification_time + policy.maximum_clock_skew:
308
+ raise CertificateValidationError("certificate provider attestation is from the future")
309
+ if verification_time - attestation.attested_at > policy.maximum_attestation_age:
310
+ raise CertificateValidationError("certificate provider attestation is stale")
311
+
312
+
313
+ def _validate_attested_leaf(
314
+ certificate: Any,
315
+ *,
316
+ attestation: ProviderCertificateAttestation,
317
+ policy: AttestedCertificateValidationPolicy,
318
+ verification_time: datetime,
319
+ crypto: dict[str, Any],
320
+ ) -> None:
321
+ _validate_attested_leaf_profile(certificate, crypto)
322
+ not_before = certificate.not_valid_before_utc
323
+ not_after = certificate.not_valid_after_utc
324
+ if not_before > verification_time + policy.maximum_clock_skew or not_after <= verification_time:
325
+ raise CertificateValidationError("certificate validity window is not currently usable")
326
+ if not_after - not_before > policy.maximum_certificate_lifetime + policy.maximum_clock_skew:
327
+ raise CertificateValidationError("certificate lifetime exceeds policy")
328
+ if (
329
+ abs(attestation.not_before - not_before) > policy.maximum_clock_skew
330
+ or abs(attestation.not_after - not_after) > policy.maximum_clock_skew
331
+ ):
332
+ raise CertificateValidationError("certificate dates disagree with provider attestation")
333
+ thumbprint = _certificate_thumbprint(certificate, encoding=crypto["Encoding"])
334
+ if not hmac.compare_digest(thumbprint, attestation.certificate_thumbprint):
335
+ raise CertificateValidationError("certificate fingerprint disagrees with provider attestation")
336
+
337
+
338
+ def _certificate_thumbprint(certificate: Any, *, encoding: Any) -> str:
339
+ der = certificate.public_bytes(encoding.DER)
340
+ return base64.urlsafe_b64encode(hashlib.sha256(der).digest()).rstrip(b"=").decode("ascii")
341
+
342
+
343
+ def _metadata_from_leaf(certificate: Any, *, trust_anchor: str, encoding: Any) -> CertificateMetadata:
344
+ return _certificate_metadata(
345
+ thumbprint=_certificate_thumbprint(certificate, encoding=encoding),
346
+ trust_anchor=trust_anchor,
347
+ not_before=certificate.not_valid_before_utc,
348
+ not_after=certificate.not_valid_after_utc,
349
+ subject_dn=certificate.subject.rfc4514_string(),
350
+ issuer_dn=certificate.issuer.rfc4514_string(),
351
+ serial_number=format(certificate.serial_number, "x"),
352
+ )
353
+
354
+
355
+ def _validate_identifier(value: str, *, name: str) -> None:
356
+ if _IDENTIFIER_PATTERN.fullmatch(value) is None:
357
+ msg = f"{name} must match {_IDENTIFIER_PATTERN.pattern!r}"
358
+ raise ValueError(msg)
359
+
360
+
361
+ __all__ = (
362
+ "AttestedCertificateValidationPolicy",
363
+ "CertificateValidationError",
364
+ "CertificateValidationPolicy",
365
+ "ProviderCertificateAttestation",
366
+ "validate_attested_certificate",
367
+ "validate_public_certificate",
368
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "authweave-workload"
3
- version = "7.3.0"
3
+ version = "7.3.2"
4
4
  description = "Framework-neutral sender-constrained workload authentication"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.14",
24
24
  ]
25
25
  requires-python = "<3.15.0,>=3.12.0"
26
- dependencies = ["authweave-core==7.3.0"]
26
+ dependencies = ["authweave-core==7.3.2"]
27
27
 
28
28
  [project.urls]
29
29
  homepage = "https://github.com/ZYLVEXT/litestar-auth"
@@ -40,7 +40,7 @@ spiffe = ["authweave-workload[mtls]", "spiffe>=0.3.0,<0.4.0"]
40
40
  introspection = ["authweave-workload[jwt]"]
41
41
  token-exchange = ["authweave-workload[jwt]"]
42
42
  redis = ["redis>=8.1.0,<9.0"]
43
- litestar = ["authweave-workload[sqlalchemy]", "litestar>=2.24.0,<3.0", "litestar-auth==7.3.0"]
43
+ litestar = ["authweave-workload[sqlalchemy]", "litestar>=2.24.0,<3.0", "litestar-auth==7.3.2"]
44
44
  all = ["authweave-workload[jwt,dpop,spiffe,introspection,token-exchange,redis,litestar]"]
45
45
 
46
46
  [build-system]
@@ -1,167 +0,0 @@
1
- """X.509 public-certificate validation for workload registration."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import hashlib
7
- from dataclasses import dataclass
8
- from datetime import UTC, datetime, timedelta
9
- from typing import TYPE_CHECKING, Any
10
-
11
- from authweave_workload.models import CertificateMetadata, _certificate_metadata
12
-
13
- if TYPE_CHECKING:
14
- from collections.abc import Iterable
15
-
16
-
17
- class CertificateValidationError(ValueError):
18
- """Raised when public certificate material fails the release security profile."""
19
-
20
-
21
- @dataclass(frozen=True, slots=True)
22
- class CertificateValidationPolicy:
23
- """Configured trust material for one registration environment."""
24
-
25
- trust_anchor: str
26
- trust_anchor_certificates: tuple[bytes, ...]
27
- revocation_checked_at: datetime
28
- revoked_serial_numbers: frozenset[int] = frozenset()
29
- maximum_revocation_age: timedelta = timedelta(minutes=15)
30
- maximum_certificate_lifetime: timedelta = timedelta(days=90)
31
-
32
- def __post_init__(self) -> None:
33
- """Require explicit trust material.
34
-
35
- Raises:
36
- ValueError: If no trust anchor certificate is configured.
37
- """
38
- if not self.trust_anchor_certificates:
39
- msg = "at least one trust anchor certificate is required"
40
- raise ValueError(msg)
41
- if self.revocation_checked_at.utcoffset() is None:
42
- msg = "revocation_checked_at must be timezone-aware"
43
- raise ValueError(msg)
44
- if self.maximum_revocation_age <= timedelta(0):
45
- msg = "maximum_revocation_age must be positive"
46
- raise ValueError(msg)
47
- if self.maximum_certificate_lifetime <= timedelta(0):
48
- msg = "maximum_certificate_lifetime must be positive"
49
- raise ValueError(msg)
50
-
51
-
52
- def validate_public_certificate(
53
- certificate: bytes,
54
- *,
55
- policy: CertificateValidationPolicy,
56
- intermediates: Iterable[bytes] = (),
57
- verification_time: datetime | None = None,
58
- ) -> CertificateMetadata:
59
- """Validate a CA-issued client certificate and return only safe public facts.
60
-
61
- Returns:
62
- Canonical certificate metadata. Certificate bytes are not retained.
63
-
64
- Raises:
65
- CertificateValidationError: If parsing, path validation, key policy, usage,
66
- validity, or revocation checks fail.
67
- ImportError: If the ``mtls`` extra is not installed.
68
- """
69
- if b"PRIVATE KEY" in certificate:
70
- raise CertificateValidationError("private key material is not accepted")
71
- crypto = _load_cryptography()
72
- x509 = crypto["x509"]
73
- try:
74
- leaf = _load_certificate(x509, certificate)
75
- anchors = [_load_certificate(x509, value) for value in policy.trust_anchor_certificates]
76
- untrusted = [_load_certificate(x509, value) for value in intermediates]
77
- now = datetime.now(UTC) if verification_time is None else verification_time
78
- if now.utcoffset() is None:
79
- raise CertificateValidationError("verification_time must be timezone-aware")
80
- if policy.revocation_checked_at > now or now - policy.revocation_checked_at > policy.maximum_revocation_age:
81
- raise CertificateValidationError("certificate revocation information is stale")
82
- verifier = crypto["PolicyBuilder"]().store(crypto["Store"](anchors)).time(now).build_client_verifier()
83
- verifier.verify(leaf, untrusted)
84
- _validate_leaf_profile(leaf, crypto)
85
- if leaf.not_valid_after_utc - leaf.not_valid_before_utc > policy.maximum_certificate_lifetime:
86
- raise CertificateValidationError("certificate lifetime exceeds policy")
87
- except CertificateValidationError:
88
- raise
89
- except (TypeError, ValueError, crypto["VerificationError"]) as exc:
90
- raise CertificateValidationError("certificate validation failed") from exc
91
- if leaf.serial_number in policy.revoked_serial_numbers:
92
- raise CertificateValidationError("certificate is revoked")
93
- der = leaf.public_bytes(crypto["Encoding"].DER)
94
- thumbprint = base64.urlsafe_b64encode(hashlib.sha256(der).digest()).rstrip(b"=").decode("ascii")
95
- return _certificate_metadata(
96
- thumbprint=thumbprint,
97
- trust_anchor=policy.trust_anchor,
98
- not_before=leaf.not_valid_before_utc,
99
- not_after=leaf.not_valid_after_utc,
100
- subject_dn=leaf.subject.rfc4514_string(),
101
- issuer_dn=leaf.issuer.rfc4514_string(),
102
- serial_number=format(leaf.serial_number, "x"),
103
- )
104
-
105
-
106
- def _load_cryptography() -> dict[str, Any]:
107
- try:
108
- from cryptography import x509
109
- from cryptography.hazmat.primitives.asymmetric import ec, rsa
110
- from cryptography.hazmat.primitives.serialization import Encoding
111
- from cryptography.x509 import ExtendedKeyUsageOID
112
- from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
113
- except ImportError as exc:
114
- msg = "X.509 validation requires the 'authweave-workload[mtls]' extra."
115
- raise ImportError(msg) from exc
116
- return {
117
- "Encoding": Encoding,
118
- "ExtendedKeyUsageOID": ExtendedKeyUsageOID,
119
- "PolicyBuilder": PolicyBuilder,
120
- "Store": Store,
121
- "VerificationError": VerificationError,
122
- "ec": ec,
123
- "rsa": rsa,
124
- "x509": x509,
125
- }
126
-
127
-
128
- def _load_certificate(x509: Any, value: bytes) -> Any:
129
- try:
130
- if b"-----BEGIN CERTIFICATE-----" in value:
131
- return x509.load_pem_x509_certificate(value)
132
- return x509.load_der_x509_certificate(value)
133
- except ValueError as exc:
134
- raise CertificateValidationError("certificate encoding is invalid") from exc
135
-
136
-
137
- def _validate_leaf_profile(certificate: Any, crypto: dict[str, Any]) -> None:
138
- x509 = crypto["x509"]
139
- public_key = certificate.public_key()
140
- if isinstance(public_key, crypto["rsa"].RSAPublicKey):
141
- if public_key.key_size < 3072:
142
- raise CertificateValidationError("RSA certificate keys must be at least 3072 bits")
143
- elif isinstance(public_key, crypto["ec"].EllipticCurvePublicKey):
144
- if public_key.curve.name not in {"secp256r1", "secp384r1"}:
145
- raise CertificateValidationError("ECDSA certificate keys must use P-256 or P-384")
146
- else:
147
- raise CertificateValidationError("certificate public key type is not supported")
148
-
149
- signature_hash = certificate.signature_hash_algorithm
150
- if signature_hash is None or signature_hash.name.lower() in {"md5", "sha1"}:
151
- raise CertificateValidationError("certificate signature hash is not supported")
152
- try:
153
- key_usage = certificate.extensions.get_extension_for_class(x509.KeyUsage).value
154
- extended_key_usage = certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
155
- except x509.ExtensionNotFound as exc:
156
- raise CertificateValidationError("client certificate key usage extensions are required") from exc
157
- if not key_usage.digital_signature:
158
- raise CertificateValidationError("client certificate must permit digital signatures")
159
- if crypto["ExtendedKeyUsageOID"].CLIENT_AUTH not in extended_key_usage:
160
- raise CertificateValidationError("client certificate must include clientAuth EKU")
161
-
162
-
163
- __all__ = (
164
- "CertificateValidationError",
165
- "CertificateValidationPolicy",
166
- "validate_public_certificate",
167
- )