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