ADR-0006: Name-constrained dev CA stored per profile
Status: accepted · Date: 2026-09-29 · Decision makers: James Hebden
Context and Problem Statement
Local clusters terminate TLS for hostnames like gitlab.test. Browsers and tools should trust those certificates without click-through warnings. That means a CA the developer trusts system-wide, and a way for the cluster’s cert-manager to issue certificates from it. A CA that’s trusted everywhere and has its key sitting on disk and in a cluster is a sharp tool, so the design needed to limit what it can sign.
Decision Drivers
- Trust only what the developer’s own environment needs
- No manual certificate steps for the common case
- The cluster has to hold signing capability for cert-manager to issue leaf certificates
- Regeneration and rotation shouldn’t leave a half-written CA visible
- Existing trusted CAs from Caproni shouldn’t be orphaned
Considered Options
- Generate an ECDSA P-256 CA per profile with Name Constraints (the default subtrees
.local,.test, and.svc, plus the configured hostname and cluster domain when they fall outside those); store it under the data directory; deploy it to the cluster as a Secret for cert-manager - Shell out to
mkcert - Use an unconstrained CA
- Use a CA generated inside the cluster only, and export its certificate for trust
Decision Outcome
Chosen option: “Generated, name-constrained CA per profile”, because Name Constraints bound the damage if the key leaks (to non-public names, though the defaults cover every .test, .local, and .svc name, not just the project’s) and the whole flow needs no extra tool. Option 4 avoids putting the key in a Secret but makes the CA vanish with the cluster, so the trusted certificate would change on every recreate.
Details: rcgen (ring backend) with a 10-year lifetime, notBefore back-dated one hour, a 128-bit random serial, IsCa::Ca(BasicConstraints::Constrained(0)), KeyCertSign and CrlSign. Files live in gen-<random>/{ca.key,ca.crt} under ~/.local/share/xianyun/<profile>/pki/ and a relative current symlink is swapped atomically. ensure_current re-signs with the same key when constraints change and regenerates on expiry.
Consequences
Good:
- Atomic publish means readers never see a partial CA
- The key file is created
0600withcreate_new
Bad:
- The CA key is copied into a kustomize staging directory under
$TMPDIRwith mode644inside a755directory (measured at the backend on 2026-10-13; latent until the deployer registry gap is fixed) (TM-PKI-001) - The key is stored as a cluster Secret, and
PkiCaProvider::loadcan generate a CA with no Name Constraints on a profile that has none (TM-PKI-002) - With
driver: external, the cluster reports its own DNS zone and that value feeds the constraints without validation (TM-PKI-003) - Directories are created with the default umask, loading doesn’t verify that the key matches the certificate, and client enforcement of Name Constraints varies (TM-PKI-004, TM-PKI-005, TM-PKI-006)
- A 10-year lifetime is long for a certificate whose key lives in a cluster
Neutral:
- The common name is still “Caproni Dev CA”; see ADR-0003
Implementation Location
crates/xianyun-pki/src/ca.rscrates/xianyun-pki/src/constraints.rscrates/xianyun-pki/src/truststore/install.rscrates/xianyun-deploy/src/deployer/kustomize/camaterial.rs