Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user