feat(secrets): chiffrement des secrets avec SOPS + age

Met en place la mécanique de gestion des secrets du dépôt :

- .sops.yaml : deux périmètres séparés par acteur. Les
  stacks/*/secrets.env.yaml sont déchiffrables par le compte de
  déploiement de chioggia ET l'admin ; tout le reste par l'admin seul.
  Une compromission du serveur ne donne donc pas accès aux credentials
  OVH, qui restent gérés par pass.
- .gitignore : tout .env est traité comme un accident. La configuration
  non sensible de chaque stack vit dans un fichier `env_file` versionné.
- README : création des clés, emplacements, recettes et pièges.

Les secrets sont injectés à la volée par `sops exec-env` : aucun fichier
déchiffré ne touche le disque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 16:38:34 +02:00
parent 7b8a41ea4a
commit d619f165f0
3 changed files with 168 additions and 0 deletions

22
.gitignore vendored
View File

@@ -42,3 +42,25 @@ terraform.rc
# Optional: ignore plan files saved before destroying Terraform configuration
# Uncomment the line below if you want to ignore planout files.
# planout
**/.claude
# ============================================
# Secrets — voir .sops.yaml
# ============================================
# Les secrets vivent UNIQUEMENT dans des stacks/*/secrets.enc.yaml chiffrés,
# consommés à la volée par `sops exec-env`. Aucun secret déchiffré ne doit
# exister sur le filesystem : les règles ci-dessous sont une ceinture de
# sécurité, pas un mode de fonctionnement.
.env
*.env
*.dec.yaml
*.decrypted.*
# La configuration NON sensible de chaque stack vit dans un fichier nommé
# `env_file` (sans extension) : il échappe donc volontairement aux règles
# ci-dessus et reste versionné en clair.
# Clés privées age (ne doivent jamais approcher le dépôt)
*.agekey
age-key.txt
keys.txt

53
.sops.yaml Normal file
View File

@@ -0,0 +1,53 @@
# ============================================================
# Politique de chiffrement SOPS
# ============================================================
# Deux périmètres, séparés par ACTEUR — c'est le garde-fou principal
# de ce dépôt :
#
# 1. stacks/*/secrets.env.yaml
# Déchiffrables par chioggia (déploiement automatique, sans humain)
# ET par l'admin. YAML PLAT uniquement (CLE: valeur) : `sops exec-env`
# ne sait pas exporter une structure imbriquée.
#
# 2. tout le reste
# Déchiffrables par l'admin UNIQUEMENT.
# Conséquence voulue : une compromission de chioggia ne donne PAS
# accès aux credentials OVH / registrar. Sans cette séparation,
# prendre le serveur reviendrait à prendre le domaine (donc les MX,
# le SPF, et la capacité d'émettre des certificats).
#
# Les credentials OpenTofu (OVH, AdGuard) restent gérés par `pass` :
# ils sont utilisés en interactif depuis le poste admin, jamais par une
# machine. Voir dns/ovh.tf et dns/local.tf.
#
# ------------------------------------------------------------
# Générer une clé — une par machine, seule la PUBLIQUE arrive ici :
#
# poste admin : age-keygen | pass insert -m infra/age-key
# (publique : pass show infra/age-key | age-keygen -y)
# serveur : mkdir -p ~/.config/sops/age
# (umask 077; age-keygen -o ~/.config/sops/age/keys.txt)
# age-keygen -y ~/.config/sops/age/keys.txt
#
# Fournir la clé privée à sops :
# poste : export SOPS_AGE_KEY="$(pass show infra/age-key)"
# serveur : ~/.config/sops/age/keys.txt est l'emplacement par défaut,
# lu sans aucune variable d'environnement
#
# Rappel : ajouter une clé ici ne rechiffre RIEN.
# sops updatekeys -y stacks/*/secrets.env.yaml
# ------------------------------------------------------------
keys:
- &lafrite_combava age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s
- &waha_chioggia age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj
creation_rules:
- path_regex: ^stacks/.*/secrets\.env\.yaml$
age:
- *lafrite_combava
- *waha_chioggia
- path_regex: .*
age:
- *lafrite_combava

View File

