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,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contains all startup/shutdown/rebuild business logic.
|
|
3
|
+
Each framework inherits and adds only its own specifics:
|
|
4
|
+
- Flask: init_sync()
|
|
5
|
+
- Django: ready() hook
|
|
6
|
+
- FastAPI: already async, just uses as-is
|
|
7
|
+
- aiohttp: cleanup_ctx, uses as-is
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import shutil
|
|
15
|
+
|
|
16
|
+
from tiny_ca import CAConfig, CAFileLoader, CertificateFactory
|
|
17
|
+
from tiny_ca.managers.async_lifecycle_manager import AsyncCertLifecycleManager
|
|
18
|
+
|
|
19
|
+
from tiny_ca_gateway.db import LocalDBHandler
|
|
20
|
+
from tiny_ca_gateway.models import API_SETTINGS
|
|
21
|
+
from tiny_ca_gateway.utils.singleton import SingletonMeta
|
|
22
|
+
|
|
23
|
+
LOGGER = logging.getLogger("tiny-ca")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseCAManager(metaclass=SingletonMeta):
|
|
27
|
+
"""
|
|
28
|
+
Singleton base CA manager.
|
|
29
|
+
|
|
30
|
+
Subclass per framework and override ``_make_exception`` if the
|
|
31
|
+
framework uses a custom HTTP exception type.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
class FlaskCAManager(BaseCAManager):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
class MyCAManager(BaseCAManager):
|
|
39
|
+
_logger_name = "my-app-ca"
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_manager: AsyncCertLifecycleManager
|
|
43
|
+
_logger_name: str = "tiny-ca"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
logger: logging.Logger | None = None,
|
|
48
|
+
common_name: str = "Root CA",
|
|
49
|
+
organization: str = "Organization",
|
|
50
|
+
) -> None:
|
|
51
|
+
self._logger = logger or logging.getLogger(self._logger_name)
|
|
52
|
+
self._common_name = common_name
|
|
53
|
+
self._organization = organization
|
|
54
|
+
self._db_handler = LocalDBHandler(
|
|
55
|
+
API_SETTINGS.db_url,
|
|
56
|
+
logger=self._logger,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Properties
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def manager(self) -> AsyncCertLifecycleManager:
|
|
65
|
+
return self._manager
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def logger(self) -> logging.Logger:
|
|
69
|
+
return self._logger
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Internal async machinery
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
async def _rebuild_root_ca_pair(self, ca_config: CAConfig) -> None:
|
|
76
|
+
"""Generate a new self-signed root CA and write to disk."""
|
|
77
|
+
tmp = AsyncCertLifecycleManager(
|
|
78
|
+
db_handler=self._db_handler, logger=self._logger
|
|
79
|
+
)
|
|
80
|
+
ca_cert_path, ca_key_path = await tmp.create_self_signed_ca(
|
|
81
|
+
config=ca_config, is_overwrite=True
|
|
82
|
+
)
|
|
83
|
+
self._logger.warning("Generated new self-signed root CA.")
|
|
84
|
+
shutil.copy(ca_cert_path, API_SETTINGS.path_to_ca_cer)
|
|
85
|
+
shutil.copy(ca_key_path, API_SETTINGS.path_to_ca_key)
|
|
86
|
+
|
|
87
|
+
async def _rebuild_manager(self) -> None:
|
|
88
|
+
"""(Re-)load the CA factory from disk."""
|
|
89
|
+
self._manager = AsyncCertLifecycleManager(
|
|
90
|
+
db_handler=self._db_handler,
|
|
91
|
+
factory=CertificateFactory(
|
|
92
|
+
ca_loader=CAFileLoader(
|
|
93
|
+
ca_cert_path=API_SETTINGS.path_to_ca_cer,
|
|
94
|
+
ca_key_path=API_SETTINGS.path_to_ca_key,
|
|
95
|
+
logger=self._logger,
|
|
96
|
+
),
|
|
97
|
+
logger=self._logger,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Public lifecycle API
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async def on_startup(self) -> None:
|
|
106
|
+
"""Bootstrap DB, create CA if missing, load factory."""
|
|
107
|
+
await self._db_handler._db.init_db()
|
|
108
|
+
await self._db_handler.init_system_time_if_empty()
|
|
109
|
+
|
|
110
|
+
if not Path(API_SETTINGS.path_to_ca_cer).exists():
|
|
111
|
+
await self._rebuild_root_ca_pair(
|
|
112
|
+
CAConfig(
|
|
113
|
+
common_name=self._common_name,
|
|
114
|
+
organization=self._organization,
|
|
115
|
+
country="UA",
|
|
116
|
+
key_size=4096,
|
|
117
|
+
days_valid=3650,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
await self._rebuild_manager()
|
|
121
|
+
self._logger.info("✓ CA initialised.")
|
|
122
|
+
|
|
123
|
+
async def on_shutdown(self) -> None:
|
|
124
|
+
"""Graceful shutdown hook (no-op by default)."""
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Called from POST /root endpoint
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def rebuild_root_ca_pair(self, ca_config: CAConfig) -> None:
|
|
132
|
+
await self._rebuild_root_ca_pair(ca_config)
|
|
133
|
+
|
|
134
|
+
async def rebuild_manager(self) -> None:
|
|
135
|
+
await self._rebuild_manager()
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Sync bootstrap (Flask / Django WSGI)
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def init_sync(self) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Run on_startup() synchronously.
|
|
144
|
+
|
|
145
|
+
Use in WSGI contexts (Flask create_app, Django AppConfig.ready).
|
|
146
|
+
Safe to call even if an async loop is already running — in that
|
|
147
|
+
case the call is a no-op and the ASGI lifespan handler takes over.
|
|
148
|
+
"""
|
|
149
|
+
import asyncio
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
loop = asyncio.get_running_loop()
|
|
153
|
+
except RuntimeError:
|
|
154
|
+
loop = None
|
|
155
|
+
|
|
156
|
+
if loop and loop.is_running():
|
|
157
|
+
self._logger.debug(
|
|
158
|
+
"Async loop running — CA init delegated to ASGI lifespan."
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
asyncio.run(self.on_startup())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cryptography import x509
|
|
8
|
+
|
|
9
|
+
from tiny_ca_gateway.core.schemas import (
|
|
10
|
+
ArtifactType,
|
|
11
|
+
CertListItem,
|
|
12
|
+
IssueCertRequest,
|
|
13
|
+
)
|
|
14
|
+
from tiny_ca_gateway.models import API_SETTINGS
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from tiny_ca.models.certificate import ClientConfig
|
|
18
|
+
|
|
19
|
+
LOGGER = logging.getLogger("tiny-ca")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Record → schema
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_to_item(r: object) -> CertListItem:
|
|
28
|
+
"""Convert a DB record to a CertListItem schema."""
|
|
29
|
+
return CertListItem(
|
|
30
|
+
serial_number=r.serial_number, # type: ignore[attr-defined]
|
|
31
|
+
common_name=r.common_name, # type: ignore[attr-defined]
|
|
32
|
+
status=r.status, # type: ignore[attr-defined]
|
|
33
|
+
key_type=r.key_type, # type: ignore[attr-defined]
|
|
34
|
+
not_valid_after=r.not_valid_after, # type: ignore[attr-defined]
|
|
35
|
+
uuid=r.uuid, # type: ignore[attr-defined]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# File system
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def get_artifact_path(uuid: str, object_type: ArtifactType) -> Path | None:
|
|
45
|
+
"""
|
|
46
|
+
Return the path to ``<certs_dir>/<uuid>/<any_name>.<object_type>``
|
|
47
|
+
or ``None`` if not found.
|
|
48
|
+
|
|
49
|
+
tiny_ca stores files as ``<common_name>.<ext>`` inside a UUID folder,
|
|
50
|
+
so we scan by extension rather than assuming a fixed filename.
|
|
51
|
+
"""
|
|
52
|
+
folder = Path(API_SETTINGS.path_to_certificates_folder) / uuid
|
|
53
|
+
if not folder.is_dir():
|
|
54
|
+
LOGGER.debug("Artifact folder not found: %s", folder)
|
|
55
|
+
return None
|
|
56
|
+
ext = f".{object_type}"
|
|
57
|
+
for f in folder.iterdir():
|
|
58
|
+
if f.is_file() and f.suffix == ext:
|
|
59
|
+
return f
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# PEM loaders (raise ValueError — caller converts to HTTP error)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_pem_cert(pem: str) -> x509.Certificate:
|
|
69
|
+
"""Parse PEM certificate; raises ValueError on failure."""
|
|
70
|
+
try:
|
|
71
|
+
return x509.load_pem_x509_certificate(pem.encode())
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise ValueError(f"Invalid PEM certificate: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_pem_crl(pem: str) -> x509.CertificateRevocationList:
|
|
77
|
+
"""Parse PEM CRL; raises ValueError on failure."""
|
|
78
|
+
try:
|
|
79
|
+
return x509.load_pem_x509_crl(pem.encode())
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise ValueError(f"Invalid PEM CRL: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# ClientConfig builder (issue / rotate share identical logic)
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_client_config(payload: IssueCertRequest) -> ClientConfig:
|
|
90
|
+
"""
|
|
91
|
+
Build a ``ClientConfig`` from an ``IssueCertRequest``.
|
|
92
|
+
|
|
93
|
+
Centralises the payload → config mapping that was copy-pasted
|
|
94
|
+
in every framework's issue and rotate handlers.
|
|
95
|
+
"""
|
|
96
|
+
from tiny_ca.const import CertType
|
|
97
|
+
from tiny_ca.models.certificate import ClientConfig
|
|
98
|
+
|
|
99
|
+
return ClientConfig(
|
|
100
|
+
common_name=payload.common_name,
|
|
101
|
+
serial_type=CertType.SERVICE,
|
|
102
|
+
key_size=payload.key_size,
|
|
103
|
+
days_valid=payload.days_valid,
|
|
104
|
+
valid_from=payload.valid_from,
|
|
105
|
+
email=payload.email,
|
|
106
|
+
is_server_cert=payload.is_server_cert,
|
|
107
|
+
is_client_cert=payload.is_client_cert,
|
|
108
|
+
san_dns=payload.san_dns,
|
|
109
|
+
san_ip=[str(ip) for ip in payload.san_ip] if payload.san_ip else None,
|
|
110
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Routes(StrEnum):
|
|
5
|
+
# Public
|
|
6
|
+
GET_PUBLIC_CERT = "/cert"
|
|
7
|
+
BASE_CRL = "/crl"
|
|
8
|
+
|
|
9
|
+
# Lists
|
|
10
|
+
GET_LIST_CERTS = ""
|
|
11
|
+
GET_LIST_EXPIRING_CERTS = "/expiring"
|
|
12
|
+
|
|
13
|
+
# CA bootstrap
|
|
14
|
+
CA_ROOT = "/root"
|
|
15
|
+
CA_INTERMEDIATE = "/intermediate"
|
|
16
|
+
|
|
17
|
+
# Issuance
|
|
18
|
+
ISSUE = "/issue"
|
|
19
|
+
|
|
20
|
+
# File downloads
|
|
21
|
+
DOWNLOAD = "/{uuid_certificate}"
|
|
22
|
+
DOWNLOAD_STREAM = "/stream/{uuid_certificate}"
|
|
23
|
+
|
|
24
|
+
# Maintenance
|
|
25
|
+
MAINTENANCE_EXPIRE = "/maintenance/expire"
|
|
26
|
+
|
|
27
|
+
# CRL
|
|
28
|
+
CRL_REFRESH = "/crl/refresh"
|
|
29
|
+
CRL_VERIFY = "/crl/verify"
|
|
30
|
+
|
|
31
|
+
# Operations
|
|
32
|
+
VERIFY = "/verify"
|
|
33
|
+
COSIGN = "/cosign"
|
|
34
|
+
EXPORT = "/export-p12/{serial}"
|
|
35
|
+
REVOKE = "/revoke"
|
|
36
|
+
ROTATE = "/rotate/{serial}"
|
|
37
|
+
RENEW = "/renew/{serial}"
|
|
38
|
+
DELETE = "/{serial}"
|
|
39
|
+
STATUS = "/status/{serial}"
|
|
40
|
+
INSPECT = "/inspect/{serial}"
|
|
41
|
+
CHAIN = "/chain/{serial}"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from cryptography import x509 as _x509
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# Reason map
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
REASON_MAP: dict[str, _x509.ReasonFlags] = {
|
|
14
|
+
"unspecified": _x509.ReasonFlags.unspecified,
|
|
15
|
+
"keyCompromise": _x509.ReasonFlags.key_compromise,
|
|
16
|
+
"cACompromise": _x509.ReasonFlags.ca_compromise,
|
|
17
|
+
"affiliationChanged": _x509.ReasonFlags.affiliation_changed,
|
|
18
|
+
"superseded": _x509.ReasonFlags.superseded,
|
|
19
|
+
"cessationOfOperation": _x509.ReasonFlags.cessation_of_operation,
|
|
20
|
+
"certificateHold": _x509.ReasonFlags.certificate_hold,
|
|
21
|
+
"removeFromCRL": _x509.ReasonFlags.remove_from_crl,
|
|
22
|
+
"privilegeWithdrawn": _x509.ReasonFlags.privilege_withdrawn,
|
|
23
|
+
"aACompromise": _x509.ReasonFlags.aa_compromise,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ArtifactType = Literal["pem", "key", "csr"]
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Response schemas
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CertListItem(BaseModel):
|
|
34
|
+
serial_number: str
|
|
35
|
+
common_name: str
|
|
36
|
+
status: str
|
|
37
|
+
key_type: str | None
|
|
38
|
+
not_valid_after: datetime.datetime
|
|
39
|
+
uuid: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExpiringResponse(BaseModel):
|
|
43
|
+
within_days: int
|
|
44
|
+
count: int
|
|
45
|
+
certificates: list[CertListItem]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CreateRootCaResponse(BaseModel):
|
|
49
|
+
ca_id: str = Field(description="Storage UUID of the new CA.")
|
|
50
|
+
expires_at: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IssueCertResponse(BaseModel):
|
|
54
|
+
uuid: str
|
|
55
|
+
serial_number: int
|
|
56
|
+
common_name: str
|
|
57
|
+
not_valid_before: datetime.datetime
|
|
58
|
+
not_valid_after: datetime.datetime
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MaintenanceResponse(BaseModel):
|
|
62
|
+
updated: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class VerifyResponse(BaseModel):
|
|
66
|
+
valid: bool
|
|
67
|
+
detail: str = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CRLRefreshResponse(BaseModel):
|
|
71
|
+
next_update: datetime.datetime
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RevokeResponse(BaseModel):
|
|
75
|
+
revoked: bool
|
|
76
|
+
serial_number: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DeleteResponse(BaseModel):
|
|
80
|
+
deleted: bool
|
|
81
|
+
serial: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StatusResponse(BaseModel):
|
|
85
|
+
serial: int
|
|
86
|
+
status: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RenewResponse(BaseModel):
|
|
90
|
+
old_serial: int
|
|
91
|
+
new_serial: int
|
|
92
|
+
not_valid_after: datetime.datetime
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CosignResponse(BaseModel):
|
|
96
|
+
serial_number: int
|
|
97
|
+
not_valid_before: datetime.datetime
|
|
98
|
+
not_valid_after: datetime.datetime
|
|
99
|
+
pem: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ChainResponse(BaseModel):
|
|
103
|
+
serial: int
|
|
104
|
+
chain_length: int
|
|
105
|
+
chain: list[str]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RotateResponse(BaseModel):
|
|
109
|
+
uuid: str
|
|
110
|
+
serial_number: int
|
|
111
|
+
common_name: str
|
|
112
|
+
not_valid_before: datetime.datetime
|
|
113
|
+
not_valid_after: datetime.datetime
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Request schemas
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class IntermediateCARequest(BaseModel):
|
|
122
|
+
common_name: str
|
|
123
|
+
key_size: int = Field(default=4096, ge=2048, le=8192)
|
|
124
|
+
days_valid: int = Field(default=1825, ge=1, le=36500)
|
|
125
|
+
path_length: int | None = 0
|
|
126
|
+
organization: str | None = None
|
|
127
|
+
country: str | None = "UA"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class IssueCertRequest(BaseModel):
|
|
131
|
+
common_name: str
|
|
132
|
+
key_size: int = Field(default=2048, ge=1024, le=8192)
|
|
133
|
+
days_valid: int = Field(default=365, ge=1, le=36500)
|
|
134
|
+
valid_from: datetime.datetime | None = None
|
|
135
|
+
email: str | None = None
|
|
136
|
+
is_server_cert: bool = False
|
|
137
|
+
is_client_cert: bool = False
|
|
138
|
+
san_dns: list[str] | None = None
|
|
139
|
+
san_ip: list[str] | None = None
|
|
140
|
+
is_overwrite: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CRLVerifyRequest(BaseModel):
|
|
144
|
+
pem: str = Field(description="PEM-encoded CRL.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class CRLVerifyResponse(BaseModel):
|
|
148
|
+
valid: bool
|
|
149
|
+
detail: str = ""
|
|
150
|
+
next_update: str = ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class VerifyRequest(BaseModel):
|
|
154
|
+
pem: str = Field(description="PEM-encoded X.509 certificate.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CosignRequest(BaseModel):
|
|
158
|
+
pem: str = Field(description="PEM-encoded certificate to co-sign.")
|
|
159
|
+
days_valid: int | None = None
|
|
160
|
+
valid_from: datetime.datetime | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ExportP12Request(BaseModel):
|
|
164
|
+
password: str | None = Field(
|
|
165
|
+
default=None,
|
|
166
|
+
description="Passphrase to encrypt the PKCS#12 bundle.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RevokeRequest(BaseModel):
|
|
171
|
+
serial_number: int
|
|
172
|
+
reason: str = Field(
|
|
173
|
+
default="unspecified",
|
|
174
|
+
description="unspecified | keyCompromise | cACompromise | ...",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class RenewRequest(BaseModel):
|
|
179
|
+
days_valid: int = Field(default=365, ge=1, le=36500)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RoutesSummary(StrEnum):
|
|
5
|
+
DOWNLOAD = "Download certificate artefact (pem / key / csr)"
|
|
6
|
+
DOWNLOAD_STREAM = "Stream certificate artefact"
|
|
7
|
+
CHAIN = "Full PEM certificate chain [leaf, ca]"
|
|
8
|
+
INSPECT = "Structured certificate details"
|
|
9
|
+
STATUS = "Get certificate lifecycle status"
|
|
10
|
+
DELETE = "Hard-delete certificate from DB and storage"
|
|
11
|
+
RENEW = "Renew certificate (same key, new validity)"
|
|
12
|
+
ROTATE = "Rotate certificate (revoke + re-issue with new key)"
|
|
13
|
+
REVOKE = "Revoke a certificate"
|
|
14
|
+
EXPORT = "Export PKCS#12 (.p12) bundle"
|
|
15
|
+
CRL_VERIFY = "Verify CRL signature and expiry"
|
|
16
|
+
COSIGN = "Co-sign a third-party certificate with this CA"
|
|
17
|
+
VERIFY = "Verify certificate (chain + signature + revocation)"
|
|
18
|
+
CRL_REFRESH = "Force-regenerate the CRL"
|
|
19
|
+
MAINTENANCE_EXPIRE = "Bulk-mark expired certificates"
|
|
20
|
+
ISSUE = "Issue leaf certificate"
|
|
21
|
+
CA_INTERMEDIATE = "Issue intermediate CA certificate"
|
|
22
|
+
CA_ROOT = "Bootstrap self-signed root CA"
|
|
23
|
+
GET_LIST_EXPIRING_CERTS = "Certificates expiring soon"
|
|
24
|
+
GET_LIST_CERTS = "List certificates"
|
|
25
|
+
BASE_CRL = "Download the current CRL (public, no auth)"
|
|
26
|
+
GET_PUBLIC_CERT = "Download CA public certificate (no auth)"
|
tiny_ca_gateway/db.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from logging import INFO, Logger, getLogger
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import (
|
|
5
|
+
Boolean,
|
|
6
|
+
Column,
|
|
7
|
+
DateTime,
|
|
8
|
+
ForeignKey,
|
|
9
|
+
Integer,
|
|
10
|
+
LargeBinary,
|
|
11
|
+
String,
|
|
12
|
+
delete,
|
|
13
|
+
select,
|
|
14
|
+
)
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
16
|
+
from tiny_ca.db.async_db_manager import AsyncDBHandler
|
|
17
|
+
from tiny_ca.db.models import Base
|
|
18
|
+
|
|
19
|
+
from tiny_ca_gateway.models import API_SETTINGS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SystemSettings(Base):
|
|
23
|
+
__tablename__ = "system_settings"
|
|
24
|
+
id = Column(Integer, primary_key=True)
|
|
25
|
+
|
|
26
|
+
internal_token_valid_until = Column(DateTime)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LocalDBHandler(AsyncDBHandler):
|
|
30
|
+
async def get_system_init_time(self) -> SystemSettings | None:
|
|
31
|
+
async with self._db.get_session() as session:
|
|
32
|
+
stmt = select(SystemSettings)
|
|
33
|
+
|
|
34
|
+
result = await session.execute(stmt)
|
|
35
|
+
cert = result.scalar_one_or_none()
|
|
36
|
+
|
|
37
|
+
return cert # type: ignore[no-any-return]
|
|
38
|
+
|
|
39
|
+
async def _init_sys_time(self) -> None:
|
|
40
|
+
async with self._db.get_session() as session:
|
|
41
|
+
try:
|
|
42
|
+
internal_token_valid_until = (
|
|
43
|
+
datetime.datetime.now()
|
|
44
|
+
+ datetime.timedelta(minutes=API_SETTINGS.ttl_token)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
new_cert = SystemSettings(
|
|
48
|
+
internal_token_valid_until=internal_token_valid_until
|
|
49
|
+
)
|
|
50
|
+
session.add(new_cert)
|
|
51
|
+
API_SETTINGS.internal_token_valid_until = internal_token_valid_until
|
|
52
|
+
await session.commit()
|
|
53
|
+
except Exception:
|
|
54
|
+
self._logger.warning("Can't save init time")
|
|
55
|
+
|
|
56
|
+
async def init_system_time_if_empty(self) -> None:
|
|
57
|
+
sys_settings = await self.get_system_init_time()
|
|
58
|
+
if sys_settings is None:
|
|
59
|
+
await self._init_sys_time()
|
|
60
|
+
else:
|
|
61
|
+
API_SETTINGS.internal_token_valid_until = (
|
|
62
|
+
sys_settings.internal_token_valid_until # type: ignore
|
|
63
|
+
)
|