Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E

This commit is contained in:
2026-10-23 04:26:35 +00:00
parent 4f3a632f4b
commit 5eb7866f5b
27 changed files with 2054 additions and 134 deletions
@@ -152,6 +152,9 @@ class Certificate(Model):
issuer_hash = fields.CharField(max_length=64, db_index=True)
not_before = fields.DatetimeField()
not_after = fields.DatetimeField()
# True when the certificate intentionally has no well-defined expiry
# (the ASN.1 notAfter is a far-future sentinel, XCA's "no expiration").
no_expiration = fields.BooleanField(default=False)
is_ca = fields.BooleanField(default=False, db_index=True)
path_length = fields.IntField(null=True)
cert_der = fields.BinaryField()
@@ -185,6 +188,9 @@ class Authority(Model):
template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField(
"models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True
)
# A built-in template key (e.g. "builtin:ca"); mutually exclusive with
# the user-template FK above.
template_key = fields.CharField(max_length=64, null=True)
crl_expire = fields.DatetimeField(null=True)
crl_number = fields.IntField(default=0)
crl_days = fields.IntField(default=30)
@@ -28,6 +28,7 @@ from ..service import (
generate_crl,
issue_certificate,
load_private_key,
preview_certificate,
parse_datetime,
parse_uuid,
renew_certificate,
@@ -96,6 +97,25 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/preview")
@operation(summary="Preview/validate a certificate payload", tags=["certificates"])
@require_auth(kca.oidc)
async def preview(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:
result = await preview_certificate(vault, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, result)
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
@operation(summary="Certificate detail", tags=["certificates"])
@require_auth(kca.oidc)
@@ -273,7 +293,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
"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,
"default_template": authority.template_key or (str(authority.template_id) if authority.template_id else None),
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@@ -309,20 +329,30 @@ def register(app: KayaApp, kca: "Kca") -> None:
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
text = str(template_id)
if text.startswith("builtin:"):
if text not in {item.id for item in template_core.builtin_templates()}:
await send_error(ctx, 400, "unknown built-in template")
return
authority.template_key = text
authority.template_id = None
else:
template = await Template.get_or_none(id=parse_uuid(text), vault_id=vault.id)
if template is None:
await send_error(ctx, 400, "template not found")
return
authority.template_id = template.id
authority.template_key = None
else:
authority.template_id = None
authority.template_key = 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,
"default_template": authority.template_key or (str(authority.template_id) if authority.template_id else None),
"has_tls_auth_key": bool(authority.tls_auth_key),
})
@@ -1,6 +1,10 @@
"""Vaults (XCA databases) and their settings."""
from __future__ import annotations
import base64
import io
import json
import zipfile
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional
@@ -15,6 +19,7 @@ from ..service import (
DEFAULT_VAULT_SETTINGS,
ValidationError,
iso,
load_private_key,
merge_settings,
parse_uuid,
serialize_certificate_summary,
@@ -27,11 +32,13 @@ from ..service import (
vault_for_owner,
vault_settings,
)
from ..core import certificates as cert_core
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
from ..core import requests as req_core
from ..core import templates as template_core
if TYPE_CHECKING:
from ..mixin import Kca
@@ -219,6 +226,86 @@ def register(app: KayaApp, kca: "Kca") -> None:
"recent_keys": [await serialize_key_summary(k) for k in recent_keys],
})
@app.POST("/api/vaults/${vault_id}/export")
@operation(summary="Export the whole vault as a ZIP", tags=["vaults"])
@require_auth(kca.oidc)
async def export_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
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
used: set[str] = set()
def safe(name: str, suffix: str) -> str:
base = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name).strip("._") or "item"
candidate = f"{base}{suffix}"
index = 2
while candidate.lower() in used:
candidate = f"{base}_{index}{suffix}"
index += 1
used.add(candidate.lower())
return candidate
buffer = io.BytesIO()
manifest = {
"format": "kca-vault",
"version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(),
"vault": {"id": str(vault.id), "name": vault.name, "description": vault.description},
"items": [],
"warnings": [],
"private_keys_encrypted": bool(password),
}
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for key in await Key.filter(vault_id=vault.id).order_by("name"):
public = key_core.public_key_from_der(bytes(key.public_der))
public_name = safe(key.name, ".pub")
archive.writestr(f"keys/{public_name}", key_core.public_pem(public))
if key.has_private and password:
try:
private = await load_private_key(key)
private_name = safe(key.name, ".key")
archive.writestr(f"keys/{private_name}", key_core.private_pem(private, password=password))
except Exception: # noqa: BLE001 - keep the rest of the dump
manifest["warnings"].append(f"could not export private key {key.name!r}")
manifest["items"].append({"kind": "key", "id": str(key.id), "name": key.name, "has_private": key.has_private})
for cert in await Certificate.filter(vault_id=vault.id).order_by("name"):
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
archive.writestr(f"certificates/{safe(cert.name, '.crt')}", cert_core.certificate_pem(parsed))
manifest["items"].append({"kind": "certificate", "id": str(cert.id), "name": cert.name, "subject": parsed.subject.rfc4514_string()})
for request in await Request.filter(vault_id=vault.id).order_by("name"):
parsed = req_core.parse_csr(bytes(request.req_der))
archive.writestr(f"requests/{safe(request.name, '.csr')}", req_core.csr_pem(parsed))
manifest["items"].append({"kind": "request", "id": str(request.id), "name": request.name})
for crl in await Crl.filter(vault_id=vault.id).order_by("name"):
parsed = crl_core.parse_crl(bytes(crl.crl_der))
archive.writestr(f"crls/{safe(crl.name, '.crl')}", crl_core.crl_pem(parsed))
manifest["items"].append({"kind": "crl", "id": str(crl.id), "name": crl.name})
for template in await Template.filter(vault_id=vault.id).order_by("name"):
data = template_core.template_to_xca_data({
"name": template.name,
"comment": template.comment,
"subject": template.subject,
"settings": template.settings,
})
archive.writestr(f"templates/{safe(template.name, '.xca')}", data)
manifest["items"].append({"kind": "template", "id": str(template.id), "name": template.name})
archive.writestr("manifest.json", json.dumps(manifest, indent=2))
body_bytes = buffer.getvalue()
await send_json(ctx, 200, {
"filename": f"{safe(vault.name, '.zip')}",
"content_type": "application/zip",
"content_base64": base64.b64encode(body_bytes).decode("ascii"),
})
@app.GET("/api/vaults/${vault_id}/options")
@operation(summary="Reference data for editors", tags=["vaults"])
@require_auth(kca.oidc)
+98 -12
View File
@@ -193,6 +193,7 @@ async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
"serial_hex": cert.serial_hex,
"not_before": iso(cert.not_before),
"not_after": iso(cert.not_after),
"no_expiration": cert.no_expiration,
"is_ca": cert.is_ca,
"path_length": cert.path_length,
"has_private_key": bool(cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()),
@@ -270,7 +271,10 @@ async def serialize_certificate_detail(cert: Certificate, vault: Vault) -> Dict[
"crl_expire": iso(authority.crl_expire) if authority else None,
"crl_number": authority.crl_number if authority else None,
"crl_days": authority.crl_days if authority else None,
"default_template_id": str(authority.template_id) if authority and authority.template_id else None,
"default_template": (
authority.template_key
or (str(authority.template_id) if authority.template_id else None)
),
"has_tls_auth_key": bool(authority and authority.tls_auth_key),
}
if authority
@@ -560,14 +564,16 @@ def parse_extensions(payload: Any) -> Dict[str, Any]:
return payload
def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime]:
def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime, bool]:
now = datetime.now(timezone.utc)
not_before = parse_datetime(payload.get("not_before") or now.isoformat(), "not_before")
raw_after = payload.get("not_after")
if raw_after:
not_after = parse_datetime(raw_after, "not_after")
validity = payload.get("validity") or {}
no_expiration = bool(validity.get("no_expiration"))
if no_expiration:
not_after = cert_core.FAR_FUTURE
elif payload.get("not_after"):
not_after = parse_datetime(payload["not_after"], "not_after")
else:
validity = payload.get("validity") or {}
amount = int(validity.get("amount", 365))
unit = str(validity.get("unit", "days"))
if unit == "days":
@@ -578,12 +584,12 @@ def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime]:
not_after = not_before + timedelta(days=amount * 365)
else:
raise ValidationError("validity unit must be days, months or years")
if validity.get("midnight"):
not_before = not_before.replace(hour=0, minute=0, second=0, microsecond=0)
not_after = not_after.replace(hour=23, minute=59, second=59, microsecond=0)
if validity.get("midnight") and not no_expiration:
not_before = not_before.replace(hour=0, minute=0, second=0, microsecond=0)
not_after = not_after.replace(hour=23, minute=59, second=59, microsecond=0)
if not_after <= not_before:
raise ValidationError("not_after must be later than not_before")
return not_before, not_after
return not_before, not_after, no_expiration
async def unique_serial(vault: Vault, issuer: Optional[Certificate], bits: int) -> int:
@@ -621,6 +627,20 @@ async def find_request(vault: Vault, request_id: Optional[str]) -> Optional[Requ
return await Request.get_or_none(id=uuid, vault_id=vault.id)
def check_mandatory_dn(subject: x509.Name, settings_obj: Mapping[str, Any]) -> None:
mandatory = settings_obj.get("mandatory_dn") or []
missing = []
for name in mandatory:
try:
oid = name_core.resolve_oid(str(name))
except ValueError:
continue
if not subject.get_attributes_for_oid(x509.oid.ObjectIdentifier(oid)):
missing.append(str(name))
if missing:
raise ValidationError("missing mandatory subject entries: " + ", ".join(missing))
async def issue_certificate(
vault: Vault,
payload: Mapping[str, Any],
@@ -692,8 +712,9 @@ async def issue_certificate(
if request_obj is not None and payload.get("copy_request_extensions"):
extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions)
not_before, not_after = validate_validity(payload)
not_before, not_after, no_expiration = validate_validity(payload)
settings_obj = vault_settings(vault)
check_mandatory_dn(subject, settings_obj)
bits = int(settings_obj.get("serial_len_bits", 64))
raw_serial = payload.get("serial") or payload.get("serial_hex") or 0
try:
@@ -748,6 +769,7 @@ async def issue_certificate(
issuer_hash=info.issuer_hash,
not_before=info.not_before,
not_after=info.not_after,
no_expiration=no_expiration,
is_ca=info.is_ca,
path_length=info.path_length,
cert_der=cert_core.certificate_der(cert),
@@ -784,6 +806,67 @@ async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None:
await cert.delete()
async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Build the extension list for a wizard payload without persisting it."""
request_obj = await find_request(vault, payload.get("request_id"))
if payload.get("request_id") and request_obj is None:
raise ValidationError("signing request not found")
signer = await find_certificate(vault, payload.get("signer_id"))
if payload.get("signer_id") and signer is None:
raise ValidationError("signing certificate not found")
if signer is not None and not signer.is_ca:
raise ValidationError("the signing certificate is not a CA")
if request_obj is not None and not payload.get("modify_subject"):
subject = req_core.parse_csr(bytes(request_obj.req_der)).subject
else:
subject = parse_subject(payload.get("subject") or [])
if not subject.rdns:
raise ValidationError("the subject must not be empty")
check_mandatory_dn(subject, vault_settings(vault))
public_key = None
key_model = await find_key(vault, payload.get("key_id"))
if key_model is not None:
public_key = key_core.public_key_from_der(bytes(key_model.public_der))
elif request_obj is not None:
public_key = req_core.csr_public_key(req_core.parse_csr(bytes(request_obj.req_der)))
else:
new_key = payload.get("new_key")
if isinstance(new_key, Mapping):
generated = key_core.generate_key(
str(new_key.get("key_type") or "ec"),
int(new_key.get("size") or 2048),
new_key.get("curve") or "secp256r1",
)
else:
generated = key_core.generate_key("ec", curve="secp256r1")
public_key = generated.public_key()
extensions_spec = parse_extensions(payload.get("extensions"))
if request_obj is not None and payload.get("copy_request_extensions"):
extensions_spec = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions)
issuer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) if signer is not None else None
try:
extensions = ext_core.build_extensions(
extensions_spec,
public_key=public_key,
subject_name=subject,
issuer_cert=issuer_cert,
)
except ValueError as exc:
raise ValidationError(str(exc)) from exc
not_before, not_after, no_expiration = validate_validity(payload)
return {
"subject": [entry.as_dict() for entry in name_core.entries_from_name(subject)],
"extensions": ext_core.extension_rows(extensions),
"openssl_config": ext_core.extension_config_lines(extensions),
"not_before": iso(not_before),
"not_after": iso(not_after),
"no_expiration": no_expiration,
}
async def renew_certificate(
vault: Vault,
cert: Certificate,
@@ -796,7 +879,8 @@ async def renew_certificate(
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
keep_serial = bool(payload.get("keep_serial"))
serial = parsed.serial_number if keep_serial else await unique_serial(vault, signer, int(vault_settings(vault).get("serial_len_bits", 64)))
not_before, not_after = validate_validity(payload)
not_before, not_after, no_expiration = validate_validity(payload)
check_mandatory_dn(parsed.subject, vault_settings(vault))
signer_private = await load_private_key(signer_key)
signer_cert = cert_core.parse_certificate(bytes(signer.cert_der))
new_cert = cert_core.build_certificate(
@@ -825,6 +909,7 @@ async def renew_certificate(
issuer_hash=info.issuer_hash,
not_before=info.not_before,
not_after=info.not_after,
no_expiration=no_expiration,
is_ca=info.is_ca,
path_length=info.path_length,
cert_der=cert_core.certificate_der(new_cert),
@@ -1252,6 +1337,7 @@ __all__ = [
"load_private_key",
"merge_settings",
"parse_datetime",
"preview_certificate",
"parse_key_generation",
"parse_subject",
"parse_uuid",