TL;DR
- Chiffrement hybride : RSA-OAEP (SHA-256) pour les champs courts, AES-256-GCM pour les gros payloads.
- Le serveur ne détient que la clé publique : il sait chiffrer, jamais déchiffrer.
- La clé privée vit uniquement dans une app desktop séparée (Electron), derrière un accès physique + authentification.
- Recherche sur données chiffrées via HMAC + pepper, pas de déchiffrement pour matcher.
- Résultat : une fuite de base = du bruit cryptographique. Le RGPD adore.
Le bon point de départ : le modèle de menace
La plupart des applis « chiffrent les données ». En pratique ça veut dire : la base est chiffrée au repos par le cloud provider, et l'application déchiffre tout à la volée avec une clé… qui est sur le serveur. Si on vole le serveur, on vole la clé. Le chiffrement n'a servi à rien contre l'attaque qui compte vraiment.
Pour weblame, j'ai inversé la question. Pas « comment chiffrer les données ? » mais « qu'est-ce qu'un attaquant obtient s'il a un dump complet de la base ET le code du serveur ? » La réponse devait être : rien d'exploitable. C'est la définition pragmatique du zero-knowledge côté serveur.
La règle d'or : le serveur qui reçoit les données ne doit jamais avoir les moyens de les relire. Il chiffre, il range, il oublie.
L'architecture : chiffrer ici, déchiffrer là-bas
Deux applications, deux responsabilités étanches :
- Le serveur (API publique), reçoit les témoignages, les chiffre avec la clé publique, calcule des empreintes de recherche, et écrit en base. Il ne possède aucun secret de déchiffrement.
- L'app de revue (desktop Electron), détient seule la clé privée. C'est le seul endroit du système où une donnée redevient lisible, sur une machine contrôlée, après authentification.
Cette séparation est le cœur de tout. Compromettre l'API, le composant exposé à Internet, donc le plus attaquable, ne donne accès qu'à une moitié du système : celle qui ne sait que chiffrer.
[ navigateur ]
│ POST /testimony (clair, sur TLS)
▼
┌───────────────────┐
│ API (publique) │ clé PUBLIQUE seulement
│ encrypt + hash │ → ne peut PAS déchiffrer
└─────────┬─────────┘
│ écrit du chiffré
▼
[ PostgreSQL ] ← un dump ici = du bruit
▲
│ lit du chiffré
┌─────────┴─────────┐
│ app de revue │ clé PRIVÉE (hors-ligne)
│ (Electron) │ → seul point de déchiffrement
└───────────────────┘
Pourquoi un chiffrement hybride (et pas juste RSA)
RSA ne chiffre pas de gros volumes : il est limité par la taille de la clé et il est lent. AES est rapide et chiffre n'importe quelle taille, mais c'est du symétrique, il faut partager une clé, ce qu'on veut justement éviter. La solution classique mais bien exécutée : combiner les deux.
- Champs courts (email, nom, téléphone) →
RSA-OAEPavecSHA-256. Simple, direct, parfait pour quelques dizaines d'octets. - Gros payloads (le témoignage lui-même) → on génère une clé AES-256 éphémère, on chiffre le texte en
AES-256-GCM, puis on chiffre cette clé AES avec la clé publique RSA. On stocke les deux.
GCM est choisi pour une raison précise : c'est un mode authentifié. Il ne garantit pas seulement la confidentialité, mais aussi l'intégrité, toute altération du chiffré est détectée au déchiffrement via le tag d'authentification. Pas de chiffré « malléable » qu'on pourrait trafiquer en base.
// côté serveur, chiffrer un gros champ (schéma)
const aesKey = randomBytes(32); // clé éphémère
const iv = randomBytes(12); // nonce GCM
const cipher = createCipheriv('aes-256-gcm', aesKey, iv);
const enc = Buffer.concat([cipher.update(text,'utf8'), cipher.final()]);
const tag = cipher.getAuthTag(); // intégrité
// la clé AES est scellée par la clé PUBLIQUE, le serveur ne pourra plus l'ouvrir
const sealedKey = publicEncrypt(
{ key: RSA_PUBLIC, padding: RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
aesKey
);
store({ enc, iv, tag, sealedKey }); // tout est inerte sans la clé privée
Le vrai casse-tête : chercher dans des données chiffrées
Voilà où ça devient intéressant. Une base zero-knowledge, c'est joli, mais il faut quand même retrouver des choses : « a-t-on déjà un témoignage avec cet email ? », « ce numéro de plaque revient-il ? ». Or on ne peut pas déchiffrer pour comparer, sinon toute l'architecture s'effondre.
La parade : on stocke, à côté du chiffré, une empreinte déterministe du champ, un HMAC-SHA-256 calculé avec un pepper secret côté serveur. Le même email donne toujours la même empreinte, donc on peut faire des égalités et des jointures… sans jamais lire la valeur.
// empreinte cherchable, déterministe mais non réversible
const fingerprint = hmacSha256(normalize(email), SERVER_PEPPER);
// → on indexe `fingerprint`, jamais l'email en clair
// WHERE fingerprint = :needle ✅ recherche exacte sans déchiffrer
Le pepper est crucial : sans lui, un attaquant qui dump la base pourrait pré-calculer les empreintes de millions d'emails connus (attaque par dictionnaire) et retrouver les valeurs. Avec un pepper secret qui ne vit pas en base, ce calcul devient impossible. C'est la différence entre un hash naïf et un hash défendable.
Le moteur de rapprochement, déplacé du bon côté
Le métier de weblame, c'est de détecter des récidivistes : repérer que deux témoignages distincts parlent probablement de la même personne. Ça demande du flou, distance de Levenshtein sur les noms, normalisation de plaques, distance géographique (haversine) pour regrouper par zone. Impossible à faire sur du chiffré.
La décision d'archi : ce moteur ne tourne pas sur le serveur. Il vit dans l'app de revue, après déchiffrement local, sur une machine de confiance. Le serveur public reste « bête et aveugle » ; toute l'intelligence sensible est du côté qui a le droit de voir.
Détail pratique appris en route : PostgREST plafonne les réponses à 1000 lignes. Pour rejoindre des milliers de témoignages chiffrés à leurs métadonnées, l'app de revue pagine et reconstitue les jointures côté client. Moins élégant qu'un gros JOIN serveur, mais cohérent avec le principe : le serveur ne doit jamais voir l'ensemble en clair.
« Et si l'utilisateur perd son accès ? »
Le talon d'Achille de tout système chiffré, c'est la récupération de compte. Un lien « mot de passe oublié » classique est souvent une porte dérobée : si le serveur peut réinitialiser un accès, il peut potentiellement accéder aux données. On a donc évité les liens de reset exploitables.
À la place : des codes de récupération générés à l'inscription, stockés uniquement sous forme de hash bcrypt. Quand un code est utilisé, l'app de revue régénère des identifiants et les envoie par un canal transactionnel (gabarit email via Brevo). Pas de lien magique qui traîne, pas de secret réutilisable en base, juste une preuve à usage unique que seul l'utilisateur possède.
Ce que je retiens
- Le chiffrement n'est pas une case à cocher, c'est une frontière. La vraie question n'est pas « est-ce chiffré ? » mais « qui détient la clé, et où vit-elle ? »
- Séparez les pouvoirs. Un composant qui chiffre n'a aucune raison de pouvoir déchiffrer. Cette asymétrie réduit la surface d'attaque de moitié, gratuitement.
- La recherche est le piège. 90% de la difficulté d'une base zero-knowledge n'est pas de chiffrer, c'est de rester utilisable. HMAC + pepper pour l'exact, déchiffrement local pour le flou.
- RGPD & EU AI Act adorent ça. Une fuite qui ne révèle rien, c'est une obligation de notification… sans dommage à notifier. La conformité devient une conséquence de l'archi, pas une couche par-dessus.
Ce genre de raisonnement, partir de la menace, séparer les pouvoirs, assumer les compromis, c'est exactement la mentalité que j'applique aux agents IA en production. Là aussi, la vraie question n'est pas « est-ce que ça marche ? » mais « qu'est-ce qui se passe quand ça casse, et qui peut faire quoi ? »
Des données qui ne doivent jamais sortir d'Europe ?
C'est exactement le terrain de Gérer.ai : des agents IA déployés sur votre infra, avec des modèles open-source auto-hébergés. Banque, santé, juridique, public, rien ne part chez un LLM américain.
Découvrir Gérer.ai ↗