From 7523c0b8c4a4507b3ded3a206c8c58bb20f6d20d Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 26 Oct 2026 10:55:24 +0000 Subject: [PATCH] Make public key extraction idempotent --- .../kca-app/src/kca/routes/certificates.py | 6 +-- .../kca-app/src/kca/routes/requests.py | 5 +- server/packages/kca-app/src/kca/service.py | 31 +++++++++++++ server/tests/test_routes_extra.py | 46 +++++++++++++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py index e3fcabc..a2654ee 100644 --- a/server/packages/kca-app/src/kca/routes/certificates.py +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -36,7 +36,7 @@ from ..service import ( renew_certificate, resolve_signature_hash, revoke_certificate, - save_key, + save_key_once, search_text, serialize_certificate_detail, serialize_certificate_summary, @@ -592,14 +592,14 @@ def register(app: KayaApp, kca: "Kca") -> None: await send_error(ctx, 404, "certificate not found") return parsed = cert_core.parse_certificate(bytes(cert.cert_der)) - key = await save_key( + key, created = await save_key_once( vault, cert_core.certificate_public_key(parsed), 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}) + 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") @operation(summary="Create a request from a certificate", tags=["certificates"]) diff --git a/server/packages/kca-app/src/kca/routes/requests.py b/server/packages/kca-app/src/kca/routes/requests.py index 4c599ba..8185978 100644 --- a/server/packages/kca-app/src/kca/routes/requests.py +++ b/server/packages/kca-app/src/kca/routes/requests.py @@ -27,6 +27,7 @@ from ..service import ( parse_uuid, resolve_signature_hash, save_key, + save_key_once, search_text, serialize_request_detail, serialize_request_summary, @@ -258,14 +259,14 @@ def register(app: KayaApp, kca: "Kca") -> 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( + key, created = await save_key_once( 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}) + 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") @operation(summary="Create a template from a request", tags=["requests"]) diff --git a/server/packages/kca-app/src/kca/service.py b/server/packages/kca-app/src/kca/service.py index b93ffb1..74cc5df 100644 --- a/server/packages/kca-app/src/kca/service.py +++ b/server/packages/kca-app/src/kca/service.py @@ -9,6 +9,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from uuid import UUID from cryptography import x509 +from tortoise.exceptions import IntegrityError from tortoise.expressions import Q from kca.core.algorithms import ( @@ -577,6 +578,35 @@ async def save_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: 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") @@ -1544,6 +1574,7 @@ __all__ = [ "renew_certificate", "revoke_certificate", "save_key", + "save_key_once", "serialize_certificate_detail", "serialize_certificate_summary", "serialize_crl_detail", diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index bf3e39b..d29c713 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -177,6 +177,52 @@ class RouteExtraTests(unittest.TestCase): response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}") 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 def test_invalid_csr_signature_cannot_be_issued(self) -> None: from cryptography import x509