tiny-ca-gateway 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )