Phase 45 : Architecture Zero-Knowledge E2EE

Arc OS — "Nous ne pouvons pas lire tes données même si nous le voulions" Auteur : Sentinel (Security Architect) | Date : 2026-04-24 Statut : DONE ✅ (2026-04-28) — Tickets #16-#20 complets Approuvé par : CEO

Checklist d'implémentation


Le problème

État actuel (Phase 43) : Le serveur stocke les données utilisateur en clair.

-- data/citadel.db (SQLite, fichier non chiffré)
CREATE TABLE chat_messages (
  content TEXT NOT NULL  -- ❌ "sk-ant-abc123xyz (ma clé API)"
);

CREATE TABLE account_settings (
  anthropic_key TEXT,  -- ❌ "sk-ant-live-prod-key-123"
  openai_key TEXT      -- ❌ "sk-proj-sensitive-456"
);

CREATE TABLE users (
  totp_secret TEXT     -- ❌ "JBSWY3DPEHPK3PXP" (seed 2FA)
);

Scénarios d'attaque :

  1. Violation de base de données — l'attaquant télécharge citadel.db → lit tous les messages, clés API, seeds TOTP
  2. Compromission du serveur — l'attaquant obtient l'accès SSH → sqlite3 citadel.db .dump → export complet des données
  3. Menace interne — admin avec accès root → lit les conversations utilisateur
  4. Fuite de sauvegarde — sauvegarde DB non chiffrée uploadée dans le cloud → exposée
  5. Contrainte légale — ordonnance judiciaire force la remise des données → toutes les données utilisateur lisibles

Verdict actuel : Sécurisé pour le multi-tenancy (les utilisateurs ne peuvent pas accéder aux données des autres), PAS sécurisé pour la vie privée des utilisateurs (le serveur peut tout lire).


La vision : Architecture Zero-Knowledge

Principe : Le serveur est non fiable. Même avec accès SSH root + accès base de données, nous ne pouvons pas déchiffrer les données utilisateur.

Ce qui change :

AVANT (Phase 43) :
L'utilisateur tape un message → le serveur stocke en clair → les admins peuvent le lire ❌

APRÈS (Phase 45) :
L'utilisateur tape un message → le navigateur chiffre → le serveur stocke un blob → les admins NE PEUVENT PAS le lire ✅

La promesse :

"Tes données sont chiffrées avec une clé dérivée de ton mot de passe. Nous n'avons pas ton mot de passe, donc nous ne pouvons pas déchiffrer tes données — même si nous le voulions."

Modèle : Protocole Signal (simplifié), ProtonMail, 1Password.


Analyse de décision : Modèles de chiffrement

Option A : Chiffrement côté serveur (Au repos uniquement)

Utilisateur → serveur → chiffrer avec clé serveur → stocker en DB
Critère Évaluation Détails
Sécurité contre outsider Moyenne Fichier DB chiffré, mais le serveur a la clé → accès root = peut déchiffrer
Sécurité contre admin ZÉRO L'admin a la clé serveur → accès complet
Conformité (RGPD) Faible "Protection des données" mais pas zero-knowledge
Complexité Faible SQLCipher ou PRAGMA key
Récupération de clé Facile Le serveur a la clé → pas d'action utilisateur nécessaire

Verdict : Protège contre un disque dur volé, PAS contre la compromission du serveur ou les menaces internes.

Option B : Chiffrement au niveau applicatif (côté serveur)

Utilisateur → serveur → chiffrer avec clé vault → stocker en DB
Le serveur a la clé vault (AES-256-GCM dans vault.json)
Critère Évaluation Détails
Sécurité contre outsider Moyenne Mieux que le clair, mais clé vault sur le même serveur
Sécurité contre admin ZÉRO L'admin a vault.json → déchiffre tout
Conformité Faible Toujours pas zero-knowledge
Complexité Moyenne Wrappers de chiffrement au niveau des champs
Récupération de clé Facile Géré par le serveur

Verdict : Marginalement mieux que l'Option A. Échoue toujours au test "menace interne".

Option C : Chiffrement de bout en bout (Zero-Knowledge) — RETENU

