diff --git a/.sops.yaml b/.sops.yaml index ad8295f..424b3a1 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -1,12 +1,12 @@ # .sops.yaml — SOPS creation rules for the platform repo (ADR-0007, R-SEC-01). # # ───────────────────────────────────────────────────────────────────────────── -# EFFECTIVE LOCATION: this file must live at the REPOSITORY ROOT to govern -# encryption of secrets under platform/** and apps/**. It is AUTHORED here in -# bootstrap/sops/ (the security engineer's subtree); the PM/clusters owner -# installs it at the repo root (copy or symlink). See bootstrap/sops/README.md -# "Interface / handoff". SOPS discovers .sops.yaml by walking UP from the file -# being encrypted to the repo root, so it only takes effect from root. +# CANONICAL LOCATION: this is the ONE .sops.yaml, at the REPOSITORY ROOT. SOPS +# discovers it by walking UP from the file being encrypted, so a root file governs +# every secret under platform/**, apps/**, catalog/**, and secrets/**. +# There is deliberately NO second copy under bootstrap/sops/ — a duplicate there +# would be discovered first for files in that subtree and shadow this one (that +# footgun is why the earlier bootstrap/sops/.sops.yaml was removed). # ───────────────────────────────────────────────────────────────────────────── # # HOW ENCRYPTION WORKS HERE: @@ -26,11 +26,11 @@ creation_rules: # or live under a `secrets/` directory anywhere in the tree. - path_regex: (^|/)(platform|apps|catalog)/.*(secret|sops)\.ya?ml$ encrypted_regex: "^(data|stringData)$" - # >>> REPLACE-ME <<< paste the age PUBLIC recipient printed by age-keygen.sh. - # Example shape (NOT a real key): age1qy...placeholder...0abcd - age: "age1u70g00m38pt4zte00n4fsqfyj3rf8725xn4w7e6lx80evt4rcalsdnprp8" + # age PUBLIC recipient (from age-keygen.sh) — safe to commit. To rotate, see + # bootstrap/sops/age-key-secret.md (add new recipient, re-encrypt, drop old). + age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg" - path_regex: (^|/)secrets/.*\.ya?ml$ encrypted_regex: "^(data|stringData)$" - # >>> REPLACE-ME <<< same age PUBLIC recipient as above. - age: "age1u70g00m38pt4zte00n4fsqfyj3rf8725xn4w7e6lx80evt4rcalsdnprp8" + # same age PUBLIC recipient as above. + age: "age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg" diff --git a/bootstrap/flux/bootstrap.sh b/bootstrap/flux/bootstrap.sh index d1e8b87..1732765 100755 --- a/bootstrap/flux/bootstrap.sh +++ b/bootstrap/flux/bootstrap.sh @@ -31,7 +31,7 @@ # source-url.yaml into clusters/dev/kustomization.yaml (or set the URL) so the source is real. # - ./patches/platform-sops-decryption.yaml + apps-sops-decryption.yaml are wired into # clusters/dev/kustomization.yaml `patches:` so decryption is enabled at reconcile. -# - .sops.yaml (from ../sops) is installed at the repo root with the real age recipient. +# - the repo-root .sops.yaml carries the real age recipient (single canonical file; no copy under ../sops). # # PHASE 6 (WS-GITOPS, later — NOT Phase 1): once Gitea is up, cut the source over with # `flux bootstrap git` against gitea.home.keithjackson.dev — see ../README.md "Phase-6 cutover". diff --git a/bootstrap/sops/.sops.yaml b/bootstrap/sops/.sops.yaml deleted file mode 100644 index 359bbd8..0000000 --- a/bootstrap/sops/.sops.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# .sops.yaml — SOPS creation rules for the platform repo (ADR-0007, R-SEC-01). -# -# ───────────────────────────────────────────────────────────────────────────── -# EFFECTIVE LOCATION: this file must live at the REPOSITORY ROOT to govern -# encryption of secrets under platform/** and apps/**. It is AUTHORED here in -# bootstrap/sops/ (the security engineer's subtree); the PM/clusters owner -# installs it at the repo root (copy or symlink). See bootstrap/sops/README.md -# "Interface / handoff". SOPS discovers .sops.yaml by walking UP from the file -# being encrypted to the repo root, so it only takes effect from root. -# ───────────────────────────────────────────────────────────────────────────── -# -# HOW ENCRYPTION WORKS HERE: -# - Only committed secrets are encrypted; plaintext never enters git. -# - `encrypted_regex` encrypts ONLY the value fields (data/stringData), leaving -# keys and structure in cleartext → readable, reviewable diffs (ADR-0007). -# - `age` recipient below is the PUBLIC key from age-keygen.sh. Filling it is a -# bootstrap step; the matching PRIVATE key is installed in-cluster as the -# `sops-age` Secret (age-key-secret.md) so Flux can decrypt at reconcile. -# -# TO ENCRYPT A SECRET (operator, not agents): -# sops --encrypt --in-place path/to/secret.sops.yaml -# # picks up the age recipient + rules below automatically. - -creation_rules: - # Platform + app Secrets. Convention: encrypted files are named `*.sops.yaml` - # or live under a `secrets/` directory anywhere in the tree. - - path_regex: (^|/)(platform|apps|catalog)/.*(secret|sops)\.ya?ml$ - encrypted_regex: "^(data|stringData)$" - # >>> REPLACE-ME <<< paste the age PUBLIC recipient printed by age-keygen.sh. - # Example shape (NOT a real key): age1qy...placeholder...0abcd - age: "REPLACE-ME-AGE-PUBLIC-RECIPIENT" - - - path_regex: (^|/)secrets/.*\.ya?ml$ - encrypted_regex: "^(data|stringData)$" - # >>> REPLACE-ME <<< same age PUBLIC recipient as above. - age: "REPLACE-ME-AGE-PUBLIC-RECIPIENT" diff --git a/bootstrap/sops/README.md b/bootstrap/sops/README.md index 5cd3ad6..04abedc 100644 --- a/bootstrap/sops/README.md +++ b/bootstrap/sops/README.md @@ -6,16 +6,19 @@ Spec: [ADR-0007 (SOPS+age)](../../../home-network-k3s/decisions/adr-0007-secrets [docs/07](../../../home-network-k3s/docs/07-security-and-secrets.md) · Requirement: **R-SEC-01** (secrets encrypted at rest in git, decrypted only in-cluster). -> **AUTHOR-ONLY. Nothing here has been executed. No key or secret material exists in this output.** -> No `age-keygen`, `sops`, or `kubectl` was run. The age keypair is generated later by the operator; -> `.sops.yaml` carries only a **placeholder** for the public recipient. +> The SOPS **creation rules** live in the single canonical [repo-root `.sops.yaml`](../../.sops.yaml). +> The operator generates the age keypair via `age-keygen.sh`, pastes the **public** recipient into that +> root file, and installs the **private** key in-cluster as the `sops-age` Secret. No private key +> material ever enters git. (`CREDENTIALS.md` is the end-to-end provisioning checklist.) ## Files | File | Purpose | |---|---| | `age-keygen.sh` | Operator-run script: generates the age keypair to a path **outside the repo**, prints only the **public** recipient. **Refuses** to write inside the repo or clobber an existing key. | -| `.sops.yaml` | SOPS creation rules (encrypt only `data`/`stringData` → readable diffs). `age:` recipient is a clearly-marked `REPLACE-ME` placeholder filled at bootstrap. **Effective home is the repo root** (see below). | | `age-key-secret.md` | Runbook + `kubectl create secret` template installing the age **private** key as the `sops-age` Secret in `flux-system` (the sanctioned out-of-band step). Rotation + DR custody included. | +| `CREDENTIALS.md` | End-to-end operator checklist: SOPS setup + the full secret inventory (per component/phase). | +| `secret-templates/` | `*.yaml.example` scaffolds (correct name/namespace/keys, placeholder values) to copy → fill → `sops -e`. | +| repo-root [`.sops.yaml`](../../.sops.yaml) | The single canonical SOPS creation rules (encrypt only `data`/`stringData` → readable diffs). **At the repo root, NOT here** — a copy in this dir would shadow it for files under `bootstrap/**`. | ## How this satisfies R-SEC-01 / ADR-0007 - Secrets are committed **encrypted** (`sops --encrypt`) using the age **public** recipient in @@ -27,11 +30,11 @@ Spec: [ADR-0007 (SOPS+age)](../../../home-network-k3s/decisions/adr-0007-secrets - No key material appears in scripts, docs, PRs, or logs (AGENTS.md §5). ## Interface / handoff -- **`.sops.yaml` belongs at the REPOSITORY ROOT.** SOPS discovers it by walking up from the file - being encrypted, so it only governs `platform/**` and `apps/**` from root. It is authored here (in - this worker's subtree); the **PM/clusters owner installs it at the repo root** (copy or symlink). - Flagged as an escalation in the T-1.4 report. -- **Fill two placeholders at bootstrap:** the `age:` recipient in `.sops.yaml` (from - `age-keygen.sh`'s output) — same value in both creation rules. +- **`.sops.yaml` is a SINGLE file at the REPOSITORY ROOT.** SOPS discovers it by walking up from the + file being encrypted, so a root file governs `platform/**`, `apps/**`, `catalog/**`, and `secrets/**`. + Do **not** keep a second copy in this directory — it would be discovered first for files under + `bootstrap/**` and shadow the root config (that duplicate was removed after it caused exactly that). +- **Fill the recipient at bootstrap:** paste `age-keygen.sh`'s public recipient into the root + `.sops.yaml` — the same value in both creation rules. - **Order** (see `../flux/README.md`): `age-keygen.sh` → install `sops-age` Secret (`age-key-secret.md`) → `../flux/bootstrap.sh`. The Flux entrypoint fails fast if the Secret is missing. diff --git a/bootstrap/sops/age-keygen.sh b/bootstrap/sops/age-keygen.sh index 420819b..9dcc324 100755 --- a/bootstrap/sops/age-keygen.sh +++ b/bootstrap/sops/age-keygen.sh @@ -12,7 +12,7 @@ # ~/.config/sops/age/keys.txt — SOPS's conventional location, honoured by the # SOPS_AGE_KEY_FILE default lookup). # - Prints ONLY the PUBLIC recipient (age1...) to stdout. The public recipient -# is safe to paste into bootstrap/sops/.sops.yaml. The PRIVATE key stays in +# is safe to paste into the repo-root .sops.yaml. The PRIVATE key stays in # the file and is NEVER printed, NEVER committed (see .gitignore: age.key, # *.key). Spec docs/07 + AGENTS.md §5: no secret material in logs/PR/output. # @@ -81,5 +81,5 @@ age-keygen -o "${key_abs}" >/dev/null echo "age private key written (0600) to: ${key_abs}" echo "This file is OUTSIDE the repo and must stay that way — do NOT commit it." echo -echo "Public recipient (safe to share — paste into bootstrap/sops/.sops.yaml):" +echo "Public recipient (safe to share — paste into the repo-root .sops.yaml, both age: lines):" age-keygen -y "${key_abs}" # prints ONLY the public recipient (age1...) diff --git a/platform/cert-manager/cloudflare-api-token.cert-manager.sops.yaml b/platform/cert-manager/cloudflare-api-token.cert-manager.sops.yaml new file mode 100644 index 0000000..bfc262e --- /dev/null +++ b/platform/cert-manager/cloudflare-api-token.cert-manager.sops.yaml @@ -0,0 +1,43 @@ +# ============================================================================= +# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize +# and never auto-encrypted by SOPS). Reference scaffold only. +# +# (a) Copy to: platform/cert-manager/cloudflare-api-token.sops.yaml +# (b) kustomization: add this line under `resources:` in +# platform/cert-manager/kustomization.yaml +# - cloudflare-api-token.sops.yaml +# (c) Encrypt: sops -e -i platform/cert-manager/cloudflare-api-token.sops.yaml +# (d) Consumed by: cert-manager ClusterIssuers (DNS-01 solver). The Cloudflare +# DNS-01 solver's apiTokenSecretRef points at secret +# `cloudflare-api-token`, key `api-token`. [Phase 2] +# +# The token must be a Cloudflare API token with Zone:DNS:Edit + Zone:Read scope. +# ============================================================================= +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-api-token + namespace: cert-manager +type: Opaque +stringData: + api-token: ENC[AES256_GCM,data:3B3SYYtEDObLThD5FcgJnUP3YRhPSo3p/44HUz5s0u0QZZY888NvOBXp8OhV65n0Q9Wa6MY=,iv:qZ6Qfytl99+uGQX5DYfXtGZpywr6TRA0oZ8d6s8B3TE=,tag:hRHbcyBqsHArkf184Lm8ZQ==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrbXZHTncvcVJ5VnArSklG + K0FLUVFPdGlDNHpPRHh0TWlhS3dLRUFPSmdvCjY2WlYrY2sxUUt6VmxNM2VhcnFu + dkF1clJTdFgrU1VSa2hMREZSUWlCY0UKLS0tIDU0cDkwU0xvQzJzUHYrR3A0cEpV + cDNGWFZQUWFVN3g4cEtVUHJzV0RtV0EKrajH83JeVGFs5xf5tqy6TbLIYDXNcDLU + YMVzmw16FL4UbUQy/z/sC19voOuty5tlV0oQH01uE12qzYYR24bXzQ== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-17T04:07:14Z" + mac: ENC[AES256_GCM,data:4rhbN0ZWlxuLZeiN1yNSE33EC+xEyNNEavIsetxGSaOIxqPAK4SDvS5HAqpiUBFSDVIzvSIV6cDA3Y/M0IaOQ5bkhYbElxPEFGNfrz1R693UV39AA8f11ZkzwDHQSgGKSBTJGM+YSteblB+6jfbw0ktpurI4JNcVqGHG3PpOWNY=,iv:+BBJHn3i1n296A+Vi3wUTFNWupxccv6LOpF06F9QAuI=,tag:Z6GOMmlpzttEqNf1iH2rXw==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4 diff --git a/platform/externaldns/cloudflare-api-token.external-dns.sops.yaml b/platform/externaldns/cloudflare-api-token.external-dns.sops.yaml new file mode 100644 index 0000000..b5e6b2e --- /dev/null +++ b/platform/externaldns/cloudflare-api-token.external-dns.sops.yaml @@ -0,0 +1,47 @@ +# ============================================================================= +# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize +# and never auto-encrypted by SOPS). Reference scaffold only. +# +# (a) Copy to: platform/externaldns/cloudflare-api-token.sops.yaml +# (component DIR is platform/externaldns/, but the Kubernetes +# NAMESPACE is external-dns — note the hyphen.) +# (b) kustomization: add this line under `resources:` in +# platform/externaldns/kustomization.yaml +# - cloudflare-api-token.sops.yaml +# (c) Encrypt: sops -e -i platform/externaldns/cloudflare-api-token.sops.yaml +# (d) Consumed by: external-dns reads env CF_API_TOKEN via secretKeyRef +# name `cloudflare-api-token`, key `api-token`. [Phase 2] +# +# NOTE: This is the SAME Cloudflare token as the cert-manager copy — just a +# second copy living in the external-dns namespace. ClusterIssuer secrets and +# external-dns run in different namespaces, so the token must exist in both. A +# single Cloudflare API token with Zone:DNS:Edit + Zone:Read scope serves both. +# ============================================================================= +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-api-token + namespace: external-dns +type: Opaque +stringData: + api-token: ENC[AES256_GCM,data:8+4CNm8QZh4Q8Z33r+YRKXUlh7CGHzSaXcUEC9pWbGhyaAeAbea8mFm3eXVxKqbmuclgiYw=,iv:1NsSyjiH1ZU7Nh9FW9aBGhmY5KDB9koKFx+T8aeSd4k=,tag:GVL5/EWJW6TmSGo9wDGJ5w==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqcEdxSklUSWkwVHJqRU5S + NEJuZjc1L0VkeStQdTJtS3RuRnpwUTIwMFNnClFzaFE3SDRXN0NIL3AzSzVmcjlL + N0RPb1FNR1UzNVFMbVFXSFA5bkZ6ZmcKLS0tIGNKbG1yRGN4QW91aEgwSDJuN2t6 + blFCdXlLVDBGK3JESGhuME5QeVRpOE0Ke9WNjV8CSEdzelFyhn/ZWCUJfAhwSZcf + KE2ms+cJ7cR6hSjLFQWal6Rbt4fX7qogPtWSpQ1pR3+8F/00X6ruTw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-08-17T03:56:14Z" + mac: ENC[AES256_GCM,data:Hr2Q0EuzUbF08yE6pK34FbJ8hBWDoKuNnqzB4TZsauLKz9ZUHq0A+UKNX6oG+PNUujSTGf3BlyY4hffNWrH5mcGAA7k9yWpqUKDdALlde89X3KqVckUe7RWHLgcz7h8pDSr3kekoowXc49XtGS86ArqulEnH4QdwoPcHNvo/qwI=,iv:aC/dU2zQ1oK8SxndVJw81URPe2rgH6KJTsvh/K4K8jY=,tag:bIuEtbB+p294EqxzqX3D9Q==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.4