feat(ca): EJBCA Community manifests + root ceremony runbook for A-01/A-06
Manifests and a runbook for the internal CA that issues 1-year S/MIME certificates. Per the agreed split: these are applied by hand, and the root-key ceremony in section 3 is deliberately NOT automated - the whole value of an offline root is that its private key never exists on a machine that runs services or tooling. Structural recommendation up front (section 0), because it decides whether promoting to vncmail later is a config change or a re-rooting: name the root for the ORGANISATION, not the environment. One root, generated once at prod grade, with per-environment intermediates under it. Promotion is then "issue a second intermediate from the same root" - a one-hour ceremony - and the trust anchor already distributed to laptops, phones and partners does not change. A throwaway "VNC Sandbox Root" instead means redistributing a new anchor to every device and every external party who ever verified a signature. That cost is invisible today and expensive later. Security shape of the deployment: - Own namespace (vnc-ca), NOT vncmail. The webmail pod is internet-facing; the CA signs certificates. A compromise of the former must not be a compromise of the latter. - Port 8080 (CRL + OCSP) is the ONLY thing the public ingress routes, and only two path prefixes. Not the admin web, not the REST API, not the public enrolment pages. - Port 8443 (admin + REST, client-cert authenticated) is never exposed through an ingress - cluster-internal or kubectl port-forward only, enforced by NetworkPolicy as defence in depth. - The RA credential the enrolment route uses gets its own EJBCA role limited to issue/revoke under one profile. It lives on an internet-facing pod, so its blast radius should be "mint an S/MIME cert" and not "reconfigure the CA". Two things the runbook makes you prove rather than assume: - The NetworkPolicy actually enforces. Applying one on a CNI that does not implement it succeeds silently and protects nothing, so section 6 has a probe that MUST time out - a 401 means the REST API is exposed cluster-wide. - The CA backup restores. ejbca-db-data holds the intermediate private key and, with key recovery on, escrowed user decryption keys; an untested CA backup is a belief. Section 7 surfaces a decision rather than making it silently. S/MIME is unlike TLS in that losing a private key makes every message ever encrypted to that user permanently unreadable - re-issuing does not help, the old mail was encrypted to the old key. So key escrow is on by default here, which is the defensible choice when mail is a business record, but it means the CA operator can decrypt user mail. That is worth deciding consciously and being able to explain, not discovering. MariaDB rather than the container's embedded H2 deliberately: H2 is not supported for data you intend to keep, and the database is the one component that must not need re-platforming on promotion. Image tag pinned. The env-var contract is the part most likely to have drifted between EJBCA releases, so the runbook says to verify it against the tag pulled rather than trusting these values, and gives the log grep that shows the failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fb40e74713
commit
759ab7fe8c
@@ -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='<p12 passphrase>' \
|
||||
--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.
|
||||
@@ -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:<tag> 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user