Aller au contenu
Sylvain Joffraud l4w
Retour

Hash avec `$` en env var : tronqué en silence

Backend Node + Fastify avec login Basic Auth. Je stocke le hash scrypt du mot de passe dans une variable d’environnement, lue par compose et injectée dans le conteneur :

# .env ou Environment Variables du PaaS
APP_PASSWORD_HASH=scrypt$16384$8$1$67eeyJldFrZtCScDvEy6eA==$pALJGOYxdvMma/45xQTTDyTfA...

L’auth refuse en 401 sur des creds que je sais corrects. Aucune erreur visible côté backend — juste un 401 « Invalid credentials » silencieux.

Symptôme

Dans les logs de build / compose :

warning msg="The \"pALJGOYxdvMma\" variable is not set. Defaulting to a blank string."

pALJGOYxdvMma est un fragment de mon hash. Bizarre.

Dans le conteneur, le hash semble présent — startsWith('scrypt') vrai, longueur > 100 chars. Mais en comptant les segments :

process.env.APP_PASSWORD_HASH.split('$').length
// → 5 au lieu de 6 attendus pour scrypt$N$r$p$salt$hash

Cause

Le format scrypt utilise $ comme séparateur : scrypt$N$r$p$saltB64$hashB64. Quand cette valeur passe par un mécanisme d’interpolation de variables (cas pour .env lu par compose, mais AUSSI pour les Environment Variables stockées dans certains PaaS via leur UI), chaque $<nom-valide> est traité comme une référence à substituer.

Dans le hash, après scrypt$16384$8$1$67ee...== (jusque-là OK car $16384, $8, $1 ne sont pas des noms de variables valides — ils commencent par un chiffre), arrive le segment $pALJGOYxdvMma.... pALJGOYxdvMma est un nom de variable valide (commence par une lettre). L’interpolateur le remplace par chaîne vide.

Résultat : le hash arrive dans le conteneur silencieusement tronqué, sans $ séparateur entre le salt et le hash. La verify échoue logiquement, mais sans erreur lisible.

Longueur observée = longueur attendue − longueur du segment mangé. Le len = 116 vs len = 130 attendu colle au calcul.

Solution

Single quotes dans .env désactivent l’interpolation pour compose :

APP_PASSWORD_HASH='scrypt$16384$8$1$salt==$hash=='

Mais ça ne marche pas dans tous les contextes :

La solution robuste : ne pas utiliser $ dans la valeur stockée. Pour scrypt, remplacer le séparateur :

// Avant — fragile
return `scrypt$${N}$${r}$${p}$${saltB64}$${hashB64}`;

// Après — `:` n'est pas dans le base64 standard, ni interpolé
return `scrypt:${N}:${r}:${p}:${saltB64}:${hashB64}`;

Et au verify, split(':') au lieu de split('$'). Breaking change pour les hash existants — les régénérer.

Vérification post-déploiement, sans exposer le hash :

docker exec <container> node -e "
  const h = process.env.APP_PASSWORD_HASH;
  console.log('parts:', h.split(':').length);   // doit être 6
"

Lessons learned

Questions fréquentes

Pourquoi un hash de mot de passe en variable d’environnement provoque-t-il un 401 ?
Le caractère $ sépare les segments d’un hash scrypt/bcrypt/argon2. Docker Compose et certains PaaS interprètent $nom comme une variable à substituer et remplacent le segment par une chaîne vide : le hash arrive tronqué et la vérification échoue en 401, sans erreur explicite.
Comment empêcher Compose d’interpréter le $ d’un secret ?
Entourer la valeur de single quotes dans le .env désactive l’interpolation côté Compose, mais certains PaaS conservent les quotes littéralement. La parade robuste : ne pas stocker de $ du tout, en changeant le séparateur du hash pour : (absent du base64 standard).
Comment vérifier qu’un hash n’a pas été tronqué dans le conteneur ?
Compter les segments : node -e "console.log(process.env.APP_PASSWORD_HASH.split(':').length)". Pour scrypt on attend 6 segments ; toute autre valeur trahit une interpolation qui a mangé une partie du hash.

Partager cet article :

Article précédent
Le piège RCS : configurer un device GSM-only en SMS
Article suivant
Override d’entrypoint : l’image perd son ENTRYPOINT