Files
public/docs/secrets-and-pki.md
T
thesabear 41a6f569b4 docs: rework security/hardening batch with diagrams and callouts
Mermaid diagrams up front, GFM alert callouts for key decisions and
gotchas, tables instead of bullet walls - replaces plain prose with
the visual template established for security-monitoring.md.
2026-08-21 23:17:10 +03:00

100 lines
6.0 KiB
Markdown

# 🔐 Secrets & PKI
> [!NOTE]
> A single Vault cluster serves as both the internal certificate authority and the secret store for the whole fleet — one trust root, not two parallel systems.
| | |
|---|---|
| **Role** | Certificate authority + secret store |
| **Distribution** | Vault Agent on every host, AppRole auth, renders locally |
| **Replaces** | A prior plaintext-`.env`-in-git secrets model |
| **Biggest known risk** | A non-expiring root token — ranked, tracked, not ignored |
## What it does
- **Certificate authority** — two PKI mounts: one for public-facing wildcard certs, one for internal mTLS certs. Both issue short-lived certificates to the hosts that need them.
- **Secret store** — a KV v2 mount holds every migrated application's real secret values, one namespace per host.
- **Distribution** — Vault Agent runs on every fleet host, authenticates via AppRole, and renders both certificates and secrets to local disk. No application or service ever talks to Vault directly.
## Why it's built this way
```mermaid
flowchart LR
Before["Before:<br/>plaintext secrets<br/>in git-tracked .env files"] -->|replaced| After
subgraph After["Now"]
direction TB
T["Git-tracked template<br/>placeholder syntax only"]
V["Vault<br/>real value, one place"]
R["Rendered file<br/>gitignored, 0600, root-owned"]
T -.no real value ever.-> T
V -->|renders| R
end
```
- The real secret value lives in exactly one place: Vault.
- What's git-tracked is a template with placeholder syntax, never a real value.
- The rendered file is gitignored, tightly permissioned, and owned entirely by Vault Agent — never hand-edited.
- If a secret rotates, updating it once in Vault is enough; every consumer picks it up on its next render cycle.
> [!TIP]
> The mechanism (Vault Agent authenticating via AppRole, rendering a template to disk) already existed for TLS certificates before it was extended to cover application secrets — reusing a trust model that was already proven, rather than inventing a second one.
## Architecture
```mermaid
flowchart TD
Vault["Vault<br/>PKI (public + internal mounts) + KV v2 store<br/>TLS listener, cert-verified · audit log"]
Proxy["Internal app-to-app reverse-proxy zone"]
A1["Vault Agent — App Host<br/>~12 stacks' secrets + internal CA cert"]
A2["Vault Agent — Automation Host<br/>~10 stacks' secrets"]
A3["Vault Agent — Monitoring Host<br/>admin credentials + OIDC secret"]
A4["Vault Agent — Edge Proxy Host<br/>mTLS zone certs"]
A5["Vault Agent — DNS Host<br/>admin UI cert"]
A6["Vault Agent — Security Monitoring Host<br/>manager/indexer/dashboard secrets"]
Vault -->|HTTPS, cert-verified| Proxy
Proxy --> A1
Proxy --> A2
Proxy --> A3
Proxy --> A4
Proxy --> A5
Proxy --> A6
```
**Two authentication flows, not one:**
| Flow | When | Path |
|---|---|---|
| AppRole auth | Every host, ongoing | Vault Agent → role_id + secret_id → Vault → short-lived token, cached, auto-renewed |
| Bootstrap auth | Rare, human-initiated | Operator uses a root token to mint new AppRole credentials or update policies — never part of any workload's runtime path |
> [!IMPORTANT]
> **The one deliberately-asymmetric trust decision**: every other backend connection inside the internal zone skips certificate verification on the reverse proxy hop (still encrypted, just doesn't check identity) — a pragmatic tradeoff for low-stakes internal traffic. The connection to Vault is the single exception: the proxy verifies Vault's certificate for real. Given what's on the other end of that connection, the asymmetry is intentional, not an oversight.
## What's protected
| Layer | Protection |
|---|---|
| Transport | Every Vault Agent ↔ Vault connection is TLS, certificate-verified |
| Secrets at rest | KV v2, encrypted by Vault's storage layer; rendered files tightly permissioned, root-owned, gitignored |
| Audit trail | Every request/response logged; secret values HMAC-hashed, not plaintext |
| Alerting | Root-token use or permission-denied bursts trigger an alert through the fleet's notification pipeline |
| Cert renewal | A dependent service's TLS bundle auto-rebuilds when its cert re-issues — closed a real gap where an expired cert was silently served for over two weeks |
## Known gaps, ranked by actual risk
1. **The bootstrap root token never expires.** Unlimited access, mints every AppRole, writes every policy. Encrypted at rest, but that doesn't help if the plaintext ever gets used somewhere it shouldn't. The single highest-value target in the whole system — tracked for replacement with a narrowly-scoped, short-TTL token.
2. **No SSH certificate signing.** Every host's SSH key is static, baked in at image build time, no rotation, no revocation short of rebuilding the host. Vault's SSH secrets engine is the natural fit — a bigger lift since it touches every host's SSH config.
3. **Single-node storage, no replication.** One disk failure is a full outage and, without a recent backup, real data loss. An availability concern, not a confidentiality one — deliberately lower priority than the two above.
> [!TIP]
> **Deliberately accepted, not gaps**: memory locking is disabled (normally a real concern, but this host has zero swap, so there's no actual leak vector) — and the Vault server's own config is intentionally kept out of the same automation that manages every client-side Vault Agent.
## Access model
One policy per host, scoped to exactly what that host needs — no host's credentials can read another host's secrets. Every host-scoped policy follows the same shape: a shared grant for lightweight self-registration, plus a private grant with full read/write access only to that host's own namespace. Onboarding a new host copies this shape rather than reinventing it — and the shared grant does *not* automatically imply the private one, so both get verified before assuming a host is fully provisioned.
---
Related stacks: [`stacks/identity-and-security/vaultwarden/`](../stacks/identity-and-security/vaultwarden/)