Files
home-network-k3s-impl/platform/vpn-ingress/wg-server-key.sops.yaml.example
T
jakman85andClaude Opus 5 e35612dc7e
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
fix(vpn-ingress): keep the server key in source through SOPS, not as an out-of-band Secret
Operator correction: secrets are kept in source through sops. #55 shipped the
WireGuard server private key as a Secret to `kubectl create` by hand. Two real
costs, not just a convention break:

  * R-SEC-01 / spec docs/07 -- secrets live in source, SOPS-encrypted, and
    reach the cluster only through Flux.
  * It broke DR for the one credential that cannot be re-derived. A rebuild
    restores everything in git; it does not restore a hand-made Secret, and
    losing this key means re-enrolling EVERY device, because each client
    profile pins its public half. #55 papered over that with "keep a copy in
    your password manager".

(Note secret-provenance did NOT forbid the original shape -- it scopes to
app-namespace=true and vpn-ingress is deliberately a platform namespace. The
rule broken was the written requirement, not an enforced control.)

An agent still must not mint a long-lived private key (CLAUDE.md §6), so the
file is committed ALREADY ENCRYPTED with `privatekey: REPLACE-ME`. That makes
the wiring complete and green from the first commit -- resource line plus both
overlay decryption patches -- leaving the operator exactly one act: replace the
value. A commented-out resource to uncomment later is the "in git, no generator
entry, reviewed, merged, still broken" shape this repo keeps hitting.

The init guard is now a validation rather than an emptiness check: the value is
piped through `wg pubkey`, which accepts only a real curve25519 private key, and
the pod names which case it hit -- placeholder, empty, mangled, or (the silent
one) still `ENC[` ciphertext because an overlay is missing its decryption patch,
which otherwise applies cleanly with every Flux condition green.

Second, independent finding that also fixes #55's step 2: `wg` is on the HOST
only and `sops` is in the CONTAINER only, so `sops -d | wg pubkey` cannot run in
one shell -- the command #55 documented could not have worked. Every step is now
split by where it runs, and the written key is verified by comparing sha256
digests rather than printing anything. `qrencode` is on neither, so the QR step
says so and gives the install command.

Touches clusters/** and adds a **/*.sops.yaml, so approval-policy will block
pending the operator's /approve-override. Deliberately not posted by me.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:55:29 -05:00

83 lines
5.4 KiB
Plaintext

# =============================================================================
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize and never
# auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
#
# WHAT THIS IS: the WireGuard SERVER's long-term private key for the remote-access VPN
# (platform/vpn-ingress). It is the ONE secret in the whole component. The init container in
# daemonset.yaml reads it at `/run/wg-server-key/privatekey` and joins it to the PLAINTEXT peer
# register (peers.yaml) in a tmpfs, so the assembled wg0.conf never touches a node's disk.
#
# WHY IT IS IN GIT, SOPS-ENCRYPTED, AND NOT HAND-CREATED. R-SEC-01 / spec docs/07: secrets live in
# source, encrypted, and reach the cluster only through Flux. An out-of-band `kubectl create secret`
# would put the single credential every enrolled device depends on OUTSIDE the GitOps tree, where a
# cluster rebuild silently loses it — and losing it means RE-ENROLLING EVERY DEVICE, because each
# client profile pins this key's public half.
#
# *** THE REAL FILE IS COMMITTED WITH `privatekey: REPLACE-ME`, ALREADY ENCRYPTED. ***
# That is deliberate, so the wiring is complete and green before the key exists: the resource is
# already listed in kustomization.yaml and both overlays already carry the `vpn-ingress` decryption
# patch. Your job is ONLY to replace the value (step 3 below). Until you do, the DaemonSet
# CrashLoops with an explicit FATAL naming this file — the init container validates the value with
# `wg pubkey` before writing wg0.conf, so a placeholder fails CLOSED and LOUDLY rather than
# producing a server that silently cannot complete a handshake.
#
# ---------------------------------------------------------------------------------------------
# MIND WHERE EACH COMMAND RUNS. The two tools do not live in the same place (measured 2026-10-05):
# wg -> the HOST only (/usr/bin/wg; NOT in the dev container)
# sops -> the dev CONTAINER only (/usr/local/bin/sops; NOT on the host)
# So you cannot pipe `sops -d ... | wg pubkey` in one shell. The steps below are split accordingly,
# and they never put the key on a terminal or in a pipeline that crosses the boundary.
# ---------------------------------------------------------------------------------------------
#
# HAND-OFF STEPS (operator — an agent must not mint a long-lived private key, CLAUDE.md §6).
# Full procedure with verification: runbooks/vpn-remote-access.md step 2.
#
# 1. HOST — generate it. This file is also what infra/vpn-ingress/new-peer.sh reads to derive the
# public half for each client profile, so keep it where the script's default expects it:
# umask 077
# mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
# wg genkey > ~/.config/wireguard-home/server.key
#
# 2. Put that value in your PASSWORD MANAGER. It cannot be regenerated without invalidating every
# client profile. (Velero backs this namespace up to R2 too, but the password-manager copy is
# the one that survives losing the cluster AND the backups.)
# Record the public half alongside it — safe to display, and useful when debugging a client:
# wg pubkey < ~/.config/wireguard-home/server.key
#
# 3. Stage it where the CONTAINER can read it, then write it into the encrypted file in place:
# mkdir -p .cache && cp ~/.config/wireguard-home/server.key .cache/server.key
# git check-ignore -v .cache/server.key # MUST print `.gitignore:11:*.key` — see below
# ./infra/devcontainer/dev.sh run bash -c \
# 'cd /work && sops set platform/vpn-ingress/wg-server-key.sops.yaml \
# "[\"stringData\"][\"privatekey\"]" "\"$(tr -d "\r\n" < /work/.cache/server.key)\""'
# shred -u .cache/server.key
# `sops set` edits the ENCRYPTED document directly — no plaintext copy of it is ever written.
#
# KEEP THE `.key` SUFFIX on that staged copy. `.gitignore` line 11 is `*.key`, and it is the
# ONLY rule covering that path: `.cache/` itself is NOT ignored. A copy named `server.pem` or
# `privatekey` in the same directory is COMMITTABLE, and the gate's `no-plaintext-secrets` stage
# does not recognise a bare WireGuard key.
#
# 4. Verify you wrote the right bytes WITHOUT printing them — compare digests, not values:
# ./infra/devcontainer/dev.sh run bash -c \
# 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
# platform/vpn-ingress/wg-server-key.sops.yaml | tr -d "\r\n" | sha256sum'
# tr -d '\r\n' < ~/.config/wireguard-home/server.key | sha256sum
# The two digests must match. (This is the only sanctioned decrypt of this file, and its output
# goes straight into sha256sum — never onto a terminal. CLAUDE.md §6.)
#
# 5. PR it. Nothing else to wire: the resource line and both overlay decryption patches already
# exist. Flux rolls the DaemonSet and the pods leave CrashLoop.
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: wg-server-key
namespace: vpn-ingress
type: Opaque
stringData:
# The server's WireGuard private key: `wg genkey` output — 44 chars of base64 ending in '='.
# NOT a passphrase, and NOT the public key. The init container validates it with `wg pubkey`
# before writing wg0.conf, so a placeholder or a mangled value fails closed with a clear message.
privatekey: REPLACE-ME