Utilisateur → le navigateur chiffre avec une clé maître (JAMAIS envoyée au serveur) → le serveur stocke le blob
Le serveur ne peut pas déchiffrer (pas de clé)
Critère Évaluation Détails
Sécurité contre outsider ÉLEVÉE Données chiffrées + pas de clé = inutile
Sécurité contre admin ÉLEVÉE L'admin a la DB mais ne peut pas déchiffrer
Conformité (RGPD) EXCELLENTE Vraie minimisation des données (Art. 25)
Complexité ÉLEVÉE API WebCrypto, gestion de clés, UX de récupération
Récupération de clé DIFFICILE L'utilisateur perd son mot de passe = données perdues (nécessite une clé de récupération)

Verdict : Seule option qui offre le zero-knowledge. Le coût de complexité vaut la peine pour la vie privée des utilisateurs.


Vue d'ensemble de l'architecture

1. Dérivation à deux clés (Conception Split-Brain)

Problème : Le mot de passe est envoyé au serveur pour l'auth. Comment dériver la clé de chiffrement sans envoyer le mot de passe ?

Solution : Dériver DEUX clés depuis le mot de passe avec des sels différents.

Mot de passe utilisateur : "MySecurePass123!"
      │
      ├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
      │     ↓
      │  authBits (256-bit)
      │     ↓
      │  bcrypt(authBits, cost=12) → authHash
      │     ↓
      │  ENVOYÉ AU SERVEUR (pour la vérification de connexion)
      │
      └─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
            ↓
         masterKey (clé de chiffrement AES-256-GCM)
            ↓
         RESTE DANS LE NAVIGATEUR (sessionStorage)
         JAMAIS envoyée au serveur

Propriété de sécurité : Compromission serveur → l'attaquant obtient authHashne peut pas dériver masterKey (sel différent = sortie différente).

Code (frontend/src/crypto/e2ee.ts) :

// Hash d'auth (envoyé au serveur)
async function deriveAuthHash(password: string): Promise<string> {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    "PBKDF2",
    false,
    ["deriveBits"]
  );
  
  const bits = await crypto.subtle.deriveBits(
    {
      name: "PBKDF2",
      salt: new TextEncoder().encode("citadel-auth-v1"),
      iterations: 100000,
      hash: "SHA-256"
    },
    keyMaterial,
    256
  );
  
  // Le serveur hachera cela à nouveau avec bcrypt pour le stockage
  return Buffer.from(bits).toString("hex");
}

// Clé maître (conservée dans le navigateur)
async function deriveMasterKey(password: string): Promise<CryptoKey> {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );
  
  const masterKey = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: new TextEncoder().encode("citadel-master-v1"),
      iterations: 100000,
      hash: "SHA-256"
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,  // NON extractable (ne peut pas fuir)
    ["encrypt", "decrypt"]
  );
  
  return masterKey;
}

2. Flux d'inscription

┌─────────────────────────────────────────────────────────────┐
│ Inscription utilisateur (navigateur)                        │
├─────────────────────────────────────────────────────────────┤
│  1. L'utilisateur entre : email, mot de passe               │
│                                                              │
│  2. Le navigateur dérive :                                  │
│     authHash = PBKDF2(password, "auth-salt") → bcrypt       │
│     masterKey = PBKDF2(password, "master-salt") → clé AES   │
│                                                              │
│  3. POST /api/auth/register                                  │
│     {                                                        │
│       email: "[email protected]",                            │
│       authHash: "2a12...bcrypt..."  ← stocké en DB          │
│     }                                                        │
│                                                              │
│  4. Serveur :                                               │
│     - bcrypt(authHash) → password_hash (double hash)        │
│     - INSERT INTO users (email, password_hash)              │
│     - Envoyer email de vérification                         │
│                                                              │
│  5. Navigateur :                                            │
│     - Stocker masterKey dans sessionStorage                 │
│     - Générer clé de récupération (chiffrer masterKey)      │
│     - Afficher clé de récupération : ⚠️ SAUVEGARDE ÇA !    │
│       "A83Z-KL9P-MM4X-VN2Q-8JC7"                           │
│     - L'utilisateur télécharge le PDF                       │
│                                                              │
│  6. POST /api/crm/account/recovery-key                       │
│     {                                                        │
│       encrypted_master_key: base64(...),  ← blob AES-GCM    │
│       recovery_key_iv: base64(...)                          │
│     }                                                        │
└─────────────────────────────────────────────────────────────┘

