Feature exclusive du fork
egyptian— Cette fonctionnalité n'existe pas dans Mayan EDMS upstream. Elle ajoute un chiffrement transparent AES-256-GCM sur tous les fichiers documents stockés sur le filesystem.
EncryptedPassthroughStorage est un backend de stockage qui s'intercale de manière transparente entre Mayan et le filesystem. Chaque fichier document est chiffré à l'écriture et déchiffré à la lecture, sans que les couches applicatives n'aient à s'en préoccuper.
Caractéristiques clés :
cryptography (audités, maintenue)Chaque fichier chiffré suit ce format binaire :
┌─────────────────────────────────────────────────────────────────┐
│ Offset │ Taille │ Contenu │
├─────────┼──────────┼────────────────────────────────────────────┤
│ 0 │ 4 octets │ Magic header : b'MENC' (0x4D 0x45 0x4E 0x43)│
│ 4 │ 1 octet │ Version : 0x01 │
│ 5 │ 1 octet │ Longueur du sel (N, typiquement 32) │
│ 6 │ N octets │ Sel PBKDF2 (aléatoire, unique par fichier) │
│ 6+N │ 12 octets│ Nonce AES-GCM (aléatoire, unique par fichier)│
│ 18+N │ M octets │ Ciphertext + Tag GCM (16 octets en fin) │
└─────────┴──────────┴────────────────────────────────────────────┘
En-tête fixe : 6 octets (magic + version + longueur sel)
Overhead total par fichier : 6 + 32 (sel) + 12 (nonce) + 16 (tag) = 66 octets
Un fichier MENC est opaque sur le filesystem — file et mimetype retournent application/octet-stream.
| Paramètre | Valeur | Norme |
|---|---|---|
| Algorithme | AES-256-GCM | NIST FIPS 197 + SP 800-38D |
| Taille de clé | 256 bits (32 octets) | — |
| KDF | PBKDF2-HMAC-SHA256 | NIST SP 800-132 |
| Itérations PBKDF2 | 600 000 | OWASP 2024 |
| Taille du sel | 32 octets | — |
| Taille du nonce | 12 octets (96 bits) | NIST recommandé |
| Tag d'authentification | 16 octets | — |
Dérivation de clé :
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # AES-256
salt=salt, # 32 octets aléatoires, unique par fichier
iterations=600_000,
)
key = kdf.derive(password) # password normalisé en UTF-8 bytes
Les 600 000 itérations ajoutent ~100–200 ms par opération de déchiffrement — coût délibéré pour ralentir les attaques par force brute.
| Paramètre | Obligatoire | Type | Description |
|---|---|---|---|
password |
Oui | str ou bytes | Mot de passe maître (normalisé en UTF-8) |
next_storage_backend |
Non | str (dotted path) | Backend sous-jacent (défaut : FileSystemStorage) |
next_storage_backend_arguments |
Non | dict | Arguments du backend sous-jacent |
DOCUMENTS_FILE_STORAGE_BACKEND: mayan.apps.storage.backends.encryptedstorage.EncryptedPassthroughStorage
DOCUMENTS_FILE_STORAGE_BACKEND_ARGUMENTS:
password: votre-mot-de-passe-fort-ici
next_storage_backend: django.core.files.storage.FileSystemStorage
next_storage_backend_arguments:
location: /var/lib/mayan/document_file_storage
Ne jamais mettre le mot de passe en dur dans un fichier de configuration versionné. Utiliser une variable d'environnement :
# Générer un mot de passe fort
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
# Dans docker-compose.yml ou .env
MAYAN_DOCUMENTS_FILE_STORAGE_BACKEND=mayan.apps.storage.backends.encryptedstorage.EncryptedPassthroughStorage
MAYAN_DOCUMENTS_FILE_STORAGE_BACKEND_ARGUMENTS="{password: '${MAYAN_ENCRYPTION_PASSWORD}', next_storage_backend: 'django.core.files.storage.FileSystemStorage', next_storage_backend_arguments: {location: '/var/lib/mayan/document_file_storage'}}"
# docker-compose.yml
environment:
MAYAN_DOCUMENTS_FILE_STORAGE_BACKEND: mayan.apps.storage.backends.encryptedstorage.EncryptedPassthroughStorage
MAYAN_DOCUMENTS_FILE_STORAGE_BACKEND_ARGUMENTS: >
{password: "${MAYAN_ENCRYPTION_PASSWORD}",
next_storage_backend: "django.core.files.storage.FileSystemStorage",
next_storage_backend_arguments: {location: "/var/lib/mayan/document_file_storage"}}
Les backends se chaînent : chiffrement → compression → filesystem. Les données sont d'abord compressées, puis chiffrées à l'écriture (ordre inversé à la lecture).
DOCUMENTS_FILE_STORAGE_BACKEND: mayan.apps.storage.backends.encryptedstorage.EncryptedPassthroughStorage
DOCUMENTS_FILE_STORAGE_BACKEND_ARGUMENTS:
password: votre-mot-de-passe
next_storage_backend: mayan.apps.storage.backends.compressedstorage.ZipCompressedPassthroughStorage
next_storage_backend_arguments:
next_storage_backend_arguments:
location: /var/lib/mayan/document_file_storage
Flux en écriture : plaintext → compression ZIP → chiffrement AES-GCM → filesystem
Flux en lecture : filesystem → déchiffrement AES-GCM → décompression ZIP → plaintext
1. Mayan appelle storage.save(name, content)
2. Lecture du plaintext complet en mémoire (content.read())
3. Génération d'un sel aléatoire (32 octets)
4. Génération d'un nonce aléatoire (12 octets)
5. Dérivation de la clé AES via PBKDF2 (salt + password)
6. Chiffrement AES-256-GCM → ciphertext + tag 16 octets
7. Construction du fichier MENC :
b'MENC' + version + len(salt) + salt + nonce + ciphertext+tag
8. Écriture du fichier MENC dans le backend sous-jacent
1. Mayan appelle storage.open(name, mode='rb')
2. Lecture du fichier MENC complet depuis le backend sous-jacent
3. Validation de l'en-tête :
- Magic = b'MENC' ?
- Version = 0x01 ?
- Données suffisantes pour sel + nonce + tag ?
4. Extraction : sel (N octets) + nonce (12 octets) + ciphertext+tag
5. Dérivation de la clé via PBKDF2 (même sel → même clé)
6. Déchiffrement AES-256-GCM + validation du tag
7. Retour d'un objet fichier wrappant le plaintext (BufferedEncryptedFile)
Pas de streaming : AES-GCM nécessite le message complet pour valider le tag d'authentification. Le fichier entier est chargé en mémoire lors du déchiffrement. Sur des documents de plusieurs centaines de Mo, surveiller la RAM disponible.
from mayan.apps.storage.backends.encryptedstorage import EncryptedFileError
try:
with storage.open('document.pdf') as f:
data = f.read()
except EncryptedFileError as e:
# "GCM authentication failure -- mot de passe incorrect ou données corrompues"
print(e)
AES-GCM ne permet pas de distinguer un mauvais mot de passe d'un fichier corrompu — les deux lèvent EncryptedFileError. C'est une propriété de sécurité intentionnelle.
1. len(data) >= 6 → EncryptedFileError("Fichier trop court")
2. data[:4] == b'MENC' → EncryptedFileError("Magic header invalide")
3. data[4] == 0x01 → EncryptedFileError("Version non supportée")
4. len(data) >= 6+N+12+16 → EncryptedFileError("Données insuffisantes")
5. AESGCM.decrypt() ok → EncryptedFileError("Échec authentification GCM")
# Générer un mot de passe cryptographiquement sûr (recommandé : 48+ caractères)
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
# Exemple : Kx7mP2qR9nL4vD8wA1sE6jF3hN0tY5iO_uB...
# Alternative : 256 bits aléatoires en hex
python3 -c "import secrets; print(secrets.token_hex(32))"
Règles absolues :
document_file_storage/)document_file_page_image_cache/) — les pages décodées sont en clairSi la confidentialité des noms de fichiers est requise, envisager un stockage objet avec noms générés (UUID).
| Ancienne (CBC) | Actuelle (GCM) | |
|---|---|---|
| Mode AES | CBC (non authentifié) | GCM (authentifié) |
| Sel | Fixe (SECRET_KEY) | Aléatoire par fichier |
| Intégrité | Non garantie | Garantie (tag GCM) |
| Bibliothèque | pyCrypto (dépréciée) | PyCA cryptography |
| Détection tampering | Non | Oui |
La version actuelle est une amélioration sécuritaire majeure.
Aucun mécanisme automatique n'existe dans l'implémentation actuelle.
Pour changer le mot de passe de chiffrement, une migration manuelle est nécessaire :
# Script de rotation — à adapter selon l'environnement
from mayan.apps.documents.models import DocumentFile
from mayan.apps.storage.backends.encryptedstorage import EncryptedPassthroughStorage
old_storage = EncryptedPassthroughStorage(
password='ancien-mot-de-passe',
next_storage_backend_arguments={'location': '/var/lib/mayan/document_file_storage'}
)
new_storage = EncryptedPassthroughStorage(
password='nouveau-mot-de-passe',
next_storage_backend_arguments={'location': '/var/lib/mayan/document_file_storage_new'}
)
for doc_file in DocumentFile.objects.all():
name = doc_file.file.name
# Déchiffrer avec l'ancien mot de passe
with old_storage.open(name, mode='rb') as f:
plaintext = f.read()
# Rechiffrer avec le nouveau
from django.core.files.base import ContentFile
new_storage.save(name=name, content=ContentFile(plaintext))
print(f"Migré : {name}")
# Après vérification, mettre à jour le mot de passe dans config.yml
# et remplacer le répertoire de stockage
Précautions :
Décryptage direct sans Mayan, en Python pur :
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_menc_file(file_path: str, password: str) -> bytes:
with open(file_path, 'rb') as f:
data = f.read()
# Valider l'en-tête
assert data[:4] == b'MENC', "Pas un fichier MENC"
assert data[4] == 0x01, "Version non supportée"
salt_len = data[5]
offset = 6
salt = data[offset:offset + salt_len]
offset += salt_len
nonce = data[offset:offset + 12]
offset += 12
ciphertext_tag = data[offset:]
# Dériver la clé
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=600_000,
)
key = kdf.derive(password.encode('utf-8'))
# Déchiffrer
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext_tag, None)
# Usage
plaintext = decrypt_menc_file('/var/lib/mayan/document_file_storage/abc123', 'mon-mot-de-passe')
with open('document_récupéré.pdf', 'wb') as f:
f.write(plaintext)
Sans le mot de passe, les fichiers sont inaccessibles. Les 600 000 itérations PBKDF2 rendent la force brute infaisable sur du matériel standard (< 1 000 tentatives/seconde sur CPU).
Actions préventives obligatoires :
# Django shell
import os
from django.conf import settings
# Vérifier la configuration du backend
from mayan.apps.documents.models import DocumentFile
doc_file = DocumentFile.objects.first()
# Lire les premiers octets du fichier physique (bypass decrypt)
storage = doc_file.file.storage
with storage._call_backend_method('open', name=doc_file.file.name, mode='rb') as f:
header = f.read(4)
print(header) # Doit afficher b'MENC' si chiffré
doc_file = DocumentFile.objects.first()
with doc_file.file.open('rb') as f:
data = f.read(100)
print(f"Lu {len(data)} octets, premiers bytes : {data[:10]}")
# Si EncryptedPassthroughStorage est actif : données en clair (PDF, etc.)
# Si pas actif : données brutes
| Fichier | Rôle |
|---|---|
mayan/apps/storage/backends/encryptedstorage.py |
Implémentation complète (393 lignes) |
mayan/apps/storage/backends/literals.py |
Constantes (SALT_SIZE, KEY_SIZE, ITERATIONS) |
mayan/apps/storage/classes.py |
Classe de base PassthroughStorage |
mayan/apps/storage/backends/compressedstorage.py |
Backend de compression (chaînable) |
mayan/apps/storage/tests/test_backends.py |
Tests unitaires et d'intégration |