Chiffrer des secrets

Stocker un token tiers en base sans le laisser en clair, et gérer l'absence de clé proprement.

Certaines valeurs doivent être stockées en base et relues en clair : token d'API d'un service tiers, secret de webhook, identifiants d'un connecteur. Un hachage à sens unique ne convient pas, puisqu'il faut retrouver la valeur d'origine.

core/crypto chiffre ces valeurs en AES-256-GCM.

Ce guide ne concerne pas les mots de passe utilisateur. Ceux-là sont hachés par hashPassword de core/users et ne sont jamais déchiffrables.

Générer la clé

Un projet créé par npx @lrvo/start a déjà sa clé : le CLI l'écrit dans le .env qu'il génère. Cette section ne concerne qu'un projet monté à la main, ou un .env où la ligne est restée vide.

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
LRVO_ENCRYPTION_KEY=<64 caractères hexadécimaux>

La clé vit dans le .env du serveur, jamais dans le dépôt.

Chiffrer à l'écriture

import { encryptSecret } from '@lrvo/core/crypto';
import { create } from '@lrvo/core/database';

export async function saveConnector({ name, apiToken }) {
  return create('lrvo_blog_connectors', {
    name,
    token: encryptSecret(apiToken),
  }, { allowedColumns: ['name', 'token'] });
}

La colonne doit être de type TEXT : la valeur chiffrée est plus longue que l'originale et de taille variable.

Déchiffrer à la lecture

import { decryptSecret } from '@lrvo/core/crypto';
import { findById } from '@lrvo/core/database';

export async function callConnector(id, path) {
  const row = await findById('lrvo_blog_connectors', id);
  if (!row) return null;

  const token = decryptSecret(row.token);
  return fetch(`https://api.exemple.com${path}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
}

Déchiffrer au dernier moment, au point d'utilisation. Ne pas déchiffrer en amont pour passer la valeur en clair à travers plusieurs couches : elle finirait par apparaître dans un journal ou dans une réponse.

Ne jamais renvoyer le secret au client

Une route d'admin qui liste des connecteurs ne renvoie pas les tokens, même chiffrés.

async function handleListConnectors() {
  const rows = await find('lrvo_blog_connectors', {});
  return apiSuccess({
    connectors: rows.map(({ id, name, created_at }) => ({ id, name, created_at })),
  });
}

Pour indiquer qu'un token est configuré, renvoyer un booléen, pas une version masquée du secret.

Gérer l'absence de clé

encryptSecret et decryptSecret lèvent quand LRVO_ENCRYPTION_KEY est absente ou mal formée. C'est voulu : mieux vaut une erreur qu'un secret écrit en clair.

Pour un chemin qui doit se désactiver plutôt que crasher, typiquement la vérification d'activation d'un module :

import { isEncryptionAvailable } from '@lrvo/core/crypto';

export function getConnectorStatus() {
  if (!isEncryptionAvailable()) {
    return { enabled: false, reason: 'LRVO_ENCRYPTION_KEY absente' };
  }
  return { enabled: true };
}

isEncryptionAvailable() ne lève jamais.

Déclarer la variable dans le manifest du module pour qu'un avertissement soit émis au boot :

envVars: [
  { key: 'LRVO_ENCRYPTION_KEY', required: true, description: 'Chiffrement des tokens de connecteurs' },
],

Format stocké

"<iv_b64>:<tag_b64>:<ciphertext_b64>"

Un IV aléatoire est tiré à chaque chiffrement. Deux chiffrements de la même valeur produisent donc deux chaînes différentes.

Conséquence pratique : on ne peut pas comparer deux colonnes chiffrées pour savoir si elles portent le même secret, ni indexer la colonne pour une recherche par valeur. Si un tel besoin existe, stocker en plus un hachage à sens unique de la valeur.

Le tag d'authentification garantit qu'une valeur altérée est détectée : decryptSecret lève au lieu de retourner des octets corrompus.

Rotation de clé

Il n'y a pas de versionnage de clé. Changer LRVO_ENCRYPTION_KEY rend illisible tout ce qui a été chiffré avec l'ancienne.

Une rotation demande donc une migration explicite, avec les deux clés disponibles simultanément :

  1. Déchiffrer chaque valeur avec l'ancienne clé.
  2. Re-chiffrer avec la nouvelle.
  3. Écrire, puis basculer la variable d'environnement.

Traiter la clé comme permanente pour un déploiement donné, sauf à planifier cette migration.

Sauvegardes

Une sauvegarde de base sans la clé est inexploitable pour les colonnes chiffrées. Sauvegarder la clé séparément de la base, et vérifier que la restauration a été testée au moins une fois.

Sur cette page