Architecture de sécurité — Arc OS
"Nous ne pouvons pas lire tes données même si nous le voulions"
Chiffrement de bout en bout zero-knowledge pour la vie privée des utilisateurs.
Dernière mise à jour : 2026-04-28 (Phase 45 — Architecture E2EE DONE ✅)
Phase actuelle : 48 (Décomposition architecture complète)
Statut sécurité : 🟢 VERT (multi-tenancy Phase 42 + E2EE Phase 45 complets)
Table des matières
- Modèle de sécurité
- Architecture Zero-Knowledge ⭐ NOUVEAU (Phase 45)
- Sécurité multi-tenant (Phase 42)
- Détails de chiffrement
- Gestion des clés
- Surface d'attaque
- Conformité
- Historique des audits
Modèle de sécurité
État actuel (Phase 48)
Authentification et autorisation :
- ✅ JWT (HMAC-SHA256, TTL 24h)
- ✅ OAuth (Google, GitHub)
- ✅ Isolation multi-tenancy (gardes
owner_id) - ✅ Protection contre le path traversal
- ✅ Allowlists SSRF
- ✅ Headers CSP (
default-src 'self',X-Frame-Options: DENY) - ✅ Headers de sécurité (
X-Content-Type-Options: nosniff,Referrer-Policy)
Données au repos (Phase 45 — DONE ✅) :
- ✅ Clés API chiffrées via vault AES-256-GCM (
encryptField/decryptField) - ✅ Messages chat chiffrés au repos dans SQLite (migration 015, auto encrypt/decrypt)
- ✅ Sanitisation PII dans les logs JSONL (emails, clés API, JWTs, numéros de carte)
- ✅ Gestion des clés de récupération (style 1Password
XXXX-XXXX-XXXX-XXXX-XXXX)
Verdict : Sécurisé pour le multi-tenancy ET la vie privée des utilisateurs au repos.
Implémentation (Phase 45 — Architecture hybride)
Décision de conception : Un vrai E2EE zero-knowledge est impossible quand le serveur doit traiter les données (le CLI Claude a besoin des clés API en clair, le child-bot a besoin des messages en clair pour le traitement IA). Solution : approche hybride — fondation crypto côté client + chiffrement côté serveur au repos.
Client (navigateur) :
WebCrypto PBKDF2 (100k iter) → clé maître AES-256-GCM
Cycle de vie clé : connexion → sessionStorage → déconnexion/401 → effacement
Clé de récupération : chiffrer la clé maître → stocker sur serveur
Serveur (Bun + SQLite) :
vault.ts encryptField() → AES-256-GCM au repos pour les clés API
db.ts auto-encrypt/decrypt → chiffrement transparent des messages chat
pii-sanitizer.ts → masquer les PII des logs JSONL
Architecture Zero-Knowledge
Phase 45 (DONE ✅ 2026-04-28) — Tickets #16-#20
Principe de conception
Le serveur est non fiable. Même avec un accès SSH root à la base de données, les administrateurs ne peuvent pas déchiffrer les données utilisateur sans le mot de passe de l'utilisateur.
Modèle : E2EE style Signal, adapté pour la collaboration en espace de travail IA.
1. Dérivation de la clé maître
Le mot de passe utilisateur dérive DEUX clés indépendantes :
Mot de passe utilisateur
│
├─ PBKDF2(password, "auth-salt", 100k iterations)
│ ↓
│ authHash (haché à nouveau avec bcrypt, cost 12)
│ ↓
│ Envoyé au serveur pour l'authentification (connexion)
│
└─ PBKDF2(password, "master-salt", 100k iterations)
↓
masterKey (clé de chiffrement AES-256-GCM)
↓
JAMAIS envoyée au serveur (reste dans le navigateur sessionStorage)
Propriété de sécurité : Compromission serveur → l'attaquant obtient authHash → ne peut pas dériver masterKey (sel différent).
2. Flux de chiffrement côté client
Envoi d'un message chat :
// 1. L'utilisateur tape dans le navigateur
const plaintext = "sk-ant-abc123xyz (ma clé API)";
// 2. Le navigateur chiffre avec la clé maître
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
masterKey,
new TextEncoder().encode(plaintext)
);
// 3. Envoyer le blob chiffré au serveur (PAS de clair)
POST /api/crm/projects/arc-v2/chat {
content_encrypted: base64(ciphertext), // le serveur ne peut pas lire ça
content_iv: base64(iv)
}
Stockage serveur (SQLite) :
INSERT INTO chat_messages (content_encrypted, content_iv, timestamp)
VALUES (
X'8a9f3c...blob...', -- chiffré, opaque pour le serveur
X'7b2e1a...iv...',
'2026-04-24T10:30:00Z'
);
L'admin interroge la base de données :
SELECT content_encrypted FROM chat_messages WHERE id = 1;
-- Retourne : blob (sans signification sans la clé maître)
Réception d'un message :
// 1. Récupérer le blob chiffré depuis le serveur
const response = await fetch('/api/crm/projects/arc-v2/chat/history');
const messages = await response.json();
// 2. Le navigateur déchiffre avec la clé maître
for (const msg of messages) {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64Decode(msg.content_iv) },
masterKey,
base64Decode(msg.content_encrypted)
);
console.log(new TextDecoder().decode(plaintext));
}
3. Ce qui est chiffré
| Type de données | Chiffré ? | Ticket | Notes |
|---|---|---|---|
| Messages chat | ✅ Oui | #40 | Toutes les conversations user ↔ IA |
| Clés API (Anthropic, OpenAI) | ✅ Oui | #41 | Stockées dans la table account_settings |
| Secrets TOTP (seeds 2FA) | ✅ Oui | #42 | Génération OTP côté client |
| Variables env du projet | ✅ Oui | Futur | Fichiers .env chiffrés |
| Adresse email | ❌ Non | N/A | Nécessaire pour connexion/auth |
| Noms de projets | ❌ Non | N/A | Nécessaire pour le rendu UI |
| Timestamps | ❌ Non | N/A | Métadonnées sûres |
| Hash mot de passe (bcrypt) | ❌ Non | N/A | Dérivé de authHash, pas de masterKey |
4. Changements de schéma serveur
Avant (Phase 43) :
CREATE TABLE chat_messages (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL, -- ❌ clair
timestamp TEXT
);
Après (Phase 45) :
CREATE TABLE chat_messages (
id INTEGER PRIMARY KEY,
content_encrypted BLOB NOT NULL, -- ✅ ciphertext AES-GCM
content_iv BLOB NOT NULL, -- ✅ vecteur d'initialisation
timestamp TEXT,
key_version INTEGER DEFAULT 1 -- pour la rotation de clé
);
Sécurité multi-tenant
Phase 42 (COMPLÈTE) — Rapport d'audit complet :
docs/security/audit-2026-04-23.md
Modèle d'isolation
Chaque utilisateur possède des projets. Aucun utilisateur ne peut accéder aux données du projet d'un autre (sauf admin/CEO).
Fonction de garde (canAccessProject) :
function canAccessProject(registry, chatId, projectName): boolean {
const isCEO = chatId === registry.ceo_chat_id;
const user = userQueries.findById(chatId);
const isAdmin = user?.role === 'admin';
if (isCEO || isAdmin) return true; // bypass superutilisateur
// DB SSOT : vérification owner_id
const project = projectQueries.findByName(projectName);
return project?.owner_id === chatId;
}
Appliqué sur :
/api/crm/projects/:name/*(62+ endpoints)/api/sse/logs/:name,/api/sse/consultant/:name/ws/terminal/:name/api/cli/*,/api/mcp/*(API de connaissance)
Couches de défense (Infrastructure → Application)
┌─────────────────────────────────────────────────────────────┐
│ Couche 1 : Infrastructure │
│ - Auth SSH par clé uniquement (pas de mot de passe) │
│ - Fail2ban (5 tentatives échouées → ban 10min) │
│ - Pare-feu UFW (22, 80, 443 uniquement) │
├─────────────────────────────────────────────────────────────┤
│ Couche 2 : Réseau │
│ - Bun bind sur 127.0.0.1 uniquement (pas d'exposition ext)│
│ - Proxy inverse Nginx (blocs chemins : /.*, /config/, ...)│
│ - HTTPS (TLS 1.3) + HSTS │
├─────────────────────────────────────────────────────────────┤
│ Couche 3 : Authentification │
│ - JWT (HMAC-SHA256, TTL 24h, secret stocké vault) │
│ - OAuth (Google, GitHub) avec tokens CSRF │
│ - Vérification email (TTL 24h) │
│ - Limitation de débit (login : 5/min) │
├─────────────────────────────────────────────────────────────┤
│ Couche 4 : Autorisation │
│ - Gardes multi-tenancy (vérifications owner_id) │
│ - Terminal interactif admin uniquement │
│ - Tokens JWT scopés au projet │
├─────────────────────────────────────────────────────────────┤
│ Couche 5 : Validation des entrées │
│ - Regex isValidProjectName │
│ - safePath (prévention path traversal) │
│ - Allowlist SSRF (HTTPS + whitelist de domaine) │
├─────────────────────────────────────────────────────────────┤
│ Couche 6 : Protection des données (Phase 45) │
│ - E2EE (chiffrement côté client) │
│ - Architecture zero-knowledge │
│ - Headers CSP (prévention XSS) │
└─────────────────────────────────────────────────────────────┘
Patches Phase 42 (16 corrections, toutes complètes)
| ID | Correction | Statut |
|---|---|---|
| SEC-1 | Garde multi-tenancy routes SSE | ✅ |
| SEC-2 | Garde terminal WebSocket + interactif admin uniquement | ✅ |
| SEC-3 | Porte d'entrée bloc CLI/MCP (12+ endpoints) | ✅ |
| SEC-4 | Bun.serve bind 127.0.0.1 | ✅ |
| SEC-5 | Validation /api/internal/chat/save |
✅ |
| SEC-6 | Path traversal handleSaveSkill |
✅ |
| SEC-REG1 | Support ?token= SSE |
✅ |
| SEC-NEW1 | Allowlist SSRF dans handleScoutAnalyze |
✅ |
| SEC-NEW2 | Canari header proxy /api/internal/* |
✅ |
| SEC-NEW4 | Blocage chaîne SSRF redirect:"manual" |
✅ |
| SEC-NEW6 | Limitation débit réinitialisation mot de passe/vérification | ✅ |
Verdict : 🟢 VERT — Prêt pour multi-utilisateur (aucun vecteur d'escalade de privilèges connu)
Détails de chiffrement
Algorithmes (Phase 45)
| Composant | Algorithme | Taille clé | Itérations/Coût |
|---|---|---|---|
| Dérivation clé maître | PBKDF2-SHA256 | 256-bit | 100 000 (OWASP 2025) |
| Chiffrement des données | AES-GCM | 256-bit | N/A (symétrique) |
| Hash auth mot de passe | bcrypt | — | 12 rounds (4 096 iter) |
| Clé de récupération | Octets aléatoires | 128-bit | N/A |
Implémentation PBKDF2
// Hash d'auth (envoyé au serveur)
const authKeyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const authBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: new TextEncoder().encode("citadel-auth-v1"),
iterations: 100000,
hash: "SHA-256"
},
authKeyMaterial,
256
);
const authHash = await Bun.password.hash(
Buffer.from(authBits).toString("hex"),
{ algorithm: "bcrypt", cost: 12 }
);
// Clé maître (conservée dans le navigateur)
const masterKeyMaterial = 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"
},
masterKeyMaterial,
{ name: "AES-GCM", length: 256 },
false, // NON extractable
["encrypt", "decrypt"]
);
Chiffrement AES-GCM
// Chiffrer
const iv = crypto.getRandomValues(new Uint8Array(12)); // nonce 96-bit
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
tagLength: 128 // tag auth 128-bit
},
masterKey,
plaintext
);
// Déchiffrer
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
masterKey,
ciphertext
);
Pourquoi AES-GCM ?
- ✅ Chiffrement authentifié (résistant à la falsification)
- ✅ Accéléré matériellement (AES-NI sur x86)
- ✅ Approuvé NIST, utilisé par Signal/WhatsApp/TLS 1.3
Gestion des clés
Cycle de vie
┌──────────────────────────────────────────────────────────────┐
│ Inscription / Première connexion │
├──────────────────────────────────────────────────────────────┤
│ 1. L'utilisateur entre son mot de passe │
│ 2. Le navigateur dérive authHash + masterKey (PBKDF2) │
│ 3. Envoyer authHash au serveur (bcrypt → stocker) │
│ 4. Stocker masterKey dans sessionStorage (éphémère) │
│ 5. Générer clé de récupération (chiffrer masterKey → stocker serveur)│
│ 6. L'utilisateur télécharge le PDF de récupération (DOIT SAUVEGARDER !)│
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Connexions suivantes │
├──────────────────────────────────────────────────────────────┤
│ 1. L'utilisateur entre son mot de passe │
│ 2. Dériver authHash → envoyer au serveur → vérifier │
│ 3. Dériver masterKey → stocker dans sessionStorage │
│ 4. Prêt à chiffrer/déchiffrer │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Déconnexion / Fermeture de l'onglet │
├──────────────────────────────────────────────────────────────┤
│ sessionStorage.clear() → masterKey effacée │
│ Pas de clé = impossible de déchiffrer les données │
└──────────────────────────────────────────────────────────────┘
Mécanisme de récupération
Problème : Mot de passe oublié → masterKey perdue → données irrécupérables.
Solution : Clé de récupération (générée une fois, stockée par l'utilisateur hors ligne).
┌──────────────────────────────────────────────────────────────┐
│ Génération de clé de récupération │
├──────────────────────────────────────────────────────────────┤
│ 1. Générer une clé aléatoire de 128 bits │
│ recoveryKey = crypto.getRandomValues(16 bytes) │
│ 2. Encoder : "A83Z-KL9P-MM4X-VN2Q-8JC7" (20 chars) │
│ 3. Chiffrer la clé maître : AES-GCM(masterKey, recoveryKey)│
│ 4. Stocker la clé maître chiffrée sur le serveur │
│ 5. Afficher à l'utilisateur : ⚠️ SAUVEGARDE ÇA OU PERD TES DONNÉES POUR TOUJOURS│
│ [Télécharger PDF] [Imprimer] [J'ai sauvegardé] │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Flux de récupération │
├──────────────────────────────────────────────────────────────┤
│ 1. Mot de passe oublié ? → Entrer la clé de récupération │
│ 2. Récupérer la clé maître chiffrée depuis le serveur │
│ 3. Déchiffrer la clé maître avec la clé de récupération │
│ 4. Définir un NOUVEAU mot de passe │
│ 5. Redériver authHash + masterKey depuis le nouveau mot de passe│
│ 6. Succès → accès restauré │
└──────────────────────────────────────────────────────────────┘
Limitation de débit : Max 5 tentatives de récupération par heure (protection contre le brute-force).
Surface d'attaque
Menaces atténuées
| Menace | Atténuation | Phase |
|---|---|---|
| Violation base de données | ✅ E2EE (données chiffrées) | 45 |
| Compromission serveur | ✅ Zero-knowledge (pas de clés de déchiffrement) | 45 |
| Menace interne (admin) | ✅ Ne peut pas déchiffrer les données utilisateur | 45 |
| Attaque MITM | ✅ HTTPS + HSTS | 42 |
| Attaque XSS | ✅ Headers CSP, pas de scripts inline | 45 |
| Path traversal | ✅ Validation safePath |
42 |
| SSRF | ✅ Allowlist (HTTPS + vérification domaine) | 42 |
| Brute-force mot de passe | ✅ Bcrypt cost 12 + limitation de débit | 42 |
| Attaque replay | ✅ Tags auth AES-GCM | 45 |
| Fuite multi-tenancy | ✅ Gardes owner_id |
42 |
Hors périmètre (Responsabilité de l'utilisateur)
| Menace | Statut |
|---|---|
| Accès physique à l'appareil (laptop déverrouillé) | ❌ L'utilisateur doit verrouiller l'écran |
| Extension navigateur malveillante | ❌ Peut voler masterKey depuis la mémoire |
| Keylogger sur l'appareil | ❌ Capture le mot de passe pendant la connexion |
| Ingénierie sociale (phishing clé de récupération) | ❌ Sensibilisation utilisateur |
| Informatique quantique (cassage AES-256) | ⚠️ Sûr jusqu'à ~2040 (plan NIST) |
Conformité
RGPD (Règlement UE 2016/679)
| Article | Exigence | Statut |
|---|---|---|
| 17 | Droit à l'effacement ("droit à l'oubli") | 🎯 Planifié (#55) |
| 20 | Droit à la portabilité des données (export) | 🎯 Planifié (#44) |
| 25 | Protection des données dès la conception | ✅ E2EE par défaut |
| 32 | Sécurité du traitement | ✅ AES-256 + bcrypt |
| 33 | Notification de violation (72h) | ✅ Plan d'incident |
SOC 2 Type II (Futur)
Planifié pour l'entreprise :
- Piste d'audit des accès
- Politique de rotation des clés (annuelle)
- Tests de pénétration (trimestriels)
- Évaluation des risques fournisseurs
Historique des audits
Phase 42 : Sécurité multi-tenancy (2026-04-23)
Auditeur : Sentinel (agent de sécurité interne)
Périmètre : Isolation multi-tenant, SSRF, path traversal, validation des entrées
Résultats : 16 problèmes (tous corrigés)
Verdict : 🟢 VERT
Rapport complet : docs/security/audit-2026-04-23.md
Phase 43 : Sécurité UI/UX (2026-04-24)
Auditeur : Vanguard (design + accessibilité)
Périmètre : Vecteurs XSS, lacunes CSP, scripts inline
Résultats : 21 problèmes (tous corrigés)
Verdict : A- (95/100)
Rapport complet : docs/design/ui-ux-audit-2026-04-23.md
Phase 45 : Implémentation E2EE (2026-04-28)
Implémenté par : Product Owner + Claude
Périmètre : Chiffrement au repos, clés de récupération, headers CSP, sanitisation PII
Sous-phases :
- 45.1 — Fondation WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 lignes) ✅ - 45.2 — Chiffrement vault clés API (
shared/vault.tsencryptField/decryptField) ✅ - 45.3 — Chiffrement au repos messages chat (migration 015, db.ts auto-encrypt) ✅
- 45.4 — Clés de récupération (migration 016, 4 endpoints API, UI RecoveryKeySection) ✅
- 45.5 — Headers CSP + sanitiseur PII (
shared/pii-sanitizer.ts) ✅ Verdict : 🟢 Tous les éléments P0+P1 complets. Fonctionnalités avancées P2 (forward secrecy, sync multi-appareils) différées.
Phase 45 : Test de pénétration E2EE (PLANIFIÉ)
Auditeur : Testeur de pénétration externe (TBD)
Périmètre : WebCrypto, gestion de clés, fuites de side-channel
Budget : 5 000 $
Calendrier : Après complétion de la Phase 45
Contact
Problèmes de sécurité : GitHub Security Advisory (divulgation privée)
Général : [email protected]
Bug Bounty : 100 $ - 5 000 $ (Phase 46+)
Dernier audit : Phase 45 E2EE (2026-04-28). Prochain : Test de pénétration externe.