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