3. Flux de connexion

┌─────────────────────────────────────────────────────────────┐
│ Connexion utilisateur (navigateur)                          │
├─────────────────────────────────────────────────────────────┤
│  1. L'utilisateur entre : email, mot de passe               │
│                                                              │
│  2. Le navigateur dérive :                                  │
│     authHash = PBKDF2(password, "auth-salt") → bcrypt       │
│     masterKey = PBKDF2(password, "master-salt") → clé AES   │
│                                                              │
│  3. POST /api/auth/login                                     │
│     {                                                        │
│       email: "[email protected]",                            │
│       authHash: "2a12..."                                   │
│     }                                                        │
│                                                              │
│  4. Serveur :                                               │
│     - Récupérer user.password_hash depuis DB                │
│     - bcrypt.compare(authHash, password_hash)               │
│     - Si correspondance : générer token JWT                 │
│     - Retourner { token, user_id }                          │
│                                                              │
│  5. Navigateur :                                            │
│     - Stocker JWT dans localStorage (pour auth API)         │
│     - Stocker masterKey dans sessionStorage (pour déchiffrement)│
│     - Prêt à chiffrer/déchiffrer les données               │
└─────────────────────────────────────────────────────────────┘

4. Chiffrer et envoyer un message

┌─────────────────────────────────────────────────────────────┐
│ Envoyer un message chat (navigateur)                        │
├─────────────────────────────────────────────────────────────┤
│  1. L'utilisateur tape : "Deploy to production with sk-ant-xyz123"  │
│                                                              │
│  2. Le navigateur chiffre :                                 │
│     plaintext = "Deploy to production with sk-ant-xyz123"   │
│     iv = crypto.getRandomValues(12 bytes)  ← nonce aléatoire│
│     ciphertext = AES-GCM.encrypt(plaintext, masterKey, iv)  │
│                                                              │
│  3. POST /api/crm/projects/arc-v2/chat                       │
│     {                                                        │
│       worker_id: "developer",                               │
│       content_encrypted: "8a9f3c...",  ← blob base64        │
│       content_iv: "7b2e1a..."          ← IV base64          │
│       // PAS de champ content en clair                      │
│     }                                                        │
│                                                              │
│  4. Serveur :                                               │
│     - Vérifier JWT (utilisateur autorisé)                   │
│     - INSERT INTO chat_messages (                           │
│         project_name,                                        │
│         worker_id,                                           │
│         role = 'user',                                       │
│         content_encrypted = BLOB,  ← opaque pour le serveur │
│         content_iv = BLOB,                                   │
│         timestamp                                            │
│       )                                                       │
│     - Retourner { message_id }                              │
│                                                              │
│  5. Le serveur NE PEUT PAS lire "sk-ant-xyz123" — c'est chiffré   │
└─────────────────────────────────────────────────────────────┘

5. Recevoir et déchiffrer un message

┌─────────────────────────────────────────────────────────────┐
│ Récupérer l'historique chat (navigateur)                    │
├─────────────────────────────────────────────────────────────┤
│  1. GET /api/crm/projects/arc-v2/chat/history                │
│     Authorization: Bearer <JWT>                             │
│                                                              │
│  2. Serveur :                                               │
│     - Vérifier JWT + propriété (canAccessProject)           │
│     - SELECT content_encrypted, content_iv, timestamp       │
│       FROM chat_messages                                     │
│       WHERE project_name = 'arc-v2'                         │
│       ORDER BY timestamp DESC                                │
│       LIMIT 50                                              │
│     - Retourner [{ content_encrypted, content_iv, ... }]   │
│                                                              │
│  3. Le navigateur déchiffre CHAQUE message :                │
│     for (const msg of messages) {                           │
│       const ciphertext = base64Decode(msg.content_encrypted);│
│       const iv = base64Decode(msg.content_iv);              │
│       const plaintext = AES-GCM.decrypt(                    │
│         ciphertext,                                          │
│         masterKey,  ← depuis sessionStorage                 │
│         iv                                                   │
│       );                                                     │
│       displayMessage(plaintext);                            │
│     }                                                         │
│                                                              │
│  4. L'utilisateur voit : "Deploy to production with sk-ant-xyz123"   │
│     Le serveur a vu : blob sans signification (8a9f3c...)   │
└─────────────────────────────────────────────────────────────┘

