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.
- tiny_ca_gateway/aiohttp/api/v1/ca_routes.py +546 -0
- tiny_ca_gateway/aiohttp/lifespan/manager.py +11 -0
- tiny_ca_gateway/core/__init__.py +71 -0
- tiny_ca_gateway/core/auth.py +26 -0
- tiny_ca_gateway/core/base_manager.py +162 -0
- tiny_ca_gateway/core/helpers.py +110 -0
- tiny_ca_gateway/core/routes.py +41 -0
- tiny_ca_gateway/core/schemas.py +179 -0
- tiny_ca_gateway/core/summary.py +26 -0
- tiny_ca_gateway/db.py +63 -0
- tiny_ca_gateway/django/api/v1/ca_router.py +594 -0
- tiny_ca_gateway/django/lifespan/manager.py +5 -0
- tiny_ca_gateway/fastapi/api/v1/__init__.py +1 -0
- tiny_ca_gateway/fastapi/api/v1/ca_routes.py +580 -0
- tiny_ca_gateway/fastapi/api/v1/utils.py +87 -0
- tiny_ca_gateway/fastapi/auth/__init__.py +0 -0
- tiny_ca_gateway/fastapi/auth/verify.py +61 -0
- tiny_ca_gateway/fastapi/lifespan/__init__.py +3 -0
- tiny_ca_gateway/fastapi/lifespan/manager.py +7 -0
- tiny_ca_gateway/flask/api/v1/ca_blueprint.py +629 -0
- tiny_ca_gateway/flask/lifespan/manager.py +5 -0
- tiny_ca_gateway/models.py +79 -0
- tiny_ca_gateway/openapi.py +536 -0
- tiny_ca_gateway/utils/singleton.py +27 -0
- tiny_ca_gateway-0.1.0.dist-info/METADATA +233 -0
- tiny_ca_gateway-0.1.0.dist-info/RECORD +29 -0
- tiny_ca_gateway-0.1.0.dist-info/WHEEL +5 -0
- tiny_ca_gateway-0.1.0.dist-info/licenses/LICENSE +54 -0
- tiny_ca_gateway-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
from .ca_routes import router as ca_router
|