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:
@@ -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.
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user