capiscio-sdk 0.2.0__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.
@@ -0,0 +1,384 @@
1
+ """Certificate validation for TLS/SSL connections.
2
+
3
+ This module provides validation for TLS/SSL certificates used in agent
4
+ communication. It checks certificate validity, expiry, hostname matching,
5
+ and certificate chain integrity.
6
+
7
+ Validates:
8
+ - Certificate validity and expiry
9
+ - Hostname verification
10
+ - Certificate chain integrity
11
+ - Self-signed certificate detection
12
+ - Certificate trust
13
+ """
14
+
15
+ from typing import Optional, List
16
+ from datetime import datetime
17
+ import ssl
18
+ import socket
19
+ from urllib.parse import urlparse
20
+ from cryptography import x509
21
+ from cryptography.hazmat.backends import default_backend
22
+ from cryptography.x509.oid import NameOID
23
+ import httpx
24
+
25
+ from ..types import ValidationResult, ValidationIssue, ValidationSeverity, create_simple_validation_result
26
+
27
+
28
+ class CertificateValidator:
29
+ """Validates TLS/SSL certificates for secure agent communication.
30
+
31
+ Performs certificate validation including expiry checks, hostname
32
+ verification, and chain validation to ensure secure connections.
33
+ """
34
+
35
+ # Warning threshold for certificate expiry (days)
36
+ EXPIRY_WARNING_DAYS = 30
37
+
38
+ def __init__(self, http_client: Optional[httpx.AsyncClient] = None):
39
+ """Initialize certificate validator.
40
+
41
+ Args:
42
+ http_client: Optional HTTP client with certificate verification
43
+ """
44
+ self.http_client = http_client or httpx.AsyncClient(
45
+ timeout=10.0,
46
+ verify=True # Enable certificate verification
47
+ )
48
+
49
+ async def validate_url_certificate(
50
+ self,
51
+ url: str,
52
+ verify_hostname: bool = True
53
+ ) -> ValidationResult:
54
+ """Validate certificate for a given URL.
55
+
56
+ Args:
57
+ url: URL to validate certificate for
58
+ verify_hostname: Whether to verify hostname matches certificate
59
+
60
+ Returns:
61
+ ValidationResult with certificate validation issues
62
+ """
63
+ issues: List[ValidationIssue] = []
64
+
65
+ try:
66
+ parsed = urlparse(url)
67
+
68
+ # Only validate HTTPS URLs
69
+ if parsed.scheme != "https":
70
+ issues.append(ValidationIssue(
71
+ severity=ValidationSeverity.WARNING,
72
+ code="NON_HTTPS_URL",
73
+ message="Certificate validation only applies to HTTPS URLs",
74
+ path="certificate"
75
+ ))
76
+ return create_simple_validation_result(
77
+ success=True,
78
+ issues=issues,
79
+ simple_score=80,
80
+ dimension="trust"
81
+ )
82
+
83
+ hostname = parsed.hostname
84
+ port = parsed.port or 443
85
+
86
+ if not hostname:
87
+ issues.append(ValidationIssue(
88
+ severity=ValidationSeverity.ERROR,
89
+ code="INVALID_HOSTNAME",
90
+ message="Cannot extract hostname from URL",
91
+ path="certificate"
92
+ ))
93
+ return create_simple_validation_result(
94
+ success=False,
95
+ issues=issues,
96
+ simple_score=0,
97
+ dimension="trust"
98
+ )
99
+
100
+ # Get certificate from server
101
+ cert_pem = self._get_certificate(hostname, port)
102
+ if not cert_pem:
103
+ issues.append(ValidationIssue(
104
+ severity=ValidationSeverity.ERROR,
105
+ code="CERTIFICATE_FETCH_FAILED",
106
+ message=f"Failed to fetch certificate from {hostname}:{port}",
107
+ path="certificate"
108
+ ))
109
+ return create_simple_validation_result(
110
+ success=False,
111
+ issues=issues,
112
+ simple_score=0,
113
+ dimension="trust"
114
+ )
115
+
116
+ # Parse certificate
117
+ cert = x509.load_pem_x509_certificate(cert_pem.encode(), default_backend())
118
+
119
+ # Validate certificate
120
+ issues.extend(self._validate_certificate_expiry(cert))
121
+
122
+ if verify_hostname:
123
+ issues.extend(self._validate_hostname(cert, hostname))
124
+
125
+ issues.extend(self._check_self_signed(cert))
126
+
127
+ # Calculate score
128
+ error_count = sum(1 for i in issues if i.severity == ValidationSeverity.ERROR)
129
+ warning_count = sum(1 for i in issues if i.severity == ValidationSeverity.WARNING)
130
+ score = max(0, 100 - (error_count * 25) - (warning_count * 10))
131
+
132
+ return create_simple_validation_result(
133
+ success=error_count == 0,
134
+ issues=issues,
135
+ simple_score=score,
136
+ dimension="trust"
137
+ )
138
+
139
+ except Exception as e:
140
+ issues.append(ValidationIssue(
141
+ severity=ValidationSeverity.ERROR,
142
+ code="CERTIFICATE_VALIDATION_ERROR",
143
+ message=f"Certificate validation error: {str(e)}",
144
+ path="certificate"
145
+ ))
146
+ return create_simple_validation_result(
147
+ success=False,
148
+ issues=issues,
149
+ simple_score=0,
150
+ dimension="trust"
151
+ )
152
+
153
+ def _get_certificate(self, hostname: str, port: int) -> Optional[str]:
154
+ """Retrieve certificate from server.
155
+
156
+ Args:
157
+ hostname: Server hostname
158
+ port: Server port
159
+
160
+ Returns:
161
+ PEM-encoded certificate or None if failed
162
+ """
163
+ try:
164
+ context = ssl.create_default_context()
165
+ with socket.create_connection((hostname, port), timeout=10) as sock:
166
+ with context.wrap_socket(sock, server_hostname=hostname) as ssock:
167
+ cert_der = ssock.getpeercert(binary_form=True)
168
+ if cert_der:
169
+ cert = x509.load_der_x509_certificate(cert_der, default_backend())
170
+ return cert.public_bytes(encoding=x509.Encoding.PEM).decode() # type: ignore[attr-defined]
171
+ return None
172
+ except Exception:
173
+ return None
174
+
175
+ def _validate_certificate_expiry(self, cert: x509.Certificate) -> List[ValidationIssue]:
176
+ """Validate certificate expiry dates.
177
+
178
+ Args:
179
+ cert: Certificate to validate
180
+
181
+ Returns:
182
+ List of validation issues
183
+ """
184
+ issues: List[ValidationIssue] = []
185
+ now = datetime.utcnow()
186
+
187
+ # Check if certificate has expired
188
+ if cert.not_valid_after < now:
189
+ days_expired = (now - cert.not_valid_after).days
190
+ issues.append(ValidationIssue(
191
+ severity=ValidationSeverity.ERROR,
192
+ code="CERTIFICATE_EXPIRED",
193
+ message=f"Certificate expired {days_expired} days ago (expired: {cert.not_valid_after.isoformat()})",
194
+ path="certificate.expiry"
195
+ ))
196
+ # Check if certificate is not yet valid
197
+ elif cert.not_valid_before > now:
198
+ days_early = (cert.not_valid_before - now).days
199
+ issues.append(ValidationIssue(
200
+ severity=ValidationSeverity.ERROR,
201
+ code="CERTIFICATE_NOT_YET_VALID",
202
+ message=f"Certificate not valid for {days_early} more days (valid from: {cert.not_valid_before.isoformat()})",
203
+ path="certificate.validity"
204
+ ))
205
+ # Check if certificate expires soon
206
+ else:
207
+ days_until_expiry = (cert.not_valid_after - now).days
208
+ if days_until_expiry <= self.EXPIRY_WARNING_DAYS:
209
+ issues.append(ValidationIssue(
210
+ severity=ValidationSeverity.WARNING,
211
+ code="CERTIFICATE_EXPIRING_SOON",
212
+ message=f"Certificate expires in {days_until_expiry} days (expires: {cert.not_valid_after.isoformat()})",
213
+ path="certificate.expiry"
214
+ ))
215
+
216
+ return issues
217
+
218
+ def _validate_hostname(self, cert: x509.Certificate, hostname: str) -> List[ValidationIssue]:
219
+ """Validate certificate hostname matches.
220
+
221
+ Args:
222
+ cert: Certificate to validate
223
+ hostname: Expected hostname
224
+
225
+ Returns:
226
+ List of validation issues
227
+ """
228
+ issues: List[ValidationIssue] = []
229
+
230
+ try:
231
+ # Get subject common name
232
+ subject = cert.subject
233
+ common_names = [
234
+ attr.value for attr in subject
235
+ if attr.oid == NameOID.COMMON_NAME
236
+ ]
237
+
238
+ # Get subject alternative names
239
+ san_names: List[str] = []
240
+ try:
241
+ san_ext = cert.extensions.get_extension_for_oid(
242
+ x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME
243
+ )
244
+ san_names = [
245
+ str(name.value) for name in san_ext.value # type: ignore[attr-defined]
246
+ if isinstance(name, x509.DNSName)
247
+ ]
248
+ except x509.ExtensionNotFound:
249
+ pass
250
+
251
+ # Check if hostname matches
252
+ all_names = common_names + san_names
253
+ if not any(self._matches_hostname(name, hostname) for name in all_names):
254
+ issues.append(ValidationIssue(
255
+ severity=ValidationSeverity.ERROR,
256
+ code="HOSTNAME_MISMATCH",
257
+ message=f"Certificate hostname mismatch. Expected: {hostname}, Certificate names: {', '.join(all_names) if all_names else 'none'}",
258
+ path="certificate.hostname"
259
+ ))
260
+
261
+ except Exception as e:
262
+ issues.append(ValidationIssue(
263
+ severity=ValidationSeverity.WARNING,
264
+ code="HOSTNAME_VALIDATION_ERROR",
265
+ message=f"Failed to validate hostname: {str(e)}",
266
+ path="certificate.hostname"
267
+ ))
268
+
269
+ return issues
270
+
271
+ def _matches_hostname(self, cert_name: str, hostname: str) -> bool:
272
+ """Check if certificate name matches hostname.
273
+
274
+ Supports wildcard certificates (*.example.com).
275
+
276
+ Args:
277
+ cert_name: Name from certificate
278
+ hostname: Hostname to match
279
+
280
+ Returns:
281
+ True if matches, False otherwise
282
+ """
283
+ # Exact match
284
+ if cert_name.lower() == hostname.lower():
285
+ return True
286
+
287
+ # Wildcard match (*.example.com matches foo.example.com)
288
+ if cert_name.startswith("*."):
289
+ cert_domain = cert_name[2:].lower()
290
+ if "." in hostname:
291
+ _, host_domain = hostname.split(".", 1)
292
+ if host_domain.lower() == cert_domain:
293
+ return True
294
+
295
+ return False
296
+
297
+ def _check_self_signed(self, cert: x509.Certificate) -> List[ValidationIssue]:
298
+ """Check if certificate is self-signed.
299
+
300
+ Args:
301
+ cert: Certificate to check
302
+
303
+ Returns:
304
+ List of validation issues
305
+ """
306
+ issues: List[ValidationIssue] = []
307
+
308
+ try:
309
+ # A certificate is self-signed if issuer == subject
310
+ if cert.issuer == cert.subject:
311
+ issues.append(ValidationIssue(
312
+ severity=ValidationSeverity.WARNING,
313
+ code="SELF_SIGNED_CERTIFICATE",
314
+ message="Certificate is self-signed (not issued by trusted CA)",
315
+ path="certificate.issuer"
316
+ ))
317
+ except Exception:
318
+ pass
319
+
320
+ return issues
321
+
322
+ def validate_certificate_chain(
323
+ self,
324
+ cert: x509.Certificate,
325
+ chain: List[x509.Certificate]
326
+ ) -> ValidationResult:
327
+ """Validate certificate chain integrity.
328
+
329
+ Args:
330
+ cert: End-entity certificate
331
+ chain: Certificate chain (intermediate + root)
332
+
333
+ Returns:
334
+ ValidationResult with chain validation issues
335
+ """
336
+ issues: List[ValidationIssue] = []
337
+
338
+ try:
339
+ # Basic chain validation
340
+ if not chain:
341
+ issues.append(ValidationIssue(
342
+ severity=ValidationSeverity.WARNING,
343
+ code="NO_CERTIFICATE_CHAIN",
344
+ message="No certificate chain provided",
345
+ path="certificate.chain"
346
+ ))
347
+ else:
348
+ # Verify each certificate in chain is issued by the next
349
+ current = cert
350
+ for idx, issuer_cert in enumerate(chain):
351
+ if current.issuer != issuer_cert.subject:
352
+ issues.append(ValidationIssue(
353
+ severity=ValidationSeverity.ERROR,
354
+ code="CHAIN_BROKEN",
355
+ message=f"Certificate chain broken at position {idx}",
356
+ path=f"certificate.chain[{idx}]"
357
+ ))
358
+ break
359
+ current = issuer_cert
360
+
361
+ error_count = sum(1 for i in issues if i.severity == ValidationSeverity.ERROR)
362
+ warning_count = sum(1 for i in issues if i.severity == ValidationSeverity.WARNING)
363
+ score = max(0, 100 - (error_count * 25) - (warning_count * 10))
364
+
365
+ return create_simple_validation_result(
366
+ success=error_count == 0,
367
+ issues=issues,
368
+ simple_score=score,
369
+ dimension="trust"
370
+ )
371
+
372
+ except Exception as e:
373
+ issues.append(ValidationIssue(
374
+ severity=ValidationSeverity.ERROR,
375
+ code="CHAIN_VALIDATION_ERROR",
376
+ message=f"Certificate chain validation error: {str(e)}",
377
+ path="certificate.chain"
378
+ ))
379
+ return create_simple_validation_result(
380
+ success=False,
381
+ issues=issues,
382
+ simple_score=0,
383
+ dimension="trust"
384
+ )