Chiffrer vos données existantes
Votre base PostgreSQL contient des centaines de milliers d'utilisateurs avec des données personnelles en clair. NIS2 ou le RGPD vous imposent le chiffrement. Voici comment migrer sans interruption de service.
1. Audit de vos données
Avant de chiffrer, identifiez précisément quelles colonnes contiennent des données personnelles (PII). Ne chiffrez que ce qui doit l'être : les identifiants techniques et les horodatages restent en clair.
-- Identifier les colonnes contenant des données sensibles
-- Exemple : table users avec 500 000 lignes
SELECT
column_name,
data_type,
COUNT(*) OVER() as total_rows
FROM information_schema.columns
WHERE table_name = 'users';
-- Résultat :
-- id | integer | 500000 → ne pas chiffrer
-- email | varchar | 500000 → CHIFFRER (PII)
-- first_name | varchar | 500000 → CHIFFRER (PII)
-- last_name | varchar | 500000 → CHIFFRER (PII)
-- phone | varchar | 500000 → CHIFFRER (PII)
-- address | text | 500000 → CHIFFRER (PII)
-- created_at | timestamp | 500000 → ne pas chiffrer
-- role | varchar | 500000 → ne pas chiffrer2. Migration du schéma
Ajoutez les colonnes chiffrées sans toucher aux colonnes existantes. L'application continue de fonctionner normalement pendant toute la durée de la migration.
-- Étape 1 : Ajouter les colonnes chiffrées
ALTER TABLE users ADD COLUMN email_encrypted JSONB;
ALTER TABLE users ADD COLUMN first_name_encrypted JSONB;
ALTER TABLE users ADD COLUMN last_name_encrypted JSONB;
ALTER TABLE users ADD COLUMN phone_encrypted JSONB;
ALTER TABLE users ADD COLUMN address_encrypted JSONB;
ALTER TABLE users ADD COLUMN email_hash TEXT;
-- Étape 2 : Index pour la recherche
CREATE INDEX idx_users_email_hash ON users(email_hash);3. Script de chiffrement par lots
Script prêt pour la production : traitement par lots de 500 lignes, barre de progression, reprise après erreur, journalisation et estimation du temps restant.
import { Mafate } from '@mafate/sdk'
import pg from 'pg'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const BATCH_SIZE = 500
const KEY_ID = 'key-users-pii'
async function backfillUsers() {
// Compter le total
const { rows: [{ count }] } = await pool.query(
'SELECT COUNT(*) as count FROM users WHERE email_encrypted IS NULL'
)
const total = parseInt(count)
console.log(`🔐 ${total} utilisateurs à chiffrer`)
let processed = 0
let errors = 0
const startTime = Date.now()
while (true) {
// Récupérer un batch
const { rows } = await pool.query(`
SELECT id, email, first_name, last_name, phone, address
FROM users
WHERE email_encrypted IS NULL
ORDER BY id
LIMIT $1
`, [BATCH_SIZE])
if (rows.length === 0) break
// Chiffrer chaque ligne
for (const user of rows) {
try {
const [encEmail, encFirst, encLast, encPhone, encAddress] = await Promise.all([
mafate.encryptLocal(user.email || '', KEY_ID),
mafate.encryptLocal(user.first_name || '', KEY_ID),
mafate.encryptLocal(user.last_name || '', KEY_ID),
mafate.encryptLocal(user.phone || '', KEY_ID),
mafate.encryptLocal(user.address || '', KEY_ID),
])
const emailHash = await mafate.hash((user.email || '').toLowerCase(), KEY_ID)
await pool.query(`
UPDATE users SET
email_encrypted = $1,
first_name_encrypted = $2,
last_name_encrypted = $3,
phone_encrypted = $4,
address_encrypted = $5,
email_hash = $6
WHERE id = $7
`, [
JSON.stringify(encEmail),
JSON.stringify(encFirst),
JSON.stringify(encLast),
JSON.stringify(encPhone),
JSON.stringify(encAddress),
emailHash,
user.id,
])
processed++
} catch (err) {
errors++
console.error(`❌ Erreur utilisateur #${user.id}:`, err.message)
// Continue, on ne bloque pas le batch pour une erreur
}
}
// Progression
const elapsed = (Date.now() - startTime) / 1000
const rate = processed / elapsed
const remaining = (total - processed) / rate
console.log(
`📊 ${processed}/${total} (${((processed/total)*100).toFixed(1)}%) ` +
`| ${rate.toFixed(0)} users/sec | ~${Math.ceil(remaining/60)} min restantes | ${errors} erreurs`
)
}
console.log(`\n✅ Migration terminée : ${processed} chiffrés, ${errors} erreurs`)
}
backfillUsers()4. Validation
Avant de supprimer les colonnes en clair, vérifiez sur un échantillon aléatoire que le chiffrement est correct.
// Validation : déchiffrer et comparer avec l'original
async function validateMigration(sampleSize = 100) {
const { rows } = await pool.query(`
SELECT id, email, email_encrypted
FROM users
WHERE email_encrypted IS NOT NULL
ORDER BY RANDOM()
LIMIT $1
`, [sampleSize])
let ok = 0, ko = 0
for (const row of rows) {
const decrypted = await mafate.decryptLocalToString(JSON.parse(row.email_encrypted))
if (decrypted === row.email) ok++
else { ko++; console.error(`❌ Mismatch user #${row.id}`) }
}
console.log(`✅ Validation: ${ok}/${rows.length} OK, ${ko} erreurs`)
}5. Nettoyage
Ne supprimez les colonnes en clair qu'après validation complète et sauvegarde vérifiée.
-- UNIQUEMENT après validation complète
-- Backup d'abord !
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
ALTER TABLE users DROP COLUMN phone;
ALTER TABLE users DROP COLUMN address;
-- Renommer les colonnes chiffrées
ALTER TABLE users RENAME COLUMN email_encrypted TO email;
-- etc.6. Estimation du temps
| Volume | Temps estimé (5 champs/user) | Opérations de chiffrement |
|---|---|---|
| 10K users | ~5 min | 50K |
| 100K users | ~45 min | 500K |
| 500K users | ~4h | 2,5M |
| 1M+ users | ~8h | 5M+ |
Migration depuis une app existante
Votre application est déjà en production avec des données non chiffrées ? Ce guide explique comment migrer progressivement sans interruption de service et sans risque de perte de données.
Stratégie : chiffrement progressif, pas de big bang
Ne jamais migrer toutes les données d'un coup en production. Utilisez le pattern dual-write + backfill pour garantir une migration sans risque, rollback possible à chaque étape.
1. Les 4 phases de migration
Dual-write
1-2 joursModifier le code de sauvegarde pour écrire en clair ET en chiffré simultanément. Aucune donnée perdue, rollback facile.
Lecture depuis encrypted
1-2 joursModifier le code de lecture pour utiliser la colonne chiffrée en priorité, avec fallback sur la colonne en clair.
Backfill des données existantes
2-7 jours (selon volume)Script de migration qui chiffre toutes les lignes existantes par batchs. L'application continue de fonctionner normalement.
Suppression des colonnes en clair
1 jourUne fois le backfill validé à 100%, supprimer les colonnes en clair et renommer les colonnes chiffrées.
2. Migrations SQL (PostgreSQL)
Les migrations SQL correspondent aux phases. Exécutez-les dans l'ordre, en validant chaque phase avant de passer à la suivante.
-- PHASE 0 : Ajouter les nouvelles colonnes (sans casser l'existant)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_encrypted JSONB,
ADD COLUMN IF NOT EXISTS phone_encrypted JSONB,
ADD COLUMN IF NOT EXISTS email_hash TEXT;
-- Index sur le hash (pour la recherche future)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_hash ON users(email_hash);
-- PHASE 1 : Dual-write en production (code applicatif)
-- → Modifier saveUser() pour écrire dans les deux colonnes
-- PHASE 3 : Vérifier que le backfill est complet
SELECT
COUNT(*) FILTER (WHERE email_encrypted IS NULL) AS not_yet_encrypted,
COUNT(*) AS total
FROM users;
-- → not_yet_encrypted = 0 ← backfill terminé, on peut passer à la phase 4
-- PHASE 4 : Supprimer les colonnes en clair
ALTER TABLE users
DROP COLUMN IF EXISTS email,
DROP COLUMN IF EXISTS phone;
-- Renommer les colonnes chiffrées
ALTER TABLE users
RENAME COLUMN email_encrypted TO email;
ALTER TABLE users
RENAME COLUMN phone_encrypted TO phone;3. Code de dual-write (Phase 1 & 2)
Modifier vos fonctions de sauvegarde et de lecture pour gérer les deux colonnes. Le fallback sur la colonne en clair garantit que l'application fonctionne pendant la migration.
import { Mafate } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
async function computeHash(value: string): Promise<string> {
return await mafate.hash(value.toLowerCase(), 'key-users-pii')
}
// PHASE 1 : Dual-write, écrire les deux versions
// Conserver email en clair ET ajouter email_encrypted
async function saveUser(userId: string, email: string, phone: string) {
const [encEmail, encPhone] = await Promise.all([
mafate.encryptLocal(email, 'key-users-pii'),
mafate.encryptLocal(phone, 'key-users-pii'),
])
await db.query(`
UPDATE users SET
email = $1, -- conserver en clair (temporaire)
phone = $2, -- conserver en clair (temporaire)
email_encrypted = $3, -- ajouter la version chiffrée
phone_encrypted = $4, -- ajouter la version chiffrée
email_hash = $5 -- hash pour la recherche
WHERE id = $6
`, [
email,
phone,
JSON.stringify(encEmail),
JSON.stringify(encPhone),
await computeHash(email),
userId,
])
}
// PHASE 2 : Lire depuis encrypted, fallback sur plain
async function getUser(emailHash: string) {
const row = await db.query(
'SELECT * FROM users WHERE email_hash = $1',
[emailHash]
)
const r = row.rows[0]
if (!r) return null
// Lecture depuis la colonne chiffrée avec fallback
let email: string
if (r.email_encrypted) {
email = mafate.decryptLocalToString(JSON.parse(r.email_encrypted))
} else {
email = r.email // fallback sur l'ancien champ non chiffré
}
return { ...r, email }
}4. Script de backfill (Phase 3)
Le backfill chiffre toutes les lignes existantes par batchs de 500, avec une concurrence de 50 pour maximiser le débit sans surcharger l'API MAFATE.
import { Mafate } from '@mafate/sdk'
const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const BATCH_SIZE = 500
async function computeHash(value: string): Promise<string> {
return await mafate.hash(value.toLowerCase(), 'key-users-pii')
}
async function backfillUsers(): Promise<void> {
let totalProcessed = 0
let hasMore = true
while (hasMore) {
// Sélectionner un batch d'utilisateurs sans chiffrement
const { rows } = await db.query(
'SELECT id, email, phone FROM users WHERE email_encrypted IS NULL LIMIT $1',
[BATCH_SIZE]
)
if (rows.length === 0) {
hasMore = false
break
}
// Chiffrer en parallèle par batch de 50
const chunks = chunkArray(rows, 50)
for (const chunk of chunks) {
await Promise.all(chunk.map(async (user) => {
const [encEmail, encPhone] = await Promise.all([
mafate.encryptLocal(user.email, 'key-users-pii'),
mafate.encryptLocal(user.phone, 'key-users-pii'),
])
await db.query(
'UPDATE users SET email_encrypted = $1, phone_encrypted = $2, email_hash = $3 WHERE id = $4',
[JSON.stringify(encEmail), JSON.stringify(encPhone), await computeHash(user.email), user.id]
)
}))
}
totalProcessed += rows.length
console.log(`Backfill en cours : ${totalProcessed} utilisateurs chiffrés`)
// Pause de 100ms entre les batchs pour ne pas surcharger l'API
await new Promise(r => setTimeout(r, 100))
}
console.log(`Backfill terminé : ${totalProcessed} utilisateurs migrés`)
}
function chunkArray<T>(arr: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size)
)
}
// Lancer le backfill
backfillUsers().catch(console.error)5. Timeline recommandée
| Semaine | Action | Risque |
|---|---|---|
| S1 | Ajout des colonnes SQL + déploiement dual-write | Faible |
| S1-S2 | Lancement du backfill en production (nuit) | Moyen |
| S2-S3 | Validation : 100% des lignes chiffrées | Faible |
| S3-S4 | Suppression des colonnes en clair | Faible (après validation) |
Plan de rollback
Phases 1 et 2 : rollback immédiat possible, supprimer les colonnes ajoutées et redéployer l'ancienne version. Phase 3 (backfill) : arrêter le script et vérifier les données. Phase 4 : pas de rollback facile, ne supprimer les colonnes en clair qu'après validation complète.
Besoin d’accompagnement pour votre migration ?
Notre équipe peut réaliser la migration de vos données existantes. Audit, script sur mesure, validation et accompagnement, de 10K à 100M+ lignes.