Harden Kubernetes lab CA profile [skip ci]

This commit is contained in:
2026-08-05 21:17:32 +02:00
parent bf2f02891f
commit 3b9ae901dd
3 changed files with 66 additions and 2 deletions
+5 -2
View File
@@ -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 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 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 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`: 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 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 ### Enroll the first administrator
+34
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from contextlib import redirect_stdout from contextlib import redirect_stdout
from dataclasses import replace
import io import io
import json import json
from pathlib import Path from pathlib import Path
import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
@@ -24,6 +26,7 @@ from govoplan_lab.lifecycle import ( # noqa: E402
LabOperationError, LabOperationError,
_assert_domain_owned, _assert_domain_owned,
_domain_description, _domain_description,
_ensure_certificates,
_render_kubectl_wrapper, _render_kubectl_wrapper,
destroy, destroy,
) )
@@ -234,6 +237,37 @@ class KubernetesLabTests(unittest.TestCase):
self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+27
View File
@@ -857,6 +857,7 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
ca_valid = ( ca_valid = (
ca_key.is_file() ca_key.is_file()
and ca_cert.is_file() and ca_cert.is_file()
and _ca_key_usage_is_valid(runner, ca_cert)
and runner.run( and runner.run(
[ [
"openssl", "openssl",
@@ -900,6 +901,10 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
str(ca_key), str(ca_key),
"-subj", "-subj",
f"/CN={config.name} private lab CA", f"/CN={config.name} private lab CA",
"-addext",
"basicConstraints=critical,CA:TRUE",
"-addext",
"keyUsage=critical,keyCertSign,cRLSign",
"-out", "-out",
str(ca_cert), str(ca_cert),
], ],
@@ -988,6 +993,28 @@ def _ensure_certificates(config: LabConfig, runner: CommandRunner) -> None:
write_private(config.state_directory / "hosts", render_hosts(config)) 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( def _deploy_state_services(
config: LabConfig, config: LabConfig,
runner: CommandRunner, runner: CommandRunner,