Référentiel : OWASP ASVS niveau 2, recommandations ANSSI. La PSSI de ST et du LIRMM prime. Règle d'or : aucune primitive maison.
| Usage | Choix | Bibliothèque | Module |
|---|---|---|---|
| Transport | TLS 1.3, X25519MLKEM768 |
OpenSSL 3.5+, Caddy/Nginx | déploiement |
| Mots de passe | Argon2id | argon2-cffi (hasher Django) |
M0 |
| Second facteur | WebAuthn (clé d'accès), TOTP en secours | webauthn, django-otp |
M1 |
| Chiffrement symétrique | XChaCha20-Poly1305 ou AES-256-GCM (AEAD) | PyNaCl / cryptography |
M2 |
| Enveloppe de clés | DEK par document, KEK par niveau | cryptography |
M2 |
| Journal d'audit | chaîne SHA-256 + signature Ed25519 quotidienne | hashlib, PyNaCl |
M2 |
| Signatures de validation | Ed25519 puis hybride Ed25519 + ML-DSA-65 | PyNaCl, liboqs-python |
M10 |
| Bout en bout | X25519 (+ ML-KEM-768 hybride) côté navigateur | libsodium.js, WebCrypto | M10 |
- À l'enregistrement : générer une DEK aléatoire de 32 octets.
- Chiffrer le contenu avec la DEK en AEAD ; données associées =
document_id || version || confidentiality_level || thesis_id. - Chiffrer la DEK avec la KEK du niveau de confidentialité ; stocker
wrapped_dek,kek_id,nonce,algorithm. - KEK : en développement, dérivées d'une clé maîtresse en variable d'environnement
(
MASTER_KEY, 32 octets en base64) via HKDF-SHA256 avec info = niveau ; en production, Vault/OpenBao (transit) ou HSM. - Rotation : nouvelle KEK => re-chiffrement des DEK seulement (pas des contenus).
- L'interface d'administration Django ne doit jamais afficher un contenu déchiffré.
kek_id = "<fournisseur>:<DOMAINE>:<version>"(ex.env:CONFIDENTIAL:3), calculé par PostgreSQL (colonne générée) à partir des trois champs deKeyEncryptionKey, qui ne contient aucun matériel de clé.- Chiffrement sous la version courante du domaine, avec
VAULT_KEY_PROVIDER; déchiffrement avec le fournisseur désigné par le préfixe dukek_id, s'il figure dansVAULT_ENABLED_PROVIDERS. Version retirée ou inconnue : refus. - Verrous consultatifs PostgreSQL par domaine (
pg_advisory_xact_lock, espace0x564B, clé = code figé du domaine) : partagé danswrap, exclusif dansnew_versionetretire. Une rotation attend donc la validation de toute transaction qui chiffre sous l'ancienne version. retirerefuse la version courante, une version encore référencée, et tout domaine sans fonction de comptage enregistrée (refus par défaut).
- Une clé transit
aes256-gcm96par domaine (vault-kek-<domaine>) ;seal=transit/encryptavecassociated_data,open=transit/decrypt; la version de la clé transit est laversiondukek_id(bao:<DOMAINE>:<n>). new_version=transit/keys/<nom>/rotate;min_decryption_versionrelevé aprèsretirepour que l'ancienne version ne puisse plus déchiffrer.- Points de contrôle : clé transit
ed25519(transit/sign,transit/keyspour la clé publique, exportée une seule fois). - Authentification de l'application auprès d'OpenBao par AppRole, politique
limitée à
encrypt,decrypt,signsur ces clés ;rotateréservé à un rôle d'administration utilisé par la commande de rotation.
| En clair (base de données) | Chiffré |
|---|---|
| Titre du document (avertissement sur le formulaire de dépôt) | Contenu de chaque version (blob AES-256-GCM sur disque) |
| Niveau de confidentialité | DEK de chaque version (wrapped_dek, sous la KEK du niveau) |
| Taille, type MIME, numéro de version | Graine TOTP et graine des codes de récupération (KEK MFA_SECRETS) |
| Dates de création et de modification, auteur, thèse | |
Chemin du blob (nom aléatoire), kek_id |
Le titre n'apparaît jamais dans le journal d'audit, l'admin, str() d'un
document, les journaux ni les noms de fichiers stockés (tests). Le nom du
fichier téléchargé est reconstruit depuis le titre nettoyé
([a-z0-9-], 80 caractères au plus) et l'extension du type détecté.
- Identifiant = courriel ; hasher Django
Argon2PasswordHasheren premier. - MFA obligatoire pour tous ; WebAuthn recommandé.
- Ré-authentification (< 10 min) pour : valider, changer un rôle, télécharger un
document
CONFIDENTIAL, exporter un rapport. - Limitation des tentatives de connexion (
django-axesou équivalent). - Invitations : jeton aléatoire 32 octets, stocké haché, usage unique, 72 h.
DEBUG=False, ALLOWED_HOSTS explicite, SECURE_SSL_REDIRECT,
SECURE_HSTS_SECONDS=31536000 + INCLUDE_SUBDOMAINS + PRELOAD,
SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, SESSION_COOKIE_SAMESITE="Strict",
SESSION_COOKIE_AGE court (8 h), X_FRAME_OPTIONS="DENY",
SECURE_REFERRER_POLICY="same-origin", CSP via django-csp (pas d'inline script).
AuditEvent(seq, timestamp, actor, thesis, action, object_type, object_id, ip_hash, details_json, prev_hash, hash), en ajout seul (apps/vault/audit.py).
- Chaîne :
hash = SHA-256(prev_hash (32 octets bruts) ‖ JSON_canonique(charge)),prev_hashinitial = 32 octets nuls ; charge ={"v": 1, "seq", "timestamp", "actor", "thesis", "action", "object_type", "object_id", "ip_hash", "details"}. - JSON canonique (
formats.canonical_json) : sous-ensemble de RFC 8785 (pas de flottants, entiers dans ±(2⁵³ − 1), clés triées par unités UTF-16, aucun espace). HorodatageYYYY-MM-DDTHH:MM:SS.ffffffZ(UTC, 6 chiffres), UUID en minuscules avec tirets,ip_hashen hexadécimal minuscule. Clés dedetailsen[a-z][a-z0-9_]*; tout champ texte haché refuse NUL et surrogates isolés. seqcontigu à partir de 1, attribué paraudit()sous le verrou consultatif du journal (0x5641, 1), dernier appel de la transaction.- Triggers PostgreSQL : UPDATE, DELETE et TRUNCATE refusés ; un INSERT doit
prolonger la chaîne (
seq = dernier + 1,prev_hash = dernier hash) et prend le même verrou queaudit()(les insertions SQL directes sont mises en file). Mêmes protections pourAuditCheckpoint, dont le haché doit être celui de son événement. manage.py audit_verify --public-key <clé épinglée> --checkpoints <export>: recalcule la chaîne (comparaisons en temps constant) ; détecte une modification (haché), une suppression (trou deseq), une insertion au milieu (chaînage rompu) ; contre les points de contrôle externes, une réécriture complète et une troncature de la fin, indétectable sans eux (avertissement explicite). Un horodatage qui recule est une information, pas une altération.- Points de contrôle (
apps/vault/checkpoints.py) : signature Ed25519 deb"these-audit-checkpoint-v1\0" ‖ seq (8 o) ‖ hash (32 o) ‖ horodatage (µs, 8 o)par la clé dérivée deCHECKPOINT_INFO. La clé de signature est sur le serveur de la chaîne : un point de contrôle ne protège qu'une fois copié hors de la plateforme (export quotidien récupéré par une machine extérieure, courriel hebdomadaire au doctorant : seule exception à la règle 7).
manage.py audit_checkpoint --export-public-keyaffiche la clé et son empreinte SHA-256 ; chaque export est journalisé et peut être refait (c'est une clé publique, recalculable par le serveur).- La clé de référence est celle du premier export, conservée hors de la
plateforme.
audit_verifyne l'accepte qu'en paramètre, jamais lue en base ni dans un courriel. Une clé différente lors d'un export ultérieur est une alerte (serveur compromis ouMASTER_KEYchangée), jamais un remplacement. - Changement légitime (rotation de
MASTER_KEY, passage à OpenBao) : nouvelle étiquetteCHECKPOINT_INFO = b"audit-checkpoint-ed25519-v2"danscrypto.py, dernier point de contrôle signé par l'ancienne clé, puis premier point de contrôle signé par la nouvelle, la nouvelle clé exportée et conservée hors de la plateforme à côté de l'ancienne, et la date du changement consignée dansdocs/decisions.md.audit_verifyvérifie chaque période avec sa clé.
Taille max (25 Mo), liste blanche de types (PDF, PNG, JPEG, CSV, TXT, ZIP, TEX),
vérification par contenu (pas seulement l'extension), antivirus ClamAV en prod,
téléchargement via vue Django avec Content-Disposition: attachment.
- Aucun dépôt n'est jamais écrit en clair sur disque.
FILE_UPLOAD_HANDLERSne contient queapps.vault.upload_handlers.LimitedMemoryUploadHandler: réception en mémoire, interrompue (StopUpload) dès que le total de la requête dépasse 25 Mo ;TemporaryFileUploadHandlerest exclu, car il écrirait les dépôts de plus de 2,5 Mo dans le répertoire temporaire avant tout chiffrement. Le proxy limite aussi la taille du corps de requête. - Validation (
apps/vault/validation.py) : 25 Mo comptés sur les octets lus, type reconnu par signature et égal à celui de l'extension, texte en UTF-8 strict sans caractère de contrôle ;Content-Typeet nom fournis par le client ignorés (le nom téléchargé est reconstruit depuis le titre). - Blobs (
apps/vault/storage.py) :<2 hex>/<32 hex>.blob, nom tiré du CSPRNG ; écritureO_EXCLen 0600 (dossiers 0700),fsync(F_FULLFSYNCsur macOS), publication paros.link(jamais d'écrasement, refus si le système de fichiers n'a pas de liens physiques),fsyncdu dossier ; lecture par descripteurs de dossier avecO_NOFOLLOWà chaque niveau.vault_gcliste orphelins, blobs manquants et anomalies, sans rien supprimer ni lire.
pre-commit : ruff, bandit, detect-secrets. CI : tests, pip-audit, Semgrep.
Dépendances épinglées dans uv.lock, mises à jour via Renovate.
Aucune donnée scientifique réelle (traces, résultats ST) dans l'instance de développement : utiliser des données fictives.