# Audit de sécurité E2EE leggit — v2.0

**Référence** : ATT-E2EE-2026-05-18-v2.0
**Date** : 2026-05-18
**Périmètre** : leggit/coffre (app.leggit.org) — vérification non-régression des fixes v1.0 + audit des changements depuis (table de substitution E2EE chunked, Reveal Prudent en 3 modes, module admin relations, table user_relations, fix heartbeat)
**Auditeur** : Agent security-engineer (Claude) — brief automatisé focalisé sur les changements
**Statut** : ACTIF (remplace v1.0)

---

## Synthèse

| Sévérité | Nombre | Statut |
|---|---|---|
| CRITICAL | 0 | — |
| **HIGH** | **1** | ✅ Corrigé (bug fonctionnel, pas vulnérabilité) |
| MEDIUM | 2 | ✅ Corrigés (SQLSTATE[HY093] répétés) |
| LOW | 2 | ✅ Corrigés (rate-limit + constant-time) |
| **Total nouveaux** | **5** | **5 fixés** |
| Corrections v1.0 vérifiées | 9 | **9/9 OK, zéro régression** |

**Verdict** : **Production-ready** après application des 5 fixes v2.0. Aucune faille cryptographique. Le HIGH est un bug de fonctionnement (réassignation JS destructive) qui cassait le workflow dual-device sans pour autant exposer des données.

---

## Vérification de non-régression — corrections v1.0

| Item v1.0 | Statut | Constatation |
|---|---|---|
| HIGH rate-limit + msg générique anti-énum téléphone (tiers/api.php) | ✅ OK | rate-limit 20/h en place + message identique |
| HIGH idem coffres/api.php addRecipient | ✅ OK | Pattern symétrique appliqué |
| MED rate-limit `pairing_resolve` file-based | ✅ OK | 10/15min, clé user+IP |
| MED rate-limit `complete_table` | ✅ OK | Mêmes seuils, clé `pairing_complete:` |
| MED sanitization CRLF + `..` `desired_filename` ×2 | ✅ OK | Patterns identiques sur upload_file + upload_finalize_e2ee |
| MED `cryptoWalletPairingCleanup` dans cron | ✅ OK | cleanup_worker.php:372-381 |
| MED quota 10 wallets `en_attente_table` | ✅ OK | HTTP 429 si dépassé |
| LOW `Cache-Control: no-store` `complete.php` | ✅ OK | Header présent en début de page |
| LOW audit `pairing_resolved` avec `id_coffre` | ✅ OK | Helper retourne id_coffre |
| LOW reveal "Table seule" bypass déchiffrement | ✅ OK | return avant decryptItem |
| LOW `Referrer-Policy: no-referrer` global | ✅ OK | bootstrap.php:56 |

**9/9 corrections v1.0 tiennent. Aucune régression détectée.**

---

## Findings v2.0 détaillés

### [HIGH] Réassignation destructive de `window.LeggitCoffreFile` casse l'upload E2EE chunked

**Fichier** : `assets/js/coffre-file-handler.js:510-521`

**Description** : Le script exposait proprement `window.LeggitCoffreFile.uploadE2EE = uploadE2EE` (préservant les méthodes existantes via `|| {}`). Puis quelques lignes plus bas, un second bloc **écrasait entièrement** `window.LeggitCoffreFile = { attachAll, VERSION: '1.0.0' }` — sans `uploadE2EE`.

**Vecteur** : Tous les chemins suivants levaient `"Wrapper E2EE indisponible"` côté UI :
- `crypto-wallets-complete.js:166` (device B téléphone scanne le QR → upload IMPOSSIBLE)
- `crypto-wallets-module.js:248` (création wallet Prudent dual-device → upload IMPOSSIBLE)

**Impact** : Workflow dual-device crypto-wallets (cœur du Mode Prudent) **non-fonctionnel**. Pas de vulnérabilité de confidentialité, mais workflow inopérable.

**Fix appliqué** : Suppression de la réassignation destructive. Pattern correct :
```js
window.LeggitCoffreFile = window.LeggitCoffreFile || {};
window.LeggitCoffreFile.uploadE2EE = uploadE2EE;
window.LeggitCoffreFile.attachAll  = attachAll;
window.LeggitCoffreFile.VERSION    = '1.0.0';
```

---

### [MEDIUM-1] Réutilisation de placeholder PDO `:u` × 3 dans `coffres-familiaux/api.php`

**Fichier** : `modules/coffres-familiaux/api.php:1847-1865` (action `fetch_legacy_familial_items_batch`)

**Description** : La requête réutilisait `:u` à 3 endroits alors que `core/database.php` force `ATTR_EMULATE_PREPARES=false`. PDO en mode natif refuse → `SQLSTATE[HY093] Invalid parameter number`.

**Vecteur** : N'importe quel propriétaire de coffre familial avec des items texte server-v1 à migrer en E2EE. Le rewrap handler appelle cet endpoint au login.

**Impact** : Migration legacy server-v1 → E2EE des coffres familiaux bloquée. Items restent indéchiffrables côté E2EE client.

