Guide pratique

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.