diff --git a/docs/KUBERNETES_TEST_LAB.md b/docs/KUBERNETES_TEST_LAB.md index 5a471e3..3b17ba9 100644 --- a/docs/KUBERNETES_TEST_LAB.md +++ b/docs/KUBERNETES_TEST_LAB.md @@ -187,7 +187,9 @@ digest and by the existing GovOPlaN signature policy. It deploys PostgreSQL, Redis, single-node Garage and GreenMail on the state VM. The API, WebUI, workers and scheduler run in K3s from digest-pinned release images. A private lab CA protects both ingress and S3; backend pods receive only the CA Secret and keep -TLS verification enabled. +TLS verification enabled. The CA profile carries critical `CA:TRUE` and +`keyCertSign,cRLSign` constraints. `deploy` and `update` rotate older lab CAs +that do not satisfy that profile and reissue the ingress/S3 certificate. The final output identifies two local files below `state_directory`: @@ -206,7 +208,8 @@ sudo update-ca-certificates ``` Review mappings before adding them to `/etc/hosts`; the lifecycle does not edit -the workstation's trust or resolver configuration. +the workstation's trust or resolver configuration. Reinstall `pki/ca.crt` in +the client trust store after an automatic CA rotation. ### Enroll the first administrator diff --git a/tests/test_kubernetes_lab.py b/tests/test_kubernetes_lab.py index 8d24e6c..8d322c4 100644 --- a/tests/test_kubernetes_lab.py +++ b/tests/test_kubernetes_lab.py @@ -1,9 +1,11 @@ from __future__ import annotations from contextlib import redirect_stdout +from dataclasses import replace import io import json from pathlib import Path +import shutil import stat import subprocess import sys @@ -24,6 +26,7 @@ from govoplan_lab.lifecycle import ( # noqa: E402 LabOperationError, _assert_domain_owned, _domain_description, + _ensure_certificates, _render_kubectl_wrapper, destroy, ) @@ -234,6 +237,37 @@ class KubernetesLabTests(unittest.TestCase): self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) + @unittest.skipUnless(shutil.which("openssl"), "openssl is required") + def test_generated_lab_ca_passes_strict_chain_validation(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-lab-pki-") as directory: + config = replace( + load_config(REHEARSAL_CONFIG), + state_directory=Path(directory), + ) + _ensure_certificates(config, CommandRunner(config)) + ca_certificate = config.state_directory / "pki" / "ca.crt" + server_certificate = config.state_directory / "pki" / "server.crt" + + result = subprocess.run( + [ + "openssl", + "verify", + "-x509_strict", + "-CAfile", + str(ca_certificate), + str(server_certificate), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual( + 0, + result.returncode, + (result.stdout + result.stderr).decode(errors="replace"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tools/lab/govoplan_lab/lifecycle.py b/tools/lab/govoplan_lab/lifecycle.py index 4419c5f..206988b 100644 --- a/tools/lab/govoplan_lab/lifecycle.py +++ b/tools/lab/govoplan_lab/lifecycle.py @@ -857,6 +857,7 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None: ca_valid = ( ca_key.is_file() and ca_cert.is_file() + and _ca_key_usage_is_valid(runner, ca_cert) and runner.run( [ "openssl", @@ -900,6 +901,10 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None: str(ca_key), "-subj", f"/CN={config.name} private lab CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", "-out", str(ca_cert), ], @@ -988,6 +993,28 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None: write_private(config.state_directory / "hosts", render_hosts(config)) +def _ca_key_usage_is_valid(runner: CommandRunner, certificate: Path) -> bool: + result = runner.run( + [ + "openssl", + "x509", + "-in", + str(certificate), + "-noout", + "-ext", + "keyUsage", + ], + capture=True, + check=False, + ) + output = result.stdout.decode("utf-8", errors="replace") + return ( + result.returncode == 0 + and "Certificate Sign" in output + and "CRL Sign" in output + ) + + def _deploy_state_services( config: LabConfig, runner: CommandRunner,