**Fix appliqué** : 3 placeholders distincts `:u_membre`, `:u_crypto`, `:u_proprio` avec la même valeur passée 3 fois.

---

### [MEDIUM-2] Réutilisation de placeholder PDO `:u` × 2 dans `crypto-wallets/validate.php`

**Fichier** : `modules/crypto-wallets/validate.php:80-95`

**Description** : Même bug : `:u` apparaît dans la sous-requête ET dans le JOIN sur `coffre_crypto_wallets_shares`. Erreur garantie au runtime.

**Vecteur** : Tout dépositaire qui clique sur "Examiner la demande" depuis la liste des sessions en attente.

**Impact** : Flow paranoia cassé — aucun dépositaire ne peut soumettre sa part Shamir via cette page (chemin nominal pour la récupération de wallet Paranoia post-mortem).

**Fix appliqué** : Renommage en `:u_sub` + `:u_share`. Pattern documenté.

---

### [LOW-1] Endpoints `get_my_privkey` et `request_recovery` brute-forçables sans rate-limit applicatif

**Fichier** : `modules/crypto-wallets/api.php:147-170` et `:685-710`

**Description** : Les 2 endpoints acceptent un POST `mot_de_passe` évalué via `password_verify()`. Pas de rate-limit applicatif. Un attaquant ayant volé un cookie de session valide pouvait brute-forcer le mot de passe en boucle (lent grâce à Argon2id mais non bloqué).

**Impact** : Faible (la session est déjà compromise) mais escalade de privilège temporel : récupération de la privkey X25519 utilisateur en clair → déchiffrement hors-bande possible après expiration de la session volée.

**Fix appliqué** : `leggitRateLimitUser($idUser, 'privkey_request', 5, 900)` et `leggitRateLimitUser($idUser, 'recovery_request', 5, 900)` dans les 2 actions. Audit log `*_rate_limited` ajouté.

---

### [LOW-2] Comparaison non constant-time du `code_authorization` dans `cryptoWalletSubmitShare`

**Fichier** : `core/crypto_wallets.php:730`

**Description** : Utilisait `!==` au lieu de `hash_equals`. Théoriquement vulnérable au timing attack, en pratique inexploitable (code 6 chars alphabet 30 chars = 729M combinaisons).

**Fix appliqué** : `hash_equals(strtoupper((string)$sess['code_authorization']), strtoupper($codeAuthInput))` — comparaison constant-time.

---

## Modules vérifiés sans finding (transparence)

- **`modules/relations/api.php`** : ACL super-admin strict, CSRF, validation regex, transactions propres
- **`modules/destinataires/api.php`** action update_relation : validation ENUM, ownership check, UPDATE par id
- **`modules/tiers/api.php`** action update_relation : idem patterns
- **`modules/coffres/api.php`** `actionUploadFinalizeE2EE` : validation extensive (file_token strict, ownership, cross-session, recipients whitelist, fingerprint cohérent)
- **`actionRewrapE2EEItems`** : reauth obligatoire, audit complet, fingerprint matching, transaction atomique
- **`core/rate_limit.php`** : fichier hash-named, LOCK_EX, chmod 0600
- **`core/relations_types.php`** : requêtes paramétrées, validation `code` partout
- **`cron/cleanup_worker.php`** : isolation try/catch, purges propres
- **Stockage `pairing_token`** : SHA-256 (pas de stockage en clair)

---

## Recommandations d'évolution future (INFO, non-findings)

- **INFO-1** : Plusieurs endpoints retournent `$e->getMessage()` côté client en 500 (fuite info modérée). Envisager un identifiant opaque + log détaillé serveur-side.
- **INFO-2** : `Content-Disposition: attachment; filename="..."` sans RFC 5987 (`filename*=UTF-8''`) — surface faible (recipient/owner déjà authentifié) mais bonne hygiène.
- **INFO-3** : Script de scan placeholder PDO en pre-commit hook ou CI pour bloquer cette classe de bug (heartbeat, coffres-familiaux, validate.php tous touchés).
- **INFO-4** : Ajouter `leggitRateLimit` sur `submit_share` (paranoia) — surface étroite mais cohérence avec le reste.
- **INFO-5** : Test JS d'intégration (Puppeteer headless) qui charge `complete.php` et vérifie `typeof window.LeggitCoffreFile.uploadE2EE === 'function'` → aurait attrapé le HIGH-v2 à la première PR.

---

## Conclusion v2.0

Code post-fix de **très bonne qualité** : défense en profondeur solide, validation systematique des inputs, séparation E2EE / server-v1 propre, audit logs partout, primitives crypto solides (XChaCha20-Poly1305, X25519, Argon2id, Shamir GF(2⁸)).

Les **5 fixes v2.0** ont été appliqués (~30 lignes total). Une fois déployés, le verdict est **production-ready**.

La pile leggit reste **conforme à son engagement zero-knowledge** : leggit ne peut pas lire le contenu des coffres en clair, même en cas de compromission complète du serveur (sauf période de transition très courte côté upload_file legacy, désormais limité aux fichiers non-Prudent).
