tiny-ca-gateway 0.1.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,594 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import uuid as _uuid_module
7
+
8
+ import aiofiles
9
+ from cryptography import x509
10
+ from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
11
+ from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
12
+ from ninja import Router
13
+ from ninja.errors import HttpError
14
+ from ninja.security import HttpBearer
15
+ from tiny_ca.managers.async_lifecycle_manager import AsyncCertLifecycleManager
16
+ from tiny_ca.models.certificate import CAConfig, CertificateDetails
17
+
18
+ from tiny_ca_gateway.core import (
19
+ REASON_MAP,
20
+ ArtifactType,
21
+ CertListItem,
22
+ ChainResponse,
23
+ CosignRequest,
24
+ CosignResponse,
25
+ CreateRootCaResponse,
26
+ CRLRefreshResponse,
27
+ CRLVerifyRequest,
28
+ DeleteResponse,
29
+ ExpiringResponse,
30
+ ExportP12Request,
31
+ IntermediateCARequest,
32
+ IssueCertRequest,
33
+ IssueCertResponse,
34
+ MaintenanceResponse,
35
+ RenewRequest,
36
+ RenewResponse,
37
+ RevokeRequest,
38
+ RevokeResponse,
39
+ RotateResponse,
40
+ Routes,
41
+ StatusResponse,
42
+ VerifyRequest,
43
+ VerifyResponse,
44
+ build_client_config,
45
+ get_artifact_path,
46
+ record_to_item,
47
+ )
48
+ from tiny_ca_gateway.core.auth import auth_enabled, check_token, extract_bearer
49
+ from tiny_ca_gateway.core.helpers import (
50
+ load_pem_cert as __load_pem_cert,
51
+ )
52
+ from tiny_ca_gateway.core.helpers import (
53
+ load_pem_crl as __load_pem_crl,
54
+ )
55
+ from tiny_ca_gateway.core.schemas import CRLVerifyResponse
56
+ from tiny_ca_gateway.core.summary import RoutesSummary
57
+ from tiny_ca_gateway.django.lifespan.manager import DjangoCALifespanManager
58
+ from tiny_ca_gateway.models import API_SETTINGS
59
+
60
+ ca_router = Router(tags=["ca"])
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Django Ninja-specific wrappers
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class TokenAuth(HttpBearer):
69
+ def __call__(self, request: HttpRequest) -> Any:
70
+ if not auth_enabled():
71
+ return "open"
72
+ return super().__call__(request)
73
+
74
+ def authenticate(self, request: HttpRequest, token: str) -> str | None:
75
+ if check_token(token):
76
+ return token
77
+ raise HttpError(401, "Invalid or missing bearer token.")
78
+
79
+
80
+ token_auth = TokenAuth()
81
+
82
+
83
+ def _require_factory() -> AsyncCertLifecycleManager:
84
+ mgr = DjangoCALifespanManager().manager
85
+ if mgr.factory is None:
86
+ raise HttpError(503, "CA not initialised — call POST /ca/root to bootstrap.")
87
+ return mgr
88
+
89
+
90
+ def _load_pem_cert(pem: str) -> x509.Certificate:
91
+ try:
92
+ return __load_pem_cert(pem)
93
+ except ValueError as exc:
94
+ raise HttpError(422, str(exc)) from exc
95
+
96
+
97
+ def _load_pem_crl(pem: str) -> x509.CertificateRevocationList:
98
+ try:
99
+ return __load_pem_crl(pem)
100
+ except ValueError as exc:
101
+ raise HttpError(422, str(exc)) from exc
102
+
103
+
104
+ # ===========================================================================
105
+ # Public
106
+ # ===========================================================================
107
+
108
+
109
+ @ca_router.get(Routes.GET_PUBLIC_CERT, summary=RoutesSummary.GET_PUBLIC_CERT, auth=None)
110
+ async def get_public_cert(request: HttpRequest) -> HttpResponse:
111
+ cert_path = Path(API_SETTINGS.path_to_ca_cer)
112
+ if not cert_path.exists():
113
+ raise HttpError(404, "CA not bootstrapped — POST /ca/root first.")
114
+ return HttpResponse(
115
+ cert_path.read_bytes(),
116
+ content_type="application/x-pem-file",
117
+ headers={"Content-Disposition": 'attachment; filename="ca.pem"'},
118
+ )
119
+
120
+
121
+ @ca_router.get(Routes.BASE_CRL, summary=RoutesSummary.BASE_CRL, auth=None)
122
+ async def crl_route(request: HttpRequest, pem: bool = False) -> HttpResponse:
123
+ crl_path = Path(API_SETTINGS.path_to_crl)
124
+ if not crl_path.exists():
125
+ try:
126
+ await _require_factory().generate_crl()
127
+ except Exception as exc:
128
+ raise HttpError(500, str(exc)) from exc
129
+ if not crl_path.exists():
130
+ raise HttpError(500, "CRL not found after generation.")
131
+ async with aiofiles.open(crl_path, "rb") as fh:
132
+ content = await fh.read()
133
+ if pem:
134
+ if not content.startswith(b"-----"):
135
+ content = x509.load_der_x509_crl(content).public_bytes(Encoding.PEM)
136
+ return HttpResponse(
137
+ content,
138
+ content_type="application/x-pem-file",
139
+ headers={"Content-Disposition": 'attachment; filename="ca.crl.pem"'},
140
+ )
141
+ return HttpResponse(
142
+ content,
143
+ content_type="application/pkix-crl",
144
+ headers={
145
+ "Content-Disposition": 'attachment; filename="ca.crl"',
146
+ "Cache-Control": "max-age=3600",
147
+ },
148
+ )
149
+
150
+
151
+ # ===========================================================================
152
+ # List
153
+ # ===========================================================================
154
+
155
+
156
+ @ca_router.get(
157
+ "/",
158
+ response=list[CertListItem],
159
+ auth=token_auth,
160
+ summary=RoutesSummary.GET_LIST_CERTS,
161
+ )
162
+ @ca_router.get(
163
+ Routes.GET_LIST_CERTS,
164
+ summary=RoutesSummary.GET_LIST_CERTS,
165
+ response=list[CertListItem],
166
+ auth=token_auth,
167
+ )
168
+ async def list_certificates(
169
+ request: HttpRequest,
170
+ status: str | None = None,
171
+ key_type: str | None = None,
172
+ limit: int = 100,
173
+ offset: int = 0,
174
+ ) -> list[CertListItem]:
175
+ records = await _require_factory().list_certificates(
176
+ status=status,
177
+ key_type=key_type,
178
+ limit=max(1, min(limit, 1000)),
179
+ offset=max(0, offset),
180
+ )
181
+ return [record_to_item(r) for r in records]
182
+
183
+
184
+ @ca_router.get(
185
+ Routes.GET_LIST_EXPIRING_CERTS,
186
+ summary=RoutesSummary.GET_LIST_EXPIRING_CERTS,
187
+ response=ExpiringResponse,
188
+ auth=token_auth,
189
+ )
190
+ async def get_expiring(request: HttpRequest, within_days: int = 30) -> ExpiringResponse:
191
+ records = await _require_factory().get_expiring_soon(
192
+ within_days=max(1, min(within_days, 365))
193
+ )
194
+ return ExpiringResponse(
195
+ within_days=within_days,
196
+ count=len(records),
197
+ certificates=[record_to_item(r) for r in records],
198
+ )
199
+
200
+
201
+ # ===========================================================================
202
+ # CA bootstrap
203
+ # ===========================================================================
204
+
205
+
206
+ @ca_router.post(
207
+ Routes.CA_ROOT,
208
+ summary=RoutesSummary.CA_ROOT,
209
+ response=CreateRootCaResponse,
210
+ auth=token_auth,
211
+ )
212
+ async def create_root_ca(
213
+ request: HttpRequest, payload: CAConfig
214
+ ) -> CreateRootCaResponse:
215
+ lm = DjangoCALifespanManager()
216
+ try:
217
+ await lm.rebuild_root_ca_pair(ca_config=payload)
218
+ except Exception as exc:
219
+ raise HttpError(409, str(exc)) from exc
220
+ try:
221
+ await lm.rebuild_manager()
222
+ except Exception as exc:
223
+ lm.logger.error("Factory reload failed: %s", exc)
224
+ cert_obj = x509.load_pem_x509_certificate(
225
+ Path(API_SETTINGS.path_to_ca_cer).read_bytes()
226
+ )
227
+ return CreateRootCaResponse(
228
+ ca_id="",
229
+ expires_at=cert_obj.not_valid_after_utc.strftime(API_SETTINGS.datetime_fmt),
230
+ )
231
+
232
+
233
+ @ca_router.post(
234
+ Routes.CA_INTERMEDIATE,
235
+ summary=RoutesSummary.CA_INTERMEDIATE,
236
+ response=IssueCertResponse,
237
+ auth=token_auth,
238
+ )
239
+ async def issue_intermediate_ca(
240
+ request: HttpRequest, payload: IntermediateCARequest
241
+ ) -> IssueCertResponse:
242
+ mgr = _require_factory()
243
+ _uuid = str(_uuid_module.uuid4())
244
+ try:
245
+ cert, _ = await mgr.issue_intermediate_ca(
246
+ common_name=payload.common_name,
247
+ key_size=payload.key_size,
248
+ days_valid=payload.days_valid,
249
+ path_length=payload.path_length,
250
+ organization=payload.organization,
251
+ country=payload.country,
252
+ uuid_str=_uuid,
253
+ )
254
+ except Exception as exc:
255
+ raise HttpError(422, str(exc)) from exc
256
+ return IssueCertResponse(
257
+ uuid=_uuid,
258
+ serial_number=cert.serial_number,
259
+ common_name=payload.common_name,
260
+ not_valid_before=cert.not_valid_before_utc,
261
+ not_valid_after=cert.not_valid_after_utc,
262
+ )
263
+
264
+
265
+ @ca_router.post(
266
+ Routes.ISSUE,
267
+ summary=RoutesSummary.ISSUE,
268
+ response=IssueCertResponse,
269
+ auth=token_auth,
270
+ )
271
+ async def issue_certificate(
272
+ request: HttpRequest, payload: IssueCertRequest
273
+ ) -> IssueCertResponse:
274
+ mgr = _require_factory()
275
+ _uuid = str(_uuid_module.uuid4())
276
+ try:
277
+ if payload.key_size and (payload.key_size < 2048 or payload.key_size > 4096):
278
+ raise HttpError(
279
+ 400, f"Invalid key_size: {payload.key_size}. Must be 2048-4096."
280
+ )
281
+
282
+ cert, _, _ = await mgr.issue_certificate(
283
+ config=build_client_config(payload),
284
+ uuid_str=_uuid,
285
+ is_overwrite=payload.is_overwrite,
286
+ )
287
+ except HttpError:
288
+ raise
289
+ except ValueError as exc:
290
+ if "key" in str(exc).lower():
291
+ raise HttpError(400, str(exc)) from exc
292
+ raise HttpError(409, str(exc)) from exc
293
+ except Exception as exc:
294
+ raise HttpError(409, str(exc)) from exc
295
+ return IssueCertResponse(
296
+ uuid=_uuid,
297
+ serial_number=cert.serial_number,
298
+ common_name=payload.common_name,
299
+ not_valid_before=cert.not_valid_before_utc,
300
+ not_valid_after=cert.not_valid_after_utc,
301
+ )
302
+
303
+
304
+ @ca_router.post(
305
+ Routes.MAINTENANCE_EXPIRE,
306
+ summary=RoutesSummary.MAINTENANCE_EXPIRE,
307
+ response=MaintenanceResponse,
308
+ auth=token_auth,
309
+ )
310
+ async def mark_expired(request: HttpRequest) -> MaintenanceResponse:
311
+ return MaintenanceResponse(
312
+ updated=await _require_factory().refresh_expired_statuses()
313
+ )
314
+
315
+
316
+ # ===========================================================================
317
+ # CRL
318
+ # ===========================================================================
319
+
320
+
321
+ @ca_router.post(Routes.CRL_REFRESH, response=CRLRefreshResponse, auth=token_auth)
322
+ async def refresh_crl(request: HttpRequest) -> CRLRefreshResponse:
323
+ try:
324
+ crl = await _require_factory().generate_crl()
325
+ except Exception as exc:
326
+ raise HttpError(500, str(exc)) from exc
327
+ return CRLRefreshResponse(next_update=crl.next_update_utc)
328
+
329
+
330
+ @ca_router.post(Routes.CRL_VERIFY, summary=RoutesSummary.CRL_VERIFY, auth=token_auth)
331
+ async def verify_crl(
332
+ request: HttpRequest, payload: CRLVerifyRequest
333
+ ) -> CRLVerifyResponse:
334
+ crl = _load_pem_crl(payload.pem)
335
+ try:
336
+ await _require_factory().verify_crl(crl=crl)
337
+ return CRLVerifyResponse(valid=True, next_update=str(crl.next_update_utc))
338
+
339
+ except Exception as exc:
340
+ return CRLVerifyResponse(valid=False, detail=str(exc))
341
+
342
+
343
+ # ===========================================================================
344
+ # Verification / cosign / export
345
+ # ===========================================================================
346
+
347
+
348
+ @ca_router.post(
349
+ Routes.VERIFY,
350
+ summary=RoutesSummary.VERIFY,
351
+ response=VerifyResponse,
352
+ auth=token_auth,
353
+ )
354
+ async def verify_certificate(
355
+ request: HttpRequest, payload: VerifyRequest
356
+ ) -> VerifyResponse:
357
+ cert = _load_pem_cert(payload.pem)
358
+ try:
359
+ result = await _require_factory().verify_certificate(cert=cert)
360
+ if isinstance(result, tuple):
361
+ valid, reason = result
362
+ if valid:
363
+ return VerifyResponse(valid=True)
364
+ else:
365
+ return VerifyResponse(
366
+ valid=False, detail=reason or "Verification failed"
367
+ )
368
+ return VerifyResponse(valid=True)
369
+ except Exception as exc:
370
+ return VerifyResponse(valid=False, detail=str(exc))
371
+
372
+
373
+ @ca_router.post(
374
+ Routes.COSIGN,
375
+ summary=RoutesSummary.COSIGN,
376
+ response=CosignResponse,
377
+ auth=token_auth,
378
+ )
379
+ async def cosign_certificate(
380
+ request: HttpRequest, payload: CosignRequest
381
+ ) -> CosignResponse:
382
+ cert = _load_pem_cert(payload.pem)
383
+ try:
384
+ cosigned = await _require_factory().cosign_certificate(
385
+ cert=cert,
386
+ days_valid=payload.days_valid,
387
+ valid_from=payload.valid_from,
388
+ )
389
+ except Exception as exc:
390
+ raise HttpError(422, str(exc)) from exc
391
+ return CosignResponse(
392
+ serial_number=cosigned.serial_number,
393
+ not_valid_before=cosigned.not_valid_before_utc,
394
+ not_valid_after=cosigned.not_valid_after_utc,
395
+ pem=cosigned.public_bytes(Encoding.PEM).decode(),
396
+ )
397
+
398
+
399
+ @ca_router.post(Routes.EXPORT, summary=RoutesSummary.EXPORT, auth=token_auth)
400
+ async def export_pkcs12(
401
+ request: HttpRequest, serial: int, payload: ExportP12Request
402
+ ) -> HttpResponse:
403
+ mgr = _require_factory()
404
+ record = await mgr._db.get_by_serial(serial=serial)
405
+ if not record:
406
+ raise HttpError(404, f"Certificate serial={serial} not found")
407
+ key_path = await get_artifact_path(record.uuid, "key")
408
+ if not key_path:
409
+ raise HttpError(404, "Private key not found in storage")
410
+ async with aiofiles.open(key_path, "rb") as fh:
411
+ private_key = load_pem_private_key(await fh.read(), password=None)
412
+ p12 = await mgr.export_pkcs12(
413
+ cert=x509.load_pem_x509_certificate(record.certificate_pem.encode()),
414
+ private_key=private_key,
415
+ password=payload.password.encode() if payload.password else None,
416
+ )
417
+ fname = (record.common_name or str(serial)).replace(" ", "_")
418
+ return HttpResponse(
419
+ p12,
420
+ content_type="application/x-pkcs12",
421
+ headers={"Content-Disposition": f'attachment; filename="{fname}.p12"'},
422
+ )
423
+
424
+
425
+ # ===========================================================================
426
+ # Mutations
427
+ # ===========================================================================
428
+
429
+
430
+ @ca_router.patch(
431
+ Routes.REVOKE,
432
+ summary=RoutesSummary.REVOKE,
433
+ response=RevokeResponse,
434
+ auth=token_auth,
435
+ )
436
+ async def revoke_certificate(
437
+ request: HttpRequest, payload: RevokeRequest
438
+ ) -> RevokeResponse:
439
+ reason = REASON_MAP.get(payload.reason, x509.ReasonFlags.unspecified)
440
+ ok = await _require_factory().revoke_certificate(
441
+ serial=payload.serial_number, reason=reason
442
+ )
443
+ if not ok:
444
+ raise HttpError(
445
+ 404, f"No valid certificate with serial={payload.serial_number}"
446
+ )
447
+ return RevokeResponse(revoked=True, serial_number=payload.serial_number)
448
+
449
+
450
+ @ca_router.post(
451
+ Routes.ROTATE,
452
+ summary=RoutesSummary.ROTATE,
453
+ response=RotateResponse,
454
+ auth=token_auth,
455
+ )
456
+ async def rotate_certificate(
457
+ request: HttpRequest, serial: int, payload: IssueCertRequest
458
+ ) -> RotateResponse:
459
+ _uuid = str(_uuid_module.uuid4())
460
+ try:
461
+ new_cert, _, _ = await _require_factory().rotate_certificate(
462
+ serial=serial, config=build_client_config(payload)
463
+ )
464
+ except Exception as exc:
465
+ raise HttpError(404, str(exc)) from exc
466
+ return RotateResponse(
467
+ uuid=_uuid,
468
+ serial_number=new_cert.serial_number,
469
+ common_name=payload.common_name,
470
+ not_valid_before=new_cert.not_valid_before_utc,
471
+ not_valid_after=new_cert.not_valid_after_utc,
472
+ )
473
+
474
+
475
+ @ca_router.post(
476
+ Routes.RENEW, summary=RoutesSummary.RENEW, response=RenewResponse, auth=token_auth
477
+ )
478
+ async def renew_certificate(
479
+ request: HttpRequest, serial: int, payload: RenewRequest
480
+ ) -> RenewResponse:
481
+ try:
482
+ renewed = await _require_factory().renew_certificate(
483
+ serial=serial, days_valid=payload.days_valid
484
+ )
485
+ except Exception as exc:
486
+ raise HttpError(404, str(exc)) from exc
487
+ return RenewResponse(
488
+ old_serial=serial,
489
+ new_serial=renewed.serial_number,
490
+ not_valid_after=renewed.not_valid_after_utc,
491
+ )
492
+
493
+
494
+ @ca_router.delete(
495
+ Routes.DELETE,
496
+ summary=RoutesSummary.DELETE,
497
+ response=DeleteResponse,
498
+ auth=token_auth,
499
+ )
500
+ async def delete_certificate(request: HttpRequest, serial: int) -> DeleteResponse:
501
+ if not await _require_factory().delete_certificate(serial=serial):
502
+ raise HttpError(404, f"Certificate serial={serial} not found")
503
+ return DeleteResponse(deleted=True, serial=serial)
504
+
505
+
506
+ # ===========================================================================
507
+ # Inspection
508
+ # ===========================================================================
509
+
510
+
511
+ @ca_router.get(
512
+ Routes.STATUS,
513
+ summary=RoutesSummary.STATUS,
514
+ response=StatusResponse,
515
+ auth=token_auth,
516
+ )
517
+ async def get_status(request: HttpRequest, serial: int) -> StatusResponse:
518
+ status = await _require_factory().get_certificate_status(serial=serial)
519
+ return StatusResponse(serial=serial, status=str(status))
520
+
521
+
522
+ @ca_router.get(Routes.INSPECT, auth=token_auth, summary=Routes.INSPECT)
523
+ async def inspect_certificate(request: HttpRequest, serial: int) -> CertificateDetails:
524
+ mgr = _require_factory()
525
+ record = await mgr._db.get_by_serial(serial=serial)
526
+ if not record:
527
+ raise HttpError(404, f"Certificate serial={serial} not found")
528
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
529
+ return await mgr.inspect_certificate(cert=cert)
530
+
531
+
532
+ @ca_router.get(
533
+ Routes.CHAIN,
534
+ response=ChainResponse,
535
+ auth=token_auth,
536
+ summary=RoutesSummary.CHAIN,
537
+ )
538
+ async def get_cert_chain(request: HttpRequest, serial: int) -> ChainResponse:
539
+ mgr = _require_factory()
540
+ record = await mgr._db.get_by_serial(serial=serial)
541
+ if not record:
542
+ raise HttpError(404, f"Certificate serial={serial} not found")
543
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
544
+ chain = await mgr.get_cert_chain(cert=cert)
545
+ return ChainResponse(
546
+ serial=serial,
547
+ chain_length=len(chain),
548
+ chain=[c.public_bytes(Encoding.PEM).decode() for c in chain],
549
+ )
550
+
551
+
552
+ # ===========================================================================
553
+ # Downloads
554
+ # ===========================================================================
555
+
556
+
557
+ @ca_router.get(
558
+ Routes.DOWNLOAD_STREAM, auth=token_auth, summary=RoutesSummary.DOWNLOAD_STREAM
559
+ )
560
+ async def stream_artefact(
561
+ request: HttpRequest, uuid_certificate: str, object_type: ArtifactType = "pem"
562
+ ) -> StreamingHttpResponse:
563
+ path = await get_artifact_path(uuid_certificate, object_type)
564
+ if not path:
565
+ raise HttpError(404, "Artefact not found")
566
+
567
+ async def _iter(p: Path, chunk: int = 1024 * 1024) -> AsyncGenerator[bytes, None]:
568
+ async with aiofiles.open(p, "rb") as fh:
569
+ while data := await fh.read(chunk):
570
+ yield data
571
+
572
+ return StreamingHttpResponse(
573
+ _iter(path),
574
+ content_type="application/x-pem-file",
575
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
576
+ )
577
+
578
+
579
+ @ca_router.get(
580
+ "/artifact/{uuid_certificate}", auth=token_auth, summary=RoutesSummary.DOWNLOAD
581
+ )
582
+ async def download_artefact(
583
+ request: HttpRequest, uuid_certificate: str, object_type: ArtifactType = "pem"
584
+ ) -> HttpResponse:
585
+ path = await get_artifact_path(uuid_certificate, object_type)
586
+ if not path:
587
+ raise HttpError(404, "Artefact not found")
588
+ async with aiofiles.open(path, "rb") as fh:
589
+ content = await fh.read()
590
+ return HttpResponse(
591
+ content,
592
+ content_type="application/x-pem-file",
593
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
594
+ )
@@ -0,0 +1,5 @@
1
+ from tiny_ca_gateway.core.base_manager import BaseCAManager
2
+
3
+
4
+ class DjangoCALifespanManager(BaseCAManager):
5
+ _logger_name = "django-ca"
@@ -0,0 +1 @@
1
+ from .ca_routes import router as ca_router