# M14 — Security Hardening Plan > Implements the security promises of SPEC §15 (mTLS) and closes > the M11.5 wishlist (mTLS + secrets management + network policies). > Makes broad-announce defensible to enterprise customers without > re-architecting the data plane. **Status:** planning (post-M11, parallel to M12/M13) **Target:** SPEC §15 (mTLS opt-in per source) + gRPC internal mTLS (today a TODO) + cert lifecycle UI + secrets rotation **Estimate:** 16-24 days with one engineer; 10-15 days with two engineers in parallel with M13c --- ## 0. Recap — what M14 must prove | Surface | Acceptance criteria | |---|---| | **CA hierarchy** | Internal CA (root + intermediate) provisioned via cert-manager, root offline, intermediate serves cluster + sources | | **Source-side mTLS** | A source with `mtls_required=true` is rejected at ingestd if the client cert is missing, expired, signed by an untrusted CA, or has the wrong CN/SAN. HMAC + API key still work for non-mTLS sources. | | **Internal gRPC mTLS** | `ingestd`, `routerd`, `deliverd-*` talk over mTLS with cert-manager-issued certs. The `TODO(m11)` in `internal/grpcserver/server.go:61` is closed. | | **Cert lifecycle UI** | Super-admin can: list source certs, see expiration, revoke, generate a CSR (or auto-issue), download the cert bundle. Expiration < 30 days triggers a banner. | | **Rotation** | Intermediate CA rotates annually. Certs rotate every 90 days. No downtime during rotation. Documented runbook. | | **PromQL alerts** | `CertExpiringSoon` (>30d, < 7d, < 24h buckets), `CertRevoked`, `mTLSHandshakeErrorsHigh` | | **Audit log** | Cert issue, rotate, revoke, expiration all produce audit rows. | **What M14 is NOT:** - **Not a SOC2 / ISO 27001 certification.** M14 is a prerequisite, not the certification. Compliance certification is M15+ (separate project, separate budget, separate auditor). - **Not a key-vault replacement.** M14 uses cert-manager secrets. HashiCorp Vault integration is v2. - **Not customer-facing PKI.** Sources that need mTLS get a cert signed by OUR intermediate CA. We don't issue public certs. - **Not a redesign of auth.** mTLS is layered on top of HMAC + API key in a "AND" relation: cert proves identity, HMAC proves payload integrity, API key is the rate-limit token. All three can be required for the strictest sources. --- ## 1. Threat model — what mTLS actually buys | Threat | Without mTLS | With mTLS (opt-in) | |---|---|---| | **Stolen API key from a leaked source** | Attacker forges alerts forever | Cert pinned to the source machine — rotate the source cert, attack stops | | **Replay of captured HMAC** | Within 5-min window per Stripe-style | Cert auth is per-connection; replay needs both cert AND HMAC | | **Network MITM (compromised router)** | TLS protects in transit, but anyone with the API key can call | Cert pinned; MITM can't produce a valid client cert without the private key | | **Insider abuse (operator with DB access)** | Can read API keys | Certs are on the source machine, not in the DB | | **Credential stuffing** | Common API keys, brute force | Each cert is unique; no shared secret to stuff | **M14 doesn't fix:** compromised source machine (attacker has the private key), compromised root CA (game over, but offline), social engineering of operator (cert issuance is gated by super-admin). --- ## 2. Workstream overview ``` ┌──────────────────────────┐ ┌──────────────────────────┐ │ W1: cert-manager + CA │ │ W2: ingestd mTLS │ │ (root offline, │───▶│ (opt-in per source, │ │ intermediate in K8s) │ │ cert validation, │ │ │ │ CN/SAN extraction) │ └──────────────┬───────────┘ └──────────────┬───────────┘ │ │ ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────┐ │ W3: gRPC internal mTLS │ │ W4: cert lifecycle UI │ │ (server + client, │ │ (M13 frontend: list, │ │ closes the TODO) │ │ issue, rotate, revoke) │ └──────────────┬───────────┘ └──────────────┬───────────┘ │ │ ▼ ▼ ┌──────────────────────────────────────────────────────────────┐ │ W5: rotation + PromQL alerts + audit + runbook │ └──────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ W6: smoke E2E mTLS path + M14_VERIFICATION.md │ └──────────────────────────────────────────────────────────────┘ ``` Six workstreams. W1 is the only hard blocker. W4 (UI) can be split: the API changes land in M14, the UI lands in M13b/c as a feature incremental on top of the M13 sources module. --- ## 3. Workstream details ### W1: cert-manager + CA hierarchy **Goal:** Provision a working internal PKI in the K8s cluster. Root CA offline. Intermediate CA issues certs. cert-manager handles renewal. **Scope:** - Choose CA tooling: **`step-ca` or `cfssl` for the offline root, `cert-manager` for the intermediate and serving certs.** - `step-ca` if you want a single binary and JSON-based config. - `cfssl` if you want the Cloudflare-style Go binary, lighter. - cert-manager is the standard for K8s and gives you `Certificate` CRDs, Issuer/ClusterIssuer, renewal controller. - **Root CA (offline):** - Generate root key + cert on an air-gapped machine or a passphrase-encrypted USB stick. - 10-year validity. RSA 4096 or ECDSA P-384. - Stored in a K8s `Secret` of type `kubernetes.io/tls` in a `cert-manager` namespace, with sealed-secrets or external-secrets (NOT plaintext git). - Used ONLY to sign the intermediate. Never used to serve. - **Intermediate CA (in-cluster):** - cert-manager `ClusterIssuer` backed by the root. - 1-year validity, rotated annually (cron triggers re-issue). - RSA 2048 or ECDSA P-256. ECDSA preferred (smaller, faster). - **Serving certs (per-service):** - `Certificate` CRD per service (ingestd, routerd, deliverd-*, admind, authd). - DNS SANs: `..svc.cluster.local`, plus any public DNS name. - 90-day validity, auto-renewed at 30 days. - Mounted as files via `volumeMounts` from a Secret. - **cert-manager deployment:** - Helm chart, `cert-manager v1.16+`. - RBAC for the controller. - DNS-01 or HTTP-01 challenge for public certs (none needed for in-cluster). - **Source cert issuance path (used by W2):** - `Issuer` of type `CA` (cert-manager) backed by the intermediate. - Super-admin POSTs a CSR via API → cert-manager signs → cert bundle returned. - **Tooling scripts:** - `scripts/ca-init.sh` — generates root + intermediate from a fresh machine. One-time use. Writes encrypted tarball. - `scripts/ca-rotate-intermediate.sh` — annual cron. Issues new intermediate from root, swaps cert-manager's ClusterIssuer, re-issues all serving certs. **Exit criteria:** - [ ] Root CA exists, offline, encrypted. - [ ] Intermediate CA exists in K8s, valid for 1 year. - [ ] cert-manager running, `kubectl get clusterissuer` shows `broad-announce-intermediate` Ready. - [ ] 5 sample `Certificate` resources exist (ingestd, routerd, deliverd-fcm, deliverd-telegram, admind, authd) all Ready with valid serving certs. - [ ] Auto-renewal tested: manually expire a cert at 30 days remaining, watch it renew without downtime. - [ ] `scripts/ca-init.sh` and `scripts/ca-rotate-intermediate.sh` are documented in `cmd/certmanager/README.md`. **Estimated:** 3-4 days. --- ### W2: ingestd source-side mTLS (opt-in per source) **Goal:** A source with `mtls_required=true` is rejected at ingestd if the client cert is missing, expired, signed by an untrusted CA, or has the wrong CN/SAN. HMAC + API key still work for non-mTLS sources. **Scope:** - `internal/auth/mtls.go` (new package): - `ClientCertVerifier` that wraps a `*x509.CertPool` (the intermediate) and a CN/SAN allowlist. - Extracts `r.TLS.PeerCertificates[0]` from the request. - Verifies: not expired, signed by intermediate, CN matches `source..` or SAN `source:`, key usage `ClientAuth`. - Returns 401 with a structured error if any check fails. - `cmd/ingestd/main.go`: - Two listeners: `:443` (TLS, with mTLS opt-in) and `:80` (plaintext, redirect to :443 for mTLS-required sources). - In practice, two HTTP servers on different ports. The mTLS one uses `tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool}`. - When `source.mtls_required=true`, route through the TLS server. Else, plain HTTP (existing behavior). - For sources with both HMAC and mTLS, both must succeed (AND, not OR). - `internal/config/config.go`: - New env vars: `BA_INGESTD_TLS_ADDR` (default `:8443`), `BA_INGESTD_TLS_CERT` (path), `BA_INGESTD_TLS_KEY` (path), `BA_INGESTD_TLS_CA` (path to intermediate cert PEM). - `BA_INGESTD_REQUIRE_MTLS_DEFAULT` (default `false` — controlled per source). - `internal/grpcserver/server.go`: - Close the `TODO(m11): grpc.Creds(tlsCredentials())` by wiring the cert paths from config. - The gRPC server is internal-only (not exposed to sources), so the cert is the serving cert from W1. - Migrations: `migrations/NNNN_add_source_cert_columns.sql`: - `sources.mtls_required` (already in schema, just wire it). - `sources.mtls_cn` (CN expected, auto-derived from source_id). - `sources.cert_id` (FK to a new `source_certs` table). - `source_certs(id, source_id, serial, not_before, not_after, revoked_at, cert_pem)`. - `cmd/admind/main.go` (new endpoints in M14): - `POST /v1/sources/:id/cert/csr` — accept a CSR (PEM), sign it with the intermediate, return the cert bundle. - `GET /v1/sources/:id/cert` — current cert + chain. - `DELETE /v1/sources/:id/cert` — revoke. - `GET /v1/certs/expiring?days=30` — list of certs expiring soon. - New audit log events: `cert.issue`, `cert.revoke`, `cert.expire_soon`. **Exit criteria:** - [ ] `source.mtls_required=true` → ingestd rejects requests without a valid client cert (curl with `--cert` succeeds, curl without fails with 401). - [ ] Cert signed by an untrusted CA → 401. - [ ] Expired cert → 401. - [ ] Wrong CN/SAN → 401. - [ ] Cert revoked via `DELETE /v1/sources/:id/cert` → 401 within 60s (cert-manager CRL updates). - [ ] `source.mtls_required=false` → existing HMAC + API key flow unchanged. - [ ] Both required (mTLS + HMAC) → both must succeed. - [ ] Internal gRPC between ingestd, routerd, deliverd uses mTLS (W3) — verified with `tcpdump` or by `kubescape`/`kube-bench`. **Estimated:** 5-7 days. --- ### W3: gRPC internal mTLS (server + client) **Goal:** Close the `TODO(m11)` in `internal/grpcserver/server.go:61` and the analogous client path. All service-to-service gRPC (and HTTP/2 streams) use mTLS with cert-manager-issued certs. **Scope:** - `internal/grpcserver/server.go`: - Read `BA_GRPC_TLS_CERT`, `BA_GRPC_TLS_KEY`, `BA_GRPC_TLS_CA` from env. - `grpc.Creds(credentials.NewServerTLSFromCert(&cert))` — or the mTLS variant for client certs (if any service authenticates the other). - Hot-reload: SIGHUP or file watcher on the cert files. cert-manager renews → kubelet updates the Secret → file changes → service reloads without restart. - `internal/grpcclient/client.go` and `options.go`: - Wire the `TransportCredentials` from the same env vars. - For mTLS to other services: load the cert-manager-issued client cert + CA. - `internal/grpcclient/options.go:44`: - The "Production deployments should use mTLS or at minimum TLS" comment becomes code. - `deploy/k8s/base/*/deployment.yaml`: - Mount the cert-manager Secret as a volume at `/etc/broad-announce/tls/`. - `lifecycle.preStop` hook to drain in-flight requests gracefully before pod termination (avoid 5xx during cert rotation). - `internal/observability/metrics.go`: - `ba_grpc_tls_handshakes_total{result=ok|error,peer=...}`. - `ba_grpc_tls_cert_age_seconds{service=...}` (gauge). **Exit criteria:** - [ ] Internal gRPC between any two services uses TLS. - [ ] `tcpdump` shows TLS handshake (not plaintext) for a sample gRPC call between ingestd and routerd. - [ ] Cert rotation (manually expire a cert) doesn't drop a single in-flight request (verified with a load test). - [ ] Hot-reload works: SIGHUP picks up the new cert without a pod restart. - [ ] Metrics exported for handshake success/failure. **Estimated:** 2-3 days. --- ### W4: cert lifecycle UI (M13 frontend) **Goal:** Super-admin can manage source certs from the M13 UI. CSR upload, cert download, revocation, expiration warnings. **Scope:** - Routes (added to M13b W2 — Sources module): - `/sources/:id/cert` — current cert info, expiration, status. - Modal: "Generate CSR" (paste a CSR, get a signed cert back) or "Auto-generate" (server generates a key + CSR, signs it, returns the cert bundle as a downloadable .zip). - Modal: "Revoke" with typed confirmation. - Component: `CertStatusBadge` — green/amber/red based on expiration. - Component: `CertExpirationBanner` — global banner in the top bar when any source cert is < 30 days from expiry. Clicks navigate to the cert list. - New endpoints in `cmd/admind`: - `POST /v1/sources/:id/cert/csr` (from W2). - `GET /v1/sources/:id/cert`. - `DELETE /v1/sources/:id/cert`. - `GET /v1/certs/expiring?days=30`. - Bundle: cert-management code-split (< 15 KB gzipped). **Exit criteria:** - [ ] Super-admin can upload a CSR and download a signed cert. - [ ] Super-admin can use "Auto-generate" and get a `.zip` with cert + chain + private key (one-time download). - [ ] Revoke button removes the cert; subsequent requests with that cert fail within 60s. - [ ] Expiration banner appears 30 days before any cert expires. - [ ] Tenant-admin sees cert info for their sources (read-only, no revoke). - [ ] Bundle size < 15 KB gzipped. **Estimated:** 3-5 days (in parallel with M13b, lands in M13c W7). --- ### W5: rotation + PromQL alerts + audit + runbook **Goal:** Automated cert rotation. PromQL alerts for expiration and revocation. Audit log for every cert event. Runbook for incident response. **Scope:** - Rotation: - cert-manager handles serving certs automatically (90-day, renew at 30). - `cert-manager.io/renew-before: 720h` annotation. - Source certs: 90-day validity, super-admin is notified 30/7/1 days before via the banner. - Annual intermediate CA rotation: `scripts/ca-rotate-intermediate.sh` (from W1). - PromQL alerts (in `deploy/prometheus/alerts.yml`): - `CertExpiringSoon` (severity warning): `cert_not_after - time() < 30 * 86400` for any source cert. - `CertExpiringCritical` (severity critical): `< 7 * 86400`. - `CertExpired` (severity critical): `cert_not_after < time()`. - `CertRevoked` (severity info): counter increment. - `mTLSHandshakeErrorsHigh`: rate of failed handshakes > 1% for 5 min. - Audit log: - `cert.issue` — who, which source, cert serial. - `cert.rotate` — auto-rotation, no actor. - `cert.revoke` — who, which source, reason. - `cert.expire` — auto, when cert is detected expired. - Runbook (`docs/runbooks/mtls-incident.md`): - "Cert expired and source is down" — reissue flow. - "Private key compromise" — revoke + reissue + audit. - "CA compromise" — generate new root offline, re-issue intermediate, re-issue all certs. Documented as M14.5 emergency procedure. - "Handshake errors spiking" — likely clock skew or CA pool issue. Check cert chain, check issuer. **Exit criteria:** - [ ] All 5 PromQL alerts fire in a test scenario (expire a cert, revoke a cert, simulate clock skew). - [ ] Annual rotation script tested in a staging cluster. - [ ] Runbook reviewed by 2 people (you + me, or you + a peer). - [ ] Audit log entries for issue, rotate, revoke, expire. **Estimated:** 2-3 days. --- ### W6: smoke E2E mTLS path + M14_VERIFICATION.md **Goal:** End-to-end smoke that exercises the full mTLS path: cert issuance → cert-bound source → ingestd validation → alert accepted → cert revocation → alert rejected. **Scope:** - `scripts/m14_smoke.sh`: 1. Bring up stack with cert-manager + intermediate CA. 2. Super-admin creates a company. 3. Super-admin creates a source with `mtls_required=true`. 4. Super-admin generates a cert for the source via API. 5. `curl` with `--cert` and `--key` to ingestd using that cert + HMAC → 200. 6. `curl` without cert → 401. 7. `curl` with expired cert → 401. 8. `curl` with HMAC-only (cert stripped) → 401. 9. Revoke the cert via API. 10. `curl` with revoked cert → 401. 11. Send a real alert via the working path → see it in live tail (M13c). - `M14_VERIFICATION.md`: - Smoke log. - Cert chain diagram (root → intermediate → serving). - Bundle size breakdown. - The 5 PromQL alerts with sample output. - Runbook link. - Sign-off: "broad-announce v1 supports opt-in mTLS per source, with cert-manager-managed rotation, lifecycle UI, and incident response runbook." **Exit criteria:** - [ ] `make m14-smoke` exits 0 from a clean state. - [ ] 3 consecutive green runs. - [ ] M13 functionality not regressed. - [ ] M14_VERIFICATION.md published. **Estimated:** 1-2 days. --- ## 4. Sequencing & parallelism ``` W1 (3-4d) ──┬──▶ W2 (5-7d) ──┬──▶ W4 UI portion (3-5d, parallel with M13) │ │ │ ├──▶ W3 (2-3d, parallel with W2) │ │ │ └──▶ W5 (2-3d) ──▶ W6 (1-2d) │ └── (W1 is the only hard blocker) ``` **With one engineer (sequential):** - W1: 3-4d - W2 + W3: 7-10d (can overlap, W3 short) - W4: 3-5d (after M13b lands) - W5: 2-3d - W6: 1-2d - **Total: 16-24 days** **With two engineers (W4 lands in M13c):** - Eng 1: W1 → W2 → W5 → W6 (9-12 days) - Eng 2: M13c + W4 (parallel with W2) → W3 → W5 (10-15 days) - **Total wall time: 10-15 days** --- ## 5. What M14 is NOT (Recorded so a future reader doesn't re-litigate.) - **Not a SOC2 certification.** M14 builds the technical controls. SOC2 is an audit process (M15+). - **Not customer-issued public certs.** We sign with our internal intermediate. Sources that need a public cert bring their own. - **Not Vault / KMS integration.** cert-manager secrets are K8s Secrets (sealed-secrets or external-secrets encrypted at rest). HashiCorp Vault + dynamic certs is v2. - **Not HSM-backed root.** The root is on an encrypted USB stick (or air-gapped VM). HSM is v3. - **Not a full PKI replacement.** External CAs (DigiCert, Let's Encrypt) are still used for any public-facing endpoints (the broad-announce marketing site, the public docs). M14 is for in-cluster + source-side certs. - **Not a redesign of the data plane.** ingestd, routerd, deliverd keep their Go code. M14 layers TLS on top, doesn't rewrite. - **Not retroactive.** Existing sources with HMAC + API key continue working. mTLS is opt-in per source. No flag day. --- ## 6. Dependencies | From | What | Status | Affects | |---|---|---|---| | M12 W1 | K8s manifests exist | ⏳ in flight | W1 (cert-manager deploy) | | M11 | gRPC infra stable | ✅ done | W3 (gRPC mTLS) | | M13a | authd + JWT gate | ⏳ not started | W4 (cert UI uses auth) | | M13b | Sources CRUD | ⏳ not started | W4 (cert UI is incremental on Sources) | | Postgres | `source_certs` table | to be created in W2 migration | W2 | | cert-manager | upstream chart | ✅ available | W1 | --- ## 7. Risks | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | Root CA compromise | Very low | Catastrophic | Root offline + encrypted; documented re-issue procedure (full cluster cert rotation) | | Cert rotation drops in-flight requests | Medium | High | PreStop hook + graceful drain + 30-day renewal window + W3 hot-reload | | Operator issues cert to wrong source | Medium | Medium | Cert bound to source_id in CN/SAN; UI shows source label before issuance | | `mtls_required=true` breaks an existing source | Medium | High | Opt-in per source; rollout plan: 1 source, 5 sources, all sources, default-on (3 quarters) | | `cert-manager` operator upgrade breaks Issuer | Low | High | Pin chart version; test upgrade in staging first | | CRL distribution lag → revoked cert still works | Medium | Medium | 60s SLA in W2; OCSP responder for real-time (v2) | | Sources can't generate CSRs (legacy systems) | Medium | Medium | "Auto-generate" button in UI: server generates key+CSR, returns bundle | --- ## 8. Definition of done — M14 - [ ] All 6 workstreams have their per-workstream exit criteria checked. - [ ] `make m14-smoke` green for 3 consecutive runs. - [ ] M13 functionality not regressed. - [ ] M14_VERIFICATION.md published. - [ ] Runbook reviewed by 2 people. - [ ] SPEC.md `M14` row flipped to **✅ shipped YYYY-MM-DD** with the entry: *"opt-in mTLS per source + cert-manager-managed rotation + cert lifecycle UI + internal gRPC mTLS + runbook. Closes M11.5 wishlist. See M14_VERIFICATION.md for evidence."* --- ## 9. What M14 buys the business | Before (today) | After (M14) | |---|---| | "API key + HMAC" in marketing | "mTLS opt-in per source" in marketing | | Stolen API key = game over | Cert rotation stops the attack | | gRPC internal plaintext (TODO) | gRPC internal mTLS | | Manual cert management (if any) | cert-manager + UI | | "Enterprise" in pitch deck | "Enterprise" in feature matrix | | No compliance trail | Audit log for every cert event | | Not SOC2-ready | **SOC2-ready controls in place** (cert audit is one of the SOC2 controls) | **What it does NOT buy:** SOC2 itself, FedRAMP, HIPAA. Those are certifications, not features. M14 builds the technical controls that the auditors will check; getting the certification is a separate project (M15). --- **Next step:** start W1 (cert-manager + CA). If we have 2 engineers, W4 UI work starts as soon as M13b W1 (Companies CRUD) is done — the cert UI is a Sources incremental.