Add full XCA curve and digest parity
CI / Build and push docker image (push) Successful in 37s
CI / Build and push OCSP image (push) Successful in 38s

This commit is contained in:
2026-10-26 12:06:17 +08:00
parent e83dc42d3d
commit 1a7b4a10e8
36 changed files with 4326 additions and 331 deletions
@@ -34,6 +34,7 @@ from ..service import (
parse_datetime,
parse_uuid,
renew_certificate,
resolve_signature_hash,
revoke_certificate,
save_key,
search_text,
@@ -593,7 +594,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
key = await save_key(
vault,
parsed.public_key(),
cert_core.certificate_public_key(parsed),
name=f"{cert.name} public key",
comment=f"Extracted from certificate {cert.name}",
source="transformed",
@@ -617,11 +618,17 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
try:
private_key = await load_private_key(key)
signature_hash = resolve_signature_hash(
private_key,
None,
cert_core.signature_hash_name(parsed) or "sha256",
)
csr = req_core.build_csr(
subject=parsed.subject,
private_key=await load_private_key(key),
private_key=private_key,
extension_spec=cert_core.certificate_extension_specs(parsed),
signature_hash=cert_core.signature_hash_name(parsed),
signature_hash=signature_hash,
)
except ValueError as exc:
await send_error(ctx, 400, str(exc))
@@ -22,8 +22,10 @@ from ..service import (
ValidationError,
generate_key,
parse_extensions,
parse_key_generation,
parse_subject,
parse_uuid,
resolve_signature_hash,
save_key,
search_text,
serialize_request_detail,
@@ -31,6 +33,7 @@ from ..service import (
serialize_template_summary,
load_private_key,
vault_for_owner,
vault_settings,
)
if TYPE_CHECKING:
@@ -96,26 +99,40 @@ def register(app: KayaApp, kca: "Kca") -> None:
key_obj = await load_private_key(key_model)
elif isinstance(body.get("new_key"), dict):
new_key = body["new_key"]
generated = await 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"),
params = parse_key_generation({
"key_type": new_key.get("key_type") or "rsa",
"size": new_key.get("size", key_core.DEFAULT_KEY_SIZE),
"curve": new_key.get("curve"),
"name": new_key.get("name") or name,
})
generated = await generate_key(params["key_type"], params["size"], params["curve"])
# Reject an invalid explicit/default digest before leaving a
# newly generated key persisted without a corresponding CSR.
resolve_signature_hash(
generated,
body.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
)
key_model = await save_key(
vault,
generated,
name=str(new_key.get("name") or name),
name=params["name"],
comment="Generated while creating a request",
source="generated",
)
key_obj = generated
else:
raise ValidationError("select an existing key or generate a new one")
signature_hash = resolve_signature_hash(
key_obj,
body.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
)
csr = req_core.build_csr(
subject=subject,
private_key=key_obj,
extension_spec=extensions,
signature_hash=body.get("signature_hash"),
signature_hash=signature_hash,
attributes=body.get("attributes") or {},
)
except (ValidationError, ValueError) as exc:
@@ -319,8 +319,10 @@ def register(app: KayaApp, kca: "Kca") -> 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),
"hash_algorithms": [item.name for item in key_core.HASHES],
"hash_options": key_core.hash_options(),
"curves": [curve.name for curve in key_core.CURVES],
"curve_options": key_core.curve_options(),
"dn_attributes": name_core.name_attribute_options(),
"eku": name_core.eku_options(),
"key_usages": ext_core.KEY_USAGES,
+86 -19
View File
@@ -11,6 +11,12 @@ from uuid import UUID
from cryptography import x509
from tortoise.expressions import Q
from kca.core.algorithms import (
HASH_BY_NAME,
canonical_curve_name,
default_signature_hash,
validate_signature_hash,
)
from kca.core import certificates as cert_core
from kca.core import crls as crl_core
from kca.core import extensions as ext_core
@@ -66,8 +72,24 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict
raise ValidationError(f"unknown setting {key!r}")
merged[key] = value
# Validate the values that affect cryptographic operations.
if merged.get("default_hash") not in key_core.HASH_ALGORITHMS:
raise ValidationError("default_hash must be sha1, sha224, sha256, sha384 or sha512")
if merged.get("default_hash") not in HASH_BY_NAME:
raise ValidationError("default_hash must be one of: " + ", ".join(HASH_BY_NAME))
default_key = merged.get("default_key") or {}
if not isinstance(default_key, Mapping):
raise ValidationError("default_key must be an object")
try:
parsed_default_key = parse_key_generation({
"key_type": default_key.get("type") or "rsa",
"size": default_key.get("size", key_core.DEFAULT_KEY_SIZE),
"curve": default_key.get("curve"),
})
except ValueError as exc:
raise ValidationError(str(exc)) from exc
merged["default_key"] = {
"type": parsed_default_key["key_type"],
"size": parsed_default_key["size"],
"curve": parsed_default_key["curve"],
}
try:
bits = int(merged.get("serial_len_bits", 64))
except (TypeError, ValueError) as exc:
@@ -498,8 +520,13 @@ def parse_key_generation(payload: Mapping[str, Any]) -> Dict[str, Any]:
except (TypeError, ValueError) as exc:
raise ValidationError("key size must be an integer") from exc
curve = payload.get("curve")
if key_type == "ec" and curve not in key_core.EC_CURVES:
raise ValidationError("unsupported EC curve")
if key_type == "ec":
try:
curve = canonical_curve_name(str(curve or "secp256r1"))
except ValueError as exc:
raise ValidationError(str(exc)) from exc
else:
curve = None
name = str(payload.get("name") or "").strip()
if not name:
name = f"{key_type.upper()} {size}" if key_type != "ec" else f"EC {curve}"
@@ -567,9 +594,19 @@ async def key_public_object(key: Key) -> Any:
def _public_part(key_obj: Any) -> Any:
"""Return a public key object from either a private or public key."""
if hasattr(key_obj, "public_key"):
return key_obj.public_key()
return key_obj
return key_core.public_part(key_obj)
def resolve_signature_hash(
key_obj: Any,
requested: Any = None,
default: Optional[str] = "sha256",
) -> Optional[str]:
"""Resolve an omitted default or validate an explicit hash for this key."""
kind = key_core.key_type_name(key_obj)
if requested not in (None, ""):
return validate_signature_hash(kind, str(requested))
return default_signature_hash(kind, default)
# ---------------------------------------------------------------------------
@@ -691,6 +728,7 @@ async def issue_certificate(
user: User,
) -> Certificate:
"""Create a certificate from the wizard payload."""
settings_obj = vault_settings(vault)
name = str(payload.get("name") or "").strip()
if not name:
raise ValidationError("name is required")
@@ -722,18 +760,33 @@ async def issue_certificate(
if key_model.has_private and (signer is None or payload.get("use_key_private", True)):
key_obj = await load_private_key(key_model)
elif new_key_payload:
new_key_params = parse_key_generation({
"key_type": new_key_payload.get("key_type") or "rsa",
"size": new_key_payload.get("size", key_core.DEFAULT_KEY_SIZE),
"curve": new_key_payload.get("curve"),
"name": new_key_payload.get("name") or name,
})
try:
generated = await generate_key(
str(new_key_payload.get("key_type") or "rsa"),
int(new_key_payload.get("size") or key_core.DEFAULT_KEY_SIZE),
new_key_payload.get("curve"),
new_key_params["key_type"],
new_key_params["size"],
new_key_params["curve"],
)
if signer is None:
validation_key_type = key_core.key_type_name(generated)
else:
assert signer_key is not None # validated above for CA-signed issuance
validation_key_type = signer_key.key_type
if payload.get("signature_hash") not in (None, ""):
validate_signature_hash(validation_key_type, str(payload.get("signature_hash")))
else:
default_signature_hash(validation_key_type, settings_obj.get("default_hash", "sha256"))
except ValueError as exc:
raise ValidationError(str(exc)) from exc
key_model = await save_key(
vault,
generated,
name=str(new_key_payload.get("name") or name),
name=new_key_params["name"],
comment="Generated while creating a certificate",
source="generated",
)
@@ -760,7 +813,6 @@ async def issue_certificate(
extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions)
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
@@ -772,12 +824,13 @@ async def issue_certificate(
serial = await unique_serial(vault, signer, bits)
elif await _serial_in_use(vault, signer, serial):
raise ValidationError("serial number is already in use or revoked by the issuer")
signature_hash = payload.get("signature_hash") or settings_obj.get("default_hash", "sha256")
if signer is None:
if key_model is None or not key_model.has_private:
raise ValidationError("a self-signed certificate requires the private key")
private_key = await load_private_key(key_model)
signature_hash = resolve_signature_hash(
private_key, payload.get("signature_hash"), settings_obj.get("default_hash", "sha256")
)
cert = cert_core.build_self_signed(
subject=subject,
private_key=private_key,
@@ -790,6 +843,9 @@ async def issue_certificate(
else:
signer_private = await load_private_key(signer_key)
signer_cert = cert_core.parse_certificate(bytes(signer.cert_der))
signature_hash = resolve_signature_hash(
signer_private, payload.get("signature_hash"), settings_obj.get("default_hash", "sha256")
)
cert = cert_core.build_signed(
subject=subject,
public_key=_public_part(key_obj),
@@ -895,7 +951,7 @@ async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[
)
else:
generated = await generate_key("ec", curve="secp256r1")
public_key = generated.public_key()
public_key = key_core.public_part(generated)
extensions_spec = parse_extensions(payload.get("extensions"))
if request_obj is not None and payload.get("copy_request_extensions"):
@@ -951,16 +1007,21 @@ async def renew_certificate(
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))
signature_hash = resolve_signature_hash(
signer_private,
payload.get("signature_hash"),
cert_core.signature_hash_name(parsed) or "sha256",
)
new_cert = cert_core.build_certificate(
subject=parsed.subject,
public_key=parsed.public_key(),
public_key=cert_core.certificate_public_key(parsed),
issuer_cert=None if signer.id == cert.id else signer_cert,
issuer_private_key=signer_private,
serial=serial,
not_before=not_before,
not_after=not_after,
extensions=list(parsed.extensions),
signature_hash=payload.get("signature_hash") or cert_core._signature_hash_name(parsed),
signature_hash=signature_hash,
)
info = cert_core.certificate_info(new_cert)
row = await Certificate.create(
@@ -1111,9 +1172,15 @@ async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]
next_update = last_update + timedelta(days=days)
crl_number = int(payload.get("crl_number") or (authority.crl_number + 1))
revocations = await CertificateRevocation.filter(ca_id=ca.id)
issuer_private = await load_private_key(key)
signature_hash = resolve_signature_hash(
issuer_private,
payload.get("signature_hash"),
vault_settings(vault).get("default_hash", "sha256"),
)
crl = crl_core.build_crl(
issuer_cert=cert_core.parse_certificate(bytes(ca.cert_der)),
issuer_private_key=await load_private_key(key),
issuer_private_key=issuer_private,
revocations=[
{
"serial_hex": row.serial_hex,
@@ -1126,7 +1193,7 @@ async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]
last_update=last_update,
next_update=next_update,
crl_number=crl_number,
signature_hash=payload.get("signature_hash") or vault_settings(vault).get("default_hash", "sha256"),
signature_hash=signature_hash,
with_reason=bool(payload.get("with_reason", True)),
include_aki=bool(payload.get("include_aki", True)),
include_issuer_alt_name=bool(payload.get("include_issuer_alt_name", True)),