diff --git a/deploy/k8s/ca/README.md b/deploy/k8s/ca/README.md new file mode 100644 index 00000000..c579c896 --- /dev/null +++ b/deploy/k8s/ca/README.md @@ -0,0 +1,451 @@ +# VNC internal CA — EJBCA Community on microk8s + +Runbook for `A-01` / `A-06`. Issues 1-year S/MIME certificates for VNCmail+. + +You run every command here. Claude wrote the manifests and cannot reach the +cluster (no kubeconfig on the authoring machine), and the root-key ceremony in +§3 **must not** be automated by an agent — the entire value of an offline root is +that its private key never exists on a machine that runs services or tooling. + +--- + +## 0. One decision to make before you type anything + +**Name the root for the organisation, not the environment.** + +You asked for sandbox first with the ability to promote to `vncmail` at any time. +The way that stays cheap is a single root, generated once, with *per-environment +intermediates* underneath it: + +``` +VNC Root CA R1 offline · 15y · RSA 4096 · pathlen:1 +├─ VNC S/MIME Issuing CA Sandbox R1 in-cluster · 5y · RSA 4096 · pathlen:0 → *@sandbox.vnc.de +└─ VNC S/MIME Issuing CA R1 in-cluster · 5y · RSA 4096 · pathlen:0 → *@vncmail.de (later) +``` + +Promotion is then "issue a second intermediate from the same root" — a one-hour +ceremony. The trust anchor you distribute to laptops, phones and partners does +not change, and certificates already issued keep validating. + +The alternative — a throwaway `VNC Sandbox Root` — means that on promotion you +redistribute a new trust anchor to every device and every external party who +ever verified one of your signatures. That is the expensive path, and it is only +visible as expensive later. + +So: **generate the root at prod grade, once, now**, even though the first +intermediate only serves `@sandbox.vnc.de`. The extra cost today is choosing a +better passphrase and a safe to keep the USB key in. + +> RSA 4096 rather than an elliptic curve throughout, deliberately. ECDSA S/MIME +> is still poorly handled by older Outlook and by several mobile clients, and +> S/MIME interop failures are silent — the recipient sees a broken signature, not +> an error you get told about. Pay the key-size cost for interop you can't test. + +--- + +## 1. Install + +```bash +kubectl apply -f deploy/k8s/ca/namespace.yaml +``` + +Fill in and apply the secret out-of-band (never commit real values): + +```bash +cp deploy/k8s/ca/secret.example.yaml /tmp/ca-secret.yaml && $EDITOR /tmp/ca-secret.yaml +``` + +```bash +kubectl apply -f /tmp/ca-secret.yaml && shred -u /tmp/ca-secret.yaml +``` + +```bash +kubectl apply -k deploy/k8s/ca/ +``` + +First boot builds the EJBCA schema and takes several minutes. Watch it rather +than assuming it hung: + +```bash +kubectl -n vnc-ca logs -f deploy/ejbca +``` + +```bash +kubectl -n vnc-ca get pods -w +``` + +### Verify before going further + +```bash +kubectl -n vnc-ca exec deploy/ejbca -- curl -sf http://localhost:8080/ejbca/publicweb/healthcheck/ejbcahealth && echo OK +``` + +If the manifests' env-var names have drifted from the image tag you pulled, this +is where it shows up — EJBCA will start but fail to bind its datasource. Check +the documented variables for your tag before editing anything else: + +```bash +kubectl -n vnc-ca logs deploy/ejbca | grep -iE "datasource|jdbc|database" +``` + +--- + +## 2. Get administrative access + +EJBCA's admin web requires a client certificate. On first boot the container +enrols a `SuperAdmin` and writes a PKCS#12 inside the pod. + +```bash +kubectl -n vnc-ca exec deploy/ejbca -- find / -name "*.p12" -newermt "-1 day" 2>/dev/null +``` + +Copy it out, import it into your browser, then reach the admin web by +port-forward — it is not exposed through any ingress and must not be: + +```bash +kubectl -n vnc-ca port-forward deploy/ejbca 8443:8443 +``` + +Then open `https://localhost:8443/ejbca/adminweb`. + +> If the container did not create a SuperAdmin (behaviour differs by tag), use +> the CLI inside the pod instead: +> `kubectl -n vnc-ca exec -it deploy/ejbca -- /opt/keyfactor/bin/ejbca.sh ra addendentity ...` +> followed by `setclearpwd` and a browser enrolment against +> `https://localhost:8443/ejbca/ra/`. + +--- + +## 3. Root ceremony — you, offline, once + +Do this on a machine that is **not** this cluster and **not** your daily laptop +if you can manage it. A live USB session on a machine with networking physically +off is enough for a sandbox-grade start; the point is that the root key never +touches a host that runs services. + +Everything below happens in one directory that you will destroy at the end. + +**3.1 Prepare the config.** Save as `root.cnf`: + +```ini +[ req ] +default_md = sha256 +prompt = no +distinguished_name = dn +x509_extensions = root_ext + +[ dn ] +C = CH +O = VNC AG +CN = VNC Root CA R1 + +[ root_ext ] +basicConstraints = critical,CA:TRUE,pathlen:1 +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash + +# --- used in 3.4 to sign the intermediate CSR --- +[ ca ] +default_ca = CA_root + +[ CA_root ] +new_certs_dir = . +database = index.txt +serial = serial +private_key = root.key +certificate = root.crt +default_md = sha256 +policy = policy_any +crl = root.crl +default_crl_days = 365 +unique_subject = no + +[ policy_any ] +countryName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied + +[ int_ext ] +basicConstraints = critical,CA:TRUE,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +# Revocation pointers for the INTERMEDIATE itself, served by the root's CRL. +crlDistributionPoints = URI:http://ca.sandbox.vnc.de/ejbca/publicweb/crls/root.crl +``` + +`pathlen:1` on the root and `pathlen:0` on the intermediate together mean the +intermediate can issue end-entity certificates and nothing else. It cannot mint +a further CA even if its key is stolen — that limits a compromise to "revoke one +intermediate" instead of "the whole hierarchy is untrustworthy". + +**3.2 Generate the root key.** You will be asked for a passphrase. Generate it +with a password manager, minimum 24 random characters, and record where it lives +*before* you type it — a root key whose passphrase is lost is a hierarchy you +have to rebuild. + +```bash +openssl genrsa -aes256 -out root.key 4096 +``` + +**3.3 Self-sign the root.** 15 years, so it outlives several intermediate +rotations and you do the ceremony once: + +```bash +openssl req -new -x509 -config root.cnf -key root.key -sha256 -days 5480 -out root.crt +``` + +```bash +openssl x509 -in root.crt -noout -text | sed -n '1,25p' +``` + +Confirm in that output: `CA:TRUE, pathlen:1`, `Key Usage: Certificate Sign, CRL Sign`, +and a 15-year validity window. If `basicConstraints` is missing the root is +useless — the config's `x509_extensions` did not apply. + +**3.4 Sign the intermediate.** EJBCA generates the intermediate key *inside the +cluster* and hands you a CSR; the intermediate's private key never leaves EJBCA +and never appears in this directory. + +In the admin web: **CA Functions → Certificate Authorities → Create CA** +- Name: `VNC S/MIME Issuing CA Sandbox R1` +- Subject DN: `CN=VNC S/MIME Issuing CA Sandbox R1,O=VNC AG,C=CH` +- Crypto Token: create a new soft token, PIN = `EJBCA_CRYPTO_TOKEN_PIN` from your secret +- Key: RSA 4096, signing algorithm SHA256WithRSA +- **Signed By: External CA** ← this is what makes it emit a CSR instead of self-signing +- Validity: `5y` +- CRL Expire Period: `1d`, CRL Overlap: `10m` +- Default CRL Distribution Point: `http://ca.sandbox.vnc.de/ejbca/publicweb/crls/search.cgi?iHash=...` (EJBCA fills the hash — take what it offers) + +Save, download the CSR, move it to the offline machine, then: + +```bash +touch index.txt && echo 1000 > serial +``` + +```bash +openssl ca -config root.cnf -extensions int_ext -days 1825 -notext -in sandbox-issuing.csr -out sandbox-issuing.crt +``` + +**3.5 Issue the root CRL.** Do this now, in the same ceremony — not later. A root +that has never published a CRL cannot revoke a compromised intermediate, and you +will not want to bring the root key out under incident pressure just to +discover the procedure doesn't work: + +```bash +openssl ca -config root.cnf -gencrl -out root.crl +``` + +```bash +openssl crl -in root.crl -noout -text | head -12 +``` + +**3.6 Take the outputs off, then destroy the directory.** Off the machine: +`root.crt`, `root.crl`, `sandbox-issuing.crt`, and `root.key` (to encrypted +storage, two copies, two physical locations). + +```bash +shred -u root.key && rm -rf ./* +``` + +The root key comes out of the safe for exactly three reasons: signing a new +intermediate (promotion to `vncmail.de`), refreshing the root CRL before it +expires (annually — put it in a calendar now), or revoking an intermediate. + +**3.7 Import the chain back into EJBCA.** Admin web → the CA you created → +**Import CA certificate**, upload `root.crt` then `sandbox-issuing.crt`. The CA +status must move to `Active`. Publish `root.crl` so the URL in the +intermediate's CDP actually resolves. + +--- + +## 4. Certificate profile — 1-year S/MIME + +**Certificate Profiles → Add** → `VNC S/MIME 1y`, type *End Entity*. + +| Setting | Value | Why | +|---|---|---| +| Validity | `1y` | your decision | +| Key algorithms | RSA 2048, 3072, 4096 | 2048 floor for interop; no ECDSA yet (§0) | +| Key Usage | `digitalSignature`, `keyEncipherment` | signing **and** decryption need both | +| Extended Key Usage | `emailProtection` | critical — see below | +| Subject Alternative Name | `rfc822Name`, **required** | this is the authoritative address | +| Basic Constraints | CA:FALSE, critical | | +| CRL Distribution Point | use CA default | | +| OCSP Service Locator (AIA) | `http://ca.sandbox.vnc.de/ejbca/publicweb/status/ocsp` | | +| Allow key recovery | **on** | see §7 | + +Two of these carry real weight: + +**`emailProtection` EKU, and only that.** A certificate with no EKU is treated by +some clients as valid for *anything* — TLS server auth included. Constrain it. + +**`rfc822Name` SAN required.** Modern clients bind the sender address from the +SAN, not the `emailAddress` DN attribute. Our forked plugin's fix-1 check +(`signerEmailMatch`, which refuses to auto-import a signer cert whose address +doesn't match the `From` header) now reads the address the same way clients do — +SAN first, and matched against *every* address the certificate carries. If EJBCA +issues certificates without an `rfc822Name` SAN, that check fails closed and +encryption silently never becomes available. + +Populating the DN `emailAddress` attribute as well, for old Outlook, is safe — +but only as of finding 11. Until then the plugin read the DN attribute *in +preference to* the SAN and compared only the first address it found, so an EJBCA +certificate with both fields populated would have reported every genuine +signature as "signer ≠ From" and blocked the import. Covered now by +`vnc/plugins/smime/verify-address-binding.mjs`. + +**End Entity Profiles → Add** → `VNC S/MIME User`: +- Default Certificate Profile: `VNC S/MIME 1y`; available: the same only +- Subject DN: `CN` required + modifiable, `O=VNC AG` and `C=CH` fixed +- Subject Alt Name: `rfc822Name` required, **and tick "Use entity email field"** +- Default CA: `VNC S/MIME Issuing CA Sandbox R1` + +--- + +## 5. RA credential for the enrolment route + +The webmail server — not the browser — calls the REST API. It needs its own +client certificate with *only* the authority to enrol end entities. + +**5.1** Create a certificate profile `VNC RA Client` (End Entity, EKU +`clientAuth`, validity `1y`) and enrol one entity `CN=vncmail-ra-sandbox` +against it. Download as PKCS#12. + +**5.2** Restrict it. **System Functions → Administrator Roles → Add** → +`VNCmail RA (sandbox)`: + +| Rule | Access | +|---|---| +| `/ca_functionality/create_certificate` | Allow | +| `/ca/VNC S/MIME Issuing CA Sandbox R1` | Allow | +| `/endentityprofilesrules/VNC S/MIME User/**` | Allow | +| `/ra_functionality/revoke_end_entity` | Allow | +| everything else | **not granted** | + +Match by the certificate's serial + issuer DN, not by CN. Do **not** give this +role `/administrator` or any `/system_functionality` rule: this credential lives +on an internet-facing pod, and the blast radius of it leaking should be "issue +and revoke S/MIME certs under one profile", not "reconfigure the CA". + +**5.3** Load it into the webmail namespace: + +```bash +kubectl -n vncmail create secret generic smime-ra \ + --from-file=client.p12=./vncmail-ra-sandbox.p12 \ + --from-literal=client-password='' \ + --from-file=ca-chain.pem=./chain.pem +``` + +`chain.pem` is `sandbox-issuing.crt` followed by `root.crt`. The enrolment route +pins this chain when it connects to EJBCA on 8443 — it does not trust the public +root store, so EJBCA's self-signed server certificate (`TLS_SETUP_ENABLED=simple`) +is correct and expected here. + +--- + +## 6. Verify the network policy actually enforces + +Applying a NetworkPolicy on a CNI that doesn't implement it succeeds silently +and protects nothing. Prove it: + +```bash +kubectl -n default run np-probe --rm -it --image=curlimages/curl --restart=Never -- \ + curl -sS -m 5 -k https://ejbca.vnc-ca.svc.cluster.local:8443/ejbca/ejbca-rest-api/v1/ca +``` + +This **must** time out or be refused. If it returns anything HTTP-shaped — +including a `401` — the policy is not being enforced and the REST API is exposed +cluster-wide. Check your CNI before continuing: + +```bash +kubectl -n kube-system get pods | grep -iE "calico|cilium|flannel" +``` + +--- + +## 7. Key recovery is not optional here + +S/MIME differs from TLS in a way that has bitten every organisation that +deployed it without thinking about this: **if a user loses their private key, +every message ever encrypted to them is permanently unreadable.** Not +inconvenient — gone. Re-issuing a certificate does not help, because the old +messages were encrypted to the old key. + +So `Allow key recovery` in §4 is deliberate, and it is a real trade-off: + +- **on** — EJBCA escrows the decryption key. Lost laptop is recoverable. But the + CA database now contains material that decrypts users' mail, so §8 backup + handling and the §5 role restrictions become load-bearing, and the escrow is + something you must be able to explain to a user asking whether their mail is + end-to-end encrypted. It is, from the wire's perspective; it is not, from the + CA operator's. +- **off** — nobody but the user can ever read their mail, and a lost device is + permanent data loss with no recourse. + +For a corporate deployment where mail is a business record, escrow on is the +defensible choice, and it's what §4 sets. Decide this consciously — it is far +cheaper to turn on now than to explain later why three years of mail is gone. + +If you keep it on, use a separate key-recovery role with two-person approval +rather than folding that authority into the RA credential. + +--- + +## 8. Backup + +`ejbca-db-data` contains the intermediate CA private key and — per §7 — escrowed +user decryption keys. A dump of it is equivalent to the CA itself. + +```bash +kubectl -n vnc-ca exec deploy/ejbca-db -- sh -c \ + 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --single-transaction ejbca' \ + | gzip > ejbca-$(date +%F).sql.gz +``` + +Encrypt before it leaves your machine — an unencrypted CA dump in object storage +is the whole hierarchy: + +```bash +gpg --symmetric --cipher-algo AES256 ejbca-$(date +%F).sql.gz +``` + +Then to the shared R2 bucket (`vnc-backups1`) and **delete the plaintext**. +Restore-test it once, now, against a scratch namespace — an untested CA backup is +a belief, not a backup. + +Not in this backup, by design and stored separately: the offline root key +(§3.6), `EJBCA_CRYPTO_TOKEN_PIN`, and the RA PKCS#12 passphrase. + +--- + +## 9. Promotion to production + +Nothing here is thrown away. Same root, new intermediate: + +1. Bring `root.key` out of the safe; repeat §3.4–3.6 for + `CN=VNC S/MIME Issuing CA R1` — sign it with the **same root**. +2. Duplicate the §4 profiles as `VNC S/MIME 1y (prod)` bound to the new CA. +3. Fresh RA credential and role for the prod webmail namespace (§5). Never share + the sandbox one across environments. +4. Point the prod CDP/AIA at a stable production hostname. Those URLs are baked + into every certificate for its full year, so get the hostname right *before* + the first issuance. + +The trust anchor on user devices does not change, and sandbox-issued +certificates keep validating. + +## 10. SwissSign (P7, deferred) + +The point of the `CaProvider` interface on the application side is that this +whole document becomes one implementation of it. Moving to SwissSign-issued +certificates — for ZertES/eIDAS-qualified signatures that external parties +validate without installing anything — is then a second implementation plus an +identity-verification step, not a rewrite of the enrolment flow. + +What survives unchanged: in-browser key generation, CSR construction, the +enrolment route, storage, sign/encrypt/decrypt, the UI. +What changes: who signs the CSR, and the fact that a human must prove their +identity before a qualified certificate is issued — which is a process +requirement, not a code one. diff --git a/deploy/k8s/ca/ejbca.yaml b/deploy/k8s/ca/ejbca.yaml new file mode 100644 index 00000000..e0e4a304 --- /dev/null +++ b/deploy/k8s/ca/ejbca.yaml @@ -0,0 +1,114 @@ +# EJBCA Community Edition. +# +# VERIFY THE ENV CONTRACT BEFORE YOU TRUST THIS FILE. EJBCA's container +# configuration has changed across releases, so pin a tag and check its +# documented variables rather than assuming these carry over: +# docker run --rm keyfactor/ejbca-ce: cat /opt/keyfactor/bin/start.sh | head -60 +# The shape below (external MariaDB, two ports, healthcheck path) is stable; the +# individual variable names are the part most likely to drift. +apiVersion: v1 +kind: Service +metadata: + name: ejbca + namespace: vnc-ca +spec: + type: ClusterIP + selector: + app: ejbca + ports: + # 8080 — plain HTTP, NO client-certificate authentication. Only the public + # web is served here: CRL distribution and the OCSP responder. This is the + # only port the public ingress touches. + - name: http + port: 8080 + targetPort: 8080 + # 8443 — HTTPS with mandatory client-certificate auth. Admin web AND the + # REST API. Never exposed through an ingress; reachable only from inside the + # cluster (the enrolment route) or via `kubectl port-forward` (you, doing + # administration). See networkpolicy.yaml. + - name: https + port: 8443 + targetPort: 8443 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ejbca + namespace: vnc-ca +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: ejbca + template: + metadata: + labels: + app: ejbca + spec: + # EJBCA needs the DB reachable before WildFly deploys its datasource. + initContainers: + - name: wait-for-db + image: mariadb:11.4 + command: + - sh + - -c + - | + until mariadb-admin ping -h ejbca-db --silent; do + echo "waiting for ejbca-db..."; sleep 3 + done + containers: + - name: ejbca + # Pin an explicit tag. `latest` on a CA is how you get an unplanned + # schema migration during an incident. + image: keyfactor/ejbca-ce:9.1.1 + env: + - name: DATABASE_JDBC_URL + value: jdbc:mariadb://ejbca-db:3306/ejbca?characterEncoding=UTF-8 + - name: DATABASE_USER + valueFrom: + secretKeyRef: { name: ejbca-db, key: MARIADB_USER } + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: { name: ejbca-db, key: MARIADB_PASSWORD } + # Lets EJBCA generate its own server TLS keypair on first boot. The + # REST/admin listener is cluster-internal and authenticated by + # CLIENT certificate, so a self-signed server cert here is fine — + # our enrolment route pins the CA chain explicitly rather than + # trusting the public roots. Do not "fix" this with cert-manager + # without also updating that pin. + - name: TLS_SETUP_ENABLED + value: "simple" + - name: LOG_LEVEL_APP + value: INFO + ports: + - name: http + containerPort: 8080 + - name: https + containerPort: 8443 + # First boot builds the schema and can take minutes. A tight + # startupProbe budget here will CrashLoop a CA that is merely slow. + startupProbe: + httpGet: + path: /ejbca/publicweb/healthcheck/ejbcahealth + port: 8080 + periodSeconds: 10 + failureThreshold: 60 + readinessProbe: + httpGet: + path: /ejbca/publicweb/healthcheck/ejbcahealth + port: 8080 + periodSeconds: 15 + livenessProbe: + httpGet: + path: /ejbca/publicweb/healthcheck/ejbcahealth + port: 8080 + periodSeconds: 30 + failureThreshold: 5 + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + memory: 4Gi diff --git a/deploy/k8s/ca/ingress.yaml b/deploy/k8s/ca/ingress.yaml new file mode 100644 index 00000000..82801f9b --- /dev/null +++ b/deploy/k8s/ca/ingress.yaml @@ -0,0 +1,51 @@ +# PUBLIC surface of the CA — revocation checking ONLY. +# +# Two prefixes are routed and nothing else. Not the admin web, not the REST API, +# not the public enrolment pages (/ejbca/ra/, /ejbca/enrol/). Anything else at +# this host 404s because no rule matches it. +# +# WHY THIS MUST BE PUBLIC AT ALL: every certificate this CA issues carries the +# CRL Distribution Point and OCSP responder URL *inside* it, and those URLs are +# fetched by whoever is validating the certificate. For internal-only S/MIME that +# could stay private — but the moment a signed message leaves the building, the +# recipient's mail client resolves these URLs from the outside. They also become +# permanent: certificates already issued keep pointing here for their full year, +# so this hostname cannot be changed casually. Fix the hostname before the first +# real issuance, not after. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: vnc-ca-public + namespace: vnc-ca + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + # Revocation data is public by design and must be cacheable — an OCSP + # responder that is slow or down makes every client either hang or + # soft-fail open, and soft-fail-open is the same as no revocation at all. + nginx.ingress.kubernetes.io/proxy-read-timeout: "20" +spec: + ingressClassName: public + tls: + - hosts: + - ca.sandbox.vnc.de + secretName: vnc-ca-public-tls + rules: + - host: ca.sandbox.vnc.de + http: + paths: + # CRL download — http://ca.sandbox.vnc.de/ejbca/publicweb/crls/... + - path: /ejbca/publicweb/crls + pathType: Prefix + backend: + service: + name: ejbca + port: + number: 8080 + # OCSP responder — POST target for status queries. + - path: /ejbca/publicweb/status/ocsp + pathType: Prefix + backend: + service: + name: ejbca + port: + number: 8080 diff --git a/deploy/k8s/ca/kustomization.yaml b/deploy/k8s/ca/kustomization.yaml new file mode 100644 index 00000000..ec9d106d --- /dev/null +++ b/deploy/k8s/ca/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# secret.example.yaml is deliberately NOT listed. Apply your filled-in copy +# out-of-band so real passwords never pass through a file in this repo. +resources: + - namespace.yaml + - mariadb.yaml + - ejbca.yaml + - ingress.yaml + - networkpolicy.yaml + +# Order matters on a cold cluster: the namespace and the secret must exist before +# the workloads. kustomize sorts by kind and handles the namespace; the secret is +# on you. See README.md § Install. diff --git a/deploy/k8s/ca/mariadb.yaml b/deploy/k8s/ca/mariadb.yaml new file mode 100644 index 00000000..1445fe0e --- /dev/null +++ b/deploy/k8s/ca/mariadb.yaml @@ -0,0 +1,92 @@ +# MariaDB for EJBCA. +# +# WHY A REAL DATABASE AND NOT THE EMBEDDED H2: the EJBCA container can run on an +# internal H2 database for a quick look, but H2 is explicitly not supported for +# anything you intend to keep. Since this sandbox CA has to be *promotable* to +# production (your decision: "sandbox first and upgrade later"), the database is +# the one thing that must not need re-platforming later — every certificate ever +# issued, every revocation, and the intermediate CA key all live in here. +# +# THIS PVC IS THE CROWN JEWELS. See README.md § Backup. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ejbca-db-data + namespace: vnc-ca +spec: + accessModes: [ReadWriteOnce] + # microk8s default. Confirm with `kubectl get sc` and match your cluster. + storageClassName: microk8s-hostpath + resources: + requests: + storage: 8Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: ejbca-db + namespace: vnc-ca +spec: + type: ClusterIP + selector: + app: ejbca-db + ports: + - name: mysql + port: 3306 + targetPort: 3306 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ejbca-db + namespace: vnc-ca +spec: + replicas: 1 + # Never run two replicas against one RWO volume, and never roll a new pod up + # while the old one still holds the data directory. + strategy: + type: Recreate + selector: + matchLabels: + app: ejbca-db + template: + metadata: + labels: + app: ejbca-db + spec: + containers: + - name: mariadb + image: mariadb:11.4 + args: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + # EJBCA is case-sensitive about its own table names. + - --lower_case_table_names=0 + envFrom: + - secretRef: + name: ejbca-db + ports: + - containerPort: 3306 + volumeMounts: + - name: data + mountPath: /var/lib/mysql + readinessProbe: + exec: + command: ["healthcheck.sh", "--connect", "--innodb_initialized"] + initialDelaySeconds: 15 + periodSeconds: 10 + livenessProbe: + exec: + command: ["healthcheck.sh", "--connect"] + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: 100m + memory: 512Mi + limits: + memory: 2Gi + volumes: + - name: data + persistentVolumeClaim: + claimName: ejbca-db-data diff --git a/deploy/k8s/ca/namespace.yaml b/deploy/k8s/ca/namespace.yaml new file mode 100644 index 00000000..83f7dfb2 --- /dev/null +++ b/deploy/k8s/ca/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vnc-ca + labels: + # The CA is deliberately in its own namespace, NOT in `vncmail`. The webmail + # pod is internet-facing; the CA signs certificates. A compromise of the + # former must not be a compromise of the latter, and namespace-scoped RBAC + # plus the NetworkPolicy in networkpolicy.yaml are what enforce that. + app.kubernetes.io/name: vnc-ca + app.kubernetes.io/part-of: vncmail-plus diff --git a/deploy/k8s/ca/networkpolicy.yaml b/deploy/k8s/ca/networkpolicy.yaml new file mode 100644 index 00000000..c7f1b743 --- /dev/null +++ b/deploy/k8s/ca/networkpolicy.yaml @@ -0,0 +1,82 @@ +# Default-deny ingress for the CA namespace, then three narrow allowances. +# +# Without this, the REST API on 8443 is reachable from every pod in the cluster. +# It is still client-cert authenticated, so this is defence in depth rather than +# the only control — but "the only thing standing between any compromised pod and +# a certificate factory is one TLS handshake" is not a position to be in. +# +# PREREQUISITE: microk8s needs a CNI that enforces NetworkPolicy. The default +# (Calico) does. If you are on flannel without a policy plugin these objects +# apply cleanly and silently enforce NOTHING — verify with the test in +# README.md § Verify the network policy rather than assuming. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-ingress + namespace: vnc-ca +spec: + podSelector: {} + policyTypes: [Ingress] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-public-web-from-ingress + namespace: vnc-ca +spec: + podSelector: + matchLabels: + app: ejbca + policyTypes: [Ingress] + ingress: + # Port 8080 (CRL/OCSP) from the ingress controller only. + # VERIFY THE NAMESPACE: microk8s' nginx addon has historically used + # `ingress`, `kube-system`, and `ingress-nginx` depending on version. + # kubectl get pods -A | grep -i ingress + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress + ports: + - port: 8080 + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-rest-from-vncmail + namespace: vnc-ca +spec: + podSelector: + matchLabels: + app: ejbca + policyTypes: [Ingress] + ingress: + # Port 8443 (REST API) from the webmail namespace only. This is the + # enrolment route calling the CA with its RA client certificate. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vncmail + ports: + - port: 8443 + protocol: TCP +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-db-from-ejbca + namespace: vnc-ca +spec: + podSelector: + matchLabels: + app: ejbca-db + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + app: ejbca + ports: + - port: 3306 + protocol: TCP diff --git a/deploy/k8s/ca/secret.example.yaml b/deploy/k8s/ca/secret.example.yaml new file mode 100644 index 00000000..5ce641db --- /dev/null +++ b/deploy/k8s/ca/secret.example.yaml @@ -0,0 +1,35 @@ +# Template only — DO NOT `kubectl apply` this file and DO NOT commit real values. +# +# Copy to secret.yaml (gitignored), fill in, apply, then delete your local copy: +# cp secret.example.yaml /tmp/ca-secret.yaml +# $EDITOR /tmp/ca-secret.yaml +# kubectl apply -f /tmp/ca-secret.yaml && shred -u /tmp/ca-secret.yaml +# +# Generate each password with: openssl rand -base64 24 +--- +apiVersion: v1 +kind: Secret +metadata: + name: ejbca-db + namespace: vnc-ca +type: Opaque +stringData: + # MariaDB credentials. The EJBCA database holds the CA private keys (soft + # crypto token, encrypted at rest by EJBCA) — treat a dump of it as + # equivalent to the intermediate CA key itself. + MARIADB_ROOT_PASSWORD: CHANGEME_root + MARIADB_USER: ejbca + MARIADB_PASSWORD: CHANGEME_ejbca + MARIADB_DATABASE: ejbca +--- +apiVersion: v1 +kind: Secret +metadata: + name: ejbca-app + namespace: vnc-ca +type: Opaque +stringData: + # Passphrase protecting EJBCA's internal soft crypto token (the one that + # wraps the intermediate CA key). Losing this loses the intermediate. + # Back it up somewhere that is NOT this cluster. + EJBCA_CRYPTO_TOKEN_PIN: CHANGEME_token