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,302 @@
|
||||
"""Core domain tests: names, extensions, keys, certificates, CRLs and exports."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from kca.core import certificates, crls, crypto, exports, imports, keys, names, requests, templates
|
||||
from kca.core.extensions import (
|
||||
build_extensions,
|
||||
default_extension_spec,
|
||||
extension_specs,
|
||||
)
|
||||
|
||||
|
||||
def _name(cn: str = "Test CA") -> x509.Name:
|
||||
return names.name_from_entries(
|
||||
names.entries_from_json(
|
||||
[
|
||||
{"name": "C", "value": "DE"},
|
||||
{"name": "O", "value": "KCA"},
|
||||
{"name": "CN", "value": cn},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _ca_spec() -> dict:
|
||||
spec = default_extension_spec()
|
||||
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
|
||||
spec["key_usage"] = {"critical": True, "usages": ["key_cert_sign", "crl_sign"]}
|
||||
spec["subject_key_identifier"] = {"critical": False}
|
||||
spec["authority_key_identifier"] = {"critical": False}
|
||||
return spec
|
||||
|
||||
|
||||
class NamesTest(unittest.TestCase):
|
||||
def test_order_and_duplicates_survive(self) -> None:
|
||||
name = _name()
|
||||
entries = names.entries_from_name(name)
|
||||
self.assertEqual([e.name for e in entries], ["C", "O", "CN"])
|
||||
self.assertEqual(name.rfc4514_string(), "CN=Test CA,O=KCA,C=DE")
|
||||
|
||||
def test_der_roundtrip(self) -> None:
|
||||
name = _name()
|
||||
self.assertEqual(names.name_from_der(names.name_to_der(name)), name)
|
||||
|
||||
|
||||
class ExtensionTest(unittest.TestCase):
|
||||
def test_roundtrip(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
spec = _ca_spec()
|
||||
spec["subject_alt_name"] = {
|
||||
"critical": False,
|
||||
"entries": [
|
||||
{"type": "dns", "value": "example.com"},
|
||||
{"type": "ip", "value": "192.0.2.1"},
|
||||
{"type": "email", "value": "ca@example.com"},
|
||||
],
|
||||
}
|
||||
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
|
||||
parsed = extension_specs(exts)
|
||||
self.assertTrue(parsed["basic_constraints"]["ca"])
|
||||
self.assertEqual(parsed["basic_constraints"]["path_length"], None)
|
||||
self.assertEqual(len(parsed["subject_alt_name"]["entries"]), 3)
|
||||
self.assertIn("key_cert_sign", parsed["key_usage"]["usages"])
|
||||
|
||||
def test_raw_extension(self) -> None:
|
||||
key = keys.generate_key("ec", curve="secp256r1")
|
||||
spec = default_extension_spec()
|
||||
spec["raw"] = [{"oid": "1.2.3.4", "critical": True, "der_hex": "0c03616263"}]
|
||||
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name())
|
||||
self.assertEqual(exts[0].oid.dotted_string, "1.2.3.4")
|
||||
self.assertTrue(exts[0].critical)
|
||||
|
||||
|
||||
class KeyTest(unittest.TestCase):
|
||||
def test_all_types(self) -> None:
|
||||
for key_type, kwargs in [
|
||||
("rsa", {"size": 2048}),
|
||||
("ec", {"curve": "secp256r1"}),
|
||||
("ed25519", {}),
|
||||
]:
|
||||
key = keys.generate_key(key_type, **kwargs)
|
||||
info = keys.key_info(key)
|
||||
self.assertTrue(info.is_private)
|
||||
self.assertEqual(info.key_type.upper() if key_type != "ed25519" else "ED25519", info.key_type)
|
||||
loaded, private = keys.load_key_bytes(keys.private_pem(key))
|
||||
self.assertTrue(private)
|
||||
self.assertEqual(keys.public_der(loaded), info.public_der)
|
||||
|
||||
def test_encrypted_export_roundtrip(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
encrypted = keys.private_pem(key, password="secret")
|
||||
loaded, private = keys.load_key_bytes(encrypted, password="secret")
|
||||
self.assertTrue(private)
|
||||
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
|
||||
|
||||
def test_ssh_and_pvk(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
public = keys.ssh_public(key, "test")
|
||||
loaded, private = keys.load_key_bytes(public.encode())
|
||||
self.assertFalse(private)
|
||||
pvk = keys.pvk_encode_private(key)
|
||||
loaded, private = keys.load_key_bytes(pvk)
|
||||
self.assertTrue(private)
|
||||
self.assertEqual(keys.public_der(loaded), keys.public_der(key))
|
||||
|
||||
|
||||
class CertificateTest(unittest.TestCase):
|
||||
def test_issue_and_verify(self) -> None:
|
||||
ca_key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
ca = certificates.build_self_signed(
|
||||
subject=_name("Root CA"),
|
||||
private_key=ca_key,
|
||||
serial=1,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=3650),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
self.assertTrue(certificates.is_self_signed(ca))
|
||||
self.assertTrue(certificates.certificate_info(ca).is_ca)
|
||||
|
||||
leaf_key = keys.generate_key("ec", curve="secp256r1")
|
||||
spec = default_extension_spec()
|
||||
spec["basic_constraints"] = {"critical": True, "ca": False, "path_length": None}
|
||||
spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_encipherment"]}
|
||||
spec["extended_key_usage"] = {"critical": False, "usages": ["serverAuth"]}
|
||||
spec["subject_alt_name"] = {"critical": False, "entries": [{"type": "dns", "value": "example.com"}]}
|
||||
leaf = certificates.build_signed(
|
||||
subject=_name("example.com"),
|
||||
public_key=leaf_key.public_key(),
|
||||
issuer_cert=ca,
|
||||
issuer_private_key=ca_key,
|
||||
serial=2,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=365),
|
||||
extension_spec=spec,
|
||||
)
|
||||
self.assertTrue(certificates.verify_directly_issued_by(leaf, ca))
|
||||
self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf))
|
||||
self.assertIn("SSL server", certificates.purposes(leaf))
|
||||
parsed = certificates.parse_certificate(certificates.certificate_der(leaf))
|
||||
self.assertEqual(parsed.serial_number, 2)
|
||||
self.assertEqual(certificates.find_issuer(parsed, [ca]), ca)
|
||||
self.assertEqual(certificates.validation_errors(parsed, [parsed, ca]), [])
|
||||
|
||||
def test_renew_spec(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = certificates.build_self_signed(
|
||||
subject=_name("Renew Me"),
|
||||
private_key=key,
|
||||
serial=3,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
spec = certificates.certificate_spec(cert)
|
||||
self.assertEqual(spec["serial_hex"], "3")
|
||||
self.assertEqual(spec["subject"][-1]["name"], "CN")
|
||||
|
||||
def test_index_and_config(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = certificates.build_self_signed(
|
||||
subject=_name("Index"),
|
||||
private_key=key,
|
||||
serial=4,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
self.assertIn("V\t", certificates.index_entry(cert))
|
||||
self.assertIn("basicConstraints", certificates.openssl_config(cert))
|
||||
self.assertIn("VCALENDAR", certificates.ics_calendar([certificates.ics_event(cert, "Index")]))
|
||||
|
||||
|
||||
class RequestTest(unittest.TestCase):
|
||||
def test_csr_roundtrip(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
csr = requests.build_csr(
|
||||
subject=_name("CSR"),
|
||||
private_key=key,
|
||||
extension_spec=_ca_spec(),
|
||||
attributes={requests.CHALLENGE_PASSWORD_OID: "secret"},
|
||||
)
|
||||
self.assertTrue(requests.verify_csr(csr))
|
||||
loaded = requests.parse_csr(requests.csr_der(csr))
|
||||
self.assertEqual(requests.request_info(loaded).subject, csr.subject)
|
||||
self.assertEqual(requests.attribute_values(loaded)[requests.CHALLENGE_PASSWORD_OID], "secret")
|
||||
|
||||
|
||||
class CrlTest(unittest.TestCase):
|
||||
def test_build_and_parse(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
ca = certificates.build_self_signed(
|
||||
subject=_name("CRL CA"),
|
||||
private_key=key,
|
||||
serial=5,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=3650),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
crl = crls.build_crl(
|
||||
issuer_cert=ca,
|
||||
issuer_private_key=key,
|
||||
revocations=[
|
||||
{
|
||||
"serial_hex": "10",
|
||||
"revoked_at": now,
|
||||
"reason": "keyCompromise",
|
||||
"invalidity_date": now - timedelta(days=1),
|
||||
}
|
||||
],
|
||||
last_update=now,
|
||||
next_update=now + timedelta(days=30),
|
||||
crl_number=7,
|
||||
)
|
||||
info = crls.crl_info(crl)
|
||||
self.assertEqual(info.crl_number, 7)
|
||||
self.assertEqual(info.revoked_count, 1)
|
||||
self.assertTrue(crls.verify_crl(crl, ca))
|
||||
parsed = crls.parse_crl(crls.crl_der(crl))
|
||||
rows = crls.crl_revocations(parsed)
|
||||
self.assertEqual(rows[0]["serial_hex"], "10")
|
||||
self.assertEqual(rows[0]["reason"], "keyCompromise")
|
||||
|
||||
|
||||
class TemplateTest(unittest.TestCase):
|
||||
def test_xca_roundtrip(self) -> None:
|
||||
template = templates.builtin_templates()[0].as_dict()
|
||||
data = templates.template_to_xca_data(template)
|
||||
parsed = templates.template_from_xca_data(data)
|
||||
self.assertEqual(parsed["name"], "CA")
|
||||
self.assertTrue(parsed["settings"]["extensions"]["basic_constraints"]["ca"])
|
||||
|
||||
def test_apply(self) -> None:
|
||||
template = templates.builtin_templates()[1].as_dict()
|
||||
merged = templates.apply_template(template, {"subject": [], "extensions": {}})
|
||||
self.assertIn("serverAuth", merged["extensions"]["extended_key_usage"]["usages"])
|
||||
|
||||
|
||||
class ExportImportTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
self.cert = certificates.build_self_signed(
|
||||
subject=_name("Export"),
|
||||
private_key=self.key,
|
||||
serial=6,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=30),
|
||||
extension_spec=_ca_spec(),
|
||||
)
|
||||
|
||||
def test_certificate_exports(self) -> None:
|
||||
for fmt, prefix in [
|
||||
("pem", b"-----BEGIN CERTIFICATE"),
|
||||
("der", b"0"),
|
||||
("pkcs7", b"0"),
|
||||
("pkcs12", b"0"),
|
||||
]:
|
||||
result = exports.export_certificate(
|
||||
self.cert, fmt, name="export", private_key=self.key, password="secret"
|
||||
)
|
||||
self.assertTrue(result.data.startswith(prefix), fmt)
|
||||
pem = exports.export_certificate(self.cert, "pem", name="export")
|
||||
items = imports.parse_file(pem.data, "export.crt")
|
||||
self.assertEqual(items[0].kind, "certificate")
|
||||
|
||||
def test_key_exports(self) -> None:
|
||||
for fmt in ("pem-public", "pem-private", "pkcs8", "ssh-public", "jwk-public", "pvk"):
|
||||
result = exports.export_key(self.key, fmt, name="export")
|
||||
self.assertTrue(result.data)
|
||||
encrypted = exports.export_key(self.key, "pkcs8-encrypted", name="export", password="secret")
|
||||
loaded, private = keys.load_key_bytes(encrypted.data, password="secret")
|
||||
self.assertTrue(private)
|
||||
|
||||
def test_pkcs12_contains_chain(self) -> None:
|
||||
result = exports.export_certificate(
|
||||
self.cert, "pkcs12-chain", name="export", chain=[self.cert], private_key=self.key, password="secret"
|
||||
)
|
||||
items = imports.parse_file(result.data, "export.pfx", "secret")
|
||||
self.assertGreaterEqual(len(items), 2)
|
||||
|
||||
|
||||
class CryptoTest(unittest.TestCase):
|
||||
def test_envelope(self) -> None:
|
||||
master = crypto.generate_master_key()
|
||||
blob = crypto.encrypt_private_key(b"secret", "key-id", crypto.decode_master_key(master))
|
||||
self.assertEqual(crypto.decrypt_private_key(blob, "key-id", crypto.decode_master_key(master)), b"secret")
|
||||
with self.assertRaises(crypto.DecryptionError):
|
||||
crypto.decrypt_private_key(blob, "other-id", crypto.decode_master_key(master))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""XCA .xdb import tests using a synthetic schema-compatible database."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import sqlite3
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from kca.core import certificates, crls, keys, names, requests, templates
|
||||
from kca.core.extensions import build_extensions, default_extension_spec
|
||||
from kca.core.xca_db import import_xca_database
|
||||
|
||||
|
||||
def _name(cn: str) -> object:
|
||||
return names.name_from_entries(names.entries_from_json([{"name": "CN", "value": cn}]))
|
||||
|
||||
|
||||
class XcaDbTest(unittest.TestCase):
|
||||
def test_import_synthetic_database(self) -> None:
|
||||
key = keys.generate_key("rsa", 2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
spec = default_extension_spec()
|
||||
spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None}
|
||||
exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name("Synthetic CA"))
|
||||
cert = certificates.build_certificate(
|
||||
subject=_name("Synthetic CA"),
|
||||
public_key=key.public_key(),
|
||||
issuer_cert=None,
|
||||
issuer_private_key=key,
|
||||
serial=42,
|
||||
not_before=now,
|
||||
not_after=now + timedelta(days=365),
|
||||
extensions=exts,
|
||||
)
|
||||
csr = requests.build_csr(subject=_name("Synthetic CSR"), private_key=key, extension_spec=spec)
|
||||
crl = crls.build_crl(
|
||||
issuer_cert=cert,
|
||||
issuer_private_key=key,
|
||||
revocations=[{"serial_hex": "2a", "revoked_at": now, "reason": "keyCompromise"}],
|
||||
last_update=now,
|
||||
next_update=now + timedelta(days=30),
|
||||
crl_number=3,
|
||||
)
|
||||
tmpl = templates.builtin_templates()[0].as_dict()
|
||||
tmpl_pem = templates.template_to_xca_data(tmpl)
|
||||
body = tmpl_pem.decode().split("-----BEGIN XCA TEMPLATE-----")[1].split("-----END XCA TEMPLATE-----")[0]
|
||||
payload = base64.b64decode("".join(body.split()))
|
||||
tmpl_data = payload[8:]
|
||||
|
||||
date = now.strftime("%Y%m%d%H%M%SZ")
|
||||
with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle:
|
||||
path = handle.name
|
||||
try:
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE settings (key_ CHAR(20) UNIQUE, value TEXT);
|
||||
CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(128), type INTEGER, source INTEGER, date CHAR(15), comment VARCHAR(2048), stamp INTEGER DEFAULT 0, del SMALLINT DEFAULT 0);
|
||||
CREATE TABLE public_keys (item INTEGER, type CHAR(4), hash INTEGER, len INTEGER, "public" TEXT);
|
||||
CREATE TABLE private_keys (item INTEGER, ownPass INTEGER, private TEXT);
|
||||
CREATE TABLE x509super (item INTEGER, subj_hash INTEGER, pkey INTEGER, key_hash INTEGER);
|
||||
CREATE TABLE requests (item INTEGER, hash INTEGER, signed INTEGER, request TEXT);
|
||||
CREATE TABLE certs (item INTEGER, hash INTEGER, iss_hash INTEGER, serial VARCHAR(64), issuer INTEGER, ca INTEGER, cert TEXT);
|
||||
CREATE TABLE authority (item INTEGER, template INTEGER, crlExpire CHAR(15), crlNo INTEGER, crlDays INTEGER);
|
||||
CREATE TABLE crls (item INTEGER, hash INTEGER, num INTEGER, iss_hash INTEGER, issuer INTEGER, crl TEXT);
|
||||
CREATE TABLE revocations (caId INTEGER, serial VARCHAR(64), date CHAR(15), invaldate CHAR(15), crlNo INTEGER, reasonBit INTEGER);
|
||||
CREATE TABLE templates (item INTEGER, version INTEGER, template TEXT);
|
||||
CREATE TABLE takeys (item INTEGER UNIQUE, value TEXT);
|
||||
"""
|
||||
)
|
||||
db.execute("INSERT INTO settings VALUES ('schema', '8')")
|
||||
db.execute("INSERT INTO items VALUES (1, 'Synthetic key', 1, 2, ?, '', 0, 0)", (date,))
|
||||
db.execute("INSERT INTO items VALUES (2, 'Synthetic CA', 3, 2, ?, '', 0, 0)", (date,))
|
||||
db.execute("INSERT INTO items VALUES (3, 'Synthetic CSR', 2, 2, ?, '', 0, 0)", (date,))
|
||||
db.execute("INSERT INTO items VALUES (4, 'Synthetic CRL', 4, 2, ?, '', 0, 0)", (date,))
|
||||
db.execute("INSERT INTO items VALUES (5, 'Synthetic template', 5, 2, ?, '', 0, 0)", (date,))
|
||||
encrypted = key.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.BestAvailableEncryption(b"dbpass"),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO public_keys VALUES (1, 'RSA', 0, 2048, ?)",
|
||||
(base64.b64encode(keys.public_der(key)).decode(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO private_keys VALUES (1, 0, ?)",
|
||||
(base64.b64encode(encrypted).decode(),),
|
||||
)
|
||||
db.execute("INSERT INTO x509super VALUES (2, 0, 1, 0)")
|
||||
db.execute("INSERT INTO x509super VALUES (3, 0, 1, 0)")
|
||||
db.execute(
|
||||
"INSERT INTO certs VALUES (2, 0, 0, '2a', NULL, 1, ?)",
|
||||
(base64.b64encode(certificates.certificate_der(cert)).decode(),),
|
||||
)
|
||||
db.execute("INSERT INTO authority VALUES (2, NULL, NULL, 0, 30)")
|
||||
db.execute(
|
||||
"INSERT INTO revocations VALUES (2, '2a', ?, NULL, 0, 1)",
|
||||
(date,),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO requests VALUES (3, 0, 0, ?)",
|
||||
(base64.b64encode(requests.csr_der(csr)).decode(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO crls VALUES (4, 0, 1, 0, 2, ?)",
|
||||
(base64.b64encode(crls.crl_der(crl)).decode(),),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO templates VALUES (5, 10, ?)",
|
||||
(base64.b64encode(tmpl_data).decode(),),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with open(path, "rb") as handle:
|
||||
items = import_xca_database(handle.read(), "synthetic.xdb", "dbpass")
|
||||
finally:
|
||||
import os
|
||||
|
||||
os.unlink(path)
|
||||
kinds = [item.kind for item in items]
|
||||
self.assertEqual(kinds, ["key", "certificate", "request", "crl", "template"])
|
||||
self.assertTrue(items[0].is_private)
|
||||
self.assertEqual(items[1].name, "Synthetic CA")
|
||||
self.assertEqual(items[3].name, "Synthetic CRL")
|
||||
self.assertEqual(items[4].obj["name"], "Synthetic template")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user