Changements de schéma de base de données

Migration 010 : Champs E2EE

-- Migration : Ajouter les colonnes chiffrées, déprécier le clair

-- 1. Messages chat
ALTER TABLE chat_messages 
  ADD COLUMN content_encrypted BLOB,
  ADD COLUMN content_iv BLOB,
  ADD COLUMN key_version INTEGER DEFAULT 1;

-- Marquer l'ancien content comme déprécié (sera rechiffré au prochain accès)
-- NE PAS supprimer la colonne `content` encore (compat rétrograde pendant la migration)

-- 2. Paramètres compte (clés API)
ALTER TABLE account_settings
  ADD COLUMN anthropic_key_encrypted BLOB,
  ADD COLUMN anthropic_key_iv BLOB,
  ADD COLUMN openai_key_encrypted BLOB,
  ADD COLUMN openai_key_iv BLOB;

-- 3. Utilisateurs (secrets TOTP)
ALTER TABLE users
  ADD COLUMN totp_secret_encrypted BLOB,
  ADD COLUMN totp_secret_iv BLOB;

-- 4. Clés de récupération
CREATE TABLE key_recovery (
  user_id TEXT PRIMARY KEY,
  encrypted_master_key BLOB NOT NULL,
  recovery_key_iv BLOB NOT NULL,
  recovery_key_hint TEXT,  -- hint optionnel (4 premiers chars : "A83Z-****-****")
  created_at TEXT NOT NULL,
  last_used_at TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 5. Log de rotation des clés de chiffrement
CREATE TABLE key_versions (
  version INTEGER PRIMARY KEY,
  created_at TEXT NOT NULL,
  deprecated_at TEXT,
  notes TEXT  -- ex. "Rotation annuelle", "Incident de sécurité"
);

INSERT INTO key_versions (version, created_at) VALUES (1, datetime('now'));

Gestion des clés

Génération de clé de récupération

Problème : L'utilisateur oublie son mot de passe → masterKey perdue → toutes les données irrécupérables.

Solution : Clé de récupération (générée une fois, stockée hors ligne par l'utilisateur).

// Générer la clé de récupération (navigateur)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
  // 1. Générer une clé aléatoire de 128 bits
  const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
  
  // 2. Encoder comme chaîne lisible (Base32, pas de chars ambigus)
  // Résultat : "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 groupes de 4 chars)
  const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
  
  // 3. Dériver une clé AES depuis les octets de récupération
  const recoveryKeyMaterial = await crypto.subtle.importKey(
    "raw",
    recoveryBytes,
    "PBKDF2",
    false,
    ["deriveKey"]
  );
  
  const recoveryAESKey = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: new TextEncoder().encode("citadel-recovery-v1"),
      iterations: 10000,  // itérations réduites (la récupération est déjà aléatoire)
      hash: "SHA-256"
    },
    recoveryKeyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
  
  // 4. Exporter la clé maître (pour le chiffrement)
  const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
  
  // 5. Chiffrer la clé maître avec la clé de récupération
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encryptedMasterKey = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    recoveryAESKey,
    masterKeyExport
  );
  
  // 6. Envoyer au serveur pour stockage
  await fetch('/api/crm/account/recovery-key', {
    method: 'POST',
    body: JSON.stringify({
      encrypted_master_key: base64Encode(encryptedMasterKey),
      recovery_key_iv: base64Encode(iv)
    })
  });
  
  // 7. Retourner la clé de récupération à l'utilisateur (AFFICHER UNE SEULE FOIS)
  return recoveryKey;  // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}

Flux de récupération (mot de passe oublié) :

