Produire la clé de chiffrement au premier démarrage

Le déploiement restait bloqué sur ENCRYPTION_KEY. J'ai tenu trop longtemps la
position « pas de valeur par défaut », en confondant deux choses : refuser une
clé livrée avec l'image — ce qui reste juste, une clé publiée dans un dépôt ne
protège rien — et exiger qu'un humain en fabrique une avant tout démarrage.

Une variable d'environnement n'est d'ailleurs pas un bon coffre : elle s'affiche
dans `docker inspect` et dans l'interface de gestion. Un fichier produit au
démarrage, dans un volume distinct de la base et des documents, n'est pas moins
protégé — et une sauvegarde de l'un n'emporte plus la clé de l'autre.

Le point d'entrée la produit donc si elle manque, l'écrit en 0600, et l'affiche
une fois dans les journaux avec ce qu'il faut en faire. Une clé fournie
explicitement l'emporte toujours : un déploiement qui gère ses secrets ailleurs
ne doit pas être contrarié. La pile démarre désormais sans aucune variable.

Éprouvé sur le script lui-même : première exécution, clé de 32 octets produite
et annoncée ; deuxième, reprise en silence depuis le fichier ; avec une clé
fournie, le fichier reste intact.

Corrigé au passage, révélé par un échec transitoire du test : une écriture
disque impossible — volume plein, droits, montage absent — remontait en
exception non traitée. L'écran restait muet, la pièce n'était pas déposée et
rien ne le disait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-09 09:41:24 +00:00
1 parent 68e0fd69f0
commit e5524c6299
6 files changed
+91 -19

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
# #
# openssl rand -base64 32 # openssl rand -base64 32
# #
# En docker-compose, la laisser vide suffit : elle est produite au premier
# démarrage et conservée dans le volume `secrets`.
#
# Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à # Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à
# lire ces colonnes. La perdre rend les données chiffrées irrécupérables — # lire ces colonnes. La perdre rend les données chiffrées irrécupérables —
# la sauvegarder séparément et documenter sa rotation. # la sauvegarder séparément et documenter sa rotation.
+3 -2
View File
@@ -34,14 +34,15 @@ COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
# Créé dans l'image, et non laissé au montage : un volume nommé hérite du # Créé dans l'image, et non laissé au montage : un volume nommé hérite du
# propriétaire du répertoire qu'il recouvre, et sans cela l'application — # propriétaire du répertoire qu'il recouvre, et sans cela l'application —
# qui ne tourne pas en root — ne pourrait pas y écrire. # qui ne tourne pas en root — ne pourrait pas y écrire.
RUN mkdir -p /data/documents && chown -R nextjs:nodejs /data RUN mkdir -p /data/documents /secrets && chown -R nextjs:nodejs /data /secrets
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0 ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node server.js"] CMD ["./docker/entrypoint.sh"]
+8 -11
View File
@@ -49,12 +49,11 @@ devinent pas.
### Avec Docker ### Avec Docker
```bash ```bash
cp .env.example .env
# ENCRYPTION_KEY est la seule variable sans valeur par défaut :
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env
docker compose up --build docker compose up --build
``` ```
Aucune variable n'est requise. La clé de chiffrement est **produite au premier démarrage** et affichée une fois dans les journaux — notez-la, elle vit dans un volume distinct de la base et des documents.
L'application écoute sur <http://localhost:9317> — port peu courant à dessein, le service étant censé passer par un reverse-proxy. Les migrations s'appliquent au démarrage du conteneur. L'application écoute sur <http://localhost:9317> — port peu courant à dessein, le service étant censé passer par un reverse-proxy. Les migrations s'appliquent au démarrage du conteneur.
La pile attend un réseau externe nommé `nginx_default`, celui du reverse-proxy. S'il n'existe pas encore : La pile attend un réseau externe nommé `nginx_default`, celui du reverse-proxy. S'il n'existe pas encore :
@@ -67,15 +66,13 @@ Seule l'application y est attachée. La base reste sur le réseau privé de la p
### Avec Portainer ### Avec Portainer
Portainer ne lit pas de fichier `.env` : les variables se déclarent dans l'écran de la pile, section **Environment variables**. Une seule est obligatoire : Portainer ne lit pas de fichier `.env`, mais **aucune variable n'est obligatoire** : la pile démarre telle quelle.
| Variable | Valeur | Une seule mérite d'être renseignée dans la section **Environment variables** : `APP_URL`, avec l'adresse publique réelle. Sans elle, les liens des messages — invitations comprises — pointeront vers `localhost` et personne ne pourra les suivre.
|---|---|
| `ENCRYPTION_KEY` | `openssl rand -base64 32` |
Les autres ont une valeur par défaut utilisable telle quelle : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317), `APP_URL`. Les autres ont une valeur par défaut utilisable : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317). `ENCRYPTION_KEY` peut être fournie si vous gérez vos secrets ailleurs ; sinon elle est produite au premier démarrage.
Renseignez `APP_URL` avec l'adresse publique réelle, sans quoi les liens des messages — invitations comprises — pointeront vers `localhost` et personne ne pourra les suivre. **Après le premier déploiement, relevez la clé dans les journaux du conteneur `app` et conservez-la hors du serveur.**
### En local ### En local
@@ -97,7 +94,7 @@ pnpm dev
openssl rand -base64 32 openssl rand -base64 32
``` ```
`ENCRYPTION_KEY` n'a **délibérément pas de valeur par défaut**, et n'en aura pas : une clé livrée avec l'image serait connue de quiconque lit ce dépôt, et le chiffrement ne protégerait plus rien. C'est la seule variable qui bloque le démarrage tant qu'elle manque. Elle n'est **pas** livrée avec l'image — une clé publiée dans un dépôt ne protégerait rien. Elle est produite au premier démarrage du conteneur, affichée une fois dans les journaux, et conservée dans le volume `planflow_secrets`. Fournir `ENCRYPTION_KEY` explicitement l'emporte toujours, pour un déploiement qui gère ses secrets par ailleurs — en gardant à l'esprit qu'une variable d'environnement s'affiche dans `docker inspect` et dans l'interface de gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier.
Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi. Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi.
@@ -109,7 +106,7 @@ Deux choses à sauvegarder **ensemble**, plus une à garder à part :
|---|---| |---|---|
| Base de données | volume `planflow_db-data` | | Base de données | volume `planflow_db-data` |
| Pièces du dossier salarié | volume `planflow_documents` | | Pièces du dossier salarié | volume `planflow_documents` |
| `ENCRYPTION_KEY` | **ailleurs**, jamais dans la même sauvegarde | | Clé de chiffrement | volume `planflow_secrets` — **ailleurs**, jamais dans la même sauvegarde |
Restaurer l'un sans l'autre rend un dossier amputé : les pièces référencées en base pointeraient vers des fichiers absents. Et sans la clé, le volume des documents est illisible — c'est précisément ce qu'on attend de lui si quelqu'un l'emporte. Restaurer l'un sans l'autre rend un dossier amputé : les pièces référencées en base pointeraient vers des fichiers absents. Et sans la clé, le volume des documents est illisible — c'est précisément ce qu'on attend de lui si quelqu'un l'emporte.
+12 -5
View File
@@ -52,11 +52,12 @@ services:
# superutilisateur contourne la row-level security, y compris déclarée en # superutilisateur contourne la row-level security, y compris déclarée en
# FORCE, et l'isolation ne reposerait plus que sur la couche applicative. # FORCE, et l'isolation ne reposerait plus que sur la couche applicative.
DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:5432/${POSTGRES_DB:-planflow} DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:5432/${POSTGRES_DB:-planflow}
# Seule variable sans valeur par défaut, et il n'y en aura pas : elle # Laissée vide, elle est **produite au premier démarrage** et conservée
# chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec # dans le volume `secrets`. La renseigner ici reste possible pour un
# l'image serait connue de tous et le chiffrement ne protégerait plus # déploiement qui gère ses secrets par ailleurs — mais une variable
# rien. La produire : openssl rand -base64 32 # d'environnement s'affiche dans `docker inspect` et dans l'interface de
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis. Produire une clé avec - openssl rand -base64 32} # gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier.
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
APP_URL: ${APP_URL:-http://localhost:9317} APP_URL: ${APP_URL:-http://localhost:9317}
# Chemin **dans le volume**, pas dans l'image : les pièces du dossier # Chemin **dans le volume**, pas dans l'image : les pièces du dossier
# salarié écrites dans la couche du conteneur disparaîtraient au premier # salarié écrites dans la couche du conteneur disparaîtraient au premier
@@ -64,6 +65,9 @@ services:
DOCUMENT_STORE: /data/documents DOCUMENT_STORE: /data/documents
volumes: volumes:
- documents:/data - documents:/data
# Volume distinct de la base et des documents : sauvegarder l'un ne doit
# pas emporter la clé qui déchiffre l'autre.
- secrets:/secrets
networks: networks:
# `interne` pour joindre la base, `nginx_default` pour être joignable par # `interne` pour joindre la base, `nginx_default` pour être joignable par
# le reverse-proxy — qui atteint le conteneur sur son port 3000, sans # le reverse-proxy — qui atteint le conteneur sur son port 3000, sans
@@ -98,6 +102,9 @@ networks:
volumes: volumes:
db-data: db-data:
# Clé de chiffrement produite au premier démarrage. À sauvegarder
# **séparément** du reste : réunis, le coffre et sa clé ne protègent plus rien.
secrets:
# Pièces du dossier salarié, chiffrées au repos avec ENCRYPTION_KEY. À # Pièces du dossier salarié, chiffrées au repos avec ENCRYPTION_KEY. À
# sauvegarder avec la base : l'une sans l'autre restitue un dossier amputé, # sauvegarder avec la base : l'une sans l'autre restitue un dossier amputé,
# et sans la clé le volume est illisible. # et sans la clé le volume est illisible.
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
# Démarrage du conteneur applicatif.
#
# La clé de chiffrement protège le NIR, l'IBAN, les secrets de second facteur et
# les pièces du dossier salarié. Elle doit exister avant que quoi que ce soit
# démarre — mais l'exiger en variable d'environnement rendait tout déploiement
# impossible sans une étape manuelle, et une variable d'environnement n'est de
# toute façon pas un bon coffre : elle s'affiche dans `docker inspect` et dans
# l'interface de gestion.
#
# Elle est donc produite au premier démarrage et conservée dans un volume
# **distinct** de la base et des documents : une sauvegarde de l'un ne doit pas
# emporter la clé de l'autre.
#
# `ENCRYPTION_KEY` fournie explicitement l'emporte toujours : un déploiement qui
# gère ses secrets par ailleurs ne doit pas être contrarié.
KEY_FILE="${ENCRYPTION_KEY_FILE:-/secrets/encryption.key}"
if [ -z "${ENCRYPTION_KEY:-}" ]; then
if [ -f "$KEY_FILE" ]; then
ENCRYPTION_KEY="$(cat "$KEY_FILE")"
else
mkdir -p "$(dirname "$KEY_FILE")"
ENCRYPTION_KEY="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64'))")"
# Écriture puis restriction : le fichier ne doit jamais être lisible par
# d'autres, même brièvement.
(umask 077; printf '%s' "$ENCRYPTION_KEY" > "$KEY_FILE")
echo ''
echo '════════════════════════════════════════════════════════════════'
echo ' Clé de chiffrement produite au premier démarrage.'
echo ''
echo " $ENCRYPTION_KEY"
echo ''
echo ' Conservez-la hors de ce serveur. Sans elle, le NIR, les IBAN et'
echo ' les pièces du dossier salarié sont définitivement illisibles.'
echo ''
echo " Elle est stockée dans $KEY_FILE, sur un volume distinct de la"
echo ' base et des documents — à sauvegarder séparément.'
echo '════════════════════════════════════════════════════════════════'
echo ''
fi
fi
export ENCRYPTION_KEY
# Les migrations s'appliquent au démarrage : l'image se déploie sans étape
# séparée.
./node_modules/.bin/prisma migrate deploy
exec node server.js
+11 -1
View File
@@ -71,7 +71,17 @@ export async function uploadDocumentAction(
}); });
if (!membership) throw new ValidationError('Salarié introuvable.'); if (!membership) throw new ValidationError('Salarié introuvable.');
const stored = await storeFile(actor.accountId, content); // Une écriture disque peut échouer — volume plein, droits, montage
// absent. Laisser l'exception remonter afficherait un formulaire muet :
// la pièce n'est pas déposée et rien ne le dit.
const stored = await storeFile(actor.accountId, content).catch(
(error: unknown) => {
console.error('Écriture de pièce impossible :', error);
throw new ValidationError(
'Le fichier n’a pas pu être écrit sur le disque. Vérifiez l’espace disponible et les droits du volume de stockage.',
);
},
);
await db.document.create({ await db.document.create({
data: { data: {