Make public key extraction idempotent

This commit is contained in:
2026-10-26 10:55:24 +00:00
parent c1d8711cb4
commit 7523c0b8c4
4 changed files with 83 additions and 5 deletions
@@ -36,7 +36,7 @@ from ..service import (
renew_certificate, renew_certificate,
resolve_signature_hash, resolve_signature_hash,
revoke_certificate, revoke_certificate,
save_key, save_key_once,
search_text, search_text,
serialize_certificate_detail, serialize_certificate_detail,
serialize_certificate_summary, serialize_certificate_summary,
@@ -592,14 +592,14 @@ def register(app: KayaApp, kca: "Kca") -> None:
await send_error(ctx, 404, "certificate not found") await send_error(ctx, 404, "certificate not found")
return return
parsed = cert_core.parse_certificate(bytes(cert.cert_der)) parsed = cert_core.parse_certificate(bytes(cert.cert_der))
key = await save_key( key, created = await save_key_once(
vault, vault,
cert_core.certificate_public_key(parsed), cert_core.certificate_public_key(parsed),
name=f"{cert.name} public key", name=f"{cert.name} public key",
comment=f"Extracted from certificate {cert.name}", comment=f"Extracted from certificate {cert.name}",
source="transformed", source="transformed",
) )
await send_json(ctx, 201, {"id": str(key.id), "name": key.name}) await send_json(ctx, 201 if created else 200, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request") @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request")
@operation(summary="Create a request from a certificate", tags=["certificates"]) @operation(summary="Create a request from a certificate", tags=["certificates"])
@@ -27,6 +27,7 @@ from ..service import (
parse_uuid, parse_uuid,
resolve_signature_hash, resolve_signature_hash,
save_key, save_key,
save_key_once,
search_text, search_text,
serialize_request_detail, serialize_request_detail,
serialize_request_summary, serialize_request_summary,
@@ -258,14 +259,14 @@ def register(app: KayaApp, kca: "Kca") -> None:
await send_error(ctx, 404, "request not found") await send_error(ctx, 404, "request not found")
return return
public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der))) public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der)))
key = await save_key( key, created = await save_key_once(
vault, vault,
public_key, public_key,
name=f"{row.name} public key", name=f"{row.name} public key",
comment=f"Extracted from request {row.name}", comment=f"Extracted from request {row.name}",
source="transformed", source="transformed",
) )
await send_json(ctx, 201, {"id": str(key.id), "name": key.name}) await send_json(ctx, 201 if created else 200, {"id": str(key.id), "name": key.name})
@app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template") @app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template")
@operation(summary="Create a template from a request", tags=["requests"]) @operation(summary="Create a template from a request", tags=["requests"])
@@ -9,6 +9,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from uuid import UUID from uuid import UUID
from cryptography import x509 from cryptography import x509
from tortoise.exceptions import IntegrityError
from tortoise.expressions import Q from tortoise.expressions import Q
from kca.core.algorithms import ( from kca.core.algorithms import (
@@ -577,6 +578,35 @@ async def save_key(
return key return key
async def save_key_once(
vault: Vault,
key_obj: Any,
*,
name: str,
comment: str = "",
source: str = "transformed",
) -> Tuple[Key, bool]:
"""Return an existing key with the same public material, or save a new one.
Public-key extraction from a certificate or request is idempotent: when
the key already exists in the vault, return it instead of violating the
``(vault, public_key_sha256)`` unique constraint.
"""
info = key_core.key_info(key_obj)
existing = await Key.get_or_none(vault_id=vault.id, public_key_sha256=info.public_sha256)
if existing is not None:
return existing, False
try:
created = await save_key(vault, key_obj, name=name, comment=comment, source=source)
except IntegrityError:
# A concurrent request may have inserted the same key first.
existing = await Key.get_or_none(vault_id=vault.id, public_key_sha256=info.public_sha256)
if existing is None:
raise
return existing, False
return created, True
async def load_private_key(key: Key) -> Any: async def load_private_key(key: Key) -> Any:
if not key.has_private or key.private_der is None or key.private_nonce is None: if not key.has_private or key.private_der is None or key.private_nonce is None:
raise ValidationError("this key has no private part") raise ValidationError("this key has no private part")
@@ -1544,6 +1574,7 @@ __all__ = [
"renew_certificate", "renew_certificate",
"revoke_certificate", "revoke_certificate",
"save_key", "save_key",
"save_key_once",
"serialize_certificate_detail", "serialize_certificate_detail",
"serialize_certificate_summary", "serialize_certificate_summary",
"serialize_crl_detail", "serialize_crl_detail",
+46
View File
@@ -177,6 +177,52 @@ class RouteExtraTests(unittest.TestCase):
response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}") response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}")
assert response.json()["signed"] is True assert response.json()["signed"] is True
@async_test
async def test_public_key_extraction_is_idempotent(self) -> None:
async with api_client() as client:
with oidc_user("alice"):
vault_id = await _vault(client)
key = await _key(client, vault_id, "leaf key")
cert = await _cert(client, vault_id, key["id"], "Leaf")
# The certificate's key already exists in the vault.
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/transform/open-key"
)
assert response.status_code == 200, response.text
assert response.json()["id"] == key["id"]
response = await client.post(
f"/api/vaults/{vault_id}/requests",
json={
"name": "web request",
"subject": _subject("web.example.com"),
"new_key": {"key_type": "ec", "curve": "secp256r1", "name": "web key"},
},
)
assert response.status_code == 201, response.text
request = response.json()
response = await client.post(
f"/api/vaults/{vault_id}/requests/{request['id']}/transform/open-key"
)
assert response.status_code == 200, response.text
assert response.json()["id"] == request["key_id"]
# When the key is absent, extraction creates it once and then reuses it.
await client.delete(f"/api/vaults/{vault_id}/keys/{key['id']}")
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/transform/open-key"
)
assert response.status_code == 201, response.text
extracted = response.json()
response = await client.post(
f"/api/vaults/{vault_id}/certificates/{cert['id']}/transform/open-key"
)
assert response.status_code == 200, response.text
assert response.json()["id"] == extracted["id"]
keys = (await client.get(f"/api/vaults/{vault_id}/keys")).json()["results"]
assert len(keys) == 2
@async_test @async_test
async def test_invalid_csr_signature_cannot_be_issued(self) -> None: async def test_invalid_csr_signature_cannot_be_issued(self) -> None:
from cryptography import x509 from cryptography import x509