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.
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/PKG-INFO +10 -6
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/README.md +6 -2
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/__init__.py +1 -1
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/models.py +2 -2
- authweave_workload-7.3.2/authweave_workload/mtls.py +368 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/pyproject.toml +3 -3
- authweave_workload-7.3.0/authweave_workload/mtls.py +0 -167
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/.gitignore +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/LICENSE +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/authorization_details.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/delegation.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/dpop.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/events.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/__init__.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/litestar.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/introspection.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/jwks.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/jwt.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/lifecycle.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/migrations/0001_postgresql.sql +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/provider.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/py.typed +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/rate_limit.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/redis_store.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/spiffe.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/spiffe_snapshot.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/sqlalchemy.py +0 -0
- {authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/stores.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
98
|
-
|
|
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
|
|
30
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
{authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/authorization_details.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/__init__.py
RENAMED
|
File without changes
|
{authweave_workload-7.3.0 → authweave_workload-7.3.2}/authweave_workload/integrations/litestar.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|