@@ -2,6 +2,99 @@
Configuration Terraform pour gérer l'infrastructure domestique.
## Secrets (SOPS + age)
Les secrets sont chiffrés **dans** le dépôt. Rien de déchiffré ne doit jamais toucher
le disque : `sops exec-env` injecte les valeurs en variables d'environnement le temps
d'une commande.
### Créer une clé
Une clé par machine. La privée ne quitte jamais sa machine ; seule la **publique**
est recopiée dans `.sops.yaml`.
```bash
# poste admin — la privée va dans pass, jamais sur le disque
age-keygen | pass insert -m infra/age-key
pass show infra/age-key | age-keygen -y # publique -> .sops.yaml
# serveur (chioggia, ou toute nouvelle machine) — sous le compte qui déploie
# les stacks, PAS root : sops lit ce chemin par défaut, sans aucune variable
# d'environnement à exporter
mkdir -p ~/.config/sops/age
(umask 077; age-keygen -o ~/.config/sops/age/keys.txt)
age-keygen -y ~/.config/sops/age/keys.txt # publique -> .sops.yaml
```
`age-keygen` écrit la clé privée sur stdout (trois lignes, dont `# public key:`)
et un rappel de la publique sur stderr — c'est bien la privée seule qui entre dans
`pass`. Vérifier avec `pass show infra/age-key`.
Ajouter ensuite la publique au bon `creation_rules` de `.sops.yaml`, **puis
rechiffrer l'existant** — sans quoi la nouvelle machine ne pourra rien lire :
```bash
sops updatekeys -y stacks/*/secrets.env.yaml
```
### Où sont les clés
| Machine | Clé privée | Comment sops la trouve |
|---|---|---|
| Poste admin | dans `pass`, sous `infra/age-key` | `export SOPS_AGE_KEY="$(pass show infra/age-key)"` |
| chioggia | `~/.config/sops/age/keys.txt` (600) du compte de déploiement | emplacement par défaut : rien à exporter |
Sur chioggia, le compte qui déploie est aussi celui qui déchiffre. Il est membre du
groupe `docker` et n'a donc jamais besoin de `sudo` — ni pour `git pull`, ni pour
`docker compose`, ni pour lire sa clé.
`.sops.yaml` définit qui déchiffre quoi : chioggia n'a accès qu'aux
`stacks/*/secrets.env.yaml`. Les credentials OVH et AdGuard restent gérés par `pass`
(voir `dns/`) et sont hors de sa portée — une compromission du serveur ne doit pas
donner le domaine.
### Recettes
```bash
# éditer un secret (rechiffré à la sortie de l'éditeur)
sops edit stacks/vault.opytex.org/secrets.env.yaml
# créer les secrets d'une nouvelle stack — le chemin n'a pas besoin d'exister,
# rien n'est écrit en clair
sops edit stacks/<nom>/secrets.env.yaml
# lire sans éditer
sops -d stacks/vault.opytex.org/secrets.env.yaml
# déployer, depuis le dossier de la stack sur chioggia
sops exec-env secrets.env.yaml 'docker compose up -d'
# après TOUTE modification de .sops.yaml (nouvelle machine, clé retirée).
# Accepte plusieurs fichiers malgré l'aide qui annonce `file` au singulier.
# Sans -y, sops demande une confirmation par fichier — mais montre le diff
# des destinataires, utile pour vérifier quelle creation_rules s'applique.
sops updatekeys -y stacks/*/secrets.env.yaml
```
Le format est du **YAML plat** (`CLE: valeur`) : `exec-env` ne sait pas exporter une
structure imbriquée.
### Les pièges à connaître
- **`sops updatekeys` n'est pas automatique.** Modifier `.sops.yaml` ne rechiffre rien ;
les fichiers existants restent lisibles par les anciennes clés seulement.
- **Une clé illisible produit un message trompeur.** Mauvais propriétaire ou mauvais
`chmod` sur `keys.txt` donne `Recovery failed because no master key was able to
decrypt the file` — pas un mot sur les permissions. Vérifier `ls -l` sur la clé
avant de suspecter le chiffrement.
- **Les composes déclarent `VAR: ${VAR:?}`.** Un `docker compose up` lancé sans
`sops exec-env` échoue immédiatement, au lieu de recréer les conteneurs avec des
valeurs vides. Ne pas « simplifier » en `VAR: ${VAR}`.
- **Perdre les deux copies de la clé age = perdre tous les secrets du dépôt.**
Celle du poste vit dans `pass`, à sauvegarder hors ligne. Celle de chioggia est dans
un répertoire personnel : elle n'est PAS couverte par le backup tant qu'elle n'est
pas ajoutée à `MANUAL_DIRECTORIES` dans `backup.conf`.
## DNS
### Gestion des DNS