┌─────────────────────────────────────────────────────────────┐
│ Récupération de mot de passe                                │
├─────────────────────────────────────────────────────────────┤
│  1. L'utilisateur clique "Mot de passe oublié"              │
│                                                              │
│  2. Le navigateur affiche :                                 │
│     "Entre ta clé de récupération :"                        │
│     [____-____-____-____-____]                              │
│                                                              │
│  3. L'utilisateur entre : A83Z-KL9P-MM4X-VN2Q-8JC7          │
│                                                              │
│  4. Récupérer la clé maître chiffrée depuis le serveur :    │
│     GET /api/crm/account/[email protected]      │
│     → { encrypted_master_key, recovery_key_iv }             │
│                                                              │
│  5. Déchiffrer la clé maître :                              │
│     recoveryAESKey = deriveKey(recoveryKey)                  │
│     masterKey = AES-GCM.decrypt(                            │
│       encrypted_master_key,                                  │
│       recoveryAESKey,                                        │
│       recovery_key_iv                                        │
│     )                                                         │
│                                                              │
│  6. Demander un NOUVEAU mot de passe :                      │
│     "Définis un nouveau mot de passe :"                     │
│     [________] (doit être différent de l'ancien)            │
│                                                              │
│  7. Dériver le nouveau authHash depuis le nouveau mot de passe│
│                                                              │
│  8. Mettre à jour le serveur :                              │
│     PUT /api/auth/reset-password                             │
│     { recovery_key, new_auth_hash }                         │
│                                                              │
│  9. Le serveur met à jour password_hash                     │
│                                                              │
│ 10. Le navigateur stocke masterKey dans sessionStorage       │
│     → L'utilisateur récupère l'accès aux données chiffrées │
└─────────────────────────────────────────────────────────────┘

Limitation de débit : Max 5 tentatives de récupération par heure (prévention du brute-force).


Propriétés de sécurité

Ce contre quoi nous protégeons

Menace Atténuation Niveau
Violation de base de données (fichier .db volé) ✅ Données chiffrées, pas de clés Complet
Compromission serveur (SSH root) ✅ Pas de clés maîtres sur le serveur Complet
Menace interne (abus admin) ✅ L'admin ne peut pas déchiffrer Complet
Attaque MITM (interception réseau) ✅ HTTPS + payload chiffré Complet
Fuite de sauvegarde (S3, Drive) ✅ Les sauvegardes ne contiennent que des blobs Complet
Contrainte légale (ordonnance judiciaire) ✅ Impossible de déchiffrer sans mot de passe utilisateur Complet
Attaque XSS (vol de clé maître) ✅ Headers CSP + pas de JS inline Partiel
Brute-force mot de passe ✅ bcrypt + PBKDF2 100k itérations Fort

Ce contre quoi nous NE protégeons PAS (Hors périmètre)

Menace Responsabilité utilisateur
Vol d'appareil physique (laptop déverrouillé) Verrou d'écran, chiffrement disque complet
Extension navigateur malveillante Revoir les extensions, utiliser des extensions réputées
Keylogger sur l'appareil Antivirus, appareil sécurisé
Ingénierie sociale (phishing clé de récupération) Sensibilisation à la sécurité
Informatique quantique (cassage AES-256) Pas faisable avant ~2040 (calendrier NIST)

Feuille de route d'implémentation

Phase 45.1 : Fondation (Ticket #39) — 2 semaines

Objectif : Wrapper WebCrypto, dérivation de clé fonctionnelle.

Acceptation : La connexion dérive les deux clés, clé maître dans sessionStorage.

Phase 45.2 : Chiffrement chat (Ticket #40) — 1 semaine

Objectif : Messages chat E2EE.

Acceptation : Le chat fonctionne, le serveur ne peut pas lire les messages (la requête SQL montre des blobs).

Phase 45.3 : Chiffrement des secrets (Tickets #41-#42) — 1 semaine

Objectif : Clés API + seeds TOTP chiffrés.

Acceptation : L'UI paramètres fonctionne, le serveur ne peut pas lire les clés.

Phase 45.4 : Récupération de clé (Tickets #43-#44) — 1 semaine

Objectif : Clé de récupération + export RGPD.

Acceptation : L'utilisateur peut récupérer son compte avec la clé de récupération.

Phase 45.5 : Durcissement (Tickets #45-#46) — 3 jours

Objectif : CSP, sanitisation des logs.

Acceptation : Violations CSP = 0, logs propres.


Stratégie de test

Tests unitaires (Jest)

describe('E2EE', () => {
  test('deriveAuthHash est déterministe', async () => {
    const hash1 = await deriveAuthHash('password123');
    const hash2 = await deriveAuthHash('password123');
    expect(hash1).toBe(hash2);
  });

  test('deriveMasterKey est déterministe', async () => {
    const key1 = await deriveMasterKey('password123');
    const key2 = await deriveMasterKey('password123');
    const exported1 = await crypto.subtle.exportKey('raw', key1);
    const exported2 = await crypto.subtle.exportKey('raw', key2);
    expect(Buffer.from(exported1)).toEqual(Buffer.from(exported2));
  });

  test('chiffrement → déchiffrement aller-retour', async () => {
    const masterKey = await deriveMasterKey('test');
    const plaintext = 'Message secret';
    const { ciphertext, iv } = await encrypt(plaintext, masterKey);
    const decrypted = await decrypt(ciphertext, iv, masterKey);
    expect(decrypted).toBe(plaintext);
  });

  test('mauvaise clé ne peut pas déchiffrer', async () => {
    const key1 = await deriveMasterKey('password1');
    const key2 = await deriveMasterKey('password2');
    const { ciphertext, iv } = await encrypt('Secret', key1);
    await expect(decrypt(ciphertext, iv, key2)).rejects.toThrow();
  });

  test('ciphertext altéré échoue (tag auth GCM)', async () => {
    const key = await deriveMasterKey('test');
    const { ciphertext, iv } = await encrypt('Secret', key);
    ciphertext[0] ^= 0xFF;  // retourner un bit
    await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
  });
});

Tests d'intégration

  1. Inscription → Connexion → Déchiffrement :

    • S'inscrire avec un mot de passe → déconnexion → connexion → vérifier qu'on peut déchiffrer les anciens messages
  2. Flux de récupération :

    • S'inscrire → sauvegarder la clé de récupération → déconnexion → mot de passe oublié → récupérer → vérifier que les données sont accessibles
  3. Simulation multi-appareils :

    • Connexion sur "appareil 1" (Chrome) → envoyer un message
    • Connexion sur "appareil 2" (Firefox) → vérifier qu'on peut déchiffrer le même message

Test de pénétration (Externe, budget 5 000 $)

Scénarios :

  1. Injection de payload XSS → tentative d'exfiltration de la clé maître
  2. Dump de base de données → vérifier pas de contenu chat en clair
  3. Attaque MITM → vérifier que les blobs chiffrés résistent aux altérations (tag GCM)
  4. Brute-force clé de récupération → vérifier que la limitation de débit est efficace
  5. Attaque timing side-channel → vérifier que PBKDF2 est en temps constant

Calendrier : Après complétion des tickets #39-#42 (Q3 2026).


Stratégie de migration

Compatibilité rétrograde

Problème : Les utilisateurs existants ont des données en clair. On ne peut pas casser leur accès.

Solution : Migration progressive.

Étape 1 : Double écriture (Phase 45.2)

// Backend : écrire LES DEUX clair et chiffré
async function handlePostMessage(req) {
  const { content, content_encrypted, content_iv } = req.body;
  
  db.run(`
    INSERT INTO chat_messages (
      content,             -- ancien clair (déprécié)
      content_encrypted,   -- nouveau blob E2EE
      content_iv
    ) VALUES (?, ?, ?)
  `, [
    content || null,  // null si client E2EE
    content_encrypted,
    content_iv
  ]);
}

// Frontend : envoyer LES DEUX (pendant la transition)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
  body: JSON.stringify({
    content: plaintext,        // pour ancienne API
    content_encrypted: ciphertext,  // pour nouveau E2EE
    content_iv: iv
  })
});

Étape 2 : Préférence de lecture (Phase 45.2)

// Backend : retourner chiffré si disponible, sinon clair
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
  if (msg.content_encrypted) {
    // Message E2EE (préféré)
    yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
  } else {
    // Clair legacy (déprécié, avertir l'utilisateur)
    yield { content: msg.content, legacy: true };
  }
}

// Frontend : déchiffrer si E2EE, sinon afficher en clair avec avertissement
if (msg.content_encrypted) {
  const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
  displayMessage(plaintext);
} else {
  displayMessage(msg.content);
  showWarning('Ce message a été envoyé avant l\'activation de E2EE. Le rechiffrer ?');
}

Étape 3 : Outil de rechiffrement (Phase 45.3)

// Paramètres → Sécurité → "Chiffrer les anciens messages"
async function reEncryptOldMessages() {
  const masterKey = getMasterKeyFromSession();
  const oldMessages = await fetch('/api/chat/history?legacy=true');
  
  for (const msg of oldMessages) {
    const { ciphertext, iv } = await encrypt(msg.content, masterKey);
    await fetch(`/api/chat/messages/${msg.id}/encrypt`, {
      method: 'PUT',
      body: JSON.stringify({ content_encrypted: ciphertext, content_iv: iv })
    });
  }
  
  showSuccess('Tous les messages sont chiffrés !');
}

// Backend : mettre à jour le message, EFFACER le clair
app.put('/api/chat/messages/:id/encrypt', (req) => {
  db.run(`
    UPDATE chat_messages
    SET content_encrypted = ?,
        content_iv = ?,
        content = NULL  ← EFFACER le clair
    WHERE id = ?
  `, [req.content_encrypted, req.content_iv, req.params.id]);
});

Étape 4 : Supprimer la colonne clair (Phase 46)

-- Après 90 jours, vérifier 0 messages en clair
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- Si 0, supprimer la colonne

ALTER TABLE chat_messages DROP COLUMN content;

Considérations UX

Avertissements effrayants mais honnêtes

Génération de clé de récupération :

┌────────────────────────────────────────────────────────────┐
│  ⚠️ CRITIQUE : Sauvegarde ta clé de récupération          │
├────────────────────────────────────────────────────────────┤
│  Tes données sont chiffrées avec une clé que TU as seul.  │
│  Si tu perds ton mot de passe ET cette clé de récupération,│
│  tes données sont DÉFINITIVEMENT PERDUES. Nous ne pouvons  │
│  PAS les récupérer pour toi.                               │
│                                                             │
│  Clé de récupération :                                      │
│  ┌──────────────────────────────────────────────────────┐  │
│  │  A83Z-KL9P-MM4X-VN2Q-8JC7                            │  │
│  └──────────────────────────────────────────────────────┘  │
│                                                             │
│  [ Télécharger PDF ]  [ Imprimer ]  [ Copier ]             │
│                                                             │
│  ☐ J'ai sauvegardé cette clé en lieu sûr                  │
│                                                             │
│  [ Continuer ]  ← désactivé tant que la case n'est pas cochée│
└────────────────────────────────────────────────────────────┘

Première connexion après activation E2EE :

┌────────────────────────────────────────────────────────────┐
│  🔒 Chiffrement de bout en bout activé                     │
├────────────────────────────────────────────────────────────┤
│  Tes messages, clés API et secrets sont maintenant chiffrés │
│  sur ton appareil avant d'être envoyés à nos serveurs.     │
│                                                             │
│  ✓ Nous NE POUVONS PAS lire tes données (même si on voulait)│
│  ✓ Violation de base de données = l'attaquant obtient des blobs inutiles│
│  ✗ Mot de passe oublié + clé de récupération perdue = données perdues│
│                                                             │
│  [ En savoir plus ]  [ Compris ]                           │
└────────────────────────────────────────────────────────────┘

Retour de performance

// Afficher l'indicateur de chiffrement pendant la saisie (subtil)
<textarea 
  placeholder="Tape un message... 🔒 (chiffré)"
  onChange={handleChange}
/>

// Afficher le spinner "Chiffrement..." avant l'envoi (< 10ms, à peine visible)
<button onClick={handleSend}>
  {encrypting ? '🔐 Chiffrement...' : 'Envoyer'}
</button>

Impact sur la conformité

Articles RGPD

Article Avant (Phase 43) Après (Phase 45) Amélioration
Art. 25 (Protection des données dès la conception) Stockage en clair E2EE par défaut ✅ Conforme
Art. 32 (Sécurité du traitement) JWT + bcrypt + AES-256 E2EE ✅ Renforcé
Art. 17 (Droit à l'effacement) Suppression de DB + chiffré (déjà inutilisable) ✅ Plus fort
Art. 20 (Portabilité des données) Export serveur Déchiffrement côté client + export ✅ Contrôlé par l'utilisateur

Affirmations marketing (Révision juridique requise)

Peut dire :

Ne peut pas dire :

Recommandé :

"Tes données sont chiffrées de bout en bout en utilisant le standard industriel AES-256-GCM. Nous utilisons une architecture zero-knowledge : le chiffrement se fait sur ton appareil, et nous n'avons jamais accès à tes clés de déchiffrement. Même nos administrateurs ne peuvent pas lire tes messages chiffrés, clés API ou secrets."


Analyse coût-bénéfice

Coûts

Élément Effort Risque
Implémentation WebCrypto 2 semaines dev Moyen (la crypto c'est difficile)
UX gestion de clés 1 semaine design + dev Élevé (confusion utilisateur)
Mécanisme de récupération 1 semaine dev Élevé (mauvaise UX = perte de données)
Migration (double écriture) 3 jours dev Faible (compat rétrograde)
Test de pénétration 5 000 $
Charge support +20% tickets Moyen ("J'ai perdu ma clé de récupération")

Total : ~6 semaines dev + 5 000 $ d'audit externe.

Bénéfices

Bénéfice Impact
Confiance des utilisateurs Élevé — "confidentialité des données" est la première préoccupation des entreprises
Conformité réglementaire Conformité RGPD Art. 25 (requis pour les clients UE)
Protection contre les violations Même une violation catastrophique → blobs chiffrés (dommages minimaux)
Avantage concurrentiel Peu de plateformes IA offrent E2EE (Notion, ClickUp, Monday = clair)
Ventes entreprises Requis pour santé, finance, secteurs juridiques (HIPAA, SOC 2)

Verdict : Les bénéfices surpassent les coûts. E2EE est la base pour les plateformes IA d'entreprise.


Métriques de succès

Phase 45.1 (Fondation)

Phase 45.2 (Chiffrement chat)

Phase 45.3 (Secrets)

Phase 45.4 (Récupération)

Phase 45.5 (Durcissement)


Risques et atténuation

Risque Probabilité Impact Atténuation
L'utilisateur oublie son mot de passe + perd la clé de récupération → données perdues Moyen CRITIQUE 1. Avertissement effrayant pendant le setup
2. Envoyer le PDF de récupération par email automatiquement
3. Optionnel : Imprimer QR code de la clé de récupération
Bug WebCrypto → données corrompues Faible ÉLEVÉ 1. Tests unitaires extensifs
2. Double écriture pendant le déploiement (conserver la sauvegarde en clair)
3. Audit de sécurité externe
Dégradation des performances (PBKDF2 lent sur vieux appareils) Moyen Moyen 1. Itérations adaptatives (détecter la vitesse de l'appareil)
2. Web Worker pour dérivation non bloquante
Incompatibilité navigateur (bugs Safari) Faible Moyen 1. Tests sur Safari 15+, Chrome, Firefox
2. Fallback : polyfill pour vieux navigateurs (ou les bloquer)
Charge support ("Je ne peux pas accéder à mes données") Élevé Moyen 1. Docs complètes
2. UI assistant de récupération
3. Email proactif : "As-tu sauvegardé ta clé de récupération ?"

Annexe : Primitives cryptographiques

PBKDF2-SHA256

Objectif : Ralentir les attaques brute-force sur les mots de passe.

Paramètres :

Sécurité : À 100k itérations, un attaquant peut essayer ~1 000 mots de passe/sec sur un GPU haut de gamme (vs 1M/sec pour SHA256 simple).

AES-GCM

Objectif : Chiffrement authentifié (confidentialité + intégrité).

Paramètres :

Sécurité : AES-256 est résistant au quantique jusqu'à ~2040 (estimation NIST). Le mode GCM empêche les altérations (n'importe quel bit retourné → déchiffrement échoue).

bcrypt

Objectif : Hachage de mot de passe (côté serveur).

Paramètres :

Pourquoi double hash ? Le navigateur envoie bcrypt(PBKDF2(password)) → le serveur stocke bcrypt(receivedHash). Même si la DB serveur fuite, l'attaquant doit inverser DEUX hashes bcrypt.


Rédigé par Sentinel (Security Architect). Approuvé pour implémentation : CEO (2026-04-24).