Chiffrer les données utilisateur
Scénario réel : une application SaaS enregistre des utilisateurs avec des données personnelles (prénom, nom, email, téléphone). Ce guide montre comment chiffrer les champs PII avant insertion en base de données, et déchiffrer à la lecture.
Conformité RGPD
Ce pattern répond aux exigences RGPD Art.5(1)(f) (intégrité et confidentialité) et Art.32 (mesures techniques). En cas de fuite de la base de données, les données PII restent illisibles sans la clé MAFATE.
1. Schéma de la base de données
Les champs PII sont stockés en JSONB (le ciphertext MAFATE est un objet JSON avec ciphertext + key_id + key_version). Un hash déterministe de l'email (via mafate.hash()) permet la recherche sans déchiffrement.
-- Schéma PostgreSQL : données utilisateur chiffrées
CREATE TABLE users (
id SERIAL PRIMARY KEY,
first_name JSONB NOT NULL, -- chiffré
last_name JSONB NOT NULL, -- chiffré
email JSONB NOT NULL, -- chiffré
phone JSONB NOT NULL, -- chiffré
email_hash TEXT NOT NULL, -- hash déterministe pour la recherche
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index sur le hash pour les recherches par email
CREATE INDEX idx_users_email_hash ON users(email_hash);2. Enregistrement (chiffrement à l'écriture)
Avant d'insérer en base, chaque champ PII est chiffré en local avec la clé dédiée aux données utilisateur. Le résultat est une enveloppe de six champs, stockée telle quelle en JSONB : ciphertext, wrapped_key, iv, key_id, key_version, envelope_version.
import { Mafate } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const KEY_ID = process.env.MAFATE_USERS_KEY_ID! // UUID rendu par POST /v1/keys
app.post('/api/users/register', async (req, res) => {
const { firstName, lastName, email, phone } = req.body
// Chiffrement EN LOCAL : le clair ne quitte pas ce processus.
// Chaque appel fait emballer sa propre cle de donnees.
const [encFirstName, encLastName, encEmail, encPhone] = await Promise.all([
mafate.encryptLocal(firstName, KEY_ID),
mafate.encryptLocal(lastName, KEY_ID),
mafate.encryptLocal(email, KEY_ID),
mafate.encryptLocal(phone, KEY_ID),
])
// ⚠️ L'EMPREINTE EST LA SEULE EXCEPTION, ET ELLE SE PAIE.
// `hash` TRANSMET le clair a MAFATE, meme en mode enveloppe : c'est le prix
// de la recherche sur un champ chiffre. N'y passez que ce que vous devez
// chercher, ici l'e-mail, et jamais le reste de la fiche.
const emailHash = await mafate.hash(email.toLowerCase(), KEY_ID)
await db.query(
`INSERT INTO users (first_name, last_name, email, phone, email_hash)
VALUES ($1, $2, $3, $4, $5)`,
[
JSON.stringify(encFirstName),
JSON.stringify(encLastName),
JSON.stringify(encEmail),
JSON.stringify(encPhone),
emailHash,
]
)
res.json({ message: 'Utilisateur cree', emailHash })
})3. Lecture et recherche (déchiffrement à la lecture)
Pour trouver un utilisateur par email, on calcule son hash via mafate.hash() et on compare avec le hash stocké. Une fois la ligne trouvée, on déchiffre les champs à afficher.
// GET /api/users/:emailHash : rechercher par empreinte, sans dechiffrer.
app.get('/api/users/:emailHash', async (req, res) => {
const row = await db.query('SELECT * FROM users WHERE email_hash = $1', [
req.params.emailHash,
])
if (!row.rows[0]) return res.status(404).json({ error: 'Introuvable' })
const r = row.rows[0]
// Dechiffrement EN LOCAL. Chaque appel desemballe sa cle de donnees, puis
// ouvre l'enveloppe dans ce processus.
const [firstName, lastName, email, phone] = await Promise.all([
mafate.decryptLocalToString(JSON.parse(r.first_name)),
mafate.decryptLocalToString(JSON.parse(r.last_name)),
mafate.decryptLocalToString(JSON.parse(r.email)),
mafate.decryptLocalToString(JSON.parse(r.phone)),
])
res.json({ firstName, lastName, email, phone })
})
// ⏩ Quatre champs, quatre desemballages. Si la latence compte, chiffrez les
// champs d'une meme fiche avec UNE seule cle de donnees et stockez-la une fois.4. Suppression définitive (crypto-shredding)
Le crypto-shredding consiste à détruire la clé de chiffrement plutôt que de parcourir toutes les lignes pour les effacer. Dès que la clé est désactivée, aucun plaintext ne peut être récupéré, même avec un accès direct à la base de données. Conforme RGPD Art.17 (droit à l'effacement).
// Crypto-shredding : effacement RGPD art. 17 par destruction de la cle.
// Suppose UNE CLE PAR UTILISATEUR : avec une cle partagee, la destruction
// emporterait les donnees de tous les autres.
async function deleteUser(userId: string) {
const user = await db.query('SELECT key_id FROM users WHERE id = $1', [userId])
// `disable()` envoie DELETE /v1/keys/{id} : desactivation PUIS destruction
// des DEK emballees de toutes les versions. Irreversible, y compris pour nous.
await mafate.keys.disable(user.rows[0].key_id)
await db.query('DELETE FROM users WHERE id = $1', [userId])
// RGPD Art.17 : droit à l'effacement satisfait, aucun plaintext récupérable
}
// Alternative : si la clé est partagée entre plusieurs utilisateurs,
// il suffit de supprimer la ligne. Les autres utilisateurs ne sont pas affectés.Bonnes pratiques
- Utilisez une clé dédiée par type de données, une pour l'identité et une pour l'adresse par exemple, pour limiter la surface d'exposition.
- Ne chiffrez pas les champs utilisés en JOIN ou en filtre SQL, utilisez mafate.hash() à la place.
- Activez la rotation automatique tous les 90 jours : c’est déjà le défaut à la création d’une clé.
- Le hash utilise la clé MAFATE, pas de secret supplémentaire à gérer.
- Testez le déchiffrement dans votre pipeline CI/CD pour détecter toute régression.