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
- ✅ 45.1 — Wrapper WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 lignes) - ✅ 45.2 — Chiffrement des champs vault (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Chiffrement au repos des messages chat (migration 015, auto-encrypt/decrypt db.ts)
- ✅ 45.4 — Clés de récupération (migration 016, 4 endpoints API, UI RecoveryKeySection)
- ✅ 45.5 — Headers CSP/sécurité + sanitiseur PII (
shared/pii-sanitizer.ts) - 🔲 45.6 — Avancé (sync multi-appareils, forward secrecy, rétention de données) — P2 différé
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 :
- Violation de base de données — l'attaquant télécharge
citadel.db→ lit tous les messages, clés API, seeds TOTP - Compromission du serveur — l'attaquant obtient l'accès SSH →
sqlite3 citadel.db .dump→ export complet des données - Menace interne — admin avec accès root → lit les conversations utilisateur
- Fuite de sauvegarde — sauvegarde DB non chiffrée uploadée dans le cloud → exposée
- 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 authHash → ne 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.
- Module
frontend/src/crypto/e2ee.ts - Fonction
deriveAuthHash(password) - Fonction
deriveMasterKey(password) - Fonction
encrypt(plaintext, masterKey) - Fonction
decrypt(ciphertext, iv, masterKey) - Tests unitaires (Jest) : chiffrement aller-retour
- Audit sécurité : pas de fuite de clé dans DevTools
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.
- Migration 010 : colonnes
content_encrypted,content_iv - Workspace.jsx : chiffrer avant
handlePostMessage - Workspace.jsx : déchiffrer après la récupération de l'historique
- Backend : stocker les blobs, pas de validation en clair
- Test de performance : < 10ms par message chiffrement/déchiffrement
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.
- AccountSettings.jsx : chiffrer les clés API avant sauvegarde
- AccountSettings.jsx : déchiffrer au chargement, masquer dans l'UI (
sk-***xyz) - Configuration TOTP : chiffrer le seed avant sauvegarde
- Vérification TOTP : côté client (le serveur ne peut pas vérifier OTP)
- Migration : chiffrer les clés en clair existantes (une fois)
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.
- UI de génération de clé de récupération (avertissement effrayant)
- Téléchargement PDF (clé de récupération + instructions)
- UI de flux de récupération (mot de passe oublié → entrer la clé → nouveau mot de passe)
- Limitation de débit : 5 tentatives/heure
- Export de données : déchiffrement côté client → téléchargement JSON
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.
- Headers CSP dans Nginx (
script-src 'self', pas d''unsafe-inline') - Supprimer tous les gestionnaires d'événements inline (audit :
grep -r onClick=) - Hashes SRI pour Google Fonts
- Sanitisation des logs (fonction
sanitize()dans logger.ts) - Script d'audit :
greples logs pour emails/clés API
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
Inscription → Connexion → Déchiffrement :
- S'inscrire avec un mot de passe → déconnexion → connexion → vérifier qu'on peut déchiffrer les anciens messages
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
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 :
- Injection de payload XSS → tentative d'exfiltration de la clé maître
- Dump de base de données → vérifier pas de contenu chat en clair
- Attaque MITM → vérifier que les blobs chiffrés résistent aux altérations (tag GCM)
- Brute-force clé de récupération → vérifier que la limitation de débit est efficace
- 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 :
- ✅ "Chiffrement de bout en bout"
- ✅ "Architecture zero-knowledge"
- ✅ "Nous ne pouvons pas lire tes données"
- ✅ "Même nos admins ne peuvent pas déchiffrer tes messages"
Ne peut pas dire :
- ❌ "Inviolable" (rien ne l'est)
- ❌ "Chiffrement de niveau militaire" (marketing BS, juridiquement risqué)
- ❌ "100% sécurisé" (XSS, compromission d'appareil toujours possibles)
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)
- Flux de connexion : 100% des utilisateurs dérivent la clé maître avec succès
- sessionStorage : 0 fuites de clé dans DevTools (audit sécurité)
- Performance : dérivation PBKDF2 < 500ms sur appareil médian
Phase 45.2 (Chiffrement chat)
- Adoption : 80% des messages chiffrés dans les 30 jours
- Performance : chiffrement/déchiffrement < 10ms par message (p95)
- Audit SQL :
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 lignes (après migration)
Phase 45.3 (Secrets)
- Clés API : 100% stockées comme blobs chiffrés
- TOTP : Vérification côté client fonctionne pour 100% des utilisateurs 2FA
- UI paramètres : 0 clés en clair visibles dans l'onglet Réseau
Phase 45.4 (Récupération)
- Taux de récupération réussi : > 95% (des utilisateurs qui tentent la récupération)
- Tickets support : "Clé de récupération perdue" < 5% du total
- Téléchargements PDF : 90% des utilisateurs téléchargent le PDF de récupération
Phase 45.5 (Durcissement)
- Violations CSP : 0 (console navigateur propre)
- Audit des logs :
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 correspondances - Test de pénétration : 0 findings critiques, < 3 findings moyens
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 :
- Itérations : 100 000 (recommandation OWASP 2025)
- Sel : Fixe par type de dérivation (
citadel-auth-v1,citadel-master-v1) - Sortie : Clé 256-bit
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 :
- Taille de clé : 256-bit
- Taille IV : 96-bit (12 octets, aléatoire par message)
- Tag auth : 128-bit (16 octets, ajouté au ciphertext)
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 :
- Coût : 12 rounds (4 096 itérations)
- Sel : 128-bit aléatoire par utilisateur
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).