Implement KCA: web reimplementation of XCA PKI workflows

- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models
- AES-256-GCM private key storage and full certificate/CSR/CRL domain logic
- Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates
- imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
2026-10-23 02:06:55 +00:00
commit 4f3a632f4b
109 changed files with 21048 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# kca-app
The deployable KCA application: environment-driven settings, kaya app assembly
(sessions, OIDC, Tortoise, OpenAPI, CORS, optional OpenTelemetry), the HTTP
API, Tortoise models, aerich migrations, and the SPA/PWA shell hosting.
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "kca-app"
version = "0.1.0"
description = "KCA: kaya application wiring, Tortoise models, API and SPA hosting"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kca-core",
"kaya-core>=0.0.7",
"kaya-cors",
"kaya-session",
"kaya-session-redis",
"kaya-oidc>=0.0.7",
"kaya-openapi",
"kaya-rsgi",
"granian>=2.0",
"tortoise-orm>=0.25",
"asyncpg",
"aerich",
"httpx",
"PyJWT[crypto]",
"PyYAML",
"redis",
"cryptography>=44",
"pyOpenSSL>=25",
"paramiko>=3.5",
]
[project.optional-dependencies]
otel = [
"kaya-otel>=0.0.7",
"opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0",
]
[dependency-groups]
dev = [
"mypy",
"types-PyYAML",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
[[tool.mypy.overrides]]
module = "kca.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "kca.service"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
[[tool.mypy.overrides]]
module = "kca.routes.*"
disable_error_code = ["attr-defined", "assignment", "index", "arg-type"]
@@ -0,0 +1,14 @@
"""Tortoise ORM configuration consumed by the aerich CLI."""
from __future__ import annotations
from .config import settings
TORTOISE_ORM = {
"connections": {"default": settings.database_url},
"apps": {
"models": {
"models": ["kca.models", "aerich.models"],
"default_connection": "default",
}
},
}
+110
View File
@@ -0,0 +1,110 @@
"""Application entry point.
Assembles the kaya app with sessions, OIDC, Tortoise, OpenAPI, the KCA API
and the SPA shell. Optional CORS mirrors spendwise's environment-driven
configuration.
"""
from __future__ import annotations
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCProviders
from kaya.session import SessionMixin
from redis.asyncio import Redis
from .config import Settings, settings
from .logging_config import configure_logging
from .mixin import Kca, KcaMixin
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config)
log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
if settings.master_key_is_dev:
log.warning(
"KCA_MASTER_KEY is unset: using the built-in development key. "
"Set a random 32-byte base64 key in production."
)
if settings.redis_url is not None:
from kaya.session.redis import RedisSessionStore
session_store: Any = RedisSessionStore(Redis.from_url(settings.redis_url))
log.info("using the Redis session store")
else:
from kaya.session import InMemorySessionStore
session_store = InMemorySessionStore()
log.info("REDIS_URL unset: using the in-memory session store")
session_mixin = SessionMixin(session_store, secure=settings.session_cookie_secure)
oidc = OIDCProviders(
[
OIDCConfig(
provider=provider.provider,
issuer=provider.issuer,
client_id=provider.client_id,
client_secret=provider.client_secret,
redirect_uri=provider.redirect_uri,
post_login_redirect=provider.post_login_redirect,
post_logout_redirect=provider.post_logout_redirect,
scopes=("openid", "profile"),
fetch_userinfo=True,
)
for provider in settings.oidc_providers
],
session=session_mixin,
)
log.info("OIDC providers: %s", ", ".join(p.provider for p in settings.oidc_providers))
openapi_mixin = KcaOpenAPI(
title="KCA",
version="0.1.0",
description="Key and certificate authority API (XCA web reimplementation)",
spec_path="/api/openapi.json",
docs_path="/api/docs",
components={"schemas": SCHEMAS, "securitySchemes": SECURITY_SCHEMES},
)
tortoise_mixin = TortoiseMixin(
database_url=settings.database_url,
models_modules=["kca.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
kca = Kca(oidc=oidc)
mixins: list[KayaMixin] = [
session_mixin,
oidc,
tortoise_mixin,
openapi_mixin,
KcaMixin(kca),
]
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
mixins.insert(0, cors_mixin)
log.info("CORS enabled: origins=%s", settings.cors_allow_origins)
app = KayaApp(mixins=mixins)
# Register the SPA shell and PWA entry points.
from . import static # noqa: E402,F401
+192
View File
@@ -0,0 +1,192 @@
"""Authentication helpers on top of the kaya-oidc mixin."""
from __future__ import annotations
from logging import getLogger
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
from kaya.core import HttpContext
from kaya.oidc import OIDCProviders, OIDCUser
from kaya.session import Session
from .models import User
from .tokens import bearer_token, resolve_token
log = getLogger(__name__)
PROFILE_CLAIMS = (
"name",
"family_name",
"given_name",
"middle_name",
"nickname",
"preferred_username",
"profile",
"picture",
"website",
"gender",
"birthdate",
"zoneinfo",
"locale",
"updated_at",
)
_URL_CLAIMS = frozenset({"profile", "picture", "website"})
_CLAIM_MAX_LENGTH: Dict[str, int] = {
"name": 255,
"family_name": 255,
"given_name": 255,
"middle_name": 255,
"nickname": 255,
"preferred_username": 255,
"profile": 2048,
"picture": 2048,
"website": 2048,
"gender": 100,
"birthdate": 32,
"zoneinfo": 100,
"locale": 100,
}
_OIDC_PREFIX = "oidc:"
_USER_SUFFIX = ":user"
_CACHED_USER_ATTR = "_kca_user"
_UNAUTHENTICATED_BODY = b'{"error":"unauthenticated"}'
_UNAUTHENTICATED_HEADERS = {"content-type": ("application/json",)}
def session_user(session: Session) -> Optional[OIDCUser]:
for key in session:
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
claims = session.get(key)
if isinstance(claims, Mapping):
return OIDCUser(claims)
return None
def display_name(user: OIDCUser) -> str:
for key in ("preferred_username", "name"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
def _text_claim(value: Any, max_length: int) -> Optional[str]:
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
return value[:max_length]
def _url_claim(value: Any, max_length: int) -> Optional[str]:
value = _text_claim(value, max_length)
if value is not None and not value.startswith(("http://", "https://")):
return None
return value
def profile_claims(user: OIDCUser) -> Dict[str, Any]:
values: Dict[str, Any] = {}
for name in PROFILE_CLAIMS:
raw = user.get(name)
if name == "updated_at":
values[name] = raw if isinstance(raw, int) and not isinstance(raw, bool) else None
elif name in _URL_CLAIMS:
values[name] = _url_claim(raw, _CLAIM_MAX_LENGTH[name])
else:
values[name] = _text_claim(raw, _CLAIM_MAX_LENGTH[name])
return values
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
return next(iter(oidc.get_users(ctx).values()), None)
async def ensure_user(user: OIDCUser) -> User:
updates: Dict[str, Any] = profile_claims(user)
updates["display_name"] = display_name(user)
row = await User.get_or_none(sub=user.sub)
if row is None:
row = await User.create(sub=user.sub, **updates)
log.info("registered user sub=%s name=%r", user.sub, updates["display_name"])
return row
changed = [field for field, value in updates.items() if getattr(row, field) != value]
if changed:
for field in changed:
setattr(row, field, updates[field])
await row.save(update_fields=changed)
return row
async def _resolve_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
secret = bearer_token(ctx)
if secret is not None:
return await resolve_token(secret)
oidc_user = current_user(oidc, ctx)
if oidc_user is None:
return None
return await ensure_user(oidc_user)
async def _send_unauthenticated(ctx: HttpContext) -> None:
await ctx.send_bytes(401, _UNAUTHENTICATED_BODY, _UNAUTHENTICATED_HEADERS)
async def authenticated_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]:
cached = getattr(ctx, _CACHED_USER_ATTR, None)
if cached is not None:
return cached
return await _resolve_user(oidc, ctx)
def cached_user(ctx: HttpContext) -> Optional[User]:
user = getattr(ctx, _CACHED_USER_ATTR, None)
return user if isinstance(user, User) else None
def _require_user(
resolve: Callable[[HttpContext], Awaitable[Optional[User]]],
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
user = await resolve(ctx)
if user is None:
await _send_unauthenticated(ctx)
return
setattr(ctx, _CACHED_USER_ATTR, user)
await handler(ctx, *args, **kwargs)
return guarded
return decorator
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
return _require_user(lambda ctx: _resolve_user(oidc, ctx))
def require_session_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
async def resolve_session(ctx: HttpContext) -> Optional[User]:
oidc_user = current_user(oidc, ctx)
if oidc_user is None:
return None
return await ensure_user(oidc_user)
return _require_user(resolve_session)
__all__ = [
"PROFILE_CLAIMS",
"authenticated_user",
"cached_user",
"current_user",
"display_name",
"ensure_user",
"profile_claims",
"require_auth",
"require_session_auth",
"session_user",
]
+237
View File
@@ -0,0 +1,237 @@
"""Environment-driven configuration for KCA.
Mirrors kaya's own pattern: read ``os.environ`` directly into a frozen
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import quote
from kca.core.crypto import decode_master_key
# Per-provider OIDC settings are indexed with a 0-based suffix.
_OIDC_VARIABLES = (
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
)
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/kca"
_DEFAULT_OIDC_CLIENT_ID = "kca"
#: Development-only master key. Production deployments must set
#: KCA_MASTER_KEY to a random 32-byte base64 value.
_DEV_MASTER_KEY = hashlib.sha256(b"kca-development-master-key").digest()
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class OidcProviderSettings:
provider: str
issuer: str
client_id: str
client_secret: Optional[str]
redirect_uri: str
post_login_redirect: str
post_logout_redirect: str
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
indices: set[int] = set()
for name in os.environ:
match = _OIDC_ENV.match(name)
if match is None:
continue
index, variable = match.groups()
if variable not in _OIDC_VARIABLES:
raise RuntimeError(
f"unknown OIDC environment variable {name!r}; expected one of "
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
)
indices.add(int(index))
if not indices:
return (
OidcProviderSettings(
provider="default",
issuer=_DEFAULT_OIDC_ISSUER,
client_id=_DEFAULT_OIDC_CLIENT_ID,
client_secret=None,
redirect_uri="http://localhost:8080/auth/default/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
)
missing = sorted(set(range(max(indices) + 1)) - indices)
if missing:
raise RuntimeError(
"OIDC provider indices must be contiguous from 0; missing "
+ ", ".join(f"OIDC_{index}_*" for index in missing)
)
providers = []
seen = set()
for index in sorted(indices):
prefix = f"OIDC_{index}_"
provider = _env(prefix + "PROVIDER")
if _PROVIDER_NAME.match(provider) is None:
raise RuntimeError(
f"{prefix}PROVIDER must be a non-empty string of letters, "
f"digits, underscores or hyphens (got {provider!r})"
)
if provider in seen:
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
seen.add(provider)
providers.append(
OidcProviderSettings(
provider=provider,
issuer=_env(prefix + "ISSUER"),
client_id=_env(prefix + "CLIENT_ID"),
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
redirect_uri=_env(
prefix + "REDIRECT_URI",
f"http://localhost:8080/auth/{provider}/callback",
),
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
)
)
return tuple(providers)
def _database_url_from_parts(
engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str,
) -> str:
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_providers: Tuple[OidcProviderSettings, ...]
app_host: str
app_port: int
redis_url: Optional[str]
static_dir: str
logging_config: Optional[str]
#: AES-256-GCM master key protecting private keys at rest.
master_key: bytes
#: True when the master key came from the built-in development default.
master_key_is_dev: bool
max_upload_bytes: int
keygen_concurrency: int
session_cookie_secure: bool
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
otel_excluded_paths: Tuple[str, ...]
otel_metric_export_interval_millis: int
otel_metrics_poll_interval_seconds: int
@staticmethod
def from_env() -> "Settings":
raw_master = os.environ.get("KCA_MASTER_KEY")
master_key_is_dev = not raw_master
master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY
return Settings(
database_url=os.environ.get("DATABASE_URL")
or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "kca"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "kca"),
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_providers=_oidc_providers_from_env(),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
redis_url=os.environ.get("REDIS_URL") or None,
static_dir=_env("STATIC_DIR", "web/dist"),
logging_config=os.environ.get("LOGGING_CONFIG") or None,
master_key=master_key,
master_key_is_dev=master_key_is_dev,
max_upload_bytes=int(_env("MAX_UPLOAD_BYTES", str(20 * 1024 * 1024))),
keygen_concurrency=max(1, int(_env("KEYGEN_CONCURRENCY", "2"))),
session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE"),
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "kca"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
otel_metric_export_interval_millis=int(
_env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000")
),
otel_metrics_poll_interval_seconds=int(
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
),
)
settings: Settings = Settings.from_env()
+73
View File
@@ -0,0 +1,73 @@
"""JSON helpers for kaya HTTP handlers.
Kaya has no built-in request/response JSON helpers: the request body is an
async byte stream on ``ctx.request_body`` and responses are sent with
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
parsing JSON, and sending JSON responses.
"""
from __future__ import annotations
import json
from typing import Any, List, Mapping
from kaya.core import HttpContext
JSON_HEADERS = {"content-type": ("application/json",)}
class JsonRequestError(ValueError):
"""Raised by :func:`read_json` when the request body is not valid JSON
or is not a JSON object."""
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
"""Parse a raw query string into a mapping of param name to list of
values.
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
incantation; always returns a mapping (never None).
"""
from urllib.parse import parse_qs
return parse_qs(query_string, keep_blank_values=True)
async def read_json(ctx: HttpContext) -> dict:
"""Drain and parse the request body as JSON.
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
with a short human-readable reason on failure.
"""
body = b""
async for chunk in ctx.request_body:
body += chunk
if not body:
raise JsonRequestError("empty body")
try:
parsed = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JsonRequestError("invalid JSON") from exc
if not isinstance(parsed, dict):
raise JsonRequestError("JSON body must be an object")
return parsed
async def read_json_optional(ctx: HttpContext) -> dict:
"""Like :func:`read_json` but treats an empty body as ``{}``."""
try:
return await read_json(ctx)
except JsonRequestError as exc:
if str(exc) == "empty body":
return {}
raise
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
"""Send ``payload`` as a JSON response."""
body = json.dumps(payload).encode("utf-8")
await ctx.send_bytes(status, body, JSON_HEADERS)
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
"""Send a JSON error envelope."""
await send_json(ctx, status, {"error": message})
@@ -0,0 +1,84 @@
"""Logging setup for the kca application.
Configured once at app import time (see :mod:`kca.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
chatter does not drown the log.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
kca:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
"loggers": {
# Every pooled HTTP request is a DEBUG record; keep the logger's
# INFO+ output, drop the per-connection noise.
"urllib3.connectionpool": {
"level": "INFO",
},
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
+49
View File
@@ -0,0 +1,49 @@
"""The KCA mixin: mounts the whole API on a kaya app."""
from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCProviders
@dataclass
class Kca:
oidc: OIDCProviders
class KcaMixin(KayaMixin):
def __init__(self, kca: Kca) -> None:
self.kca = kca
def apply(self, app: KayaApp) -> None:
from .routes import (
auth,
certificates,
crls,
health,
import_,
keys,
me,
requests,
search,
templates,
tokens,
vaults,
)
for module in (
health,
auth,
me,
tokens,
vaults,
search,
keys,
requests,
certificates,
crls,
templates,
import_,
):
module.register(app, self.kca)
+332
View File
@@ -0,0 +1,332 @@
"""Tortoise ORM models for KCA.
The schema mirrors XCA's entities rather than spendwise's expense-tracking
model: a *vault* is one XCA database, and keys, certificates, requests, CRLs,
templates and revocations are its items. Private key material is stored as
AES-256-GCM ciphertext (``private_der`` + ``private_nonce``); the plaintext
exists only in memory during a request.
"""
from __future__ import annotations
from typing import List
from tortoise import fields
from tortoise.models import Model
class User(Model):
"""One OIDC identity that has logged in at least once."""
id = fields.UUIDField(pk=True)
sub = fields.CharField(max_length=255, unique=True)
display_name = fields.CharField(max_length=200)
name = fields.CharField(max_length=255, null=True)
family_name = fields.CharField(max_length=255, null=True)
given_name = fields.CharField(max_length=255, null=True)
middle_name = fields.CharField(max_length=255, null=True)
nickname = fields.CharField(max_length=255, null=True)
preferred_username = fields.CharField(max_length=255, null=True)
profile = fields.CharField(max_length=2048, null=True)
picture = fields.CharField(max_length=2048, null=True)
website = fields.CharField(max_length=2048, null=True)
gender = fields.CharField(max_length=100, null=True)
birthdate = fields.CharField(max_length=32, null=True)
zoneinfo = fields.CharField(max_length=100, null=True)
locale = fields.CharField(max_length=100, null=True)
updated_at = fields.BigIntField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
last_seen_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "app_user"
ordering = ["display_name", "id"]
def __str__(self) -> str:
return f"User(sub={self.sub!r}, display_name={self.display_name!r})"
class ApiToken(Model):
"""Personal access token for programmatic API access."""
id = fields.UUIDField(pk=True)
user_sub = fields.CharField(max_length=255, db_index=True)
name = fields.CharField(max_length=100)
token_prefix = fields.CharField(max_length=16)
token_hash = fields.CharField(max_length=64, unique=True)
created_at = fields.DatetimeField(auto_now_add=True)
last_used_at = fields.DatetimeField(null=True)
expires_at = fields.DateField(null=True)
class Meta:
table = "api_token"
ordering = ["-created_at", "id"]
def __str__(self) -> str:
return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})"
class Vault(Model):
"""One private XCA database owned by an OIDC user."""
id = fields.UUIDField(pk=True)
owner_sub = fields.CharField(max_length=255, db_index=True)
name = fields.CharField(max_length=200)
description = fields.TextField(default="")
settings = fields.JSONField(default=dict)
version = fields.IntField(default=1)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
keys: fields.ReverseRelation["Key"]
certificates: fields.ReverseRelation["Certificate"]
requests: fields.ReverseRelation["Request"]
crls: fields.ReverseRelation["Crl"]
templates: fields.ReverseRelation["Template"]
class Meta:
table = "vault"
ordering = ["name", "id"]
def __str__(self) -> str:
return f"Vault(name={self.name!r})"
class Key(Model):
"""A public or private key item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="keys", on_delete=fields.CASCADE
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
key_type = fields.CharField(max_length=20)
key_size = fields.IntField(null=True)
curve = fields.CharField(max_length=100, null=True)
public_der = fields.BinaryField()
public_key_sha256 = fields.CharField(max_length=64, db_index=True)
private_der = fields.BinaryField(null=True)
private_nonce = fields.BinaryField(null=True)
has_private = fields.BooleanField(default=False)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "key"
ordering = ["name", "id"]
unique_together = (("vault", "public_key_sha256"),)
indexes = (("vault", "name"), ("vault", "key_type"))
def __str__(self) -> str:
return f"Key(name={self.name!r}, type={self.key_type!r})"
class Certificate(Model):
"""An X.509 certificate item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="certificates", on_delete=fields.CASCADE
)
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
"models.Key", related_name="certificates", on_delete=fields.SET_NULL, null=True
)
issuer: fields.ForeignKeyRelation["Certificate"] = fields.ForeignKeyField(
"models.Certificate", related_name="children", on_delete=fields.SET_NULL, null=True
)
request: fields.ForeignKeyRelation["Request"] = fields.ForeignKeyField(
"models.Request", related_name="certificates", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
serial_hex = fields.CharField(max_length=128, db_index=True)
subject_der = fields.BinaryField()
issuer_der = fields.BinaryField()
subject_hash = fields.CharField(max_length=64, db_index=True)
issuer_hash = fields.CharField(max_length=64, db_index=True)
not_before = fields.DatetimeField()
not_after = fields.DatetimeField()
is_ca = fields.BooleanField(default=False, db_index=True)
path_length = fields.IntField(null=True)
cert_der = fields.BinaryField()
public_key_der = fields.BinaryField()
signature_algorithm = fields.CharField(max_length=100, default="")
fingerprint_md5 = fields.CharField(max_length=32, default="")
fingerprint_sha1 = fields.CharField(max_length=40, default="")
fingerprint_sha256 = fields.CharField(max_length=64, db_index=True)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "certificate"
ordering = ["name", "id"]
unique_together = (("vault", "fingerprint_sha256"),)
indexes = (("vault", "name"), ("vault", "issuer"), ("vault", "not_after"))
def __str__(self) -> str:
return f"Certificate(name={self.name!r}, serial={self.serial_hex!r})"
class Authority(Model):
"""CA-specific properties, one row per CA certificate."""
id = fields.UUIDField(pk=True)
certificate: fields.OneToOneRelation[Certificate] = fields.OneToOneField(
"models.Certificate", related_name="authority", on_delete=fields.CASCADE
)
template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField(
"models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True
)
crl_expire = fields.DatetimeField(null=True)
crl_number = fields.IntField(default=0)
crl_days = fields.IntField(default=30)
tls_auth_key = fields.BinaryField(null=True)
tls_auth_key_nonce = fields.BinaryField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "authority"
def __str__(self) -> str:
return f"Authority(certificate={self.certificate_id})"
class CertificateRevocation(Model):
"""A serial revoked by a CA (XCA stores revocations on the CA)."""
id = fields.UUIDField(pk=True)
ca: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
"models.Certificate", related_name="revocations", on_delete=fields.CASCADE
)
serial_hex = fields.CharField(max_length=128, db_index=True)
revoked_at = fields.DatetimeField()
invalidity_date = fields.DatetimeField(null=True)
reason = fields.CharField(max_length=50, default="unspecified")
crl_number = fields.IntField(default=0)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "certificate_revocation"
ordering = ["-revoked_at", "id"]
unique_together = (("ca", "serial_hex"),)
def __str__(self) -> str:
return f"Revocation(ca={self.ca_id}, serial={self.serial_hex!r})"
class Request(Model):
"""A PKCS#10 certificate signing request item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="requests", on_delete=fields.CASCADE
)
key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField(
"models.Key", related_name="requests", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
subject_der = fields.BinaryField()
req_der = fields.BinaryField()
public_key_der = fields.BinaryField()
request_sha256 = fields.CharField(max_length=64, db_index=True)
signed = fields.BooleanField(default=False)
signed_at = fields.DatetimeField(null=True)
attributes = fields.JSONField(default=list)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "request"
ordering = ["name", "id"]
unique_together = (("vault", "request_sha256"),)
indexes = (("vault", "name"), ("vault", "signed"))
def __str__(self) -> str:
return f"Request(name={self.name!r}, signed={self.signed})"
class Crl(Model):
"""A certificate revocation list item."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="crls", on_delete=fields.CASCADE
)
issuer: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField(
"models.Certificate", related_name="crls", on_delete=fields.SET_NULL, null=True
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
crl_der = fields.BinaryField()
crl_hash = fields.CharField(max_length=64, db_index=True)
crl_number = fields.IntField(null=True)
last_update = fields.DatetimeField()
next_update = fields.DatetimeField(null=True)
revoked_count = fields.IntField(default=0)
signature_algorithm = fields.CharField(max_length=100, default="")
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "crl"
ordering = ["-last_update", "id"]
unique_together = (("vault", "crl_hash"),)
indexes = (("vault", "name"), ("vault", "issuer"))
def __str__(self) -> str:
return f"Crl(name={self.name!r}, number={self.crl_number})"
class Template(Model):
"""An XCA-style template: subject + extension/validity defaults."""
id = fields.UUIDField(pk=True)
vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField(
"models.Vault", related_name="templates", on_delete=fields.CASCADE
)
name = fields.CharField(max_length=200)
comment = fields.TextField(default="")
source = fields.CharField(max_length=20, default="generated")
subject = fields.JSONField(default=list)
settings = fields.JSONField(default=dict)
version = fields.IntField(default=1)
search_text = fields.TextField(default="")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "template"
ordering = ["name", "id"]
indexes = (("vault", "name"),)
def __str__(self) -> str:
return f"Template(name={self.name!r})"
__all__: List[str] = [
"ApiToken",
"Authority",
"Certificate",
"CertificateRevocation",
"Crl",
"Key",
"Request",
"Template",
"User",
"Vault",
]
+192
View File
@@ -0,0 +1,192 @@
"""Shared OpenAPI fragments and the authentication post-processor."""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Mapping
from kaya.core import KayaApp
from kaya.openapi import OpenAPIMixin, schema_ref
from .auth import PROFILE_CLAIMS
__all__ = [
"API_SECURITY",
"PUBLIC_PATHS",
"SCHEMAS",
"SECURITY_SCHEMES",
"SESSION_ONLY_PATHS",
"KcaOpenAPI",
"error_response",
"json_response",
"schema_ref",
]
SESSION_COOKIE_NAME = "session_id"
SECURITY_SCHEMES: Dict[str, Any] = {
"sessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": SESSION_COOKIE_NAME,
"description": "The OIDC login session (interactive use).",
},
"bearerToken": {
"type": "http",
"scheme": "bearer",
"description": "A personal access token, ``Authorization: Bearer kca_...``.",
},
}
PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/providers"})
SESSION_ONLY_PATHS = frozenset({"/api/tokens", "/api/tokens/{token_id}"})
API_SECURITY: List[Dict[str, List[str]]] = [
{"sessionCookie": []},
{"bearerToken": []},
]
SESSION_SECURITY: List[Dict[str, List[str]]] = [{"sessionCookie": []}]
def _object(properties: Mapping[str, Any], required: Iterable[str] = ()) -> Dict[str, Any]:
schema: Dict[str, Any] = {"type": "object", "properties": dict(properties)}
if required:
schema["required"] = list(required)
return schema
def _string(*, nullable: bool = False) -> Dict[str, Any]:
return {"type": ["string", "null"] if nullable else "string"}
def _integer(*, nullable: bool = False) -> Dict[str, Any]:
return {"type": ["integer", "null"] if nullable else "integer"}
def _boolean() -> Dict[str, Any]:
return {"type": "boolean"}
def _date_time() -> Dict[str, Any]:
return {"type": "string", "format": "date-time"}
def _array(items: Mapping[str, Any]) -> Dict[str, Any]:
return {"type": "array", "items": items}
def _loose(name: str) -> Dict[str, Any]:
"""A named, intentionally loose object schema.
The API is documented at the field level in the README and the route
descriptions; keeping the OpenAPI component loose avoids duplicating the
large nested extension/template structures here.
"""
return {"type": "object", "description": name, "additionalProperties": True}
def json_response(description: str, schema: Mapping[str, Any]) -> Dict[str, Any]:
return {"description": description, "content": {"application/json": {"schema": dict(schema)}}}
def error_response(description: str) -> Dict[str, Any]:
return json_response(description, schema_ref("Error"))
ERROR_SCHEMA = _object({"error": _string()}, required=("error",))
HEALTH_SCHEMA = _object({"status": _string()}, required=("status",))
ME_SCHEMA = _object(
{
"sub": _string(),
"display_name": _string(),
**{
claim: _integer(nullable=True) if claim == "updated_at" else _string(nullable=True)
for claim in PROFILE_CLAIMS
},
"master_key_is_dev": _boolean(),
},
required=("sub", "display_name", *PROFILE_CLAIMS, "master_key_is_dev"),
)
AUTH_PROVIDER_SCHEMA = _object({"name": _string(), "login_url": _string()}, required=("name", "login_url"))
AUTH_PROVIDERS_PAGE_SCHEMA = _object({"providers": _array(schema_ref("AuthProvider"))}, required=("providers",))
API_TOKEN_SCHEMA = _object(
{
"id": _string(),
"name": _string(),
"prefix": _string(),
"created_at": _date_time(),
"last_used_at": _string(nullable=True),
"expires_at": _string(nullable=True),
}
)
CREATED_API_TOKEN_SCHEMA = _object(
{
"id": _string(),
"name": _string(),
"prefix": _string(),
"created_at": _date_time(),
"last_used_at": _string(nullable=True),
"expires_at": _string(nullable=True),
"token": _string(),
}
)
API_TOKENS_PAGE_SCHEMA = _object({"results": _array(schema_ref("ApiToken"))}, required=("results",))
SCHEMAS: Dict[str, Any] = {
"Error": ERROR_SCHEMA,
"Health": HEALTH_SCHEMA,
"Me": ME_SCHEMA,
"AuthProvider": AUTH_PROVIDER_SCHEMA,
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
"ApiToken": API_TOKEN_SCHEMA,
"CreatedApiToken": CREATED_API_TOKEN_SCHEMA,
"ApiTokensPage": API_TOKENS_PAGE_SCHEMA,
}
for name in (
"VaultSummary",
"VaultDetail",
"VaultsPage",
"KeySummary",
"KeyDetail",
"KeysPage",
"CertificateSummary",
"CertificateDetail",
"CertificatesPage",
"RequestSummary",
"RequestDetail",
"RequestsPage",
"CrlSummary",
"CrlDetail",
"CrlsPage",
"TemplateSummary",
"TemplateDetail",
"TemplatesPage",
"Revocation",
"RevocationsPage",
"SearchResult",
"SearchPage",
"VaultSummaryPage",
"ImportPreview",
"ImportResult",
"ExportPayload",
):
SCHEMAS[name] = _loose(name)
class KcaOpenAPI(OpenAPIMixin):
"""Attach the authentication schemes to the generated document."""
def generate_spec(self, app: KayaApp) -> Mapping[str, Any]:
spec: Dict[str, Any] = dict(super().generate_spec(app))
paths = spec.get("paths")
if isinstance(paths, Mapping):
for path, item in paths.items():
if not isinstance(item, Mapping):
continue
if path in PUBLIC_PATHS or not path.startswith("/api/"):
security: List[Dict[str, List[str]]] = []
elif path in SESSION_ONLY_PATHS:
security = SESSION_SECURITY
else:
security = API_SECURITY
for operation in item.values():
if isinstance(operation, dict):
operation["security"] = security
return spec
@@ -0,0 +1,158 @@
"""Cursor-based pagination for listing endpoints.
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
cursor encoding the sort key tuple of the last item on that page; the next
request passes that cursor and the query continues from the point it left
off. This is stable under concurrent inserts and cheaper than OFFSET for
large result sets.
Cursor is a base64-encoded JSON object mapping the sort-field names to the
values of the last item on the previous page. It's opaque to callers and
must be treated as a black box.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from tortoise.queryset import QuerySet
from .http import extract_query_params
if TYPE_CHECKING:
from tortoise.models import Model
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
def encode_cursor(values: Dict[str, Any]) -> str:
import base64
import json
# ``default=str`` handles datetimes/dates (ISO) so keyset cursors can
# carry date-typed sort fields.
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
import base64
import json
if s is None or s == "":
return None
try:
# Tolerate missing padding.
padded = s + "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
obj = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise CursorDecodeError("invalid cursor") from exc
if not isinstance(obj, dict):
raise CursorDecodeError("invalid cursor")
return obj
@dataclass(frozen=True)
class Cursor:
limit: int
after: Optional[Dict[str, Any]]
def parse_cursor_params(query_string: str) -> Cursor:
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise CursorDecodeError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
return Cursor(limit=limit, after=after)
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
def _coerce_sort_values(
model: "type[Model]", sort: Sort, after: Dict[str, Any]
) -> Dict[str, Any]:
"""Restore the Python type of every decoded cursor value.
The cursor is JSON, so field values travel as strings (datetimes via
``default=str``). asyncpg rejects strings where a ``timestamp``
parameter is expected, and sqlite only tolerates them by accident, so
each value is run through its model field's ``to_python_value`` before
it reaches the keyset filter.
"""
fields = model._meta.fields_map
coerced: Dict[str, Any] = {}
for field, _ in sort:
value = after.get(field)
field_obj = fields.get(field)
if value is not None and field_obj is not None:
value = field_obj.to_python_value(value)
coerced[field] = value
return coerced
def _keyset_where(sort: Sort, after: Dict[str, Any]):
"""Build a Tortoise ``Q`` filter from a cursor."""
from tortoise.queryset import Q # local to keep import edge narrow
clauses = []
for i, (field, direction) in enumerate(sort):
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
value = after.get(field)
if value is None:
return Q()
clause = Q(**{key: value})
for j in range(i):
prev_field, _ = sort[j]
prev_value = after.get(prev_field)
if prev_value is None:
return Q()
clause = clause & Q(**{prev_field: prev_value})
clauses.append(clause)
result = clauses[0]
for clause in clauses[1:]:
result = result | clause
return result
async def paginate(
queryset: QuerySet,
sort: Sort,
cursor: Cursor,
) -> Tuple[List[Any], Optional[str]]:
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
order_by: List[str] = []
for field, direction in sort:
order_by.append(field if direction == "ASC" else f"-{field}")
qs = queryset.order_by(*order_by)
if cursor.after:
after = _coerce_sort_values(queryset.model, sort, cursor.after)
qs = qs.filter(_keyset_where(sort, after))
rows = await qs.limit(cursor.limit + 1)
if len(rows) <= cursor.limit:
return rows, None
page = rows[: cursor.limit]
last = page[-1]
key: Dict[str, Any] = {}
for field, _ in sort:
key[field] = getattr(last, field)
return page, encode_cursor(key)
@@ -0,0 +1 @@
"""HTTP route modules."""
@@ -0,0 +1,39 @@
"""OIDC provider list and provider-agnostic login/logout routes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..http import send_json
from ..openapi import json_response, schema_ref
if TYPE_CHECKING:
from ..mixin import Kca
def _login_url(kca: "Kca", provider: str) -> str:
return f"{kca.oidc.base_path}/{provider}/login"
def register(app: KayaApp, kca: "Kca") -> None:
providers = list(kca.oidc.providers)
@app.GET("/api/auth/providers")
@operation(summary="Configured login providers", tags=["auth"],
responses={200: json_response("The configured providers", schema_ref("AuthProvidersPage"))})
async def auth_providers(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"providers": [{"name": name, "login_url": _login_url(kca, name)} for name in providers]
})
@app.GET("/auth/login")
async def login(ctx: HttpContext) -> None:
await ctx.send_empty(302, {"Location": _login_url(kca, providers[0])})
@app.GET("/auth/logout")
async def logout(ctx: HttpContext) -> None:
logged_in = kca.oidc.logged_in_providers(ctx)
location = f"{kca.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
await ctx.send_empty(302, {"Location": location})
@@ -0,0 +1,612 @@
"""Certificates: issue, renew, revoke, CA properties and revocation manager."""
from __future__ import annotations
import base64
import os
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import certificates as cert_core
from kca.core import crls as crl_core
from kca.core import exports as export_core
from kca.core import requests as req_core
from kca.core import templates as template_core
from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
from ..auth import authenticated_user, require_auth
from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
chain_for_certificate,
delete_certificate_cascade,
generate_crl,
issue_certificate,
load_private_key,
parse_datetime,
parse_uuid,
renew_certificate,
revoke_certificate,
save_key,
search_text,
serialize_certificate_detail,
serialize_certificate_summary,
serialize_revocation,
serialize_template_summary,
serialize_crl_detail,
vault_for_owner,
vault_settings,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _cert(vault, cert_id: str) -> Optional[Certificate]:
uuid = parse_uuid(cert_id)
return await Certificate.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/certificates")
@operation(summary="List certificates", tags=["certificates"])
@require_auth(kca.oidc)
async def list_certificates(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]})
@app.POST("/api/vaults/${vault_id}/certificates")
@operation(summary="Issue a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def create_certificate(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
cert = await issue_certificate(vault, body, user=user)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Certificate detail", tags=["certificates"])
@require_auth(kca.oidc)
async def get_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Update certificate metadata", tags=["certificates"])
@require_auth(kca.oidc)
async def update_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != cert.version:
await send_error(ctx, 409, "the certificate changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
cert.name = body["name"].strip()[:200]
if "comment" in body:
cert.comment = str(body["comment"])[:8000]
cert.version += 1
await cert.save()
await send_json(ctx, 200, await serialize_certificate_detail(cert, vault))
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Delete a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def delete_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
await delete_certificate_cascade(vault, cert)
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/export")
@operation(summary="Export a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def export_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
chain = [cert_core.parse_certificate(bytes(item.cert_der)) for item in await chain_for_certificate(cert, vault)]
private_key = None
if cert.key_id:
key = await Key.get_or_none(id=cert.key_id)
if key is not None and key.has_private:
private_key = await load_private_key(key)
authority = await Authority.get_or_none(certificate_id=cert.id)
tls_auth = None
if authority is not None and authority.tls_auth_key and authority.tls_auth_key_nonce:
raw = decrypt_blob(
EncryptedBlob(bytes(authority.tls_auth_key), bytes(authority.tls_auth_key_nonce)),
master_key=settings.master_key,
aad=str(cert.id).encode(),
)
tls_auth = _format_tls_auth(raw)
try:
result = export_core.export_certificate(
parsed,
str(body.get("format") or "pem"),
name=cert.name,
chain=chain,
private_key=private_key,
password=body.get("password"),
tls_auth_key=tls_auth,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/renew")
@operation(summary="Renew a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def renew(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
new_cert = await renew_certificate(vault, cert, body)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_certificate_detail(new_cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revoke")
@operation(summary="Revoke a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def revoke(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
row = await revoke_certificate(vault, cert, body)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, await serialize_revocation(row))
# ------------------------------------------------------------------
# CA properties / revocation manager / CRL generation
# ------------------------------------------------------------------
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
@operation(summary="CA properties", tags=["ca"])
@require_auth(kca.oidc)
async def get_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
await send_json(ctx, 200, {
"certificate_id": str(cert.id),
"crl_days": authority.crl_days,
"crl_number": authority.crl_number,
"crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None,
"default_template_id": str(authority.template_id) if authority.template_id else None,
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/ca")
@operation(summary="Update CA properties", tags=["ca"])
@require_auth(kca.oidc)
async def update_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
if "crl_days" in body:
try:
days = int(body["crl_days"])
except (TypeError, ValueError):
await send_error(ctx, 400, "crl_days must be an integer")
return
if days <= 0:
await send_error(ctx, 400, "crl_days must be positive")
return
authority.crl_days = days
if "template_id" in body:
template_id = body.get("template_id")
if template_id:
template = await Template.get_or_none(id=parse_uuid(str(template_id)), vault_id=vault.id)
if template is None:
await send_error(ctx, 400, "template not found")
return
authority.template_id = template.id
else:
authority.template_id = None
await authority.save()
await send_json(ctx, 200, {
"certificate_id": str(cert.id),
"crl_days": authority.crl_days,
"crl_number": authority.crl_number,
"crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None,
"default_template_id": str(authority.template_id) if authority.template_id else None,
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
@operation(summary="List CA revocations", tags=["ca"])
@require_auth(kca.oidc)
async def list_revocations(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
rows = await CertificateRevocation.filter(ca_id=cert.id).order_by("-revoked_at")
await send_json(ctx, 200, {"results": [await serialize_revocation(row) for row in rows]})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revocations")
@operation(summary="Add a revocation manually", tags=["ca"])
@require_auth(kca.oidc)
async def add_revocation(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
serial = str(body.get("serial_hex") or "").strip().lower().replace(":", "")
if not serial:
await send_error(ctx, 400, "serial_hex is required")
return
try:
revoked_at = parse_datetime(body.get("revoked_at") or datetime.now(timezone.utc).isoformat())
invalidity = parse_datetime(body["invalidity_date"]) if body.get("invalidity_date") else None
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
reason = str(body.get("reason") or "unspecified")
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
await send_error(ctx, 400, "unknown revocation reason")
return
row, created = await CertificateRevocation.get_or_create(
ca_id=cert.id,
serial_hex=serial,
defaults={"revoked_at": revoked_at, "invalidity_date": invalidity, "reason": reason},
)
if not created:
await send_error(ctx, 409, "this serial is already revoked")
return
await send_json(ctx, 201, await serialize_revocation(row))
@app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@operation(summary="Edit a revocation", tags=["ca"])
@require_auth(kca.oidc)
async def update_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "CA certificate not found")
return
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
if row is None:
await send_error(ctx, 404, "revocation not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
if "revoked_at" in body:
row.revoked_at = parse_datetime(body["revoked_at"], "revoked_at")
if "invalidity_date" in body:
row.invalidity_date = (
parse_datetime(body["invalidity_date"], "invalidity_date")
if body["invalidity_date"]
else None
)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
if "reason" in body:
if str(body["reason"]) not in {item["name"] for item in crl_core.CRL_REASONS}:
await send_error(ctx, 400, "unknown revocation reason")
return
row.reason = str(body["reason"])
await row.save()
await send_json(ctx, 200, await serialize_revocation(row))
@app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}")
@operation(summary="Delete a revocation", tags=["ca"])
@require_auth(kca.oidc)
async def delete_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "CA certificate not found")
return
row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id)
if row is None:
await send_error(ctx, 404, "revocation not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/crl")
@operation(summary="Generate a CRL", tags=["ca"])
@require_auth(kca.oidc)
async def create_crl(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
row = await generate_crl(vault, cert, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 201, await serialize_crl_detail(row))
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/tls-auth")
@operation(summary="Generate or import the CA's OpenVPN tls-auth key", tags=["ca"])
@require_auth(kca.oidc)
async def tls_auth(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None or not cert.is_ca:
await send_error(ctx, 404, "CA certificate not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
authority = await Authority.get_or_none(certificate_id=cert.id)
if authority is None:
authority = await Authority.create(certificate_id=cert.id, crl_days=30)
raw = None
if body.get("key"):
text = str(body["key"])
raw = _parse_tls_auth(text)
if raw is None:
await send_error(ctx, 400, "invalid OpenVPN tls-auth key")
return
else:
raw = os.urandom(32)
blob = encrypt_blob(raw, master_key=settings.master_key, aad=str(cert.id).encode())
authority.tls_auth_key = blob.ciphertext
authority.tls_auth_key_nonce = blob.nonce
await authority.save(update_fields=["tls_auth_key", "tls_auth_key_nonce"])
await send_json(ctx, 200, {"key": _format_tls_auth(raw)})
# ------------------------------------------------------------------
# Transforms
# ------------------------------------------------------------------
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/open-key")
@operation(summary="Extract the public key from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def extract_key(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
key = await save_key(
vault,
parsed.public_key(),
name=f"{cert.name} public key",
comment=f"Extracted from certificate {cert.name}",
source="transformed",
)
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request")
@operation(summary="Create a request from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def to_request(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
key = await Key.get_or_none(id=cert.key_id) if cert.key_id else None
if key is None or not key.has_private:
await send_error(ctx, 400, "the certificate has no private key")
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
try:
csr = req_core.build_csr(
subject=parsed.subject,
private_key=await load_private_key(key),
extension_spec=cert_core.certificate_extension_specs(parsed),
signature_hash=cert_core.signature_hash_name(parsed),
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
info = req_core.request_info(csr)
row = await Request.create(
vault_id=vault.id,
key_id=key.id,
name=f"{cert.name} request",
comment=f"Transformed from certificate {cert.name}",
source="transformed",
subject_der=info.subject.public_bytes(),
req_der=req_core.csr_der(csr),
public_key_der=info.public_key_der,
request_sha256=info.fingerprint_sha256,
attributes=info.attributes,
search_text=search_text(cert.name, "request", info.subject.rfc4514_string()),
)
await send_json(ctx, 201, {"id": str(row.id), "name": row.name})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/template")
@operation(summary="Create a template from a certificate", tags=["certificates"])
@require_auth(kca.oidc)
async def to_template(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
cert = await _cert(vault, cert_id)
if cert is None:
await send_error(ctx, 404, "certificate not found")
return
spec = template_core.template_from_certificate(
cert_core.parse_certificate(bytes(cert.cert_der)),
cert.name,
f"Transformed from certificate {cert.name}",
)
template = await Template.create(
vault_id=vault.id,
name=spec["name"],
comment=spec["comment"],
source="transformed",
subject=spec["subject"],
settings=spec["settings"],
search_text=search_text(spec["name"], spec["comment"]),
)
await send_json(ctx, 201, await serialize_template_summary(template))
def _parse_tls_auth(text: str) -> Optional[bytes]:
begin = "-----BEGIN OpenVPN Static key V1-----"
end = "-----END OpenVPN Static key V1-----"
if begin not in text or end not in text:
return None
body = text.split(begin, 1)[1].split(end, 1)[0]
try:
data = bytes.fromhex("".join(body.split()))
except ValueError:
return None
return data if len(data) == 32 else None
def _format_tls_auth(raw: bytes) -> str:
text = raw.hex()
return (
"-----BEGIN OpenVPN Static key V1-----\n"
+ "\n".join(text[i : i + 32] for i in range(0, len(text), 32))
+ "\n-----END OpenVPN Static key V1-----\n"
)
@@ -0,0 +1,109 @@
"""Revocation list list/detail/delete/export."""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import crls as crl_core
from kca.core import exports as export_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Crl
from ..openapi import error_response, json_response, schema_ref
from ..service import (
parse_uuid,
serialize_crl_detail,
serialize_crl_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _crl(vault, crl_id: str) -> Optional[Crl]:
uuid = parse_uuid(crl_id)
return await Crl.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/crls")
@operation(summary="List revocation lists", tags=["crls"])
@require_auth(kca.oidc)
async def list_crls(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Crl.filter(vault_id=vault.id).order_by("-last_update")
await send_json(ctx, 200, {"results": [await serialize_crl_summary(c) for c in rows]})
@app.GET("/api/vaults/${vault_id}/crls/${crl_id}")
@operation(summary="Revocation list detail", tags=["crls"])
@require_auth(kca.oidc)
async def get_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
await send_json(ctx, 200, await serialize_crl_detail(row))
@app.DELETE("/api/vaults/${vault_id}/crls/${crl_id}")
@operation(summary="Delete a revocation list", tags=["crls"])
@require_auth(kca.oidc)
async def delete_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/crls/${crl_id}/export")
@operation(summary="Export a revocation list", tags=["crls"])
@require_auth(kca.oidc)
async def export_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _crl(vault, crl_id)
if row is None:
await send_error(ctx, 404, "CRL not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = export_core.export_crl(
crl_core.parse_crl(bytes(row.crl_der)),
str(body.get("format") or "pem"),
name=row.name,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,20 @@
"""Liveness probe."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..openapi import json_response, schema_ref
if TYPE_CHECKING:
from ..mixin import Kca
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/health")
@operation(summary="Health check", tags=["health"],
responses={200: json_response("The service is up", schema_ref("Health"))})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(200, b'{"status":"ok"}', {"content-type": ("application/json",)})
@@ -0,0 +1,127 @@
"""Import files, PEM bundles and XCA databases."""
from __future__ import annotations
import base64
import binascii
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import certificates as cert_core
from kca.core import crls as crl_core
from kca.core import imports as import_core
from kca.core import keys as key_core
from kca.core import requests as req_core
from ..auth import authenticated_user, require_auth
from ..config import settings
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Crl, Key, Request
from ..openapi import error_response, json_response, schema_ref
from ..service import (
import_parsed_items,
parse_uuid,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _duplicate(vault, item: import_core.ParsedItem) -> bool:
if item.kind == "key":
info = key_core.key_info(item.obj)
return await Key.filter(vault_id=vault.id, public_key_sha256=info.public_sha256).exists()
if item.kind == "certificate":
info = cert_core.certificate_info(item.obj)
return await Certificate.filter(vault_id=vault.id, fingerprint_sha256=info.fingerprint_sha256).exists()
if item.kind == "request":
info = req_core.request_info(item.obj)
return await Request.filter(vault_id=vault.id, request_sha256=info.fingerprint_sha256).exists()
if item.kind == "crl":
info = crl_core.crl_info(item.obj)
return await Crl.filter(vault_id=vault.id, crl_hash=info.fingerprint_sha256).exists()
return False
def register(app: KayaApp, kca: "Kca") -> None:
@app.POST("/api/vaults/${vault_id}/import")
@operation(summary="Import items into a vault", tags=["import"],
responses={200: json_response("Import result", schema_ref("ImportResult")),
400: error_response("Invalid file, password or selection"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
413: error_response("File too large")})
@require_auth(kca.oidc)
async def import_items(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
filename = str(body.get("filename") or "import")
password = body.get("password")
if password is not None and not isinstance(password, str):
await send_error(ctx, 400, "password must be a string")
return
raw: Optional[bytes] = None
if body.get("content_base64"):
try:
raw = base64.b64decode(str(body["content_base64"]), validate=True)
except (binascii.Error, ValueError):
await send_error(ctx, 400, "content_base64 is not valid base64")
return
elif body.get("pem"):
raw = str(body["pem"]).encode("utf-8")
if raw is None:
await send_error(ctx, 400, "content_base64 or pem is required")
return
if len(raw) > settings.max_upload_bytes:
await send_error(ctx, 413, f"the file is larger than {settings.max_upload_bytes} bytes")
return
dry_run = bool(body.get("dry_run"))
use_xca_db = bool(body.get("xca_db")) or filename.lower().endswith(".xdb")
try:
if use_xca_db:
items = import_core.parse_xca_database(raw, filename, password)
else:
items = import_core.parse_file(raw, filename, password)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
if dry_run:
preview = []
for index, item in enumerate(items):
preview.append(
{
"index": index,
"kind": item.kind,
"name": item.name,
"summary": item.summary,
"is_private": item.is_private,
"duplicate": await _duplicate(vault, item),
"warnings": item.warnings,
}
)
await send_json(ctx, 200, {"dry_run": True, "items": preview, "count": len(preview)})
return
selected = body.get("selected")
if selected is not None:
if not isinstance(selected, list) or not all(isinstance(i, int) for i in selected):
await send_error(ctx, 400, "selected must be a list of item indexes")
return
result = await import_parsed_items(vault, items, selected=selected)
await send_json(ctx, 200, result)
@@ -0,0 +1,225 @@
"""Key generation, detail, rename/delete and export."""
from __future__ import annotations
import asyncio
import base64
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from tortoise.exceptions import IntegrityError
from kca.core import exports as export_core
from kca.core import keys as key_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Key, Request
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_key_generation,
parse_uuid,
save_key,
serialize_key_detail,
serialize_key_summary,
load_private_key,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
# Key generation is CPU-bound; this bounds concurrent RSA/DSA jobs.
_keygen_semaphore = asyncio.Semaphore(2)
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _key(vault, key_id: str) -> Optional[Key]:
uuid = parse_uuid(key_id)
return await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/keys")
@operation(summary="List keys", tags=["keys"],
responses={200: json_response("Keys", schema_ref("KeysPage")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def list_keys(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Key.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_key_summary(k) for k in rows]})
@app.POST("/api/vaults/${vault_id}/keys")
@operation(summary="Generate a key", tags=["keys"],
responses={201: json_response("Key created", schema_ref("KeyDetail")),
400: error_response("Invalid key parameters"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
409: error_response("Key already exists")})
@require_auth(kca.oidc)
async def generate_key(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
params = parse_key_generation(body)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
try:
async with _keygen_semaphore:
generated = await asyncio.to_thread(
key_core.generate_key,
params["key_type"],
params["size"],
params["curve"],
)
key = await save_key(
vault,
generated,
name=params["name"],
comment=params["comment"],
source="generated",
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
except IntegrityError:
await send_error(ctx, 409, "this key is already in the vault")
return
await send_json(ctx, 201, await serialize_key_detail(key))
@app.GET("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Key detail", tags=["keys"],
responses={200: json_response("Key", schema_ref("KeyDetail")),
401: error_response("Authentication required"),
404: error_response("Key not found")})
@require_auth(kca.oidc)
async def get_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
await send_json(ctx, 200, await serialize_key_detail(key))
@app.PATCH("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Rename a key", tags=["keys"],
responses={200: json_response("Key updated", schema_ref("KeyDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required"),
404: error_response("Key not found"),
409: error_response("Stale version")})
@require_auth(kca.oidc)
async def update_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != key.version:
await send_error(ctx, 409, "the key changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
key.name = body["name"].strip()[:200]
if "comment" in body:
if not isinstance(body["comment"], str):
await send_error(ctx, 400, "comment must be a string")
return
key.comment = body["comment"][:8000]
key.version += 1
await key.save()
await send_json(ctx, 200, await serialize_key_detail(key))
@app.DELETE("/api/vaults/${vault_id}/keys/${key_id}")
@operation(summary="Delete a key", tags=["keys"],
responses={204: {"description": "Deleted"},
401: error_response("Authentication required"),
404: error_response("Key not found")})
@require_auth(kca.oidc)
async def delete_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
await Certificate.filter(key_id=key.id).update(key_id=None)
await Request.filter(key_id=key.id).update(key_id=None)
await key.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/keys/${key_id}/export")
@operation(summary="Export a key", tags=["keys"])
@require_auth(kca.oidc)
async def export_key(ctx: HttpContext, vault_id: str, key_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
key = await _key(vault, key_id)
if key is None:
await send_error(ctx, 404, "key not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
fmt = str(body.get("format") or "pem-public")
password = body.get("password")
if password is not None and not isinstance(password, str):
await send_error(ctx, 400, "password must be a string")
return
try:
if fmt in ("pem-private", "pem", "pem-encrypted", "der-private", "pkcs8", "pkcs8-private", "pkcs8-encrypted", "ssh-private", "jwk-private", "pvk"):
key_obj = await load_private_key(key)
is_private = True
else:
key_obj = key_core.public_key_from_der(bytes(key.public_der))
is_private = False
result = export_core.export_key(
key_obj,
fmt,
name=key.name,
password=password,
is_private=is_private,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,36 @@
"""Whoami endpoint."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import PROFILE_CLAIMS, authenticated_user, require_auth
from ..http import send_json
from ..openapi import error_response, json_response, schema_ref
from ..config import settings
if TYPE_CHECKING:
from ..mixin import Kca
def _serialize(user: Any) -> Dict[str, Any]:
payload: Dict[str, Any] = {"sub": user.sub, "display_name": user.display_name}
for claim in PROFILE_CLAIMS:
payload[claim] = getattr(user, claim)
return payload
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/me")
@operation(summary="Current user", tags=["auth"],
responses={200: json_response("The current user", schema_ref("Me")),
401: error_response("Not logged in")})
@require_auth(kca.oidc)
async def me(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
payload = _serialize(user)
payload["master_key_is_dev"] = settings.master_key_is_dev
await send_json(ctx, 200, payload)
@@ -0,0 +1,280 @@
"""Certificate signing requests: list, create, detail, export and transforms."""
from __future__ import annotations
import asyncio
import base64
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from tortoise.exceptions import IntegrityError
from kca.core import exports as export_core
from kca.core import keys as key_core
from kca.core import requests as req_core
from kca.core import templates as template_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_extensions,
parse_subject,
parse_uuid,
save_key,
search_text,
serialize_request_detail,
serialize_request_summary,
serialize_template_summary,
load_private_key,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
_keygen_semaphore = asyncio.Semaphore(2)
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _request(vault, request_id: str) -> Optional[Request]:
uuid = parse_uuid(request_id)
return await Request.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/requests")
@operation(summary="List certificate signing requests", tags=["requests"])
@require_auth(kca.oidc)
async def list_requests(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Request.filter(vault_id=vault.id).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_request_summary(r) for r in rows]})
@app.POST("/api/vaults/${vault_id}/requests")
@operation(summary="Create a certificate signing request", tags=["requests"])
@require_auth(kca.oidc)
async def create_request(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
name = str(body.get("name") or "").strip()
if not name:
await send_error(ctx, 400, "name is required")
return
comment = str(body.get("comment") or "")
try:
subject = parse_subject(body.get("subject") or [])
extensions = parse_extensions(body.get("extensions"))
if not subject.rdns:
raise ValidationError("the subject must not be empty")
key_model = None
key_obj = None
if body.get("key_id"):
uuid = parse_uuid(str(body["key_id"]))
key_model = await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
if key_model is None:
raise ValidationError("key not found")
if not key_model.has_private:
raise ValidationError("the selected key has no private part")
key_obj = await load_private_key(key_model)
elif isinstance(body.get("new_key"), dict):
new_key = body["new_key"]
async with _keygen_semaphore:
generated = await asyncio.to_thread(
key_core.generate_key,
str(new_key.get("key_type") or "rsa"),
int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE),
new_key.get("curve"),
)
key_model = await save_key(
vault,
generated,
name=str(new_key.get("name") or name),
comment="Generated while creating a request",
source="generated",
)
key_obj = generated
else:
raise ValidationError("select an existing key or generate a new one")
csr = req_core.build_csr(
subject=subject,
private_key=key_obj,
extension_spec=extensions,
signature_hash=body.get("signature_hash"),
attributes=body.get("attributes") or {},
)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
info = req_core.request_info(csr)
try:
row = await Request.create(
vault_id=vault.id,
key_id=key_model.id if key_model else None,
name=name,
comment=comment,
source="generated",
subject_der=info.subject.public_bytes(),
req_der=req_core.csr_der(csr),
public_key_der=info.public_key_der,
request_sha256=info.fingerprint_sha256,
attributes=info.attributes,
search_text=search_text(name, comment, info.subject.rfc4514_string(), info.fingerprint_sha256),
)
except IntegrityError:
await send_error(ctx, 409, "this request is already in the vault")
return
await send_json(ctx, 201, await serialize_request_detail(row))
@app.GET("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Request detail", tags=["requests"])
@require_auth(kca.oidc)
async def get_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
await send_json(ctx, 200, await serialize_request_detail(row))
@app.PATCH("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Update a request", tags=["requests"])
@require_auth(kca.oidc)
async def update_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != row.version:
await send_error(ctx, 409, "the request changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
row.name = body["name"].strip()[:200]
if "comment" in body:
row.comment = str(body["comment"])[:8000]
row.version += 1
await row.save()
await send_json(ctx, 200, await serialize_request_detail(row))
@app.DELETE("/api/vaults/${vault_id}/requests/${request_id}")
@operation(summary="Delete a request", tags=["requests"])
@require_auth(kca.oidc)
async def delete_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/export")
@operation(summary="Export a request", tags=["requests"])
@require_auth(kca.oidc)
async def export_request(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = export_core.export_request(
req_core.parse_csr(bytes(row.req_der)),
str(body.get("format") or "pem"),
name=row.name,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/open-key")
@operation(summary="Extract the public key from a request", tags=["requests"])
@require_auth(kca.oidc)
async def extract_key(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der)))
key = await save_key(
vault,
public_key,
name=f"{row.name} public key",
comment=f"Extracted from request {row.name}",
source="transformed",
)
await send_json(ctx, 201, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template")
@operation(summary="Create a template from a request", tags=["requests"])
@require_auth(kca.oidc)
async def to_template(ctx: HttpContext, vault_id: str, request_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _request(vault, request_id)
if row is None:
await send_error(ctx, 404, "request not found")
return
spec = template_core.template_from_request(
req_core.parse_csr(bytes(row.req_der)), row.name, f"Transformed from request {row.name}"
)
template = await Template.create(
vault_id=vault.id,
name=spec["name"],
comment=spec["comment"],
source="transformed",
subject=spec["subject"],
settings=spec["settings"],
search_text=search_text(spec["name"], spec["comment"]),
)
await send_json(ctx, 201, await serialize_template_summary(template))
@@ -0,0 +1,75 @@
"""Global search across a vault's items."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_auth
from ..http import extract_query_params, send_error, send_json
from ..models import Certificate, Crl, Key, Request, Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
parse_uuid,
serialize_certificate_summary,
serialize_crl_summary,
serialize_key_summary,
serialize_request_summary,
serialize_template_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/search")
@operation(summary="Search a vault", tags=["search"],
responses={200: json_response("Search results", schema_ref("SearchPage")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def search(ctx: HttpContext, vault_id: str) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return
params = extract_query_params(ctx.query_string)
query = (params.get("q", [""])[0] or "").strip().lower()
kind = (params.get("type", [""])[0] or "").strip().lower()
if not query:
await send_json(ctx, 200, {"results": [], "counts": {}})
return
results: List[Dict[str, Any]] = []
counts: Dict[str, int] = {}
if kind in ("", "all", "certificate", "certificates"):
rows = await Certificate.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["certificates"] = len(rows)
for row in rows:
results.append({"type": "certificate", "item": await serialize_certificate_summary(row)})
if kind in ("", "all", "key", "keys"):
rows = await Key.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["keys"] = len(rows)
for row in rows:
results.append({"type": "key", "item": await serialize_key_summary(row)})
if kind in ("", "all", "request", "requests"):
rows = await Request.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["requests"] = len(rows)
for row in rows:
results.append({"type": "request", "item": await serialize_request_summary(row)})
if kind in ("", "all", "crl", "crls"):
rows = await Crl.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["crls"] = len(rows)
for row in rows:
results.append({"type": "crl", "item": await serialize_crl_summary(row)})
if kind in ("", "all", "template", "templates"):
rows = await Template.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100)
counts["templates"] = len(rows)
for row in rows:
results.append({"type": "template", "item": await serialize_template_summary(row)})
await send_json(ctx, 200, {"results": results, "counts": counts})
@@ -0,0 +1,229 @@
"""Certificate/request templates, including the built-in XCA presets."""
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from kca.core import exports as export_core
from kca.core import templates as template_core
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Template
from ..openapi import error_response, json_response, schema_ref
from ..service import (
ValidationError,
parse_uuid,
search_text,
serialize_template_detail,
serialize_template_summary,
vault_for_owner,
)
if TYPE_CHECKING:
from ..mixin import Kca
async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str):
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, parse_uuid(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
async def _template(vault, template_id: str):
for builtin in template_core.builtin_templates():
if builtin.id == template_id:
return builtin.as_dict()
uuid = parse_uuid(template_id)
return await Template.get_or_none(id=uuid, vault_id=vault.id) if uuid else None
def _validate_template_body(body: Mapping[str, Any]) -> Dict[str, Any]:
name = body.get("name")
if not isinstance(name, str) or not name.strip():
raise ValidationError("name is required")
subject = body.get("subject") or []
if not isinstance(subject, list):
raise ValidationError("subject must be a list")
settings = body.get("settings") or {}
if not isinstance(settings, dict):
raise ValidationError("settings must be an object")
return {
"name": name.strip()[:200],
"comment": str(body.get("comment") or "")[:8000],
"subject": subject,
"settings": settings,
}
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults/${vault_id}/templates")
@operation(summary="List templates", tags=["templates"])
@require_auth(kca.oidc)
async def list_templates(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
rows = await Template.filter(vault_id=vault.id).order_by("name", "id")
results = [await serialize_template_summary(t) for t in template_core.builtin_templates()]
results.extend([await serialize_template_summary(t) for t in rows])
await send_json(ctx, 200, {"results": results})
@app.POST("/api/vaults/${vault_id}/templates")
@operation(summary="Create a template", tags=["templates"])
@require_auth(kca.oidc)
async def create_template(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
values = _validate_template_body(body)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
row = await Template.create(
vault_id=vault.id,
name=values["name"],
comment=values["comment"],
source="generated",
subject=values["subject"],
settings=values["settings"],
search_text=search_text(values["name"], values["comment"]),
)
await send_json(ctx, 201, await serialize_template_detail(row))
@app.GET("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Template detail", tags=["templates"])
@require_auth(kca.oidc)
async def get_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
await send_json(ctx, 200, await serialize_template_detail(row))
@app.PATCH("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Update a template", tags=["templates"])
@require_auth(kca.oidc)
async def update_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
if row is None:
await send_error(ctx, 404, "template not found (built-in templates cannot be edited)")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != row.version:
await send_error(ctx, 409, "the template changed since it was loaded")
return
try:
values = _validate_template_body({**{
"name": row.name,
"comment": row.comment,
"subject": row.subject,
"settings": row.settings,
}, **body})
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
row.name = values["name"]
row.comment = values["comment"]
row.subject = values["subject"]
row.settings = values["settings"]
row.version += 1
await row.save()
await send_json(ctx, 200, await serialize_template_detail(row))
@app.DELETE("/api/vaults/${vault_id}/templates/${template_id}")
@operation(summary="Delete a template", tags=["templates"])
@require_auth(kca.oidc)
async def delete_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
if template_id.startswith("builtin:"):
await send_error(ctx, 400, "built-in templates cannot be deleted")
return
row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id)
if row is None:
await send_error(ctx, 404, "template not found")
return
await row.delete()
await ctx.send_empty(204)
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/apply")
@operation(summary="Apply a template to a certificate/request spec", tags=["templates"])
@require_auth(kca.oidc)
async def apply_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
template = await serialize_template_detail(row)
target = body.get("target") or {}
merged = template_core.apply_template(
template,
target,
apply_subject=bool(body.get("apply_subject", True)),
apply_extensions=bool(body.get("apply_extensions", True)),
)
await send_json(ctx, 200, merged)
@app.POST("/api/vaults/${vault_id}/templates/${template_id}/export")
@operation(summary="Export a template", tags=["templates"])
@require_auth(kca.oidc)
async def export_template(ctx: HttpContext, vault_id: str, template_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
row = await _template(vault, template_id)
if row is None:
await send_error(ctx, 404, "template not found")
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
template = await serialize_template_detail(row)
try:
result = export_core.export_template(
template,
str(body.get("format") or "xca"),
name=str(template.get("name") or "template"),
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, {
"filename": result.filename,
"content_type": result.content_type,
"content_base64": base64.b64encode(result.data).decode("ascii"),
})
@@ -0,0 +1,98 @@
"""Personal access tokens for programmatic API access."""
from __future__ import annotations
from logging import getLogger
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_session_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import ApiToken
from ..openapi import error_response, json_response, schema_ref
from ..tokens import (
MAX_TOKENS_PER_USER,
TokenValidationError,
generate_token,
parse_token_payload,
serialize_token,
)
if TYPE_CHECKING:
from ..mixin import Kca
log = getLogger(__name__)
def _parse_uuid(value: str) -> Optional[UUID]:
try:
return UUID(value)
except (ValueError, AttributeError, TypeError):
return None
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/tokens")
@operation(summary="List my API tokens", tags=["auth"],
responses={200: json_response("The caller's tokens", schema_ref("ApiTokensPage")),
401: error_response("Authentication required")})
@require_session_auth(kca.oidc)
async def list_tokens(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
rows = await ApiToken.filter(user_sub=user.sub)
await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]})
@app.POST("/api/tokens")
@operation(summary="Create an API token", tags=["auth"],
responses={201: json_response("Token created", schema_ref("CreatedApiToken")),
400: error_response("Invalid body or limit reached"),
401: error_response("Authentication required")})
@require_session_auth(kca.oidc)
async def create_token(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
values = parse_token_payload(body)
except TokenValidationError as exc:
await send_error(ctx, 400, str(exc))
return
if await ApiToken.filter(user_sub=user.sub).count() >= MAX_TOKENS_PER_USER:
await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first")
return
secret, prefix, digest = generate_token()
token = await ApiToken.create(
user_sub=user.sub,
name=values["name"],
token_prefix=prefix,
token_hash=digest,
expires_at=values["expires_at"],
)
log.info("created api token sub=%s name=%r prefix=%s", user.sub, token.name, token.token_prefix)
payload = serialize_token(token)
payload["token"] = secret
await send_json(ctx, 201, payload)
@app.DELETE("/api/tokens/${token_id}")
@operation(summary="Revoke an API token", tags=["auth"],
responses={204: {"description": "Revoked"},
401: error_response("Authentication required"),
404: error_response("No such token")})
@require_session_auth(kca.oidc)
async def revoke_token(ctx: HttpContext, token_id: str) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
token_uuid = _parse_uuid(token_id)
token = await ApiToken.get_or_none(id=token_uuid, user_sub=user.sub) if token_uuid else None
if token is None:
await send_error(ctx, 404, "token not found")
return
await ApiToken.filter(id=token.id).delete()
await ctx.send_empty(204)
@@ -0,0 +1,247 @@
"""Vaults (XCA databases) and their settings."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import authenticated_user, require_auth
from ..http import JsonRequestError, read_json, send_error, send_json
from ..models import Certificate, Crl, Key, Request, Template, Vault
from ..openapi import error_response, json_response, schema_ref
from ..service import (
DEFAULT_VAULT_SETTINGS,
ValidationError,
iso,
merge_settings,
parse_uuid,
serialize_certificate_summary,
serialize_crl_summary,
serialize_key_summary,
serialize_request_summary,
serialize_template_summary,
serialize_vault_detail,
serialize_vault_summary,
vault_for_owner,
vault_settings,
)
from ..core import crls as crl_core
from ..core import templates as template_core
from ..core import extensions as ext_core
from ..core import keys as key_core
from ..core import names as name_core
if TYPE_CHECKING:
from ..mixin import Kca
def _parse_vault_id(value: str):
return parse_uuid(value)
async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vault = await vault_for_owner(user.sub, _parse_vault_id(vault_id))
if vault is None:
await send_error(ctx, 404, "vault not found")
return vault
def register(app: KayaApp, kca: "Kca") -> None:
@app.GET("/api/vaults")
@operation(summary="List my vaults", tags=["vaults"],
responses={200: json_response("Vaults", schema_ref("VaultsPage")),
401: error_response("Authentication required")})
@require_auth(kca.oidc)
async def list_vaults(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
vaults = await Vault.filter(owner_sub=user.sub).order_by("name", "id")
await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]})
@app.POST("/api/vaults")
@operation(summary="Create a vault", tags=["vaults"],
responses={201: json_response("Vault created", schema_ref("VaultDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required")})
@require_auth(kca.oidc)
async def create_vault(ctx: HttpContext) -> None:
user = await authenticated_user(kca.oidc, ctx)
assert user is not None
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
name = body.get("name")
if not isinstance(name, str) or not name.strip():
await send_error(ctx, 400, "name is required")
return
description = body.get("description", "")
if not isinstance(description, str):
await send_error(ctx, 400, "description must be a string")
return
try:
settings_values = dict(DEFAULT_VAULT_SETTINGS)
if isinstance(body.get("settings"), dict):
settings_values = merge_settings(settings_values, body["settings"])
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
vault = await Vault.create(
owner_sub=user.sub,
name=name.strip()[:200],
description=description[:4000],
settings=settings_values,
)
await send_json(ctx, 201, await serialize_vault_detail(vault))
@app.GET("/api/vaults/${vault_id}")
@operation(summary="Vault detail", tags=["vaults"],
responses={200: json_response("Vault", schema_ref("VaultDetail")),
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def get_vault(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, await serialize_vault_detail(vault))
@app.PATCH("/api/vaults/${vault_id}")
@operation(summary="Update a vault", tags=["vaults"],
responses={200: json_response("Vault updated", schema_ref("VaultDetail")),
400: error_response("Invalid body"),
401: error_response("Authentication required"),
404: error_response("Vault not found"),
409: error_response("Stale version")})
@require_auth(kca.oidc)
async def update_vault(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != vault.version:
await send_error(ctx, 409, "the vault changed since it was loaded")
return
if "name" in body:
if not isinstance(body["name"], str) or not body["name"].strip():
await send_error(ctx, 400, "name must not be empty")
return
vault.name = body["name"].strip()[:200]
if "description" in body:
if not isinstance(body["description"], str):
await send_error(ctx, 400, "description must be a string")
return
vault.description = body["description"][:4000]
vault.version += 1
await vault.save()
await send_json(ctx, 200, await serialize_vault_detail(vault))
@app.DELETE("/api/vaults/${vault_id}")
@operation(summary="Delete a vault", tags=["vaults"],
responses={204: {"description": "Deleted"},
401: error_response("Authentication required"),
404: error_response("Vault not found")})
@require_auth(kca.oidc)
async def delete_vault(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await Vault.filter(id=vault.id).delete()
await ctx.send_empty(204)
@app.GET("/api/vaults/${vault_id}/settings")
@operation(summary="Vault settings", tags=["vaults"])
@require_auth(kca.oidc)
async def get_settings(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
@app.PATCH("/api/vaults/${vault_id}/settings")
@operation(summary="Update vault settings", tags=["vaults"])
@require_auth(kca.oidc)
async def update_settings(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
if "version" in body and int(body["version"]) != vault.version:
await send_error(ctx, 409, "the vault changed since it was loaded")
return
patch = body.get("settings", body)
if not isinstance(patch, dict):
await send_error(ctx, 400, "settings must be an object")
return
try:
vault.settings = merge_settings(vault_settings(vault), patch)
except ValidationError as exc:
await send_error(ctx, 400, str(exc))
return
vault.version += 1
await vault.save()
await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version})
@app.GET("/api/vaults/${vault_id}/summary")
@operation(summary="Vault dashboard summary", tags=["vaults"])
@require_auth(kca.oidc)
async def vault_summary(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
now = datetime.now(timezone.utc)
expiring = await Certificate.filter(
vault_id=vault.id, not_after__gte=now, not_after__lte=now + timedelta(days=30)
).order_by("not_after").limit(10)
due_crls = await Crl.filter(
vault_id=vault.id, next_update__lte=now + timedelta(days=7)
).order_by("next_update").limit(10)
recent_certs = await Certificate.filter(vault_id=vault.id).order_by("-created_at").limit(8)
recent_keys = await Key.filter(vault_id=vault.id).order_by("-created_at").limit(8)
await send_json(ctx, 200, {
"summary": await serialize_vault_summary(vault),
"expiring": [await serialize_certificate_summary(c) for c in expiring],
"crls_due": [await serialize_crl_summary(c) for c in due_crls],
"recent_certificates": [await serialize_certificate_summary(c) for c in recent_certs],
"recent_keys": [await serialize_key_summary(k) for k in recent_keys],
})
@app.GET("/api/vaults/${vault_id}/options")
@operation(summary="Reference data for editors", tags=["vaults"])
@require_auth(kca.oidc)
async def options(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault_or_error(kca, ctx, vault_id)
if vault is None:
return
await send_json(ctx, 200, {
"key_types": key_core.key_type_options(),
"hash_algorithms": list(key_core.HASH_ALGORITHMS),
"curves": list(key_core.EC_CURVES),
"dn_attributes": name_core.name_attribute_options(),
"eku": name_core.eku_options(),
"key_usages": ext_core.KEY_USAGES,
"revocation_reasons": crl_core.reason_options(),
"defaults": vault_settings(vault),
"names": {
"extensions": ext_core.EXTENSION_NAMES,
"general_name_types": list(ext_core.GENERAL_NAME_TYPES),
"netscape_cert_types": [name for name, _bit in ext_core.NETSCAPE_CERT_TYPES],
},
"templates": [
*[await serialize_template_summary(t) for t in template_core.builtin_templates()],
*[await serialize_template_summary(t) for t in await Template.filter(vault_id=vault.id)],
],
})
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
"""SPA shell hosting for the compiled single-page application."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Mapping
from kaya.core import HttpContext
from kaya.openapi import operation
from .app import app
from .config import settings
_ICON_CONTENT_TYPES: Mapping[str, str] = {
"apple-touch-icon.png": "image/png",
"icon-192.png": "image/png",
"icon-512.png": "image/png",
"icon-maskable-512.png": "image/png",
"icon.svg": "image/svg+xml",
}
async def _send_file(ctx: HttpContext, name: str, content_type: str, cache_control: str) -> None:
path = Path(settings.static_dir) / name
if not path.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(path.read_bytes)
await ctx.send_bytes(200, body, {"content-type": (content_type,), "cache-control": (cache_control,)})
async def _send_shell(ctx: HttpContext) -> None:
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
@app.GET("/sw.js")
@operation(hidden=True)
async def service_worker(ctx: HttpContext) -> None:
await _send_file(ctx, "sw.js", "text/javascript; charset=utf-8", "no-cache")
@app.GET("/manifest.webmanifest")
@operation(hidden=True)
async def web_manifest(ctx: HttpContext) -> None:
await _send_file(ctx, "manifest.webmanifest", "application/manifest+json", "no-cache")
@app.GET("/icons/${name}")
@operation(hidden=True)
async def icon(ctx: HttpContext, name: str) -> None:
content_type = _ICON_CONTENT_TYPES.get(name)
if content_type is None:
await ctx.send_empty(404)
return
await _send_file(ctx, f"icons/{name}", content_type, "public, max-age=86400")
@app.GET("/")
async def index(ctx: HttpContext) -> None:
await _send_shell(ctx)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
await _send_shell(ctx)
+107
View File
@@ -0,0 +1,107 @@
"""Personal access tokens: generation, hashing and verification."""
from __future__ import annotations
import hashlib
import secrets
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, Mapping, Optional, Tuple
from kaya.core import HttpContext
from .models import ApiToken, User
TOKEN_PREFIX = "kca_"
PREFIX_LENGTH = 12
TOKEN_ENTROPY_BYTES = 32
MAX_TOKENS_PER_USER = 50
LAST_USED_REFRESH = timedelta(minutes=1)
MAX_TOKEN_NAME = 100
class TokenValidationError(ValueError):
pass
def generate_token() -> Tuple[str, str, str]:
secret = TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)
return secret, secret[:PREFIX_LENGTH], hash_token(secret)
def hash_token(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def bearer_token(ctx: HttpContext) -> Optional[str]:
values = ctx.headers.get("authorization")
if not values:
return None
parts = values[0].split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
credential = parts[1].strip()
return credential or None
async def resolve_token(secret: str) -> Optional[User]:
token = await ApiToken.get_or_none(token_hash=hash_token(secret))
if token is None:
return None
if token.expires_at is not None and token.expires_at < date.today():
return None
now = datetime.now(timezone.utc)
last_used = token.last_used_at
if last_used is not None and last_used.tzinfo is None:
last_used = last_used.replace(tzinfo=timezone.utc)
if last_used is None or now - last_used >= LAST_USED_REFRESH:
token.last_used_at = now
await token.save(update_fields=["last_used_at"])
return await User.get_or_none(sub=token.user_sub)
def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
raise TokenValidationError("name is required")
name = name.strip()
if len(name) > MAX_TOKEN_NAME:
raise TokenValidationError(f"name must be at most {MAX_TOKEN_NAME} characters")
expires_at: Optional[date] = None
raw_expiry = payload.get("expires_at")
if raw_expiry is not None:
if not isinstance(raw_expiry, str) or not raw_expiry:
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null")
try:
expires_at = date.fromisoformat(raw_expiry)
except ValueError as exc:
raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null") from exc
if expires_at < date.today():
raise TokenValidationError("expires_at must not be in the past")
return {"name": name, "expires_at": expires_at}
def serialize_token(token: ApiToken) -> Dict[str, Any]:
return {
"id": str(token.id),
"name": token.name,
"prefix": token.token_prefix,
"created_at": token.created_at.isoformat(),
"last_used_at": token.last_used_at.isoformat() if token.last_used_at else None,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
}
__all__ = [
"LAST_USED_REFRESH",
"MAX_TOKEN_NAME",
"MAX_TOKENS_PER_USER",
"PREFIX_LENGTH",
"TOKEN_ENTROPY_BYTES",
"TOKEN_PREFIX",
"TokenValidationError",
"bearer_token",
"generate_token",
"hash_token",
"parse_token_payload",
"resolve_token",
"serialize_token",
]
@@ -0,0 +1,145 @@
"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle.
kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a
running event loop. Tortoise 1.x binds database connections to a
:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and
kaya dispatches each HTTP request as a separate ``loop.create_task``, so a
context set by an early request does not automatically reach later
requests.
This mixin therefore:
1. Lazily builds a :class:`TortoiseContext` for the active event loop
(rebuilding it if the running loop changes, which happens in tests that
use a fresh ``asyncio.run`` per test).
2. Per HTTP request, binds that context to the current task via the
``_current_context`` contextvar so the handler — running in the same
task as the ``before_request`` hook — sees an active context.
3. Exposes :meth:`ensure_context` for background work that belongs to no
request (used by the test suite and any future worker).
It deliberately avoids the global-fallback singleton
(``_enable_global_fallback``), which Tortoise only allows to be set once
per process and would therefore break across event loops.
Schema management is split by backend: in-memory sqlite databases (the
test suite) get ``generate_schemas`` on every fresh context; Postgres
schemas are owned by aerich migrations and must be applied externally
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
the app serves requests.
"""
from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger
from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin
from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
def __init__(self,
database_url: str,
models_modules: Sequence[str],
skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None:
self._database_url = database_url
self._models_modules = list(models_modules)
self._skip_paths = skip_paths
self._ctx: "Optional[TortoiseContext]" = None
self._init_loop: "Optional[AbstractEventLoop]" = None
def apply(self, app: KayaApp) -> None:
app.add_before_request_hook(self._ensure_context)
def setup(self, loop: AbstractEventLoop) -> None:
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections())
self._ctx = None
self._init_loop = None
async def aclose(self) -> None:
"""Close the current context's connections and forget it.
Must be called from the event loop that owns the context. When a
loop goes away without this, its aiosqlite connections are orphaned;
their non-daemon worker threads then block interpreter shutdown
forever. The test suite calls this at the end of every test because
each test runs in a fresh event loop.
"""
ctx = self._ctx
self._ctx = None
self._init_loop = None
if ctx is not None:
log.info("closing database connections")
await ctx.close_connections()
async def _build_context(self) -> TortoiseContext:
ctx = TortoiseContext()
with ctx:
await ctx.init(
db_url=self._database_url,
modules={"models": self._models_modules},
)
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"):
await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx
async def ensure_context(self) -> None:
"""Bind this mixin's context to the calling task.
Background work with no request of its own calls this before
touching the database. It is cheap when the context is already
built for the running loop and idempotent for callers that already
have it bound (HTTP requests).
"""
await self._bind()
async def _bind(self) -> None:
loop = get_running_loop()
if self._init_loop is not loop:
if self._ctx is not None:
# A previous test loop went away; drop its context.
self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context()
self._init_loop = loop
assert self._ctx is not None
_current_context.set(self._ctx)
async def _ensure_context(self, ctx: HttpContext):
if ctx.path in self._skip_paths:
return None
# Only API endpoints touch the database: auth callbacks and the
# static SPA catch-all must not pay for a Tortoise context.
if not ctx.path.startswith("/api/"):
return None
await self._bind()
return None
+7
View File
@@ -0,0 +1,7 @@
# kca-core
Pure, I/O-free PKI domain logic for KCA. It knows how to generate and parse
keys, build distinguished names, encode X.509 extensions, issue certificates,
create CSRs and CRLs, apply templates, and convert every supported import and
export format. It has no kaya, database or filesystem dependencies and is
unit-tested in isolation.
+18
View File
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "kca-core"
version = "0.1.0"
description = "Pure PKI domain logic for KCA (keys, names, extensions, certificates, CSRs, CRLs, templates, imports/exports)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"cryptography>=44",
"paramiko>=3.5",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
@@ -0,0 +1 @@
"""Pure PKI domain logic for KCA."""
@@ -0,0 +1,522 @@
"""Certificate construction, parsing, validation and export helpers."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa
from cryptography.x509.oid import ExtensionOID, NameOID
from .extensions import (
build_extensions,
eku_name,
extension_config_lines,
extension_rows,
extension_specs,
)
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der
from .names import entries_from_name, name_from_entries, name_hash, one_line
#: XCA's "no well-defined expiration" sentinel.
FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
MAX_SERIAL_BYTES = 20
@dataclass(frozen=True)
class CertificateInfo:
subject: x509.Name
issuer: x509.Name
serial_hex: str
not_before: datetime
not_after: datetime
is_ca: bool
path_length: Optional[int]
signature_algorithm: str
public_key_der: bytes
fingerprint_md5: str
fingerprint_sha1: str
fingerprint_sha256: str
subject_hash: str
issuer_hash: str
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def normalize_datetime(value: datetime) -> datetime:
return _as_utc(value)
def serial_to_hex(value: int) -> str:
return format(value, "x")
def parse_serial_hex(value: str) -> int:
text = (value or "").strip().lower().replace(":", "")
if text.startswith("0x"):
text = text[2:]
if not text:
raise ValueError("serial number is empty")
if len(text) > MAX_SERIAL_BYTES * 2:
raise ValueError("serial number must be at most 20 octets")
serial = int(text, 16)
if serial <= 0:
raise ValueError("serial number must be positive")
return serial
def build_certificate(
*,
subject: x509.Name,
public_key: PublicKey,
issuer_cert: Optional[x509.Certificate],
issuer_private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extensions: Iterable[x509.Extension],
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
"""Issue a certificate (self-signed when ``issuer_cert`` is None)."""
if serial <= 0:
raise ValueError("serial number must be positive")
if serial.bit_length() > MAX_SERIAL_BYTES * 8:
raise ValueError("serial number is too large")
not_before = normalize_datetime(not_before)
not_after = normalize_datetime(not_after)
if not_after <= not_before:
raise ValueError("notAfter must be later than notBefore")
issuer_name = issuer_cert.subject if issuer_cert is not None else subject
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer_name)
.public_key(public_key)
.serial_number(serial)
.not_valid_before(not_before)
.not_valid_after(not_after)
)
for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical)
algorithm = hash_algorithm(signature_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
def parse_certificate(data: bytes) -> x509.Certificate:
return x509.load_der_x509_certificate(data)
def certificate_der(cert: x509.Certificate) -> bytes:
return cert.public_bytes(serialization.Encoding.DER)
def certificate_pem(cert: x509.Certificate) -> str:
return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
def certificate_info(cert: x509.Certificate) -> CertificateInfo:
try:
bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value
is_ca = bool(bc.ca)
path_length = bc.path_length
except x509.ExtensionNotFound:
is_ca = False
path_length = None
return CertificateInfo(
subject=cert.subject,
issuer=cert.issuer,
serial_hex=serial_to_hex(cert.serial_number),
not_before=normalize_datetime(cert.not_valid_before_utc),
not_after=normalize_datetime(cert.not_valid_after_utc),
is_ca=is_ca,
path_length=path_length,
signature_algorithm=signature_algorithm_name(cert),
public_key_der=cert.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
),
fingerprint_md5=fingerprint(cert, "md5"),
fingerprint_sha1=fingerprint(cert, "sha1"),
fingerprint_sha256=fingerprint(cert, "sha256"),
subject_hash=name_hash(cert.subject),
issuer_hash=name_hash(cert.issuer),
)
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def signature_algorithm_name(cert: x509.Certificate) -> str:
algorithm = cert.signature_algorithm_oid
dotted = algorithm.dotted_string
if dotted in _SIGNATURE_OIDS:
return _SIGNATURE_OIDS[dotted]
name = getattr(algorithm, "name", None)
if name and name != "Undefined":
return name
return dotted
def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str:
data = certificate_der(cert)
try:
digest = hashlib.new(algorithm.lower())
except ValueError as exc:
raise ValueError(f"unsupported fingerprint algorithm {algorithm!r}") from exc
digest.update(data)
return digest.hexdigest()
def is_self_signed(cert: x509.Certificate) -> bool:
if cert.subject != cert.issuer:
return False
try:
cert.verify_directly_issued_by(cert)
return True
except Exception: # noqa: BLE001 - invalid signature
return False
def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool:
try:
cert.verify_directly_issued_by(issuer)
return True
except Exception: # noqa: BLE001
return False
def find_issuer(cert: x509.Certificate, candidates: Iterable[x509.Certificate]) -> Optional[x509.Certificate]:
"""Find the best issuer among ``candidates`` (latest expiry wins)."""
if is_self_signed(cert):
return cert
matches = [
candidate
for candidate in candidates
if candidate is not cert
and candidate.subject == cert.issuer
and verify_directly_issued_by(cert, candidate)
]
if not matches:
return None
return max(matches, key=lambda candidate: candidate.not_valid_after_utc)
def chain_for(
cert: x509.Certificate,
lookup: Dict[str, x509.Certificate],
max_depth: int = 20,
) -> List[x509.Certificate]:
"""Follow issuer links in ``lookup`` (issuer_hash -> cert) up to a root."""
chain = [cert]
current = cert
seen = {cert.fingerprint(hashes.SHA256())}
for _ in range(max_depth):
if is_self_signed(current):
break
issuer = lookup.get(name_hash(current.issuer))
if issuer is None or issuer.fingerprint(hashes.SHA256()) in seen:
break
if not verify_directly_issued_by(current, issuer):
break
chain.append(issuer)
seen.add(issuer.fingerprint(hashes.SHA256()))
current = issuer
return chain
def validation_errors(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> List[Dict[str, Any]]:
"""OpenSSL-backed chain validation, returning error code/message pairs.
XCA validates end-entity certificates with the X.509 strict and policy
flags and lists every error. pyOpenSSL surfaces one error per context, so
we verify the chain once and, for missing issuers, report that explicitly.
"""
errors: List[Dict[str, Any]] = []
if is_self_signed(cert):
return errors
if len(chain) < 2:
return [{"code": 20, "message": "unable to get local issuer certificate"}]
try:
from OpenSSL import crypto
store = crypto.X509Store()
# The root of the supplied chain is trusted; intermediates are
# untrusted inputs, exactly like XCA's trusted/untrusted stacks.
for item in chain[1:]:
converted = crypto.X509.from_cryptography(item)
if is_self_signed(item):
store.add_cert(converted)
else:
store.add_cert(converted)
context = crypto.X509StoreContext(
store,
crypto.X509.from_cryptography(cert),
[crypto.X509.from_cryptography(item) for item in chain[1:]],
)
context.verify_certificate()
except Exception as exc: # noqa: BLE001 - pyOpenSSL raises rich errors
code = getattr(exc, "args", [None])[-1] if getattr(exc, "args", None) else None
if not isinstance(code, int):
code = None
errors.append({"code": code, "message": str(exc)})
return errors
def purposes(cert: x509.Certificate) -> List[str]:
"""A practical purpose list derived from EKU/KU (XCA uses OpenSSL's checks)."""
result: List[str] = []
try:
eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
oids = {oid.dotted_string for oid in eku}
except x509.ExtensionNotFound:
oids = set()
labels = {
"1.3.6.1.5.5.7.3.1": "SSL server",
"1.3.6.1.5.5.7.3.2": "SSL client",
"1.3.6.1.5.5.7.3.3": "Code signing",
"1.3.6.1.5.5.7.3.4": "E-mail protection",
"1.3.6.1.5.5.7.3.8": "Time stamping",
"1.3.6.1.5.5.7.3.9": "OCSP helper",
}
for oid, label in labels.items():
if oid in oids:
result.append(label)
if not oids:
result.append("Any purpose")
return result
def certificate_spec(cert: x509.Certificate) -> Dict[str, Any]:
"""A certificate-creation spec for "similar certificate" / renewal."""
info = certificate_info(cert)
return {
"subject": [entry.as_dict() for entry in entries_from_name(cert.subject)],
"serial_hex": info.serial_hex,
"not_before": info.not_before.isoformat(),
"not_after": info.not_after.isoformat(),
"signature_hash": _signature_hash_name(cert),
"extensions": extension_specs(cert.extensions),
"public_key_der": info.public_key_der.hex(),
}
def signature_hash_name(cert: x509.Certificate) -> Optional[str]:
try:
algorithm = cert.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519
return None
if algorithm is None:
return None
return algorithm.name
# Backwards-compatible private alias used by the app layer.
_signature_hash_name = signature_hash_name
def index_entry(cert: x509.Certificate, *, revoked_at: Optional[datetime] = None) -> str:
"""One line in OpenSSL's ``index.txt`` format."""
now = datetime.now(timezone.utc)
if revoked_at is not None:
flag = "R"
elif cert.not_valid_before_utc <= now <= cert.not_valid_after_utc:
flag = "V"
else:
flag = "E"
expiry = cert.not_valid_after_utc.strftime("%Y%m%d%H%M%SZ")
revoked = revoked_at.astimezone(timezone.utc).strftime("%Y%m%d%H%M%SZ") if revoked_at else ""
subject = cert.subject.rfc4514_string()
return f"{flag}\t{expiry}\t{revoked}\t{serial_to_hex(cert.serial_number)}\tunknown\t{subject}\n"
def openssl_config(cert: x509.Certificate) -> str:
"""An OpenSSL configuration fragment reproducing the certificate."""
lines = [
"[req]",
"distinguished_name = req_distinguished_name",
"prompt = no",
"",
"[req_distinguished_name]",
]
for entry in entries_from_name(cert.subject):
lines.append(f"{entry.name} = {entry.value}")
lines.extend(["", "[v3_ext]"])
config = extension_config_lines(cert.extensions)
if config:
lines.append(config)
return "\n".join(lines) + "\n"
def ics_event(cert: x509.Certificate, name: str = "") -> str:
"""A vCalendar reminder for the certificate's expiry."""
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
start = cert.not_valid_after_utc.strftime("%Y%m%dT%H%M%SZ")
summary = f"Renew certificate: {name or cert.subject.rfc4514_string()}"
description = (
f"The certificate '{name or cert.subject.rfc4514_string()}' "
f"will expire on {cert.not_valid_after_utc.isoformat()}."
)
return "\r\n".join(
[
"BEGIN:VEVENT",
f"UID:{fingerprint(cert, 'sha256')}@kca",
f"DTSTAMP:{stamp}",
f"DTSTART:{start}",
f"SUMMARY:{_ics_escape(summary)}",
f"DESCRIPTION:{_ics_escape(description)}",
"END:VEVENT",
]
)
def _ics_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
def ics_calendar(events: Sequence[str]) -> str:
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//KCA//XCA Web//EN",
"CALSCALE:GREGORIAN",
]
for event in events:
lines.extend(event.split("\r\n"))
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
def certificate_public_key(cert: x509.Certificate) -> PublicKey:
return cert.public_key()
def subject_entries(cert: x509.Certificate) -> List[Dict[str, str]]:
return [entry.as_dict() for entry in entries_from_name(cert.subject)]
def issuer_entries(cert: x509.Certificate) -> List[Dict[str, str]]:
return [entry.as_dict() for entry in entries_from_name(cert.issuer)]
def certificate_extensions(cert: x509.Certificate) -> List[Dict[str, Any]]:
return extension_rows(cert.extensions)
def certificate_extension_specs(cert: x509.Certificate) -> Dict[str, Any]:
return extension_specs(cert.extensions)
def build_self_signed(
*,
subject: x509.Name,
private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
public_key = private_key.public_key()
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
issuer_cert=None,
)
return build_certificate(
subject=subject,
public_key=public_key,
issuer_cert=None,
issuer_private_key=private_key,
serial=serial,
not_before=not_before,
not_after=not_after,
extensions=extensions,
signature_hash=signature_hash,
)
def build_signed(
*,
subject: x509.Name,
public_key: PublicKey,
issuer_cert: x509.Certificate,
issuer_private_key: PrivateKey,
serial: int,
not_before: datetime,
not_after: datetime,
extension_spec: Optional[Dict[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
) -> x509.Certificate:
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
issuer_cert=issuer_cert,
)
return build_certificate(
subject=subject,
public_key=public_key,
issuer_cert=issuer_cert,
issuer_private_key=issuer_private_key,
serial=serial,
not_before=not_before,
not_after=not_after,
extensions=extensions,
signature_hash=signature_hash,
)
__all__ = [
"FAR_FUTURE",
"MAX_SERIAL_BYTES",
"CertificateInfo",
"build_certificate",
"build_self_signed",
"build_signed",
"certificate_der",
"certificate_extension_specs",
"certificate_extensions",
"certificate_info",
"certificate_pem",
"certificate_public_key",
"certificate_spec",
"chain_for",
"find_issuer",
"fingerprint",
"ics_calendar",
"ics_event",
"index_entry",
"is_self_signed",
"issuer_entries",
"normalize_datetime",
"openssl_config",
"parse_certificate",
"parse_serial_hex",
"purposes",
"serial_to_hex",
"signature_algorithm_name",
"signature_hash_name",
"subject_entries",
"validate_chain",
"validation_errors",
"verify_directly_issued_by",
]
@@ -0,0 +1,244 @@
"""Certificate revocation list construction and parsing."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ExtensionOID
from .certificates import normalize_datetime, signature_algorithm_name
from .extensions import extension_rows, extension_specs
from .keys import PrivateKey, hash_algorithm
#: XCA's revocation reason names, in OpenSSL order.
CRL_REASONS: List[Dict[str, Any]] = [
{"name": "unspecified", "label": "Unspecified", "flag": x509.ReasonFlags.unspecified},
{"name": "keyCompromise", "label": "Key Compromise", "flag": x509.ReasonFlags.key_compromise},
{"name": "CACompromise", "label": "CA Compromise", "flag": x509.ReasonFlags.ca_compromise},
{"name": "affiliationChanged", "label": "Affiliation Changed", "flag": x509.ReasonFlags.affiliation_changed},
{"name": "superseded", "label": "Superseded", "flag": x509.ReasonFlags.superseded},
{"name": "cessationOfOperation", "label": "Cessation Of Operation", "flag": x509.ReasonFlags.cessation_of_operation},
{"name": "certificateHold", "label": "Certificate Hold", "flag": x509.ReasonFlags.certificate_hold},
{"name": "removeFromCRL", "label": "Remove From CRL", "flag": x509.ReasonFlags.remove_from_crl},
{"name": "privilegeWithdrawn", "label": "Privilege Withdrawn", "flag": x509.ReasonFlags.privilege_withdrawn},
{"name": "AACompromise", "label": "AA Compromise", "flag": x509.ReasonFlags.aa_compromise},
]
_REASON_BY_NAME = {item["name"]: item for item in CRL_REASONS}
_REASON_BY_FLAG = {item["flag"]: item for item in CRL_REASONS}
@dataclass(frozen=True)
class CrlInfo:
issuer: x509.Name
last_update: datetime
next_update: Optional[datetime]
crl_number: Optional[int]
revoked_count: int
signature_algorithm: str
fingerprint_sha256: str
def reason_flag(name: str) -> x509.ReasonFlags:
return _REASON_BY_NAME.get(name, _REASON_BY_NAME["unspecified"])["flag"]
def reason_name(flag: x509.ReasonFlags) -> str:
return _REASON_BY_FLAG.get(flag, _REASON_BY_NAME["unspecified"])["name"]
def build_crl(
*,
issuer_cert: x509.Certificate,
issuer_private_key: PrivateKey,
revocations: Iterable[Mapping[str, Any]],
last_update: datetime,
next_update: datetime,
crl_number: Optional[int] = None,
signature_hash: Optional[str] = "sha256",
with_reason: bool = True,
include_aki: bool = True,
include_issuer_alt_name: bool = True,
) -> x509.CertificateRevocationList:
last_update = normalize_datetime(last_update)
next_update = normalize_datetime(next_update)
if next_update <= last_update:
raise ValueError("nextUpdate must be later than lastUpdate")
builder = (
x509.CertificateRevocationListBuilder()
.issuer_name(issuer_cert.subject)
.last_update(last_update)
.next_update(next_update)
)
for item in revocations:
serial = int(str(item.get("serial_hex", "0")).replace(":", ""), 16)
revoked_at = normalize_datetime(item.get("revoked_at") or last_update)
revoked = (
x509.RevokedCertificateBuilder()
.serial_number(serial)
.revocation_date(revoked_at)
)
if with_reason:
revoked = revoked.add_extension(
x509.CRLReason(reason_flag(str(item.get("reason", "unspecified")))),
critical=False,
)
invalidity = item.get("invalidity_date")
if invalidity:
revoked = revoked.add_extension(
x509.InvalidityDate(normalize_datetime(invalidity)), critical=False
)
builder = builder.add_revoked_certificate(revoked.build())
if crl_number is not None:
builder = builder.add_extension(x509.CRLNumber(int(crl_number)), critical=False)
if include_aki:
try:
ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski)
except x509.ExtensionNotFound:
aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key())
builder = builder.add_extension(aki, critical=False)
if include_issuer_alt_name:
try:
san = issuer_cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
builder = builder.add_extension(x509.IssuerAlternativeName(list(san)), critical=False)
except x509.ExtensionNotFound:
pass
algorithm = hash_algorithm(signature_hash, issuer_private_key)
return builder.sign(private_key=issuer_private_key, algorithm=algorithm)
def parse_crl(data: bytes) -> x509.CertificateRevocationList:
return x509.load_der_x509_crl(data)
def crl_der(crl: x509.CertificateRevocationList) -> bytes:
return crl.public_bytes(serialization.Encoding.DER)
def crl_pem(crl: x509.CertificateRevocationList) -> str:
return crl.public_bytes(serialization.Encoding.PEM).decode("ascii")
def crl_number(crl: x509.CertificateRevocationList) -> Optional[int]:
try:
return crl.extensions.get_extension_for_class(x509.CRLNumber).value.crl_number
except x509.ExtensionNotFound:
return None
def crl_info(crl: x509.CertificateRevocationList) -> CrlInfo:
return CrlInfo(
issuer=crl.issuer,
last_update=normalize_datetime(crl.last_update_utc),
next_update=normalize_datetime(crl.next_update_utc) if crl.next_update_utc else None,
crl_number=crl_number(crl),
revoked_count=len(list(crl)),
signature_algorithm=signature_algorithm_name(crl),
fingerprint_sha256=hashlib.sha256(crl_der(crl)).hexdigest(),
)
def crl_revocations(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]:
rows = []
for revoked in crl:
reason = None
invalidity = None
try:
reason = reason_name(
revoked.extensions.get_extension_for_class(x509.CRLReason).value.reason
)
except x509.ExtensionNotFound:
pass
try:
invalidity = normalize_datetime(
revoked.extensions.get_extension_for_class(x509.InvalidityDate).value.invalidity_date
).isoformat()
except x509.ExtensionNotFound:
pass
rows.append(
{
"serial_hex": format(revoked.serial_number, "x"),
"revoked_at": normalize_datetime(revoked.revocation_date_utc).isoformat(),
"invalidity_date": invalidity,
"reason": reason,
}
)
return rows
def verify_crl(crl: x509.CertificateRevocationList, issuer_cert: x509.Certificate) -> bool:
if crl.issuer != issuer_cert.subject:
return False
try:
return bool(crl.is_signature_valid(issuer_cert.public_key()))
except Exception: # noqa: BLE001
return False
def crl_extensions(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]:
return extension_rows(crl.extensions)
def crl_spec(crl: x509.CertificateRevocationList) -> Dict[str, Any]:
return {
"issuer": crl.issuer.rfc4514_string(),
"last_update": normalize_datetime(crl.last_update_utc).isoformat(),
"next_update": normalize_datetime(crl.next_update_utc).isoformat() if crl.next_update_utc else None,
"crl_number": crl_number(crl),
"revocations": crl_revocations(crl),
"extensions": extension_specs(crl.extensions),
}
def ics_event(crl: x509.CertificateRevocationList, name: str = "") -> str:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
next_update = crl.next_update_utc
if next_update is None:
return ""
start = next_update.strftime("%Y%m%dT%H%M%SZ")
summary = f"Renew CRL: {name or crl.issuer.rfc4514_string()}"
description = f"The CRL '{name or crl.issuer.rfc4514_string()}' expires on {next_update.isoformat()}."
return "\r\n".join(
[
"BEGIN:VEVENT",
f"UID:{hashlib.sha256(crl_der(crl)).hexdigest()}@kca",
f"DTSTAMP:{stamp}",
f"DTSTART:{start}",
f"SUMMARY:{summary.replace(',', '\\,')}",
f"DESCRIPTION:{description.replace(',', '\\,')}",
"END:VEVENT",
]
)
def reason_options() -> List[Dict[str, str]]:
return [{"name": item["name"], "label": item["label"]} for item in CRL_REASONS]
__all__ = [
"CRL_REASONS",
"CrlInfo",
"build_crl",
"crl_der",
"crl_extensions",
"crl_info",
"crl_number",
"crl_pem",
"crl_revocations",
"crl_spec",
"ics_event",
"parse_crl",
"reason_flag",
"reason_name",
"reason_options",
"verify_crl",
]
@@ -0,0 +1,83 @@
"""Small AES-256-GCM envelope used for private keys at rest."""
from __future__ import annotations
import base64
import os
from dataclasses import dataclass
from typing import Optional
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
NONCE_BYTES = 12
KEY_BYTES = 32
class DecryptionError(ValueError):
"""The ciphertext could not be authenticated with the configured key."""
@dataclass(frozen=True)
class EncryptedBlob:
ciphertext: bytes
nonce: bytes
def decode_master_key(value: str) -> bytes:
"""Decode a base64 (or hex) 32-byte master key from the environment."""
text = (value or "").strip()
if not text:
raise ValueError("KCA_MASTER_KEY is empty")
try:
raw = base64.b64decode(text, validate=True)
except Exception: # noqa: BLE001
try:
raw = bytes.fromhex(text)
except ValueError as exc:
raise ValueError("KCA_MASTER_KEY must be base64 or hex") from exc
if len(raw) != KEY_BYTES:
raise ValueError(f"KCA_MASTER_KEY must be {KEY_BYTES} bytes")
return raw
def generate_master_key() -> str:
return base64.b64encode(os.urandom(KEY_BYTES)).decode("ascii")
def encrypt_blob(plaintext: bytes, *, master_key: bytes, aad: bytes = b"") -> EncryptedBlob:
if len(master_key) != KEY_BYTES:
raise ValueError(f"master key must be {KEY_BYTES} bytes")
nonce = os.urandom(NONCE_BYTES)
ciphertext = AESGCM(master_key).encrypt(nonce, plaintext, aad)
return EncryptedBlob(ciphertext=ciphertext, nonce=nonce)
def decrypt_blob(blob: EncryptedBlob, *, master_key: bytes, aad: bytes = b"") -> bytes:
if len(master_key) != KEY_BYTES:
raise ValueError(f"master key must be {KEY_BYTES} bytes")
try:
return AESGCM(master_key).decrypt(blob.nonce, blob.ciphertext, aad)
except InvalidTag as exc:
raise DecryptionError("private key authentication failed") from exc
def encrypt_private_key(der: bytes, key_id: str, master_key: bytes) -> EncryptedBlob:
return encrypt_blob(der, master_key=master_key, aad=key_id.encode("utf-8"))
def decrypt_private_key(blob: EncryptedBlob, key_id: str, master_key: bytes) -> bytes:
return decrypt_blob(blob, master_key=master_key, aad=key_id.encode("utf-8"))
__all__ = [
"DecryptionError",
"EncryptedBlob",
"KEY_BYTES",
"NONCE_BYTES",
"decode_master_key",
"decrypt_blob",
"decrypt_private_key",
"encrypt_blob",
"encrypt_private_key",
"generate_master_key",
]
@@ -0,0 +1,324 @@
"""Export formats for certificates, keys, requests, CRLs and templates."""
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding,
NoEncryption,
pkcs7,
pkcs12,
)
from .certificates import certificate_der, certificate_pem, index_entry, ics_calendar, ics_event, openssl_config
from .crls import crl_der, crl_pem, ics_event as crl_ics_event
from .keys import (
PrivateKey,
jwk_private,
jwk_public,
private_der_bytes,
private_pem,
public_pem,
pvk_encode_private,
ssh_private,
ssh_public,
)
from .requests import csr_der, csr_pem, openssl_config as csr_openssl_config
from .templates import template_to_xca_data
OPENVPN_TA_BEGIN = "-----BEGIN OpenVPN Static key V1-----"
OPENVPN_TA_END = "-----END OpenVPN Static key V1-----"
@dataclass(frozen=True)
class ExportResult:
filename: str
content_type: str
data: bytes
def _safe_name(name: str, fallback: str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in (name or "")).strip("._")
return cleaned or fallback
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _password_encryption(password: Optional[str]) -> serialization.KeySerializationEncryption:
if password:
return BestAvailableEncryption(password.encode())
return NoEncryption()
def _pem_chain(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> bytes:
parts = [certificate_pem(cert)]
for item in chain:
if item is cert:
continue
parts.append(certificate_pem(item))
return "".join(parts).encode("ascii")
def export_certificate(
cert: x509.Certificate,
export_format: str,
*,
name: str = "",
chain: Sequence[x509.Certificate] = (),
private_key: Optional[PrivateKey] = None,
password: Optional[str] = None,
tls_auth_key: Optional[str] = None,
comment: str = "",
) -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "certificate")
if fmt == "pem":
return ExportResult(f"{stem}.crt", "application/x-pem-file", certificate_pem(cert).encode("ascii"))
if fmt in ("pem-chain", "chain"):
return ExportResult(f"{stem}.pem", "application/x-pem-file", _pem_chain(cert, chain))
if fmt == "pem-key":
if private_key is None:
raise ValueError("this certificate has no private key")
data = certificate_pem(cert).encode("ascii") + private_pem(private_key)
return ExportResult(f"{stem}.pem", "application/x-pem-file", data)
if fmt == "pem-pkcs8":
if private_key is None:
raise ValueError("this certificate has no private key")
if not password:
raise ValueError("a password is required for encrypted PKCS#8 export")
data = certificate_pem(cert).encode("ascii") + private_pem(private_key, password=password)
return ExportResult(f"{stem}.pem", "application/x-pem-file", data)
if fmt in ("pkcs7", "pkcs7-chain", "pkcs7-multi"):
certs = [cert]
if fmt != "pkcs7":
certs.extend(item for item in chain if item is not cert)
data = pkcs7.serialize_certificates(certs, Encoding.DER)
return ExportResult(f"{stem}.p7b", "application/pkcs7-mime", data)
if fmt == "der":
return ExportResult(f"{stem}.cer", "application/pkix-cert", certificate_der(cert))
if fmt in ("pkcs12", "pfx", "pkcs12-chain"):
if private_key is None:
raise ValueError("PKCS#12 export requires the private key")
cas = [item for item in chain if item is not cert] if fmt == "pkcs12-chain" else []
data = pkcs12.serialize_key_and_certificates(
name=stem.encode(),
key=private_key,
cert=cert,
cas=cas or None,
encryption_algorithm=_password_encryption(password),
)
return ExportResult(f"{stem}.pfx", "application/x-pkcs12", data)
if fmt == "openvpn":
if private_key is None:
raise ValueError("OpenVPN export requires the private key")
ca_cert = next((item for item in reversed(chain) if item is not cert), None)
ca_pem = certificate_pem(ca_cert) if ca_cert is not None else certificate_pem(cert)
key_pem = private_pem(private_key).decode("ascii")
text = (
f"<ca>\n{ca_pem}</ca>\n"
f"<cert>\n{certificate_pem(cert)}</cert>\n"
f"<key>\n{key_pem}</key>\n"
)
return ExportResult(f"{stem}.ovpn", "text/plain", text.encode("utf-8"))
if fmt in ("openssl-config", "config"):
return ExportResult(f"{stem}.conf", "text/plain", openssl_config(cert).encode("utf-8"))
if fmt == "index":
return ExportResult(f"{stem}.txt", "text/plain", index_entry(cert).encode("utf-8"))
if fmt == "vcalendar":
return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8"))
if fmt in ("jwk", "jwk-public", "jwk-chain"):
jwk = jwk_public(cert.public_key(), kid=name or None)
der = certificate_der(cert)
jwk["x5t"] = _b64u(hashlib.sha1(der).digest())
jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest())
if fmt == "jwk-chain":
jwk["x5c"] = [base64.b64encode(certificate_der(item)).decode("ascii") for item in [cert, *chain]]
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk, indent=2).encode("utf-8"))
if fmt == "tls-auth":
if not tls_auth_key:
raise ValueError("no OpenVPN tls-auth key is stored for this CA")
return ExportResult(f"{stem}.key", "text/plain", tls_auth_key.encode("ascii"))
raise ValueError(f"unsupported certificate export format {export_format!r}")
def export_key(
key: PrivateKey | Any,
export_format: str,
*,
name: str = "",
password: Optional[str] = None,
is_private: bool = True,
) -> ExportResult:
fmt = (export_format or "pem-public").lower()
stem = _safe_name(name, "key")
if fmt == "pem-public":
return ExportResult(f"{stem}.pem", "application/x-pem-file", public_pem(key).encode("ascii"))
if fmt in ("pem-private", "pem"):
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key))
if fmt == "pem-encrypted":
if not is_private:
raise ValueError("this is a public key")
if not password:
raise ValueError("a password is required")
return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key, password=password, traditional=True))
if fmt == "der-public":
from .keys import public_der_bytes
return ExportResult(f"{stem}.der", "application/pkix-key", public_der_bytes(key))
if fmt == "der-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.der", "application/pkcs8", private_der_bytes(key))
if fmt in ("pkcs8", "pkcs8-private"):
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key))
if fmt == "pkcs8-encrypted":
if not is_private:
raise ValueError("this is a public key")
if not password:
raise ValueError("a password is required")
return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key, password=password))
if fmt == "ssh-public":
return ExportResult(f"{stem}.pub", "text/plain", (ssh_public(key, name) + "\n").encode("utf-8"))
if fmt == "ssh-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.priv", "text/plain", ssh_private(key, password=password))
if fmt in ("jwk", "jwk-public"):
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_public(key, kid=name or None), indent=2).encode("utf-8"))
if fmt == "jwk-private":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_private(key, kid=name or None), indent=2).encode("utf-8"))
if fmt == "pvk":
if not is_private:
raise ValueError("this is a public key")
return ExportResult(f"{stem}.pvk", "application/octet-stream", pvk_encode_private(key))
raise ValueError(f"unsupported key export format {export_format!r}")
def export_request(
csr: x509.CertificateSigningRequest,
export_format: str,
*,
name: str = "",
) -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "request")
if fmt == "pem":
return ExportResult(f"{stem}.csr", "application/x-pem-file", csr_pem(csr).encode("ascii"))
if fmt == "der":
return ExportResult(f"{stem}.der", "application/pkcs10", csr_der(csr))
if fmt in ("openssl-config", "config"):
return ExportResult(f"{stem}.conf", "text/plain", csr_openssl_config(csr).encode("utf-8"))
raise ValueError(f"unsupported request export format {export_format!r}")
def export_crl(crl: x509.CertificateRevocationList, export_format: str, *, name: str = "") -> ExportResult:
fmt = (export_format or "pem").lower()
stem = _safe_name(name, "crl")
if fmt == "pem":
return ExportResult(f"{stem}.crl", "application/x-pem-file", crl_pem(crl).encode("ascii"))
if fmt == "der":
return ExportResult(f"{stem}.crl", "application/pkix-crl", crl_der(crl))
if fmt == "vcalendar":
return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([crl_ics_event(crl, name)]).encode("utf-8"))
raise ValueError(f"unsupported CRL export format {export_format!r}")
def export_template(template: Dict[str, Any], export_format: str = "xca", *, name: str = "") -> ExportResult:
fmt = (export_format or "xca").lower()
stem = _safe_name(name or str(template.get("name", "")), "template")
if fmt in ("xca", "pem"):
return ExportResult(f"{stem}.xca", "application/x-pem-file", template_to_xca_data(template))
if fmt == "json":
return ExportResult(f"{stem}.json", "application/json", json.dumps(template, indent=2).encode("utf-8"))
raise ValueError(f"unsupported template export format {export_format!r}")
def certificate_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "crt"},
{"id": "pem-chain", "label": "PEM chain", "extension": "pem"},
{"id": "pem-key", "label": "PEM + key", "extension": "pem"},
{"id": "pem-pkcs8", "label": "PEM + encrypted PKCS#8", "extension": "pem"},
{"id": "pkcs7", "label": "PKCS#7", "extension": "p7b"},
{"id": "pkcs7-chain", "label": "PKCS#7 chain", "extension": "p7b"},
{"id": "der", "label": "DER", "extension": "cer"},
{"id": "pkcs12", "label": "PKCS#12", "extension": "pfx"},
{"id": "pkcs12-chain", "label": "PKCS#12 chain", "extension": "pfx"},
{"id": "openvpn", "label": "OpenVPN", "extension": "ovpn"},
{"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"},
{"id": "index", "label": "Certificate index file", "extension": "txt"},
{"id": "vcalendar", "label": "vCalendar", "extension": "ics"},
{"id": "jwk", "label": "JSON Web Key", "extension": "jwk"},
{"id": "jwk-chain", "label": "JSON Web Key chain", "extension": "jwk"},
{"id": "tls-auth", "label": "OpenVPN tls-auth key", "extension": "key"},
]
def key_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem-public", "label": "PEM public", "extension": "pem"},
{"id": "pem-private", "label": "PEM private", "extension": "pem"},
{"id": "pem-encrypted", "label": "PEM encrypted", "extension": "pem"},
{"id": "pkcs8", "label": "PKCS#8", "extension": "pk8"},
{"id": "pkcs8-encrypted", "label": "PKCS#8 encrypted", "extension": "pk8"},
{"id": "der-public", "label": "DER public", "extension": "der"},
{"id": "der-private", "label": "DER private", "extension": "der"},
{"id": "ssh-public", "label": "SSH2 public", "extension": "pub"},
{"id": "ssh-private", "label": "SSH2 private", "extension": "priv"},
{"id": "jwk-public", "label": "JSON Web Key public", "extension": "jwk"},
{"id": "jwk-private", "label": "JSON Web Key private", "extension": "jwk"},
{"id": "pvk", "label": "PVK private", "extension": "pvk"},
]
def request_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "csr"},
{"id": "der", "label": "DER", "extension": "der"},
{"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"},
]
def crl_format_options() -> List[Dict[str, str]]:
return [
{"id": "pem", "label": "PEM", "extension": "crl"},
{"id": "der", "label": "DER", "extension": "crl"},
{"id": "vcalendar", "label": "vCalendar", "extension": "ics"},
]
def template_format_options() -> List[Dict[str, str]]:
return [
{"id": "xca", "label": "XCA template", "extension": "xca"},
{"id": "json", "label": "JSON", "extension": "json"},
]
__all__ = [
"ExportResult",
"certificate_format_options",
"crl_format_options",
"export_certificate",
"export_crl",
"export_key",
"export_request",
"export_template",
"key_format_options",
"request_format_options",
"template_format_options",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
"""Import parsing for PEM bundles, DER items, PKCS#7, PKCS#12 and SSH keys."""
from __future__ import annotations
import base64
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Sequence
from cryptography import x509
from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12
from .certificates import certificate_info
from .crls import crl_info
from .keys import PrivateKey, PublicKey, is_private_key, key_info, load_key_bytes
from .names import entries_from_name
from .requests import request_info
from .templates import template_from_xca_data
_PEM_BLOCK = re.compile(
r"-----BEGIN ([^-]+)-----\s*(.*?)\s*-----END \1-----",
re.DOTALL,
)
@dataclass
class ParsedItem:
kind: str
name: str
summary: str
obj: Any
is_private: bool = False
warnings: List[str] = field(default_factory=list)
extra: Dict[str, Any] = field(default_factory=dict)
@property
def fingerprint(self) -> Optional[str]:
if self.kind == "certificate":
return certificate_info(self.obj).fingerprint_sha256
if self.kind == "key":
return key_info(self.obj).public_sha256
if self.kind == "request":
return request_info(self.obj).fingerprint_sha256
if self.kind == "crl":
return crl_info(self.obj).fingerprint_sha256
return None
def _guess_name(obj: Any, filename: str = "") -> str:
stem = filename.rsplit("/", 1)[-1]
if "." in stem:
stem = stem.rsplit(".", 1)[0]
if isinstance(obj, x509.Certificate):
names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)]
return names[0] if names else (stem or "certificate")
if isinstance(obj, x509.CertificateSigningRequest):
names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)]
return names[0] if names else (stem or "request")
if isinstance(obj, x509.CertificateRevocationList):
return stem or "crl"
if isinstance(obj, dict):
return str(obj.get("name") or stem or "template")
return stem or "key"
def _summary(obj: Any) -> str:
info: Any
if isinstance(obj, x509.Certificate):
info = certificate_info(obj)
return f"{info.subject.rfc4514_string()} · {info.serial_hex}"
if isinstance(obj, x509.CertificateSigningRequest):
info = request_info(obj)
return info.subject.rfc4514_string()
if isinstance(obj, x509.CertificateRevocationList):
info = crl_info(obj)
return f"{info.issuer.rfc4514_string()} · {info.revoked_count} revoked"
if isinstance(obj, dict):
return str(obj.get("comment") or "XCA template")
try:
info = key_info(obj)
return f"{info.key_type} {info.key_size}" + (f" {info.curve}" if info.curve else "")
except Exception: # noqa: BLE001
return ""
def _key_item(key: Any, filename: str, name: Optional[str] = None) -> ParsedItem:
private = is_private_key(key)
return ParsedItem(
kind="key",
name=name or _guess_name(key, filename),
summary=_summary(key),
obj=key,
is_private=private,
)
def _cert_item(cert: x509.Certificate, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="certificate",
name=name or _guess_name(cert, filename),
summary=_summary(cert),
obj=cert,
)
def _csr_item(csr: x509.CertificateSigningRequest, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="request",
name=name or _guess_name(csr, filename),
summary=_summary(csr),
obj=csr,
)
def _crl_item(crl: x509.CertificateRevocationList, filename: str, name: Optional[str] = None) -> ParsedItem:
return ParsedItem(
kind="crl",
name=name or _guess_name(crl, filename),
summary=_summary(crl),
obj=crl,
)
def _template_item(template: Dict[str, Any], filename: str) -> ParsedItem:
return ParsedItem(
kind="template",
name=str(template.get("name") or _guess_name(template, filename)),
summary=_summary(template),
obj=template,
)
def parse_pkcs12(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
try:
key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None)
except Exception as exc: # noqa: BLE001
raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from exc
items: List[ParsedItem] = []
name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename)
if cert is not None:
items.append(_cert_item(cert, filename, name))
if key is not None:
items.append(_key_item(key, filename, f"{name}_key"))
for index, extra in enumerate(cas or []):
items.append(_cert_item(extra, filename, f"{name}_ca_{index}"))
if not items:
raise ValueError("the PKCS#12 file contains no keys or certificates")
return items
def parse_pkcs7(data: bytes, filename: str = "") -> List[ParsedItem]:
certs: Sequence[x509.Certificate] = []
try:
certs = pkcs7.load_der_pkcs7_certificates(data)
except Exception: # noqa: BLE001
try:
certs = pkcs7.load_pem_pkcs7_certificates(data)
except Exception as exc: # noqa: BLE001
raise ValueError("unable to read the PKCS#7 file") from exc
if not certs:
raise ValueError("the PKCS#7 file contains no certificates")
return [_cert_item(cert, filename, f"{_guess_name(cert, filename)}_{index}" if len(certs) > 1 else None) for index, cert in enumerate(certs)]
def parse_pem_bundle(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
text = data.decode("utf-8", "replace")
blocks = list(_PEM_BLOCK.finditer(text))
if not blocks:
return []
items: List[ParsedItem] = []
for match in blocks:
label = match.group(1).strip().upper()
block = match.group(0).encode("utf-8")
try:
if label == "CERTIFICATE":
items.append(_cert_item(x509.load_pem_x509_certificate(block), filename))
elif label in ("X509 CRL", "CRL"):
items.append(_crl_item(x509.load_pem_x509_crl(block), filename))
elif label in ("CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST"):
items.append(_csr_item(x509.load_pem_x509_csr(block), filename))
elif label in ("PKCS7", "PKCS #7"):
items.extend(parse_pkcs7(block, filename))
elif label == "XCA TEMPLATE":
items.append(_template_item(template_from_xca_data(block), filename))
elif "PRIVATE KEY" in label or "PUBLIC KEY" in label or label in ("OPENSSH PRIVATE KEY", "SSH2 PUBLIC KEY"):
key, private = load_key_bytes(block, password)
items.append(_key_item(key, filename))
else:
# Unknown PEM label: try every parser before giving up.
items.extend(_try_all(block, filename, password))
except Exception as exc: # noqa: BLE001
raise ValueError(f"unable to import the {label} block: {exc}") from exc
return items
def _try_all(data: bytes, filename: str, password: Optional[str]) -> List[ParsedItem]:
for parser in (
lambda: [_cert_item(x509.load_der_x509_certificate(data), filename)],
lambda: [_csr_item(x509.load_der_x509_csr(data), filename)],
lambda: [_crl_item(x509.load_der_x509_crl(data), filename)],
lambda: parse_pkcs12(data, filename, password),
lambda: parse_pkcs7(data, filename),
lambda: [_key_item(load_key_bytes(data, password)[0], filename)],
):
try:
return parser()
except Exception: # noqa: BLE001
continue
raise ValueError("unrecognized data")
def parse_file(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
"""Parse any supported file, detecting PEM vs DER automatically."""
if not data:
raise ValueError("the file is empty")
pem_items = parse_pem_bundle(data, filename, password)
if pem_items:
return pem_items
return _try_all(data, filename, password)
def parse_text(text: str, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
return parse_file(text.encode("utf-8"), filename, password)
def parse_xca_database(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]:
from .xca_db import import_xca_database
return import_xca_database(data, filename, password)
__all__ = [
"ParsedItem",
"parse_file",
"parse_pem_bundle",
"parse_pkcs7",
"parse_pkcs12",
"parse_text",
"parse_xca_database",
]
@@ -0,0 +1,543 @@
"""Key generation, parsing and serialization.
All key material handled here is in memory. The application layer is
responsible for encrypting private keys before they touch the database.
"""
from __future__ import annotations
import base64
import hashlib
import io
import struct
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
)
PrivateKey = (
rsa.RSAPrivateKey
| dsa.DSAPrivateKey
| ec.EllipticCurvePrivateKey
| ed25519.Ed25519PrivateKey
)
PublicKey = (
rsa.RSAPublicKey
| dsa.DSAPublicKey
| ec.EllipticCurvePublicKey
| ed25519.Ed25519PublicKey
)
#: Curves offered by the UI. ``cryptography`` supports all of them on every
#: platform we target.
EC_CURVES: Dict[str, ec.EllipticCurve] = {
"secp192r1": ec.SECP192R1(),
"secp224r1": ec.SECP224R1(),
"secp256r1": ec.SECP256R1(),
"secp384r1": ec.SECP384R1(),
"secp521r1": ec.SECP521R1(),
"secp256k1": ec.SECP256K1(),
"brainpoolP256r1": ec.BrainpoolP256R1(),
"brainpoolP384r1": ec.BrainpoolP384R1(),
"brainpoolP512r1": ec.BrainpoolP512R1(),
}
_CURVE_NAMES = {curve.name: name for name, curve in EC_CURVES.items()}
KEY_TYPES = ("rsa", "dsa", "ec", "ed25519")
DEFAULT_KEY_SIZE = 2048
MAX_KEY_SIZE = 8192
MIN_KEY_SIZE = 512
#: Hash algorithms selectable for signatures. Ed25519 has no separate hash.
HASH_ALGORITHMS: Dict[str, Optional[hashes.HashAlgorithm]] = {
"sha1": hashes.SHA1(),
"sha224": hashes.SHA224(),
"sha256": hashes.SHA256(),
"sha384": hashes.SHA384(),
"sha512": hashes.SHA512(),
}
@dataclass(frozen=True)
class KeyInfo:
key_type: str
key_size: int
curve: Optional[str]
public_der: bytes
private_der: Optional[bytes]
is_private: bool
@property
def public_sha256(self) -> str:
return hashlib.sha256(self.public_der).hexdigest()
@property
def fingerprint_sha256(self) -> str:
return self.public_sha256
def _require_private(key: Any) -> PrivateKey:
if not isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
):
raise ValueError("a private key is required")
return key
def _public_key(key: PrivateKey | PublicKey) -> PublicKey:
if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)):
return key
return key.public_key()
def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[str] = None) -> PrivateKey:
"""Generate a private key of the requested type."""
key_type = (key_type or "").lower()
if key_type == "rsa":
if not (MIN_KEY_SIZE <= int(size) <= MAX_KEY_SIZE):
raise ValueError(f"RSA key size must be between {MIN_KEY_SIZE} and {MAX_KEY_SIZE} bits")
return rsa.generate_private_key(public_exponent=65537, key_size=int(size))
if key_type == "dsa":
if not (MIN_KEY_SIZE <= int(size) <= 3072):
raise ValueError("DSA key size must be between 512 and 3072 bits")
return dsa.generate_private_key(key_size=int(size))
if key_type == "ec":
curve_name = curve or "secp256r1"
if curve_name not in EC_CURVES:
raise ValueError(f"unsupported EC curve {curve_name!r}")
return ec.generate_private_key(EC_CURVES[curve_name])
if key_type == "ed25519":
return ed25519.Ed25519PrivateKey.generate()
raise ValueError(f"unsupported key type {key_type!r}")
def key_type_name(key: PrivateKey | PublicKey) -> str:
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)):
return "RSA"
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return "DSA"
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return "EC"
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return "ED25519"
raise ValueError("unsupported key type")
def key_size(key: PrivateKey | PublicKey) -> int:
if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return key.key_size
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return key.curve.key_size
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return 256
raise ValueError("unsupported key type")
def curve_name(key: PrivateKey | PublicKey) -> Optional[str]:
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
return _CURVE_NAMES.get(key.curve.name, key.curve.name)
return None
def is_private_key(key: Any) -> bool:
return isinstance(
key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)
)
def public_der(key: PrivateKey | PublicKey) -> bytes:
return _public_key(key).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
def private_der(key: PrivateKey) -> bytes:
return _require_private(key).private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
def key_info(key: PrivateKey | PublicKey) -> KeyInfo:
private = is_private_key(key)
return KeyInfo(
key_type=key_type_name(key),
key_size=key_size(key),
curve=curve_name(key),
public_der=public_der(key),
private_der=private_der(key) if private else None,
is_private=private,
)
def possible_hashes(key: PrivateKey | PublicKey) -> List[str]:
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return []
if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)):
return ["sha1", "sha224", "sha256"]
if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)):
# Every supported curve is large enough for SHA-512 in practice.
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
return ["sha1", "sha224", "sha256", "sha384", "sha512"]
def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]:
"""The signature hash for ``key``; ``None`` means the key's built-in hash."""
if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)):
return None
name = (name or "sha256").lower()
if name not in HASH_ALGORITHMS:
raise ValueError(f"unsupported hash algorithm {name!r}")
return HASH_ALGORITHMS[name]
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
def public_pem(key: PrivateKey | PublicKey) -> str:
return _public_key(key).public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii")
def public_der_bytes(key: PrivateKey | PublicKey) -> bytes:
return public_der(key)
def private_pem(key: PrivateKey, password: Optional[str] = None, traditional: bool = False) -> bytes:
key = _require_private(key)
encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8
if traditional and isinstance(key, ed25519.Ed25519PrivateKey):
raise ValueError("Ed25519 keys cannot be exported in traditional PEM format")
return key.private_bytes(Encoding.PEM, fmt, encryption)
def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes:
key = _require_private(key)
encryption: serialization.KeySerializationEncryption
encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode())
return key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, encryption)
def ssh_public(key: PrivateKey | PublicKey, comment: str = "") -> str:
encoded = _public_key(key).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("ascii")
return f"{encoded} {comment}".strip()
def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes:
"""OpenSSH private key format, via paramiko."""
import paramiko
key = _require_private(key)
if isinstance(key, rsa.RSAPrivateKey):
pkey = paramiko.RSAKey(key=key)
elif isinstance(key, dsa.DSAPrivateKey):
pkey = paramiko.DSSKey(key=key)
elif isinstance(key, ec.EllipticCurvePrivateKey):
pkey = paramiko.ECDSAKey(key=key)
elif isinstance(key, ed25519.Ed25519PrivateKey):
pkey = paramiko.Ed25519Key(key=key)
else: # pragma: no cover - defensive
raise ValueError("unsupported key type for SSH export")
buffer = io.StringIO()
pkey.write_private_key(buffer, password=password)
return buffer.getvalue().encode("utf-8")
# ---------------------------------------------------------------------------
# JSON Web Key (RFC 7517)
# ---------------------------------------------------------------------------
def _b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _int_bytes(value: int) -> bytes:
return value.to_bytes((value.bit_length() + 7) // 8 or 1, "big")
def jwk_public(key: PrivateKey | PublicKey, *, kid: Optional[str] = None) -> Dict[str, Any]:
pub = _public_key(key)
if isinstance(pub, rsa.RSAPublicKey):
numbers = pub.public_numbers()
result: Dict[str, Any] = {
"kty": "RSA",
"n": _b64u(_int_bytes(numbers.n)),
"e": _b64u(_int_bytes(numbers.e)),
}
elif isinstance(pub, ec.EllipticCurvePublicKey):
numbers = pub.public_numbers()
curve = {
"secp256r1": "P-256",
"secp384r1": "P-384",
"secp521r1": "P-521",
"secp256k1": "secp256k1",
}.get(pub.curve.name, pub.curve.name)
result = {
"kty": "EC",
"crv": curve,
"x": _b64u(numbers.x.to_bytes((numbers.x.bit_length() + 7) // 8 or 1, "big")),
"y": _b64u(numbers.y.to_bytes((numbers.y.bit_length() + 7) // 8 or 1, "big")),
}
elif isinstance(pub, ed25519.Ed25519PublicKey):
result = {
"kty": "OKP",
"crv": "Ed25519",
"x": _b64u(pub.public_bytes(Encoding.Raw, PublicFormat.Raw)),
}
else:
raise ValueError("JWK is not supported for this key type")
if kid:
result["kid"] = kid
return result
def jwk_private(key: PrivateKey, *, kid: Optional[str] = None) -> Dict[str, Any]:
key = _require_private(key)
result = jwk_public(key, kid=kid)
if isinstance(key, rsa.RSAPrivateKey):
numbers = key.private_numbers()
result.update(
{
"d": _b64u(_int_bytes(numbers.d)),
"p": _b64u(_int_bytes(numbers.p)),
"q": _b64u(_int_bytes(numbers.q)),
"dp": _b64u(_int_bytes(numbers.dmp1)),
"dq": _b64u(_int_bytes(numbers.dmq1)),
"qi": _b64u(_int_bytes(numbers.iqmp)),
}
)
elif isinstance(key, ec.EllipticCurvePrivateKey):
result["d"] = _b64u(_int_bytes(key.private_numbers().private_value))
elif isinstance(key, ed25519.Ed25519PrivateKey):
result["d"] = _b64u(key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()))
else:
raise ValueError("JWK private export is not supported for this key type")
return result
# ---------------------------------------------------------------------------
# Microsoft PVK (RSA)
# ---------------------------------------------------------------------------
_PVK_MAGIC = 0xB0B5F11E
_PVK_KEYTYPE = 2
_CALG_RSA_KEYX = 0x00002400
_RSA2 = 0x32415352
def pvk_encode_private(key: PrivateKey) -> bytes:
if not isinstance(key, rsa.RSAPrivateKey):
raise ValueError("PVK export supports RSA keys only")
numbers = key.private_numbers()
pub = numbers.public_numbers
size = (pub.n.bit_length() + 7) // 8
blob = struct.pack("<BBBBL", 0x07, 0x02, 0, 0, _CALG_RSA_KEYX)
blob += struct.pack("<LLL", _RSA2, size * 8, pub.e)
for value in (
pub.n,
numbers.p,
numbers.q,
numbers.dmp1,
numbers.dmq1,
numbers.iqmp,
numbers.d,
):
blob += value.to_bytes(size, "little")
header = struct.pack("<LLLLLL", _PVK_MAGIC, 0, _PVK_KEYTYPE, 0, 0, len(blob))
return header + blob
def pvk_decode_private(data: bytes) -> rsa.RSAPrivateKey:
if len(data) < 24:
raise ValueError("not a PVK file")
magic, _reserved, key_type, encrypted, encrypted_len, blob_len = struct.unpack("<LLLLLL", data[:24])
if magic != _PVK_MAGIC or key_type != _PVK_KEYTYPE or encrypted:
raise ValueError("unsupported PVK file")
blob = data[24 : 24 + blob_len]
if len(blob) < 20:
raise ValueError("truncated PVK file")
_btype, _bversion, _r1, _r2, _alg = struct.unpack("<BBBBL", blob[:8])
magic2, bits, pubexp = struct.unpack("<LLL", blob[8:20])
if magic2 != _RSA2:
raise ValueError("unsupported PVK key blob")
size = (bits + 7) // 8
pos = 20
values = []
for _ in range(7):
chunk = blob[pos : pos + size]
if len(chunk) != size:
raise ValueError("truncated PVK key")
values.append(int.from_bytes(chunk, "little"))
pos += size
n, p, q, dmp1, dmq1, iqmp, d = values
return rsa.RSAPrivateNumbers(
p=p,
q=q,
d=d,
dmp1=dmp1,
dmq1=dmq1,
iqmp=iqmp,
public_numbers=rsa.RSAPublicNumbers(e=pubexp, n=n),
).private_key()
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def load_key_bytes(data: bytes, password: Optional[str] = None) -> Tuple[PrivateKey | PublicKey, bool]:
"""Load a key from PEM/DER/OpenSSH/PVK bytes.
Returns ``(key, is_private)``. Raises :class:`ValueError` when nothing
matches.
"""
password_bytes = password.encode() if password else None
errors: List[str] = []
# PEM private / public
try:
key = serialization.load_pem_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001 - probing
errors.append(str(exc))
try:
key = serialization.load_pem_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# DER private / public
try:
key = serialization.load_der_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
try:
key = serialization.load_der_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# OpenSSH private / public
try:
key = serialization.load_ssh_private_key(data, password=password_bytes)
return key, True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
try:
key = serialization.load_ssh_public_key(data)
return key, False
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# Microsoft PVK
try:
return pvk_decode_private(data), True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
# Encrypted OpenSSH keys are not supported by cryptography; paramiko can
# read them. Convert the common key types back to cryptography objects.
try:
return _load_ssh_private_with_paramiko(data, password), True
except Exception as exc: # noqa: BLE001
errors.append(str(exc))
raise ValueError("unable to parse key: " + (errors[-1] if errors else "unknown format"))
def _load_ssh_private_with_paramiko(data: bytes, password: Optional[str]) -> PrivateKey:
import paramiko
text = data.decode("utf-8", "replace")
last: Optional[Exception] = None
for cls in (paramiko.RSAKey, paramiko.ECDSAKey, paramiko.Ed25519Key, paramiko.DSSKey):
try:
pkey = cls.from_private_key(io.StringIO(text), password=password)
break
except Exception as exc: # noqa: BLE001
last = exc
else: # pragma: no cover - defensive
raise ValueError(f"unable to parse OpenSSH private key: {last}")
# Paramiko keeps the cryptography signing key on its instances. This is
# stable across 3.x and avoids re-implementing key conversion.
signing_key = getattr(pkey, "_signing_key", None)
if signing_key is not None and is_private_key(signing_key):
return signing_key
if isinstance(pkey, paramiko.RSAKey):
pub = rsa.RSAPublicNumbers(e=pkey.e, n=pkey.n)
return rsa.RSAPrivateNumbers(
p=pkey.p, q=pkey.q, d=pkey.d, dmp1=pkey.dmp1, dmq1=pkey.dmq1, iqmp=pkey.iqmp,
public_numbers=pub,
).private_key()
if isinstance(pkey, paramiko.DSSKey):
pub = dsa.DSAPublicNumbers(parameter_numbers=dsa.DSAParameterNumbers(pkey.p, pkey.q, pkey.g), y=pkey.y)
return dsa.DSAPrivateNumbers(x=pkey.x, public_numbers=pub).private_key()
raise ValueError("unable to convert the SSH private key")
def public_key_from_der(data: bytes) -> PublicKey:
return serialization.load_der_public_key(data)
def private_key_from_der(data: bytes, password: Optional[str] = None) -> PrivateKey:
return serialization.load_der_private_key(data, password=password.encode() if password else None)
def public_key_sha256(key: PrivateKey | PublicKey) -> str:
return hashlib.sha256(public_der(key)).hexdigest()
def key_type_options() -> List[Dict[str, Any]]:
return [
{"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 3072, 4096, 8192]},
{"type": "dsa", "label": "DSA", "sizes": [1024, 2048, 3072]},
{"type": "ec", "label": "EC", "curves": list(EC_CURVES)},
{"type": "ed25519", "label": "ED25519", "sizes": []},
]
__all__ = [
"DEFAULT_KEY_SIZE",
"EC_CURVES",
"HASH_ALGORITHMS",
"KEY_TYPES",
"KeyInfo",
"MAX_KEY_SIZE",
"MIN_KEY_SIZE",
"PrivateKey",
"PublicKey",
"curve_name",
"generate_key",
"hash_algorithm",
"is_private_key",
"jwk_private",
"jwk_public",
"key_info",
"key_size",
"key_type_name",
"key_type_options",
"load_key_bytes",
"possible_hashes",
"private_der",
"private_der_bytes",
"private_key_from_der",
"private_pem",
"public_der",
"public_der_bytes",
"public_key_from_der",
"public_key_sha256",
"public_pem",
"pvk_decode_private",
"pvk_encode_private",
"ssh_private",
"ssh_public",
]
@@ -0,0 +1,260 @@
"""Distinguished names and the small OID registry used by the UI.
XCA stores a subject as an ordered sequence of X.509 name entries and lets the
user add the same attribute more than once. ``cryptography`` models that as a
sequence of RDNs; we build one RDN per entry so order and duplicates survive a
round-trip.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from cryptography import x509
from cryptography.x509.oid import NameOID, ObjectIdentifier
#: Common distinguished-name attributes. The value is ``(short name, long name)``.
DN_ATTRIBUTES: Dict[str, Tuple[str, str]] = {
"2.5.4.3": ("CN", "commonName"),
"2.5.4.4": ("SN", "surname"),
"2.5.4.5": ("serialNumber", "serialNumber"),
"2.5.4.6": ("C", "countryName"),
"2.5.4.7": ("L", "localityName"),
"2.5.4.8": ("ST", "stateOrProvinceName"),
"2.5.4.9": ("street", "streetAddress"),
"2.5.4.10": ("O", "organizationName"),
"2.5.4.11": ("OU", "organizationalUnitName"),
"2.5.4.12": ("title", "title"),
"2.5.4.17": ("postalCode", "postalCode"),
"2.5.4.42": ("GN", "givenName"),
"2.5.4.43": ("initials", "initials"),
"2.5.4.44": ("generationQualifier", "generationQualifier"),
"2.5.4.46": ("dnQualifier", "dnQualifier"),
"2.5.4.65": ("pseudonym", "pseudonym"),
"2.5.4.97": ("organizationIdentifier", "organizationIdentifier"),
"1.2.840.113549.1.9.1": ("emailAddress", "emailAddress"),
"0.9.2342.19200300.100.1.1": ("UID", "userId"),
"0.9.2342.19200300.100.1.25": ("DC", "domainComponent"),
}
#: Aliases accepted from the API / template format.
DN_ALIASES: Dict[str, str] = {
"cn": "2.5.4.3",
"commonname": "2.5.4.3",
"surname": "2.5.4.4",
"sn": "2.5.4.4",
"serialnumber": "2.5.4.5",
"c": "2.5.4.6",
"countryname": "2.5.4.6",
"l": "2.5.4.7",
"localityname": "2.5.4.7",
"st": "2.5.4.8",
"stateorprovincename": "2.5.4.8",
"street": "2.5.4.9",
"streetaddress": "2.5.4.9",
"o": "2.5.4.10",
"organizationname": "2.5.4.10",
"ou": "2.5.4.11",
"organizationalunitname": "2.5.4.11",
"title": "2.5.4.12",
"postalcode": "2.5.4.17",
"gn": "2.5.4.42",
"givenname": "2.5.4.42",
"initials": "2.5.4.43",
"generationqualifier": "2.5.4.44",
"dnqualifier": "2.5.4.46",
"pseudonym": "2.5.4.65",
"organizationidentifier": "2.5.4.97",
"email": "1.2.840.113549.1.9.1",
"emailaddress": "1.2.840.113549.1.9.1",
"uid": "0.9.2342.19200300.100.1.1",
"userid": "0.9.2342.19200300.100.1.1",
"dc": "0.9.2342.19200300.100.1.25",
"domaincomponent": "0.9.2342.19200300.100.1.25",
}
#: Extended-key-usage names accepted in specs and templates.
EKU_OIDS: Dict[str, str] = {
"serverAuth": "1.3.6.1.5.5.7.3.1",
"clientAuth": "1.3.6.1.5.5.7.3.2",
"codeSigning": "1.3.6.1.5.5.7.3.3",
"emailProtection": "1.3.6.1.5.5.7.3.4",
"ipsecEndSystem": "1.3.6.1.5.5.7.3.5",
"ipsecTunnel": "1.3.6.1.5.5.7.3.6",
"ipsecUser": "1.3.6.1.5.5.7.3.7",
"timeStamping": "1.3.6.1.5.5.7.3.8",
"OCSPSigning": "1.3.6.1.5.5.7.3.9",
"id-kp-eapOverPPP": "1.3.6.1.5.5.7.3.13",
"id-kp-eapOverLAN": "1.3.6.1.5.5.7.3.14",
"secureShellClient": "1.3.6.1.5.5.7.3.21",
"secureShellServer": "1.3.6.1.5.5.7.3.22",
"msCodeInd": "1.3.6.1.4.1.311.2.1.21",
"msCodeCom": "1.3.6.1.4.1.311.2.1.22",
"msCTLSign": "1.3.6.1.4.1.311.10.3.1",
"msSGC": "1.3.6.1.4.1.311.10.3.3",
"msEFS": "1.3.6.1.4.1.311.10.3.4",
"nsSGC": "2.16.840.1.113730.4.1",
"pkInitKDC": "1.3.6.1.5.2.3.5",
"adobePDFSigning": "1.2.840.113583.1.1.5",
"msofficeSigning": "1.3.6.1.4.1.311.10.3.12",
}
_EKU_BY_OID = {oid: name for name, oid in EKU_OIDS.items()}
@dataclass(frozen=True)
class DnEntry:
oid: str
name: str
value: str
def as_dict(self) -> Dict[str, str]:
return {"oid": self.oid, "name": self.name, "value": self.value}
def resolve_oid(value: str) -> str:
"""Resolve a short name, long name or dotted OID to a dotted OID."""
value = (value or "").strip()
if not value:
raise ValueError("empty attribute name")
if value[0].isdigit() and value.count(".") >= 1:
# Validate through cryptography so malformed OIDs fail early.
ObjectIdentifier(value)
return value
key = value.lower().replace(" ", "").replace("-", "").replace("_", "")
if key in DN_ALIASES:
return DN_ALIASES[key]
if value in DN_ATTRIBUTES:
return value
raise ValueError(f"unknown distinguished-name attribute {value!r}")
def oid_name(oid: str) -> str:
"""The short display name for an attribute OID."""
return DN_ATTRIBUTES.get(oid, (oid, oid))[0]
def oid_long_name(oid: str) -> str:
return DN_ATTRIBUTES.get(oid, (oid, oid))[1]
def entry_from_dict(entry: Mapping[str, Any]) -> DnEntry:
oid = resolve_oid(str(entry.get("oid") or entry.get("name") or ""))
value = entry.get("value", "")
if not isinstance(value, str):
value = str(value)
return DnEntry(oid=oid, name=oid_name(oid), value=value.strip())
def entries_from_json(entries: Iterable[Mapping[str, Any]]) -> List[DnEntry]:
return [entry_from_dict(entry) for entry in entries]
def name_from_entries(entries: Sequence[DnEntry]) -> x509.Name:
"""Build a Name, one RDN per entry, preserving order and duplicates."""
rdns = []
for entry in entries:
if not entry.value:
continue
rdns.append(
x509.RelativeDistinguishedName(
[x509.NameAttribute(ObjectIdentifier(entry.oid), entry.value)]
)
)
return x509.Name(rdns)
def entries_from_name(name: x509.Name) -> List[DnEntry]:
"""Flatten a Name into ordered entries (one per attribute)."""
result: List[DnEntry] = []
for rdn in name.rdns:
for attribute in rdn:
oid = attribute.oid.dotted_string
result.append(DnEntry(oid=oid, name=oid_name(oid), value=str(attribute.value)))
return result
def name_from_der(data: bytes) -> x509.Name:
return x509.Name.from_bytes(data)
def name_to_der(name: x509.Name) -> bytes:
return name.public_bytes()
def rfc4514(name: x509.Name) -> str:
return name.rfc4514_string()
def one_line(name: x509.Name) -> str:
"""A stable, human-readable one-line rendering (RFC 4514 with commas)."""
return name.rfc4514_string()
def name_hash(name: x509.Name) -> str:
"""SHA-256 of the DER encoding, used for fast issuer lookup."""
import hashlib
return hashlib.sha256(name.public_bytes()).hexdigest()
def common_names(name: x509.Name) -> List[str]:
return [str(a.value) for a in name.get_attributes_for_oid(NameOID.COMMON_NAME)]
def eku_name(oid: str) -> str:
return _EKU_BY_OID.get(oid, oid)
def eku_oid(name: str) -> str:
if name in EKU_OIDS:
return EKU_OIDS[name]
return resolve_oid(name)
def name_attribute_options() -> List[Dict[str, str]]:
"""The DN attributes offered by the UI, in the usual XCA order."""
order = [
"2.5.4.6",
"2.5.4.8",
"2.5.4.7",
"2.5.4.10",
"2.5.4.11",
"2.5.4.3",
"1.2.840.113549.1.9.1",
]
seen = set(order)
ordered = order + [oid for oid in DN_ATTRIBUTES if oid not in seen]
return [
{"oid": oid, "name": DN_ATTRIBUTES[oid][0], "long_name": DN_ATTRIBUTES[oid][1]}
for oid in ordered
]
def eku_options() -> List[Dict[str, str]]:
return [{"name": name, "oid": oid} for name, oid in EKU_OIDS.items()]
__all__ = [
"DN_ALIASES",
"DN_ATTRIBUTES",
"EKU_OIDS",
"DnEntry",
"common_names",
"eku_name",
"eku_oid",
"eku_options",
"entries_from_json",
"entries_from_name",
"entry_from_dict",
"name_attribute_options",
"name_from_der",
"name_from_entries",
"name_hash",
"name_to_der",
"oid_long_name",
"oid_name",
"one_line",
"resolve_oid",
"rfc4514",
]
@@ -0,0 +1,224 @@
"""PKCS#10 certificate signing request helpers."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ObjectIdentifier
from .extensions import build_extensions, extension_config_lines, extension_rows, extension_specs
from .keys import PrivateKey, PublicKey, hash_algorithm, public_der
from .names import entries_from_name, name_from_entries
CHALLENGE_PASSWORD_OID = "1.2.840.113549.1.9.7"
UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2"
ATTRIBUTE_NAMES = {
CHALLENGE_PASSWORD_OID: "Challenge Password",
UNSTRUCTURED_NAME_OID: "Unstructured Name",
}
@dataclass(frozen=True)
class RequestInfo:
subject: x509.Name
signature_algorithm: str
public_key_der: bytes
fingerprint_sha256: str
attributes: List[Dict[str, Any]]
def build_csr(
*,
subject: x509.Name,
private_key: PrivateKey,
extension_spec: Optional[Mapping[str, Any]] = None,
signature_hash: Optional[str] = "sha256",
attributes: Optional[Mapping[str, str]] = None,
) -> x509.CertificateSigningRequest:
public_key = private_key.public_key()
extensions = build_extensions(
extension_spec,
public_key=public_key,
subject_name=subject,
include_aki=False,
)
# A CSR must not carry an Authority Key Identifier or an issuer alt name;
# XCA removes them as well.
extensions = [
ext
for ext in extensions
if ext.oid
not in (
x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER,
x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME,
)
]
builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
for extension in extensions:
builder = builder.add_extension(extension.value, extension.critical)
for oid, value in (attributes or {}).items():
if value:
builder = builder.add_attribute(ObjectIdentifier(oid), str(value).encode("utf-8"))
algorithm = hash_algorithm(signature_hash, private_key)
return builder.sign(private_key, algorithm)
def parse_csr(data: bytes) -> x509.CertificateSigningRequest:
return x509.load_der_x509_csr(data)
def csr_der(csr: x509.CertificateSigningRequest) -> bytes:
return csr.public_bytes(serialization.Encoding.DER)
def csr_pem(csr: x509.CertificateSigningRequest) -> str:
return csr.public_bytes(serialization.Encoding.PEM).decode("ascii")
def verify_csr(csr: x509.CertificateSigningRequest) -> bool:
return bool(csr.is_signature_valid)
def request_info(csr: x509.CertificateSigningRequest) -> RequestInfo:
public_key = csr.public_key()
return RequestInfo(
subject=csr.subject,
signature_algorithm=_signature_name(csr),
public_key_der=public_der(public_key),
fingerprint_sha256=hashlib.sha256(csr_der(csr)).hexdigest(),
attributes=attribute_rows(csr),
)
_SIGNATURE_OIDS = {
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.1.14": "sha224WithRSAEncryption",
"1.2.840.10045.4.1": "ecdsa-with-SHA1",
"1.2.840.10045.4.3.2": "ecdsa-with-SHA256",
"1.2.840.10045.4.3.3": "ecdsa-with-SHA384",
"1.2.840.10045.4.3.4": "ecdsa-with-SHA512",
"1.3.101.112": "Ed25519",
}
def _signature_name(csr: x509.CertificateSigningRequest) -> str:
oid = csr.signature_algorithm_oid
return _SIGNATURE_OIDS.get(oid.dotted_string, getattr(oid, "name", None) or oid.dotted_string)
def _decode_attribute_value(value: bytes) -> str:
# cryptography returns the DER-encoded value bytes. Try UTF-8 first,
# then strip a simple ASN.1 string tag/length header.
try:
return value.decode("utf-8")
except UnicodeDecodeError:
pass
if len(value) >= 2 and value[0] in (0x0C, 0x13, 0x16, 0x1E):
length = value[1]
offset = 2
if length & 0x80:
count = length & 0x7F
length = int.from_bytes(value[2 : 2 + count], "big")
offset = 2 + count
raw = value[offset : offset + length]
if value[0] == 0x1E:
return raw.decode("utf-16-be", "replace")
return raw.decode("utf-8", "replace")
return value.hex()
def attribute_rows(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]:
rows = []
for attribute in csr.attributes:
oid = attribute.oid.dotted_string
rows.append(
{
"oid": oid,
"name": ATTRIBUTE_NAMES.get(oid, oid),
"values": [_decode_attribute_value(attribute.value)],
}
)
return rows
def attribute_values(csr: x509.CertificateSigningRequest) -> Dict[str, str]:
result = {}
for row in attribute_rows(csr):
if row["values"]:
result[row["oid"]] = row["values"][0]
return result
def csr_spec(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
return {
"subject": [entry.as_dict() for entry in entries_from_name(csr.subject)],
"signature_hash": _hash_name(csr),
"extensions": extension_specs(csr.extensions),
"attributes": attribute_values(csr),
"public_key_der": public_der(csr.public_key()).hex(),
}
def _hash_name(csr: x509.CertificateSigningRequest) -> Optional[str]:
try:
algorithm = csr.signature_hash_algorithm
except Exception: # noqa: BLE001 - Ed25519
return None
return algorithm.name if algorithm is not None else None
def csr_extensions(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]:
return extension_rows(csr.extensions)
def csr_extension_specs(csr: x509.CertificateSigningRequest) -> Dict[str, Any]:
return extension_specs(csr.extensions)
def csr_public_key(csr: x509.CertificateSigningRequest) -> PublicKey:
return csr.public_key()
def openssl_config(csr: x509.CertificateSigningRequest) -> str:
lines = [
"[req]",
"distinguished_name = req_distinguished_name",
"prompt = no",
"",
"[req_distinguished_name]",
]
for entry in entries_from_name(csr.subject):
lines.append(f"{entry.name} = {entry.value}")
lines.extend(["", "[v3_ext]"])
config = extension_config_lines(csr.extensions)
if config:
lines.append(config)
return "\n".join(lines) + "\n"
__all__ = [
"ATTRIBUTE_NAMES",
"CHALLENGE_PASSWORD_OID",
"RequestInfo",
"UNSTRUCTURED_NAME_OID",
"attribute_rows",
"attribute_values",
"build_csr",
"csr_der",
"csr_extension_specs",
"csr_extensions",
"csr_pem",
"csr_public_key",
"csr_spec",
"openssl_config",
"parse_csr",
"request_info",
"verify_csr",
]
@@ -0,0 +1,583 @@
"""XCA-style certificate/request templates.
A template is a subject plus a set of extension defaults and a relative
validity range. Built-in templates mirror XCA's ``CA``, ``TLS_server`` and
``TLS_client`` templates; user templates are stored as JSON by the app.
"""
from __future__ import annotations
import base64
import binascii
import struct
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from cryptography import x509
from .extensions import (
default_extension_spec,
parse_aia_descriptions,
parse_crl_distribution_urls,
parse_eku_names,
parse_general_name_text,
parse_key_usage_names,
parse_name_constraints_text,
)
from .names import DnEntry, entries_from_json, entries_from_name, name_from_entries
TEMPLATE_VERSION = 10
PEM_STRING_XCA_TEMPLATE = "XCA TEMPLATE"
@dataclass(frozen=True)
class Template:
id: str
name: str
comment: str
subject: List[Dict[str, str]]
settings: Dict[str, Any]
builtin: bool = False
def as_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"comment": self.comment,
"subject": self.subject,
"settings": self.settings,
"builtin": self.builtin,
}
def default_settings() -> Dict[str, Any]:
extensions = default_extension_spec()
extensions["basic_constraints"] = {"critical": True, "ca": None, "path_length": None}
return {
"extensions": extensions,
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
}
def _base_settings() -> Dict[str, Any]:
return default_settings()
def builtin_templates() -> List[Template]:
ca = _base_settings()
ca["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": True, "path_length": None},
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]},
"extended_key_usage": {"critical": False, "usages": []},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
ca["validity"] = {"amount": 10, "unit": "years", "midnight": False, "no_expiration": False}
server = _base_settings()
server["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": False, "path_length": None},
"key_usage": {
"critical": True,
"usages": ["digital_signature", "content_commitment", "key_encipherment", "key_agreement"],
},
"extended_key_usage": {"critical": False, "usages": ["serverAuth"]},
"subject_alt_name": {"critical": False, "entries": [{"type": "dns", "value": "copycn"}]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
server["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}
client = _base_settings()
client["extensions"].update(
{
"basic_constraints": {"critical": True, "ca": False, "path_length": None},
"key_usage": {
"critical": True,
"usages": ["digital_signature", "key_encipherment", "data_encipherment", "key_agreement"],
},
"extended_key_usage": {"critical": False, "usages": ["clientAuth"]},
"subject_key_identifier": {"critical": False},
"authority_key_identifier": {"critical": False},
}
)
client["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}
return [
Template(
id="builtin:ca",
name="CA",
comment="Default XCA CA template",
subject=[],
settings=ca,
builtin=True,
),
Template(
id="builtin:tls-server",
name="TLS server",
comment="Default XCA TLS server template",
subject=[],
settings=server,
builtin=True,
),
Template(
id="builtin:tls-client",
name="TLS client",
comment="Default XCA TLS client template",
subject=[],
settings=client,
builtin=True,
),
]
def apply_template(
template: Mapping[str, Any],
target: Mapping[str, Any],
*,
apply_subject: bool = True,
apply_extensions: bool = True,
) -> Dict[str, Any]:
"""Merge a template into a certificate/request creation spec."""
result = dict(target)
if apply_subject and template.get("subject"):
result["subject"] = [dict(item) for item in template["subject"]]
if apply_extensions:
settings = template.get("settings") or {}
extensions = settings.get("extensions") or {}
merged = dict(result.get("extensions") or {})
for key, value in extensions.items():
if value is not None:
merged[key] = value
result["extensions"] = merged
if settings.get("validity"):
result["validity"] = dict(settings["validity"])
return result
def template_from_certificate(cert: x509.Certificate, name: str, comment: str = "") -> Dict[str, Any]:
from .certificates import certificate_extension_specs
return {
"name": name,
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(cert.subject)],
"settings": {
"extensions": certificate_extension_specs(cert),
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
},
}
def template_from_request(csr: x509.CertificateSigningRequest, name: str, comment: str = "") -> Dict[str, Any]:
from .requests import csr_extension_specs
return {
"name": name,
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(csr.subject)],
"settings": {
"extensions": csr_extension_specs(csr),
"validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False},
},
}
# ---------------------------------------------------------------------------
# XCA template binary format (Qt QDataStream, version 10)
# ---------------------------------------------------------------------------
def _qstring(value: Optional[str]) -> bytes:
if value is None:
return struct.pack(">I", 0xFFFFFFFF)
raw = value.encode("utf-16-be")
return struct.pack(">I", len(raw)) + raw
def _read_qstring(data: bytes, offset: int) -> tuple[Optional[str], int]:
if offset + 4 > len(data):
raise ValueError("truncated template")
length = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
if length == 0xFFFFFFFF:
return None, offset
if offset + length > len(data):
raise ValueError("truncated template string")
raw = data[offset : offset + length]
offset += length
return raw.decode("utf-16-be", "replace"), offset
def _qmap(values: Mapping[str, str]) -> bytes:
# QMap iterates in key order.
keys = sorted(values)
out = struct.pack(">I", len(keys))
for key in keys:
out += _qstring(key) + _qstring(values[key])
return out
def _read_qmap(data: bytes, offset: int) -> tuple[Dict[str, str], int]:
if offset + 4 > len(data):
raise ValueError("truncated template map")
count = struct.unpack(">I", data[offset : offset + 4])[0]
offset += 4
result: Dict[str, str] = {}
for _ in range(count):
key, offset = _read_qstring(data, offset)
value, offset = _read_qstring(data, offset)
if key is not None:
result[key] = value or ""
return result, offset
def _der_name_length(data: bytes) -> int:
if len(data) < 2 or data[0] != 0x30:
raise ValueError("template does not start with an X.509 name")
first = data[1]
if first < 0x80:
return 2 + first
count = first & 0x7F
if count == 0 or len(data) < 2 + count:
raise ValueError("invalid DER length")
length = int.from_bytes(data[2 : 2 + count], "big")
return 2 + count + length
def template_to_xca_data(template: Mapping[str, Any]) -> bytes:
"""Serialize a template in XCA's PEM-like template format."""
subject = name_from_entries(entries_from_json(template.get("subject") or []))
name_der = subject.public_bytes()
settings = template_to_xca_settings(template)
settings["internal_name"] = str(template.get("name") or "")
settings["internal_comment"] = str(template.get("comment") or "")
data = name_der + _qmap(settings)
payload = struct.pack(">I", len(data)) + struct.pack(">I", TEMPLATE_VERSION) + data
body = base64.encodebytes(payload).decode("ascii").replace("\n", "")
wrapped = "\n".join(body[i : i + 64] for i in range(0, len(body), 64))
return (
f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----\n"
f"{wrapped}\n"
f"-----END {PEM_STRING_XCA_TEMPLATE}-----\n"
).encode("ascii")
def template_from_xca_data(data: bytes | str) -> Dict[str, Any]:
"""Parse XCA's PEM-like template format (version 10)."""
if isinstance(data, bytes):
text = data.decode("ascii", "replace")
else:
text = data
begin = f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----"
end = f"-----END {PEM_STRING_XCA_TEMPLATE}-----"
if begin not in text or end not in text:
raise ValueError("not an XCA template")
body = text.split(begin, 1)[1].split(end, 1)[0]
try:
payload = base64.b64decode("".join(body.split()))
except binascii.Error as exc:
raise ValueError("invalid base64 in XCA template") from exc
if len(payload) < 8:
raise ValueError("truncated XCA template")
_size, version = struct.unpack(">II", payload[:8])
if version != TEMPLATE_VERSION:
raise ValueError(f"unsupported XCA template version {version}")
content = payload[8:]
name_len = _der_name_length(content)
name = x509.Name.from_bytes(content[:name_len])
settings, _offset = _read_qmap(content, name_len)
name_value = settings.pop("internal_name", "")
comment = settings.pop("internal_comment", "")
settings_json = xca_settings_to_template(settings)
return {
"name": name_value or "XCA template",
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(name)],
"settings": settings_json,
}
def template_from_xca_payload(data: bytes, version: int = TEMPLATE_VERSION) -> Dict[str, Any]:
"""Parse the raw ``templates.template`` payload from an XCA database."""
if version != TEMPLATE_VERSION:
raise ValueError(f"unsupported XCA template version {version}")
name_len = _der_name_length(data)
name = x509.Name.from_bytes(data[:name_len])
settings, _offset = _read_qmap(data, name_len)
name_value = settings.pop("internal_name", "")
comment = settings.pop("internal_comment", "")
settings_json = xca_settings_to_template(settings)
return {
"name": name_value or "XCA template",
"comment": comment,
"subject": [entry.as_dict() for entry in entries_from_name(name)],
"settings": settings_json,
}
def xca_settings_to_template(settings: Mapping[str, str]) -> Dict[str, Any]:
"""Convert XCA's ``pki_temp`` settings to the KCA template JSON."""
result = default_settings()
extensions = result["extensions"]
extensions["basic_constraints"] = None
ca = settings.get("ca", "")
if ca == "1":
extensions["basic_constraints"] = {
"critical": settings.get("bcCritical") == "1",
"ca": True,
"path_length": int(settings["basicPath"]) if settings.get("basicPath", "").isdigit() else None,
}
elif ca == "2":
extensions["basic_constraints"] = {
"critical": settings.get("bcCritical") == "1",
"ca": False,
"path_length": None,
}
key_use = int(settings.get("keyUse") or 0)
usage_names = [
name
for index, name in enumerate(
[
"digital_signature",
"content_commitment",
"key_encipherment",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only",
]
)
if key_use & (1 << index)
]
extensions["key_usage"] = {
"critical": settings.get("kuCritical") == "1",
"usages": usage_names,
}
extensions["extended_key_usage"] = {
"critical": settings.get("ekuCritical") == "1",
"usages": parse_eku_names(settings.get("eKeyUse", "")),
}
if settings.get("subAltName"):
try:
extensions["subject_alt_name"] = {
"critical": False,
"entries": [
{"type": "dns", "value": "copycn"}
]
if settings.get("subAltName") == "DNS:copycn"
else [
{"type": _kind, "value": _value}
for _kind, _value in _parse_general_text(settings["subAltName"])
],
}
except ValueError:
extensions["subject_alt_name"] = {"critical": False, "entries": []}
if settings.get("issAltName"):
try:
extensions["issuer_alt_name"] = {
"critical": False,
"entries": [
{"type": _kind, "value": _value}
for _kind, _value in _parse_general_text(settings["issAltName"])
],
}
except ValueError:
pass
if settings.get("crlDist"):
extensions["crl_distribution_points"] = {
"critical": False,
"urls": parse_crl_distribution_urls(settings["crlDist"]),
}
if settings.get("authInfAcc"):
extensions["authority_information_access"] = {
"critical": False,
"descriptions": parse_aia_descriptions(settings["authInfAcc"]),
}
if settings.get("nameCons"):
extensions["name_constraints"] = {
"critical": True,
**parse_name_constraints_text(settings["nameCons"]),
}
extensions["subject_key_identifier"] = {"critical": False} if settings.get("subKey") == "1" else None
extensions["authority_key_identifier"] = {"critical": False} if settings.get("authKey") == "1" else None
if settings.get("OCSPstaple") == "1":
extensions["tls_feature"] = {"critical": False, "features": ["status_request"]}
ns_type = int(settings.get("nsCertType") or 0)
ns_names = [
name
for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"])
if ns_type & (1 << index)
]
netscape: Dict[str, Any] = {}
if ns_names:
netscape["cert_type"] = ns_names
ns_fields = {
"nsComment": "comment",
"nsBaseUrl": "base_url",
"nsRevocationUrl": "revocation_url",
"nsCARevocationUrl": "ca_revocation_url",
"nsRenewalUrl": "renewal_url",
"nsCaPolicyUrl": "ca_policy_url",
"nsSslServerName": "ssl_server_name",
}
for source, target in ns_fields.items():
if settings.get(source):
netscape[target] = settings[source]
if netscape:
extensions["netscape"] = netscape
if settings.get("adv_ext"):
# Kept for display/export; the typed editor does not interpret the
# OpenSSL config text.
result["advanced_config"] = settings["adv_ext"]
valid_n = int(settings.get("validN") or 365)
valid_m = int(settings.get("validM") or 0)
result["validity"] = {
"amount": valid_n,
"unit": "days" if valid_m == 0 else ("months" if valid_m == 1 else "years"),
"midnight": settings.get("validMidn") == "1",
"no_expiration": settings.get("noWellDefinedExpDate") == "1",
}
return result
def _parse_general_text(text: str) -> List[tuple[str, str]]:
result = []
for chunk in str(text).split(","):
chunk = chunk.strip()
if not chunk:
continue
if ":" not in chunk:
continue
prefix, value = chunk.split(":", 1)
prefix = prefix.strip().lower()
kind = {
"dns": "dns",
"ip": "ip",
"uri": "uri",
"email": "email",
"rid": "rid",
"othername": "other_name",
"dirname": "directory_name",
}.get(prefix)
if kind:
result.append((kind, value.strip()))
return result
def template_to_xca_settings(template: Mapping[str, Any]) -> Dict[str, str]:
"""Convert the KCA template JSON to XCA's ``pki_temp`` settings map."""
settings = dict(template.get("settings") or {})
extensions = settings.get("extensions") or {}
result: Dict[str, str] = {}
bc = extensions.get("basic_constraints") or {}
if bc.get("ca") is True:
result["ca"] = "1"
result["bcCritical"] = "1" if bc.get("critical") else "0"
result["basicPath"] = str(bc.get("path_length") or "")
elif bc.get("ca") is False:
result["ca"] = "2"
result["bcCritical"] = "1" if bc.get("critical") else "0"
result["basicPath"] = ""
else:
result["ca"] = ""
result["bcCritical"] = ""
result["basicPath"] = ""
usage_index = {
"digital_signature": 0,
"content_commitment": 1,
"key_encipherment": 2,
"data_encipherment": 3,
"key_agreement": 4,
"key_cert_sign": 5,
"crl_sign": 6,
"encipher_only": 7,
"decipher_only": 8,
}
ku = extensions.get("key_usage") or {}
result["keyUse"] = str(sum(1 << usage_index[name] for name in ku.get("usages", []) if name in usage_index))
result["kuCritical"] = "1" if ku.get("critical") else "0"
eku = extensions.get("extended_key_usage") or {}
result["eKeyUse"] = ", ".join(eku.get("usages", []))
result["ekuCritical"] = "1" if eku.get("critical") else "0"
result["subKey"] = "1" if extensions.get("subject_key_identifier") else "0"
result["authKey"] = "1" if extensions.get("authority_key_identifier") else "0"
san = extensions.get("subject_alt_name") or {}
result["subAltName"] = _general_text(san.get("entries", []))
ian = extensions.get("issuer_alt_name") or {}
result["issAltName"] = _general_text(ian.get("entries", []))
result["crlDist"] = ", ".join(
f"URI:{url}" for url in (extensions.get("crl_distribution_points") or {}).get("urls", [])
)
result["authInfAcc"] = ", ".join(
f"{'OCSP' if item.get('method') == 'ocsp' else 'caIssuers'};URI:{(item.get('location') or {}).get('value', '')}"
for item in (extensions.get("authority_information_access") or {}).get("descriptions", [])
)
nc = extensions.get("name_constraints") or {}
result["nameCons"] = ", ".join(
[f"permitted;{_one_general(item)}" for item in nc.get("permitted", [])]
+ [f"excluded;{_one_general(item)}" for item in nc.get("excluded", [])]
)
result["OCSPstaple"] = "1" if extensions.get("tls_feature") else "0"
ns = extensions.get("netscape") or {}
ns_index = {name: index for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"])}
result["nsCertType"] = str(sum(1 << ns_index[name] for name in ns.get("cert_type", []) if name in ns_index))
result["nsComment"] = str(ns.get("comment", ""))
result["nsBaseUrl"] = str(ns.get("base_url", ""))
result["nsRevocationUrl"] = str(ns.get("revocation_url", ""))
result["nsCARevocationUrl"] = str(ns.get("ca_revocation_url", ""))
result["nsRenewalUrl"] = str(ns.get("renewal_url", ""))
result["nsCaPolicyUrl"] = str(ns.get("ca_policy_url", ""))
result["nsSslServerName"] = str(ns.get("ssl_server_name", ""))
result["adv_ext"] = str(settings.get("advanced_config", ""))
validity = settings.get("validity") or {}
result["validN"] = str(validity.get("amount", 365))
result["validM"] = {"days": "0", "months": "1", "years": "2"}.get(str(validity.get("unit", "days")), "0")
result["validMidn"] = "1" if validity.get("midnight") else "0"
result["noWellDefinedExpDate"] = "1" if validity.get("no_expiration") else "0"
return result
def _general_text(entries: Sequence[Mapping[str, Any]]) -> str:
return ", ".join(_one_general(entry) for entry in entries)
def _one_general(entry: Mapping[str, Any]) -> str:
kind = str(entry.get("type", "dns"))
value = entry.get("value", "")
prefix = {
"dns": "DNS",
"ip": "IP",
"uri": "URI",
"email": "email",
"rid": "RID",
"other_name": "otherName",
"directory_name": "dirName",
}.get(kind, kind)
if kind == "other_name":
return f"{prefix}:{entry.get('oid', '')};UTF8:{value}"
return f"{prefix}:{value}"
__all__ = [
"PEM_STRING_XCA_TEMPLATE",
"TEMPLATE_VERSION",
"Template",
"apply_template",
"builtin_templates",
"default_settings",
"template_from_certificate",
"template_from_request",
"template_from_xca_data",
"template_from_xca_payload",
"template_to_xca_data",
"template_to_xca_settings",
"xca_settings_to_template",
]
@@ -0,0 +1,334 @@
"""Import an XCA SQLite database (``.xdb``).
XCA stores every cryptographic object as base64 DER in a small relational
schema. This reader supports schema 7/8 (XCA 2.x), software keys and the
normal item types; PKCS#11 token rows are skipped because a browser app cannot
use a card reader. Private keys are decrypted with the database password
(individual key passwords are tried with the same value and otherwise the
public part is imported).
"""
from __future__ import annotations
import base64
import hashlib
import sqlite3
import tempfile
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.ciphers import Cipher, modes
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from .certificates import parse_certificate
from .crls import parse_crl
from .imports import ParsedItem
from .keys import is_private_key, load_key_bytes
from .requests import parse_csr
from .templates import template_from_xca_payload
_PKI_SOURCE = {
0: "unknown",
1: "imported",
2: "generated",
3: "transformed",
4: "token",
5: "legacy",
6: "renewed",
}
def _parse_date(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
text = str(value).strip()
if not text:
return None
for fmt in ("%Y%m%d%H%M%SZ", "%Y%m%d%H%M%S"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def _evp_bytes_to_key(password: bytes, salt: bytes, key_len: int = 24) -> bytes:
"""OpenSSL's EVP_BytesToKey with SHA-1 and one iteration."""
result = b""
previous = b""
while len(result) < key_len:
digest = hashlib.sha1()
digest.update(previous)
digest.update(password)
digest.update(salt)
previous = digest.digest()
result += previous
return result[:key_len]
def _legacy_decrypt(data: bytes, password: Optional[str]) -> Optional[bytes]:
if not password or len(data) <= 8:
return None
iv = data[:8]
ciphertext = data[8:]
key = _evp_bytes_to_key(password.encode(), iv, 24)
try:
decryptor = Cipher(TripleDES(key), modes.CBC(iv)).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
pad = padded[-1]
if pad < 1 or pad > 8:
return None
return padded[:-pad]
except Exception: # noqa: BLE001
return None
def _decrypt_private(encoded: str, password: Optional[str]) -> Optional[Any]:
try:
data = base64.b64decode(encoded)
except Exception: # noqa: BLE001
return None
# Modern XCA: PKCS#8 EncryptedPrivateKeyInfo.
try:
return serialization.load_der_private_key(
data, password=password.encode() if password else None
)
except Exception: # noqa: BLE001
pass
# Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key).
legacy = _legacy_decrypt(data, password)
if legacy:
try:
return serialization.load_der_private_key(legacy, password=None)
except Exception: # noqa: BLE001
pass
return None
def _item_extra(row: sqlite3.Row, item_id: int) -> Dict[str, Any]:
return {
"xca_item_id": item_id,
"source": _PKI_SOURCE.get(int(row["source"] or 0), "unknown"),
"xca_date": _parse_date(row["date"]),
"comment": str(row["comment"] or ""),
}
def import_xca_database(
data: bytes,
filename: str = "",
password: Optional[str] = None,
) -> List[ParsedItem]:
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
handle.write(data)
path = handle.name
try:
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
try:
schema_row = connection.execute(
"SELECT value FROM settings WHERE key_='schema'"
).fetchone()
if schema_row is None:
raise ValueError("not an XCA database (missing schema setting)")
schema = int(str(schema_row[0]))
if schema < 7:
raise ValueError(f"unsupported XCA database schema {schema}")
return _read_database(connection, filename, password)
finally:
connection.close()
except sqlite3.DatabaseError as exc:
raise ValueError("not a valid SQLite XCA database") from exc
finally:
try:
import os
os.unlink(path)
except OSError:
pass
def _read_database(
connection: sqlite3.Connection,
filename: str,
password: Optional[str],
) -> List[ParsedItem]:
items: Dict[int, sqlite3.Row] = {
int(row["id"]): row
for row in connection.execute(
"SELECT id, name, type, source, date, comment FROM items WHERE del=0"
)
}
if not items:
return []
# XCA's x509super table maps certificates/requests to key item ids.
key_refs: Dict[int, int] = {}
try:
for row in connection.execute("SELECT item, pkey FROM x509super"):
if row["pkey"] is not None:
key_refs[int(row["item"])] = int(row["pkey"])
except sqlite3.DatabaseError:
pass
result: List[ParsedItem] = []
key_objects: Dict[int, Any] = {}
# --- keys --------------------------------------------------------------
public_rows = {int(row["item"]): row for row in connection.execute("SELECT item, type, len, \"public\" FROM public_keys")}
private_rows = {int(row["item"]): row for row in connection.execute("SELECT item, ownPass, private FROM private_keys")}
for item_id, row in items.items():
if int(row["type"]) != 1 or item_id not in public_rows:
continue
public_row = public_rows[item_id]
try:
public_key = serialization.load_der_public_key(base64.b64decode(public_row["public"]))
except Exception: # noqa: BLE001
continue
key = public_key
private_row = private_rows.get(item_id)
if private_row is not None:
own_pass = int(private_row["ownPass"] or 0)
# ptCommon (0) and ptPrivate (1) are both tried with the supplied
# database password; ptBogus/ptPin cannot be used here.
if own_pass in (0, 1):
decrypted = _decrypt_private(str(private_row["private"]), password)
if decrypted is not None and is_private_key(decrypted):
key = decrypted
item = ParsedItem(
kind="key",
name=str(row["name"] or "key"),
summary=f"{public_row['type']} {public_row['len']}",
obj=key,
is_private=is_private_key(key),
extra=_item_extra(row, item_id),
)
item.extra["has_private"] = is_private_key(key)
result.append(item)
key_objects[item_id] = key
# --- certificates ------------------------------------------------------
cert_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, ca, cert FROM certs")}
authority_rows = {int(row["item"]): row for row in connection.execute("SELECT item, template, crlExpire, crlNo, crlDays FROM authority")}
try:
takey_rows = {int(row["item"]): row for row in connection.execute("SELECT item, value FROM takeys")}
except sqlite3.DatabaseError:
takey_rows = {}
revocations: Dict[int, List[Dict[str, Any]]] = {}
try:
for row in connection.execute(
"SELECT caId, serial, date, invaldate, crlNo, reasonBit FROM revocations"
):
revocations.setdefault(int(row["caId"]), []).append(
{
"serial_hex": str(row["serial"] or ""),
"revoked_at": _parse_date(row["date"]),
"invalidity_date": _parse_date(row["invaldate"]),
"crl_number": int(row["crlNo"] or 0),
"reason_bit": int(row["reasonBit"] or 0),
}
)
except sqlite3.DatabaseError:
pass
for item_id, row in items.items():
if int(row["type"]) != 3 or item_id not in cert_rows:
continue
cert_row = cert_rows[item_id]
try:
cert = parse_certificate(base64.b64decode(cert_row["cert"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="certificate",
name=str(row["name"] or "certificate"),
summary=cert.subject.rfc4514_string(),
obj=cert,
extra=_item_extra(row, item_id),
)
item.extra["xca_issuer_id"] = int(cert_row["issuer"]) if cert_row["issuer"] is not None else None
item.extra["xca_key_id"] = key_refs.get(item_id)
item.extra["serial_hex"] = format(cert.serial_number, "x")
authority = authority_rows.get(item_id)
if authority is not None:
item.extra["authority"] = {
"crl_expire": _parse_date(authority["crlExpire"]),
"crl_number": int(authority["crlNo"] or 0),
"crl_days": int(authority["crlDays"] or 30),
}
if item_id in takey_rows:
try:
item.extra["tls_auth_key"] = base64.b64decode(takey_rows[item_id]["value"])
except Exception: # noqa: BLE001
pass
if item_id in revocations:
item.extra["revocations"] = revocations[item_id]
result.append(item)
# --- requests ----------------------------------------------------------
request_rows = {int(row["item"]): row for row in connection.execute("SELECT item, signed, request FROM requests")}
for item_id, row in items.items():
if int(row["type"]) != 2 or item_id not in request_rows:
continue
request_row = request_rows[item_id]
try:
csr = parse_csr(base64.b64decode(request_row["request"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="request",
name=str(row["name"] or "request"),
summary=csr.subject.rfc4514_string(),
obj=csr,
extra=_item_extra(row, item_id),
)
item.extra["xca_key_id"] = key_refs.get(item_id)
item.extra["signed"] = bool(request_row["signed"])
result.append(item)
# --- CRLs --------------------------------------------------------------
crl_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, crl FROM crls")}
for item_id, row in items.items():
if int(row["type"]) != 4 or item_id not in crl_rows:
continue
crl_row = crl_rows[item_id]
try:
crl = parse_crl(base64.b64decode(crl_row["crl"]))
except Exception: # noqa: BLE001
continue
item = ParsedItem(
kind="crl",
name=str(row["name"] or "crl"),
summary=crl.issuer.rfc4514_string(),
obj=crl,
extra=_item_extra(row, item_id),
)
item.extra["xca_issuer_id"] = int(crl_row["issuer"]) if crl_row["issuer"] is not None else None
result.append(item)
# --- templates ---------------------------------------------------------
template_rows = {int(row["item"]): row for row in connection.execute("SELECT item, version, template FROM templates")}
for item_id, row in items.items():
if int(row["type"]) != 5 or item_id not in template_rows:
continue
template_row = template_rows[item_id]
try:
payload = base64.b64decode(template_row["template"])
template = template_from_xca_payload(payload, int(template_row["version"] or 10))
except Exception: # noqa: BLE001
continue
template["name"] = str(row["name"] or template.get("name") or "template")
template["comment"] = str(row["comment"] or "")
item = ParsedItem(
kind="template",
name=str(row["name"] or "template"),
summary=str(row["comment"] or ""),
obj=template,
extra=_item_extra(row, item_id),
)
result.append(item)
return result
__all__ = ["import_xca_database"]
+302
View File
@@ -0,0 +1,302 @@
"""Core domain tests: names, extensions, keys, certificates, CRLs and exports."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from kca.core import certificates, crls, crypto, exports, imports, keys, names, requests, templates
from kca.core.extensions import (
build_extensions,
default_extension_spec,
extension_specs,
)
def _name(cn: str = "Test CA") -> x509.Name:
return names.name_from_entries(
names.entries_from_json(
[
{"name": "C", "value": "DE"},
{"name": "O", "value": "KCA"},
{"name": "CN", "value": cn},
]
)
)
def _ca_spec() -> dict:
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
spec["key_usage"] = {"critical": True, "usages": ["key_cert_sign", "crl_sign"]}
spec["subject_key_identifier"] = {"critical": False}
spec["authority_key_identifier"] = {"critical": False}
return spec
class NamesTest(unittest.TestCase):
def test_order_and_duplicates_survive(self) -> None:
name = _name()
entries = names.entries_from_name(name)
self.assertEqual([e.name for e in entries], ["C", "O", "CN"])
self.assertEqual(name.rfc4514_string(), "CN=Test CA,O=KCA,C=DE")
def test_der_roundtrip(self) -> None:
name = _name()
self.assertEqual(names.name_from_der(names.name_to_der(name)), name)
class ExtensionTest(unittest.TestCase):
def test_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
spec = _ca_spec()
spec["subject_alt_name"] = {
"critical": False,
"entries": [
{"type": "dns", "value": "example.com"},
{"type": "ip", "value": "192.0.2.1"},
{"type": "email", "value": "ca@example.com"},
],
}
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
parsed = extension_specs(exts)
self.assertTrue(parsed["basic_constraints"]["ca"])
self.assertEqual(parsed["basic_constraints"]["path_length"], None)
self.assertEqual(len(parsed["subject_alt_name"]["entries"]), 3)
self.assertIn("key_cert_sign", parsed["key_usage"]["usages"])
def test_raw_extension(self) -> None:
key = keys.generate_key("ec", curve="secp256r1")
spec = default_extension_spec()
spec["raw"] = [{"oid": "1.2.3.4", "critical": True, "der_hex": "0c03616263"}]
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
self.assertEqual(exts[0].oid.dotted_string, "1.2.3.4")
self.assertTrue(exts[0].critical)
class KeyTest(unittest.TestCase):
def test_all_types(self) -> None:
for key_type, kwargs in [
("rsa", {"size": 2048}),
("ec", {"curve": "secp256r1"}),
("ed25519", {}),
]:
key = keys.generate_key(key_type, **kwargs)
info = keys.key_info(key)
self.assertTrue(info.is_private)
self.assertEqual(info.key_type.upper() if key_type != "ed25519" else "ED25519", info.key_type)
loaded, private = keys.load_key_bytes(keys.private_pem(key))
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), info.public_der)
def test_encrypted_export_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
encrypted = keys.private_pem(key, password="secret")
loaded, private = keys.load_key_bytes(encrypted, password="secret")
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
def test_ssh_and_pvk(self) -> None:
key = keys.generate_key("rsa", 2048)
public = keys.ssh_public(key, "test")
loaded, private = keys.load_key_bytes(public.encode())
self.assertFalse(private)
pvk = keys.pvk_encode_private(key)
loaded, private = keys.load_key_bytes(pvk)
self.assertTrue(private)
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
class CertificateTest(unittest.TestCase):
def test_issue_and_verify(self) -> None:
ca_key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
ca = certificates.build_self_signed(
subject=_name("Root CA"),
private_key=ca_key,
serial=1,
not_before=now,
not_after=now + timedelta(days=3650),
extension_spec=_ca_spec(),
)
self.assertTrue(certificates.is_self_signed(ca))
self.assertTrue(certificates.certificate_info(ca).is_ca)
leaf_key = keys.generate_key("ec", curve="secp256r1")
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": False, "path_length": None}
spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_encipherment"]}
spec["extended_key_usage"] = {"critical": False, "usages": ["serverAuth"]}
spec["subject_alt_name"] = {"critical": False, "entries": [{"type": "dns", "value": "example.com"}]}
leaf = certificates.build_signed(
subject=_name("example.com"),
public_key=leaf_key.public_key(),
issuer_cert=ca,
issuer_private_key=ca_key,
serial=2,
not_before=now,
not_after=now + timedelta(days=365),
extension_spec=spec,
)
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf))
self.assertIn("SSL server", certificates.purposes(leaf))
parsed = certificates.parse_certificate(certificates.certificate_der(leaf))
self.assertEqual(parsed.serial_number, 2)
self.assertEqual(certificates.find_issuer(parsed, [ca]), ca)
self.assertEqual(certificates.validation_errors(parsed, [parsed, ca]), [])
def test_renew_spec(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
cert = certificates.build_self_signed(
subject=_name("Renew Me"),
private_key=key,
serial=3,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
spec = certificates.certificate_spec(cert)
self.assertEqual(spec["serial_hex"], "3")
self.assertEqual(spec["subject"][-1]["name"], "CN")
def test_index_and_config(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
cert = certificates.build_self_signed(
subject=_name("Index"),
private_key=key,
serial=4,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
self.assertIn("V\t", certificates.index_entry(cert))
self.assertIn("basicConstraints", certificates.openssl_config(cert))
self.assertIn("VCALENDAR", certificates.ics_calendar([certificates.ics_event(cert, "Index")]))
class RequestTest(unittest.TestCase):
def test_csr_roundtrip(self) -> None:
key = keys.generate_key("rsa", 2048)
csr = requests.build_csr(
subject=_name("CSR"),
private_key=key,
extension_spec=_ca_spec(),
attributes={requests.CHALLENGE_PASSWORD_OID: "secret"},
)
self.assertTrue(requests.verify_csr(csr))
loaded = requests.parse_csr(requests.csr_der(csr))
self.assertEqual(requests.request_info(loaded).subject, csr.subject)
self.assertEqual(requests.attribute_values(loaded)[requests.CHALLENGE_PASSWORD_OID], "secret")
class CrlTest(unittest.TestCase):
def test_build_and_parse(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
ca = certificates.build_self_signed(
subject=_name("CRL CA"),
private_key=key,
serial=5,
not_before=now,
not_after=now + timedelta(days=3650),
extension_spec=_ca_spec(),
)
crl = crls.build_crl(
issuer_cert=ca,
issuer_private_key=key,
revocations=[
{
"serial_hex": "10",
"revoked_at": now,
"reason": "keyCompromise",
"invalidity_date": now - timedelta(days=1),
}
],
last_update=now,
next_update=now + timedelta(days=30),
crl_number=7,
)
info = crls.crl_info(crl)
self.assertEqual(info.crl_number, 7)
self.assertEqual(info.revoked_count, 1)
self.assertTrue(crls.verify_crl(crl, ca))
parsed = crls.parse_crl(crls.crl_der(crl))
rows = crls.crl_revocations(parsed)
self.assertEqual(rows[0]["serial_hex"], "10")
self.assertEqual(rows[0]["reason"], "keyCompromise")
class TemplateTest(unittest.TestCase):
def test_xca_roundtrip(self) -> None:
template = templates.builtin_templates()[0].as_dict()
data = templates.template_to_xca_data(template)
parsed = templates.template_from_xca_data(data)
self.assertEqual(parsed["name"], "CA")
self.assertTrue(parsed["settings"]["extensions"]["basic_constraints"]["ca"])
def test_apply(self) -> None:
template = templates.builtin_templates()[1].as_dict()
merged = templates.apply_template(template, {"subject": [], "extensions": {}})
self.assertIn("serverAuth", merged["extensions"]["extended_key_usage"]["usages"])
class ExportImportTest(unittest.TestCase):
def setUp(self) -> None:
self.key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
self.cert = certificates.build_self_signed(
subject=_name("Export"),
private_key=self.key,
serial=6,
not_before=now,
not_after=now + timedelta(days=30),
extension_spec=_ca_spec(),
)
def test_certificate_exports(self) -> None:
for fmt, prefix in [
("pem", b"-----BEGIN CERTIFICATE"),
("der", b"0"),
("pkcs7", b"0"),
("pkcs12", b"0"),
]:
result = exports.export_certificate(
self.cert, fmt, name="export", private_key=self.key, password="secret"
)
self.assertTrue(result.data.startswith(prefix), fmt)
pem = exports.export_certificate(self.cert, "pem", name="export")
items = imports.parse_file(pem.data, "export.crt")
self.assertEqual(items[0].kind, "certificate")
def test_key_exports(self) -> None:
for fmt in ("pem-public", "pem-private", "pkcs8", "ssh-public", "jwk-public", "pvk"):
result = exports.export_key(self.key, fmt, name="export")
self.assertTrue(result.data)
encrypted = exports.export_key(self.key, "pkcs8-encrypted", name="export", password="secret")
loaded, private = keys.load_key_bytes(encrypted.data, password="secret")
self.assertTrue(private)
def test_pkcs12_contains_chain(self) -> None:
result = exports.export_certificate(
self.cert, "pkcs12-chain", name="export", chain=[self.cert], private_key=self.key, password="secret"
)
items = imports.parse_file(result.data, "export.pfx", "secret")
self.assertGreaterEqual(len(items), 2)
class CryptoTest(unittest.TestCase):
def test_envelope(self) -> None:
master = crypto.generate_master_key()
blob = crypto.encrypt_private_key(b"secret", "key-id", crypto.decode_master_key(master))
self.assertEqual(crypto.decrypt_private_key(blob, "key-id", crypto.decode_master_key(master)), b"secret")
with self.assertRaises(crypto.DecryptionError):
crypto.decrypt_private_key(blob, "other-id", crypto.decode_master_key(master))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,135 @@
"""XCA .xdb import tests using a synthetic schema-compatible database."""
from __future__ import annotations
import base64
import sqlite3
import struct
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from cryptography.hazmat.primitives import serialization
from kca.core import certificates, crls, keys, names, requests, templates
from kca.core.extensions import build_extensions, default_extension_spec
from kca.core.xca_db import import_xca_database
def _name(cn: str) -> object:
return names.name_from_entries(names.entries_from_json([{"name": "CN", "value": cn}]))
class XcaDbTest(unittest.TestCase):
def test_import_synthetic_database(self) -> None:
key = keys.generate_key("rsa", 2048)
now = datetime.now(timezone.utc)
spec = default_extension_spec()
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name("Synthetic CA"))
cert = certificates.build_certificate(
subject=_name("Synthetic CA"),
public_key=key.public_key(),
issuer_cert=None,
issuer_private_key=key,
serial=42,
not_before=now,
not_after=now + timedelta(days=365),
extensions=exts,
)
csr = requests.build_csr(subject=_name("Synthetic CSR"), private_key=key, extension_spec=spec)
crl = crls.build_crl(
issuer_cert=cert,
issuer_private_key=key,
revocations=[{"serial_hex": "2a", "revoked_at": now, "reason": "keyCompromise"}],
last_update=now,
next_update=now + timedelta(days=30),
crl_number=3,
)
tmpl = templates.builtin_templates()[0].as_dict()
tmpl_pem = templates.template_to_xca_data(tmpl)
body = tmpl_pem.decode().split("-----BEGIN XCA TEMPLATE-----")[1].split("-----END XCA TEMPLATE-----")[0]
payload = base64.b64decode("".join(body.split()))
tmpl_data = payload[8:]
date = now.strftime("%Y%m%d%H%M%SZ")
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
path = handle.name
try:
db = sqlite3.connect(path)
db.executescript(
"""
CREATE TABLE settings (key_ CHAR(20) UNIQUE, value TEXT);
CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(128), type INTEGER, source INTEGER, date CHAR(15), comment VARCHAR(2048), stamp INTEGER DEFAULT 0, del SMALLINT DEFAULT 0);
CREATE TABLE public_keys (item INTEGER, type CHAR(4), hash INTEGER, len INTEGER, "public" TEXT);
CREATE TABLE private_keys (item INTEGER, ownPass INTEGER, private TEXT);
CREATE TABLE x509super (item INTEGER, subj_hash INTEGER, pkey INTEGER, key_hash INTEGER);
CREATE TABLE requests (item INTEGER, hash INTEGER, signed INTEGER, request TEXT);
CREATE TABLE certs (item INTEGER, hash INTEGER, iss_hash INTEGER, serial VARCHAR(64), issuer INTEGER, ca INTEGER, cert TEXT);
CREATE TABLE authority (item INTEGER, template INTEGER, crlExpire CHAR(15), crlNo INTEGER, crlDays INTEGER);
CREATE TABLE crls (item INTEGER, hash INTEGER, num INTEGER, iss_hash INTEGER, issuer INTEGER, crl TEXT);
CREATE TABLE revocations (caId INTEGER, serial VARCHAR(64), date CHAR(15), invaldate CHAR(15), crlNo INTEGER, reasonBit INTEGER);
CREATE TABLE templates (item INTEGER, version INTEGER, template TEXT);
CREATE TABLE takeys (item INTEGER UNIQUE, value TEXT);
"""
)
db.execute("INSERT INTO settings VALUES ('schema', '8')")
db.execute("INSERT INTO items VALUES (1, 'Synthetic key', 1, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (2, 'Synthetic CA', 3, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (3, 'Synthetic CSR', 2, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (4, 'Synthetic CRL', 4, 2, ?, '', 0, 0)", (date,))
db.execute("INSERT INTO items VALUES (5, 'Synthetic template', 5, 2, ?, '', 0, 0)", (date,))
encrypted = key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.BestAvailableEncryption(b"dbpass"),
)
db.execute(
"INSERT INTO public_keys VALUES (1, 'RSA', 0, 2048, ?)",
(base64.b64encode(keys.public_der(key)).decode(),),
)
db.execute(
"INSERT INTO private_keys VALUES (1, 0, ?)",
(base64.b64encode(encrypted).decode(),),
)
db.execute("INSERT INTO x509super VALUES (2, 0, 1, 0)")
db.execute("INSERT INTO x509super VALUES (3, 0, 1, 0)")
db.execute(
"INSERT INTO certs VALUES (2, 0, 0, '2a', NULL, 1, ?)",
(base64.b64encode(certificates.certificate_der(cert)).decode(),),
)
db.execute("INSERT INTO authority VALUES (2, NULL, NULL, 0, 30)")
db.execute(
"INSERT INTO revocations VALUES (2, '2a', ?, NULL, 0, 1)",
(date,),
)
db.execute(
"INSERT INTO requests VALUES (3, 0, 0, ?)",
(base64.b64encode(requests.csr_der(csr)).decode(),),
)
db.execute(
"INSERT INTO crls VALUES (4, 0, 1, 0, 2, ?)",
(base64.b64encode(crls.crl_der(crl)).decode(),),
)
db.execute(
"INSERT INTO templates VALUES (5, 10, ?)",
(base64.b64encode(tmpl_data).decode(),),
)
db.commit()
db.close()
with open(path, "rb") as handle:
items = import_xca_database(handle.read(), "synthetic.xdb", "dbpass")
finally:
import os
os.unlink(path)
kinds = [item.kind for item in items]
self.assertEqual(kinds, ["key", "certificate", "request", "crl", "template"])
self.assertTrue(items[0].is_private)
self.assertEqual(items[1].name, "Synthetic CA")
self.assertEqual(items[3].name, "Synthetic CRL")
self.assertEqual(items[4].obj["name"], "Synthetic template")
if __name__ == "__main__":
unittest.main()