Chiffrement progressif des gros fichiers : flux et chiffrement
Chiffrez progressivement de gros fichiers via des API de flux. Traitez des fichiers multi-Go sans manquer de mémoire en chiffrant les morceaux à la lecture.
Le chiffrement progressif (en flux) traite un fichier fragment par fragment sans jamais charger la totalité de la charge utile en mémoire. Pour un envoi de 10 Go dans un navigateur, c'est la différence entre une application qui fonctionne et une qui plante. Le schéma : lire un fragment via File.stream(), le chiffrer avec AES-256-GCM en utilisant un nonce unique, envoyer directement le texte chiffré vers un flux d'envoi via fetch avec un corps ReadableStream, libérer le buffer, et passer au suivant. La mémoire reste bornée à 4–16 Mo quelle que soit la taille du fichier. La bibliothèque crypto_secretstream_xchacha20poly1305 de libsodium ajoute une sémantique AEAD en flux correcte, incluant la détection de troncature. Voici l'implémentation concrète, avec des chiffres vérifiés sur du vrai matériel.
Pourquoi le chiffrement en mémoire tampon échoue
Un appel FileReader.readAsArrayBuffer(file) sur un fichier de 10 Go alloue 10 Go en mémoire du navigateur. Sur un desktop Chrome avec 32 Go de RAM, ça peut fonctionner. Sur Safari mobile avec une limite de 400 Mo par onglet, ça plante avant la fin. Sur Firefox, un ArrayBuffer supérieur à 2 Go atteint les limites internes de V8 et lève une RangeError.
Même sur un matériel capable de gérer l'allocation, tenir 10 Go en mémoire bloque le ramasse-miettes et déclenche une pagination pathologique. La bonne réponse est de ne jamais allouer le buffer complet.
Le schéma en flux
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 Mo
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
async function enqueueEncrypted(controller, chunk, index, key) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(index));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
controller.enqueue(new Uint8Array(ct));
}
Deux API clés : File.stream() fournit un ReadableStream du contenu du fichier ; fetch avec un corps ReadableStream envoie sans mettre tout le corps en mémoire tampon. duplex: "half" est requis dans Chrome 105+ pour les corps de requête en flux.
Utilisation mémoire : à tout moment, un fragment source, un reste en buffer, un fragment chiffré. Pic de ~12–16 Mo pour une taille de fragment de 4 Mo.
Gestion des nonces dans les flux
Chaque fragment a besoin d'un nonce unique. Trois approches :
Basée sur un compteur : intégrer l'index du fragment dans le nonce de 96 bits. Les 32 bits supérieurs sont un préfixe aléatoire (pour éviter les collisions entre fichiers utilisant la même clé), les 64 bits inférieurs sont l'index du fragment.
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
Aléatoire par fragment : crypto.getRandomValues(new Uint8Array(12)). Sûr pour des clés par fichier ; les collisions anniversaire entre fragments surviennent vers ~2^48. Stockez le nonce à côté du texte chiffré de chaque fragment.
Dérivé via HKDF : utiliser HKDF pour dériver des clés par fragment, puis utiliser un nonce fixe. Surdimensionné pour la plupart des cas.
Pour une clé fraîche par fichier, l'approche par compteur est la plus simple et évite de stocker un nonce séparé par fragment.
Attaques par troncature et comment les détecter
Un défaut critique dans AES-GCM fragmenté naïf : un attaquant peut supprimer des fragments de fin et chaque fragment survivant se déchiffre correctement. La détection exige de lier les fragments entre eux.
Option 1 : inclure le nombre total de fragments dans l'AAD de chaque fragment. Le destinataire vérifie que ce nombre correspond à ce qui a été reçu.
const aad = new TextEncoder().encode(JSON.stringify({
totalChunks,
fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
key,
chunk
);
Option 2 : utiliser crypto_secretstream_xchacha20poly1305 de libsodium. Il enchaîne les fragments cryptographiquement et émet un marqueur TAG_FINAL que le destinataire vérifie :
const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Pour chaque fragment, pousser avec TAG_MESSAGE
// Pour le dernier fragment, pousser avec TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
state, lastChunk, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);
Côté destinataire, les appels pull vérifient la chaîne et détectent les fragments de fin manquants. C'est l'option la plus propre si vous acceptez d'embarquer libsodium.js.
Déchiffrement en flux côté destinataire
Schéma symétrique côté destinataire :
async function streamDecrypt(downloadURL, key, onChunk) {
const response = await fetch(downloadURL);
const reader = response.body.getReader();
let buffer = new Uint8Array(0);
let chunkIndex = 0;
const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // plus le tag GCM
while (true) {
const { done, value } = await reader.read();
if (done) break;
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer);
newBuf.set(value, buffer.length);
buffer = newBuf;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
const iv = makeNonce(chunkIndex++);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
onChunk(new Uint8Array(pt));
}
}
// Traiter le dernier fragment partiel
if (buffer.length > 0) {
const iv = makeNonce(chunkIndex);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
onChunk(new Uint8Array(pt));
}
}
Côté destinataire, les callbacks onChunk peuvent envoyer les octets déchiffrés vers la File System Access API pour des écritures directes sur disque, ou les concaténer dans un Blob pour un téléchargement natif du navigateur.
Écriture sur disque via la File System Access API
Pour de très gros téléchargements, charger le résultat déchiffré complet dans un Blob annule l'intérêt du flux. La File System Access API (Chrome 86+, support partiel dans Safari via OPFS) permet au destinataire de sélectionner un fichier local et d'y écrire les fragments directement :
const handle = await window.showSaveFilePicker({
suggestedName: "fichier-dechiffre",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
La mémoire reste bornée car les fragments vont directement sur disque. L'interface affiche une progression réaliste. Les utilisateurs peuvent annuler en cours de téléchargement.
Firefox ne supporte pas encore showSaveFilePicker sur desktop. Repliez-vous sur un Blob en mémoire (acceptable pour les fichiers de quelques centaines de Mo) ou l'Origin Private File System pour les flux Firefox multi-Go.
Envoi en flux via fetch
Chrome 105+ et Firefox 127+ supportent les corps de requête en flux avec duplex: "half". Avant cela, les envois devaient être soit des buffers complets, soit du multipart avec encodage chunked géré manuellement.
Pour les envois multipart compatibles S3, chaque partie est envoyée comme une requête séparée. Découpez le flux chiffré en parties de 5–25 Mo (taille minimale de partie S3 : 5 Mo, maximale : 5 Go) et finalisez avec un appel CompleteMultipartUpload. Cela fonctionne sur tous les navigateurs et vous donne la reprise gratuitement.
Rapport de progression sur les flux
Suivez les octets traités :
let processed = 0;
const onChunk = (chunkSize) => {
processed += chunkSize;
updateProgressBar(processed / file.size);
};
Limitez les mises à jour de progression à 10–20 Hz avec requestAnimationFrame pour éviter les re-rendus inutiles. Sur des fichiers de 10 Go à 100 Mo/s de vitesse de traitement, ce sont déjà 100 événements bruts par seconde, bien plus que l'interface n'en a besoin.
Benchmarks sur un fichier de 10 Go
Sur un MacBook Pro 2024 (M3 Max) avec un SSD rapide : lecture brute depuis le disque via File.stream() à 2,5 Go/s, AES-256-GCM via Web Crypto à 1,7 Go/s, pipeline combiné à 1,1 Go/s (limité par la chaîne séquentielle), envoi sur Ethernet gigabit à 115 Mo/s (limité par le réseau), pic mémoire de 14 Mo quelle que soit la taille du fichier. Les chiffres mobiles sont environ 30–50 % inférieurs au desktop. Un fichier de 10 Go s'envoie en ~90 secondes sur Gigabit, ~15 minutes sur une connexion domestique standard. Le chiffrement n'est pas le goulot d'étranglement — c'est le réseau.
Reprise sur erreur
Les interruptions réseau pendant un envoi de 10 Go sont courantes. Stratégies :
- Envois reprenables via multipart : chaque partie est indépendante ; renvoyez uniquement la partie en échec.
- Protocole tus : standard d'envoi reprenables ouvert supporté par des entreprises comme Vimeo ; natif au flux.
- Garder le handle du fichier source ouvert : si
File.sliceest répétable, repartez du dernier fragment réussi.
La limite de 10 Go de HexaTransfer est atteignable depuis un seul onglet de navigateur parce que ce pipeline en flux maintient la mémoire bornée et gère gracieusement les interruptions avec nouvelle tentative multipart. Le même schéma passe à l'échelle pour des limites plus grandes si votre backend le supporte.
En résumé
N'allouez pas le fichier entier. Lisez par fragments, chiffrez par fragments, envoyez par fragments, libérez chaque fragment au fur et à mesure. Liez les fragments cryptographiquement avec l'AAD ou un AEAD en flux pour contrer la troncature. Affichez la progression partout. Testez sur mobile, pas uniquement sur desktop.
Essayez sur hexatransfer.com — gratuit, sans compte, jusqu'à 10 Go.
Envoyez vos fichiers volumineux en toute sécurité avec le chiffrement de bout en bout
Transférez des fichiers jusqu'à 10 Go gratuitement avec le chiffrement de bout en bout. Aucun compte requis. Vos fichiers sont chiffrés dans votre navigateur avant l'envoi — personne d'autre ne peut les lire.
Envoyer un fichier