Implement KCA: web reimplementation of XCA PKI workflows
- kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models - AES-256-GCM private key storage and full certificate/CSR/CRL domain logic - Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates - imports/exports, XCA .xdb migration, Docker Compose stack and tests
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Test package init: set environment overrides before importing kca.app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_0_PROVIDER", "default")
|
||||
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/kca")
|
||||
os.environ.setdefault("OIDC_0_CLIENT_ID", "kca")
|
||||
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
|
||||
os.environ.setdefault("KCA_MASTER_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""In-process HTTP client for the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import AsyncIterator
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from kca.app import app, tortoise_mixin
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def api_client() -> AsyncIterator[AsyncClient]:
|
||||
await tortoise_mixin.ensure_context()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
yield client
|
||||
@@ -0,0 +1,23 @@
|
||||
"""``async_test`` that closes the app's per-loop Tortoise context."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from kca.app import tortoise_mixin
|
||||
|
||||
|
||||
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||
@wraps(coro)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
async def run() -> None:
|
||||
try:
|
||||
await coro(*args, **kwargs)
|
||||
finally:
|
||||
await tortoise_mixin.aclose()
|
||||
|
||||
with asyncio.Runner() as runner:
|
||||
runner.run(run())
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Fake the OIDC-authenticated user during tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import unittest.mock as _mock
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
from kaya.oidc import OIDCUser
|
||||
|
||||
from kca.app import oidc
|
||||
|
||||
_PROVIDER = next(iter(oidc.providers))
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None, **claims: Any) -> OIDCUser:
|
||||
data: dict = {"sub": sub, "preferred_username": name or sub}
|
||||
data.update(claims)
|
||||
return OIDCUser(data)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
**claims: Any,
|
||||
) -> Iterator[OIDCUser]:
|
||||
data: dict = {"sub": sub, "preferred_username": name or sub}
|
||||
data.update(claims)
|
||||
user = OIDCUser(data)
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
finally:
|
||||
patcher.stop()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,126 @@
|
||||
"""End-to-end smoke tests for the core PKI workflow."""
|
||||
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 = "Test CA") -> list[dict]:
|
||||
return [
|
||||
{"name": "C", "value": "DE"},
|
||||
{"name": "O", "value": "KCA"},
|
||||
{"name": "CN", "value": cn},
|
||||
]
|
||||
|
||||
|
||||
def _extensions() -> dict:
|
||||
return {
|
||||
"basic_constraints": {"critical": True, "ca": True, "path_length": None},
|
||||
"key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]},
|
||||
"subject_key_identifier": {"critical": False},
|
||||
"authority_key_identifier": {"critical": False},
|
||||
}
|
||||
|
||||
|
||||
class SmokeTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_health_and_auth(self) -> None:
|
||||
async with api_client() as client:
|
||||
response = await client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
response = await client.get("/api/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
@async_test
|
||||
async def test_core_workflow(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice", "Alice"):
|
||||
response = await client.get("/api/me")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["display_name"] == "Alice"
|
||||
|
||||
response = await client.post("/api/vaults", json={"name": "Test Vault"})
|
||||
assert response.status_code == 201, response.text
|
||||
vault = response.json()
|
||||
vault_id = vault["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/keys",
|
||||
json={"name": "CA key", "key_type": "rsa", "size": 2048},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
key = response.json()
|
||||
assert key["has_private"] is True
|
||||
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates",
|
||||
json={
|
||||
"name": "Test CA",
|
||||
"comment": "",
|
||||
"key_id": key["id"],
|
||||
"subject": _subject(),
|
||||
"not_before": now.isoformat(),
|
||||
"not_after": (now + timedelta(days=3650)).isoformat(),
|
||||
"signature_hash": "sha256",
|
||||
"extensions": _extensions(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
cert = response.json()
|
||||
assert cert["is_ca"] is True
|
||||
assert cert["subject_rfc4514"].startswith("CN=Test CA")
|
||||
assert cert["validation_errors"] == []
|
||||
|
||||
response = await client.get(f"/api/vaults/{vault_id}/certificates")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["results"]) == 1
|
||||
|
||||
response = await client.get(f"/api/vaults/{vault_id}/search?q=test")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["results"]) >= 1
|
||||
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{cert['id']}/crl",
|
||||
json={"days": 30},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
crl = response.json()
|
||||
assert crl["crl_number"] == 1
|
||||
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{cert['id']}/export",
|
||||
json={"format": "pem"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert base64.b64decode(payload["content_base64"]).startswith(b"-----BEGIN CERTIFICATE")
|
||||
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/import",
|
||||
json={
|
||||
"filename": "ca.crt",
|
||||
"content_base64": payload["content_base64"],
|
||||
"dry_run": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["items"][0]["duplicate"] is True
|
||||
|
||||
@async_test
|
||||
async def test_ownership_isolation(self) -> None:
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice", "Alice"):
|
||||
response = await client.post("/api/vaults", json={"name": "Alice Vault"})
|
||||
vault_id = response.json()["id"]
|
||||
with oidc_user("bob", "Bob"):
|
||||
response = await client.get(f"/api/vaults/{vault_id}")
|
||||
assert response.status_code == 404
|
||||
response = await client.get("/api/vaults")
|
||||
assert response.json()["results"] == []
|
||||
Reference in New Issue
Block a user