Files
project-charlie/docs/secrets.md
T

96 lines
5.4 KiB
Markdown

# Secrets
SOPS-in-repo with one [age](https://github.com/FiloSottile/age) key per host. Encrypted files live next to `docker-compose.yml` in each `Charlie/<stack>` repo. The reusable [`deploy-stack.yml`](../.gitea/workflows/deploy-stack.yml) workflow SSHes into the target host as `sys-gitea-runner` and runs `sops --decrypt` there using `/etc/charlie/age.key`.
Operational recipe: [`onboarding.md → Host age key (SOPS)`](onboarding.md#host-age-key-sops).
Caller-facing reference: [`automation.md`](automation.md).
## Goal
Every host can:
1. Obtain only the secrets it's allowed to decrypt.
2. Materialize them where compose can consume them (typically `.env`).
3. Do this non-interactively from a Gitea Actions runner.
## Constraints
- **Source of truth lives with the stack.** A secret for `mediacompose` belongs in [`Charlie/mediacompose`](https://gitea.alexandru.macocian.me/Charlie/mediacompose), not in a separate vault. Blast radius is bounded by repo access.
- **Per-machine decryption.** A compromised host leaks only the secrets in repos whose `.sops.yaml` lists that host's key.
- **Self-identifying host.** `hostname -s | tr '[:upper:]' '[:lower:]'` everywhere.
- **Versioning is git.** Rotation = commit + (when needed) `sops updatekeys` + push.
## Layout in a stack repo
```
Charlie/<stack>/
├── docker-compose.yml
├── .env.example # tracked, redacted
├── secrets/
│ └── env.sops.yaml # encrypted; decrypts to .env
├── .sops.yaml # creation rules: which recipients can decrypt
└── .gitea/workflows/deploy.yaml # calls deploy-stack.yml
```
For shared stacks, `.sops.yaml` lists every host that runs the stack.
## Keys
- **One age key per host**, at `/etc/charlie/age.key` (`0640 root:charlie-deploy`). Public keys in [`hosts.md`](hosts.md#age-public-keys-sops).
- **One admin key** held by alex on the admin laptop, listed in *every* repo's `.sops.yaml` so secrets stay decryptable from off-host. Backup as a vaultwarden Secure Note (`Charlie SOPS admin age key`).
- **Per-host, not per-stack.** Container isolation already provides the per-stack property — `/etc/charlie/age.key` isn't bind-mounted into containers.
## Rotation
```bash
# In the stack repo on the admin laptop.
tmp=$(mktemp) && chmod 600 "$tmp"
printf 'KEY=new-value\n' > "$tmp"
sops --encrypt --input-type dotenv --output-type yaml \
--filename-override secrets/env.sops.yaml "$tmp" > secrets/env.sops.yaml
shred -u "$tmp"
git commit -am 'Rotate <KEY>'
git push # triggers redeploy
```
For per-host secret files, repeat per host.
Adding/removing recipients (e.g. onboarding mohg): edit `.sops.yaml`, then `sops updatekeys secrets/env.sops.yaml`.
## Registry credentials (read-only PAT)
`deploy-stack.yml` runs `docker login gitea.alexandru.macocian.me` on the target host immediately before `docker compose pull`. This is independent of any creds cached in `~/.docker/config.json` — necessary because `build-image.yml`'s cleanup wipes the RW caller PAT after each build, which otherwise leaves the next deploy stranded.
The login uses a dedicated **read-only** PAT (scope: `read:package`) belonging to `sys-gitea-runner`. A host compromise leaks pull access only; pushes still require the per-namespace RW PATs that callers inject into `build-image.yml`.
The ciphertext lives **inlined** in [`deploy-stack.yml`](../.gitea/workflows/deploy-stack.yml) between `SOPS_BLOB` markers in the `Registry login` step. Recipients = every host age key + the admin key (see [`/.sops.yaml`](../.sops.yaml)). Decryption happens on the target host using its `/etc/charlie/age.key`.
### Encrypt / paste recipe
On the admin laptop, in this repo:
```bash
mkdir -p secrets
tmp=$(mktemp) && chmod 600 "$tmp"
printf 'REGISTRY_USER=sys-gitea-runner\nREGISTRY_PAT=<ro-pat>\n' > "$tmp"
sops --encrypt --input-type dotenv --output-type yaml \
--filename-override secrets/registry-creds.sops.yaml \
"$tmp" > secrets/registry-creds.sops.yaml
shred -u "$tmp"
```
Open the resulting `secrets/registry-creds.sops.yaml`, copy its full contents, and paste it into `.gitea/workflows/deploy-stack.yml` replacing the `REPLACE_ME_WITH_ENCRYPTED_YAML` line (preserve the surrounding comment markers). The `secrets/registry-creds.sops.yaml` file itself is not committed (it's a scratch artifact) — only the inlined paste is the source of truth. `git commit -am 'Rotate registry RO PAT' && git push`.
### Rotation
Regenerate the PAT in Gitea (`sys-gitea-runner` → Settings → Applications → tokens, `read:package` only), repeat the recipe, repaste, push. Next deploy on every host picks it up automatically.
### Adding a host
When a new host gets an age key during onboarding, add its pubkey to the `creation_rules` block in [`/.sops.yaml`](../.sops.yaml), then `sops updatekeys secrets/registry-creds.sops.yaml` (regenerate locally first via the recipe above), repaste, push.
## What this design intentionally doesn't do
- Manage secrets for stacks that aren't runner-deployed.
- Replace vaultwarden's human-facing UX. Vaultwarden stays for browser autofill + secure notes (including the admin age key backup).
- Solve secrets for [`Charlie/gitea`](https://gitea.alexandru.macocian.me/Charlie/gitea) auto-deploy — gitea-on-melina is deployed manually (it bootstraps the runners that would deploy it). SOPS is offline crypto though, so its secrets *can* live encrypted in the repo and a manual `sops -d | docker compose up -d` works.