504 lines
24 KiB
Python
504 lines
24 KiB
Python
"""Additional route-level tests: CSRs, tokens, import/export and templates."""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from tests.helpers.api import api_client
|
|
from tests.helpers.asynctest import async_test
|
|
from tests.helpers.oidc import oidc_user
|
|
|
|
|
|
def _subject(cn: str) -> list[dict]:
|
|
return [{"name": "C", "value": "DE"}, {"name": "O", "value": "KCA"}, {"name": "CN", "value": cn}]
|
|
|
|
|
|
def _extensions(ca: bool = False) -> dict:
|
|
return {
|
|
"basic_constraints": {"critical": True, "ca": ca, "path_length": None},
|
|
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"] if ca else ["digital_signature"]},
|
|
"subject_key_identifier": {"critical": False},
|
|
"authority_key_identifier": {"critical": False},
|
|
}
|
|
|
|
|
|
async def _vault(client) -> str:
|
|
response = await client.post("/api/vaults", json={"name": "Test"})
|
|
assert response.status_code == 201, response.text
|
|
return response.json()["id"]
|
|
|
|
|
|
async def _key(client, vault_id: str, name: str = "key") -> dict:
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/keys", json={"name": name, "key_type": "rsa", "size": 2048}
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
async def _cert(client, vault_id: str, key_id: str, name: str, ca: bool = False, signer_id: str | None = None) -> dict:
|
|
now = datetime.now(timezone.utc)
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates",
|
|
json={
|
|
"name": name,
|
|
"key_id": key_id,
|
|
"signer_id": signer_id,
|
|
"subject": _subject(name),
|
|
"not_before": now.isoformat(),
|
|
"not_after": (now + timedelta(days=365)).isoformat(),
|
|
"extensions": _extensions(ca),
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
class RouteExtraTests(unittest.TestCase):
|
|
@async_test
|
|
async def test_csr_creation_and_signing(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "ca key")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "Root CA", ca=True)
|
|
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"},
|
|
"extensions": _extensions(False),
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
request = response.json()
|
|
assert request["signature_valid"] is True
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates",
|
|
json={
|
|
"name": "web cert",
|
|
"request_id": request["id"],
|
|
"signer_id": ca["id"],
|
|
"not_before": datetime.now(timezone.utc).isoformat(),
|
|
"not_after": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(),
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
cert = response.json()
|
|
assert cert["issuer_id"] == ca["id"]
|
|
response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}")
|
|
assert response.json()["signed"] is True
|
|
|
|
@async_test
|
|
async def test_token_authentication(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
response = await client.post("/api/tokens", json={"name": "script"})
|
|
assert response.status_code == 201, response.text
|
|
secret = response.json()["token"]
|
|
assert secret.startswith("kca_")
|
|
# Session is no longer used: the bearer token authenticates.
|
|
response = await client.get("/api/vaults", headers={"authorization": f"Bearer {secret}"})
|
|
assert response.status_code == 200
|
|
response = await client.post("/api/tokens", headers={"authorization": f"Bearer {secret}"}, json={"name": "x"})
|
|
assert response.status_code == 401
|
|
|
|
@async_test
|
|
async def test_import_preview_and_commit(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
key = await _key(client, vault_id, "import key")
|
|
cert = await _cert(client, vault_id, key["id"], "Import Me", ca=True)
|
|
export = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{cert['id']}/export", json={"format": "pem"}
|
|
)
|
|
pem = base64.b64decode(export.json()["content_base64"])
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/import",
|
|
json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode(), "dry_run": True},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["items"][0]["duplicate"] is True
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/import",
|
|
json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode()},
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(response.json()["skipped"]) == 1
|
|
|
|
@async_test
|
|
async def test_settings_and_templates(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
response = await client.get(f"/api/vaults/{vault_id}/settings")
|
|
assert response.status_code == 200
|
|
version = response.json()["version"]
|
|
response = await client.patch(
|
|
f"/api/vaults/{vault_id}/settings",
|
|
json={"settings": {"default_hash": "sha384", "serial_len_bits": 80}, "version": version},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["settings"]["default_hash"] == "sha384"
|
|
response = await client.get(f"/api/vaults/{vault_id}/templates")
|
|
templates = response.json()["results"]
|
|
assert any(t["id"] == "builtin:ca" for t in templates)
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/templates/builtin:ca/apply",
|
|
json={"target": {"subject": [], "extensions": {}}},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["extensions"]["basic_constraints"]["ca"] is True
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/templates/builtin:tls-server/apply",
|
|
json={
|
|
"target": {
|
|
"subject": [{"name": "CN", "value": "keep.example.com"}],
|
|
"extensions": {},
|
|
},
|
|
"apply_subject": False,
|
|
"apply_extensions": True,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
merged = response.json()
|
|
assert merged["subject"] == [{"name": "CN", "value": "keep.example.com"}]
|
|
assert "serverAuth" in merged["extensions"]["extended_key_usage"]["usages"]
|
|
settings_response = await client.get(f"/api/vaults/{vault_id}/settings")
|
|
response = await client.patch(
|
|
f"/api/vaults/{vault_id}/settings",
|
|
json={
|
|
"settings": {"explicit_dn": ["CN", "not-a-real-attribute"]},
|
|
"version": settings_response.json()["version"],
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
@async_test
|
|
async def test_revocation_and_crl(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "Root", ca=True)
|
|
leaf_key = await _key(client, vault_id, "leaf")
|
|
leaf = await _cert(
|
|
client, vault_id, leaf_key["id"], "Leaf", signer_id=ca["id"]
|
|
)
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
|
json={"reason": "keyCompromise"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
response = await client.get(
|
|
f"/api/vaults/{vault_id}/certificates/{leaf['id']}"
|
|
)
|
|
assert response.json()["revoked"] is True
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/crl",
|
|
json={"days": 30},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
crl = response.json()
|
|
assert crl["revoked_count"] == 1
|
|
assert crl["crl_number"] == 1
|
|
|
|
@async_test
|
|
async def test_key_delete_unlinks(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
key = await _key(client, vault_id)
|
|
cert = await _cert(client, vault_id, key["id"], "Unlink")
|
|
response = await client.delete(f"/api/vaults/{vault_id}/keys/{key['id']}")
|
|
assert response.status_code == 204
|
|
response = await client.get(f"/api/vaults/{vault_id}/certificates/{cert['id']}")
|
|
assert response.json()["key_id"] is None
|
|
|
|
|
|
class ExtendedRouteTests(unittest.TestCase):
|
|
@async_test
|
|
async def test_preview_advanced_extensions(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
key = await _key(client, vault_id)
|
|
payload = {
|
|
"name": "preview",
|
|
"key_id": key["id"],
|
|
"subject": _subject("preview"),
|
|
"extensions": {
|
|
"basic_constraints": {"critical": True, "ca": False, "path_length": None},
|
|
"crl_distribution_points": {"critical": False, "urls": ["http://example.com/ca.crl"]},
|
|
"authority_information_access": {
|
|
"critical": False,
|
|
"descriptions": [{"method": "ocsp", "location": {"type": "uri", "value": "http://ocsp.example.com"}}],
|
|
},
|
|
"raw": [{"oid": "1.2.3.4", "critical": False, "der_hex": "0c03616263"}],
|
|
},
|
|
}
|
|
response = await client.post(f"/api/vaults/{vault_id}/certificates/preview", json=payload)
|
|
assert response.status_code == 200, response.text
|
|
names = [row["name"] for row in response.json()["extensions"]]
|
|
assert "CRL Distribution Points" in names
|
|
assert "Authority Information Access" in names
|
|
|
|
@async_test
|
|
async def test_mandatory_dn_and_no_expiration(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
settings = (await client.get(f"/api/vaults/{vault_id}/settings")).json()
|
|
await client.patch(
|
|
f"/api/vaults/{vault_id}/settings",
|
|
json={"settings": {"mandatory_dn": ["CN"]}, "version": settings["version"]},
|
|
)
|
|
key = await _key(client, vault_id)
|
|
now = datetime.now(timezone.utc)
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates",
|
|
json={
|
|
"name": "missing cn",
|
|
"key_id": key["id"],
|
|
"subject": [{"name": "O", "value": "KCA"}],
|
|
"validity": {"amount": 1, "unit": "days"},
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "mandatory" in response.json()["error"]
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates",
|
|
json={
|
|
"name": "forever",
|
|
"key_id": key["id"],
|
|
"subject": _subject("forever"),
|
|
"validity": {"no_expiration": True},
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
assert response.json()["no_expiration"] is True
|
|
|
|
@async_test
|
|
async def test_ca_template_and_revocation_edit(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "CA", ca=True)
|
|
response = await client.patch(
|
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/ca",
|
|
json={"crl_days": 45, "template_id": "builtin:tls-server"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["default_template"] == "builtin:tls-server"
|
|
assert response.json()["crl_days"] == 45
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations",
|
|
json={"serial_hex": "1234", "reason": "keyCompromise", "invalidity_date": datetime.now(timezone.utc).isoformat()},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
rev = response.json()
|
|
response = await client.patch(
|
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations/{rev['id']}",
|
|
json={"reason": "superseded", "invalidity_date": None},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["reason"] == "superseded"
|
|
|
|
|
|
class VaultExportTests(unittest.TestCase):
|
|
@async_test
|
|
async def test_vault_zip_export(self) -> None:
|
|
import io
|
|
import zipfile
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
key = await _key(client, vault_id, "zip key")
|
|
cert = await _cert(client, vault_id, key["id"], "Zip Cert", ca=True)
|
|
response = await client.post(f"/api/vaults/{vault_id}/export", json={"password": "secret"})
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
data = base64.b64decode(payload["content_base64"])
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
names = archive.namelist()
|
|
assert "manifest.json" in names
|
|
assert any(name.startswith("certificates/") for name in names)
|
|
key_files = [name for name in names if name.startswith("keys/") and name.endswith(".key")]
|
|
assert key_files
|
|
assert b"BEGIN ENCRYPTED PRIVATE KEY" in archive.read(key_files[0])
|
|
manifest = archive.read("manifest.json")
|
|
assert b"Zip Cert" in manifest
|
|
|
|
|
|
class BulkRevocationTests(unittest.TestCase):
|
|
@async_test
|
|
async def test_bulk_revoke_reports_skips_and_errors(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "bulk ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "Bulk CA", ca=True)
|
|
leaf1_key = await _key(client, vault_id, "leaf1")
|
|
leaf1 = await _cert(client, vault_id, leaf1_key["id"], "Leaf 1", signer_id=ca["id"])
|
|
leaf2_key = await _key(client, vault_id, "leaf2")
|
|
leaf2 = await _cert(client, vault_id, leaf2_key["id"], "Leaf 2", signer_id=ca["id"])
|
|
self_key = await _key(client, vault_id, "self")
|
|
self_signed = await _cert(client, vault_id, self_key["id"], "Self")
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={
|
|
"certificate_ids": [leaf1["id"], leaf2["id"], self_signed["id"]],
|
|
"reason": "keyCompromise",
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
result = response.json()
|
|
assert len(result["revoked"]) == 2
|
|
assert len(result["errors"]) == 1
|
|
assert result["errors"][0]["id"] == self_signed["id"]
|
|
response = await client.get(f"/api/vaults/{vault_id}/certificates/{leaf1['id']}")
|
|
assert response.json()["revoked"] is True
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": [leaf1["id"], leaf2["id"]], "reason": "superseded"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert len(response.json()["skipped"]) == 2
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": [], "reason": "superseded"},
|
|
)
|
|
assert response.status_code == 400
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": ["not-a-uuid"], "reason": "superseded"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["errors"][0]["error"] == "invalid certificate id"
|
|
|
|
@async_test
|
|
async def test_bulk_revoke_validation_and_isolation(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded", "invalidity_date": "not-a-date"},
|
|
)
|
|
assert response.status_code == 400
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": [str(i) for i in range(201)], "reason": "superseded"},
|
|
)
|
|
assert response.status_code == 400
|
|
with oidc_user("bob"):
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/bulk-revoke",
|
|
json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
class OcspSnapshotTests(unittest.TestCase):
|
|
@async_test
|
|
async def test_snapshot_contains_status_and_reason(self) -> None:
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "ocsp ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "OCSP CA", ca=True)
|
|
leaf_key = await _key(client, vault_id, "ocsp leaf")
|
|
leaf = await _cert(client, vault_id, leaf_key["id"], "OCSP Leaf", signer_id=ca["id"])
|
|
await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
|
json={"reason": "keyCompromise"},
|
|
)
|
|
response = await client.get(
|
|
f"/api/vaults/{vault_id}/certificates/{ca['id']}/ocsp-status"
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["enabled"] is False
|
|
snapshot = payload["snapshot"]
|
|
assert snapshot["schema"] == "kca-ocsp-status/v1"
|
|
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
|
|
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
|
|
assert entries[leaf["serial_hex"]]["status"] == "revoked"
|
|
assert entries[leaf["serial_hex"]]["reason"] == "keyCompromise"
|
|
assert entries[leaf["serial_hex"]]["revoked_at"]
|
|
|
|
@async_test
|
|
async def test_revocation_marks_snapshot_dirty(self) -> None:
|
|
from dataclasses import replace
|
|
from unittest.mock import patch
|
|
|
|
from kca import ocsp
|
|
from kca.models import OcspSnapshot
|
|
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "dirty ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
|
|
leaf_key = await _key(client, vault_id, "dirty leaf")
|
|
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
|
|
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
|
with patch.object(ocsp, "settings", configured):
|
|
response = await client.post(
|
|
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
|
json={"reason": "superseded"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
row = await OcspSnapshot.get(ca_id=ca["id"])
|
|
assert row.dirty is True
|
|
|
|
@async_test
|
|
async def test_publisher_marks_dirty_and_uploads(self) -> None:
|
|
from dataclasses import replace
|
|
from unittest.mock import patch
|
|
|
|
from kca import ocsp
|
|
from kca.models import OcspSnapshot
|
|
|
|
class FakeS3:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def put_object(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
|
|
fake = FakeS3()
|
|
async with api_client() as client:
|
|
with oidc_user("alice"):
|
|
vault_id = await _vault(client)
|
|
ca_key = await _key(client, vault_id, "ocsp publish ca")
|
|
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
|
|
configured = replace(
|
|
ocsp.settings,
|
|
ocsp_s3_bucket="test-bucket",
|
|
ocsp_s3_prefix="ocsp",
|
|
)
|
|
with patch.object(ocsp, "settings", configured), patch.object(
|
|
ocsp, "_s3_client", lambda: fake
|
|
):
|
|
await ocsp.mark_ocsp_dirty(ca["id"])
|
|
row = await OcspSnapshot.get(ca_id=ca["id"])
|
|
assert row.dirty is True
|
|
async def ensure_context():
|
|
return None
|
|
|
|
mixin = ocsp.OcspPublisherMixin(1, ensure_context)
|
|
await mixin.run_once()
|
|
assert len(fake.calls) == 1
|
|
call = fake.calls[0]
|
|
assert call["Bucket"] == "test-bucket"
|
|
assert call["Key"] == f"ocsp/{ca['fingerprint_sha256']}.json"
|
|
assert b"kca-ocsp-status/v1" in call["Body"]
|
|
row = await OcspSnapshot.get(ca_id=ca["id"])
|
|
assert row.dirty is False
|
|
assert row.published_at is not None
|