Anatomie d'un upload chiffré dans le navigateur
Emilien Mantel
Vous glissez un fichier de 3 Go dans la page, vous choisissez un destinataire, vous cliquez sur « Envoyer ». La barre de
progression démarre presque aussitôt. Entre les deux, le navigateur a déjà généré une paire de clés, chiffré cette clé
pour chaque destinataire, chiffré le nom du fichier, et commencé à découper le reste en morceaux qu'il chiffre un par un
avant de les envoyer. Le serveur, lui, ne reçoit jamais que des fichiers .age.
Cet article suit ce trajet dans l'ordre, avec le code qui le fait. Les extraits viennent du front de Retyc (Nuxt, TypeScript) et de l'API (FastAPI). Ils sont raccourcis pour la lecture, et chacun indique le fichier d'origine.
Une paire de clés pour chaque transfert
Tout repose sur age, un format de chiffrement de fichiers à la
spécification publique,
et sur son implémentation JavaScript, age-encryption. Au début de chaque transfert, le navigateur crée une identité
age qui ne servira qu'à ce transfert :
async function generateSessionIdentity()
{
const {generateHybridIdentity, identityToRecipient} = await loadAge()
const identity = await generateHybridIdentity()
return {
public_key: await identityToRecipient(identity),
private_key: identity
}
}
generateHybridIdentity() produit une paire hybride : elle combine X25519, l'échange de clés elliptique classique,
et ML-KEM-768, le mécanisme d'encapsulation standardisé par le NIST pour résister aux ordinateurs quantiques. Pour lire
un fichier, il faut casser les deux. La clé publique qui en sort commence par age1pq1 et fait plus d'un millier de
caractères, contre une soixantaine pour une clé age classique. Ce préfixe servira plus loin.
Pourquoi une clé par transfert plutôt que la clé de l'expéditeur directement ? Parce qu'un transfert a plusieurs lecteurs (vous, vos destinataires, parfois une passphrase), et qu'on ne veut chiffrer les 3 Go qu'une seule fois. La clé de session chiffre le contenu. Le reste du travail consiste à distribuer cette clé.
Emballer la clé pour chaque lecteur
C'est le principe du chiffrement d'enveloppe. Les données sont chiffrées avec une clé, et c'est cette clé qu'on chiffre ensuite pour chaque personne autorisée. Chez nous, ça tient en quelques lignes :
// Vous gardez l'accès à votre propre transfert
if (encryptWithMyKey) {
public_keys.push(keyPair.value.public_key)
}
const session_identity = await generateSessionIdentity()
// La clé privée de session, chiffrée pour tous les destinataires d'un coup
const session_private_key_enc = await encryptStringWithRecipients(
session_identity.private_key,
public_keys
)
age accepte plusieurs destinataires dans un même fichier : chacun a sa propre entrée dans l'en-tête, et n'importe laquelle suffit à retrouver la clé de fichier. Un destinataire qui a un compte ouvre donc l'enveloppe avec sa clé privée, que son navigateur a déverrouillée avec sa passphrase, sans jamais l'envoyer en clair.
Un destinataire sans compte n'a pas de clé publique. Pour lui, le navigateur crée une deuxième paire, éphémère, dont la clé privée est protégée par la passphrase du transfert :
if (passphrase.length > 0) {
// Clé privée éphémère chiffrée avec la passphrase (scrypt)
const ephemeralKeypair = await createAgeIdentityPair(passphrase)
session_private_key_enc_for_passphrase = await encryptStringWithRecipients(
session_identity.private_key,
[ephemeralKeypair.public_key]
)
}
La passphrase passe par scrypt, une fonction de dérivation volontairement lente et gourmande en mémoire. Chaque tentative de devinette coûte cher, même à quelqu'un qui aurait mis la main sur l'enveloppe. La passphrase elle-même ne quitte jamais le navigateur : vous la transmettez à votre destinataire par un autre canal.
Les noms de fichiers aussi
Un contenu chiffré sous un nom en clair, comme 2026-pentest-application-xyz.pdf, en dit déjà beaucoup. Avant d'envoyer le
moindre octet de contenu, le navigateur déclare donc chaque fichier à l'API avec un nom et un type MIME déjà chiffrés
pour la clé de session :
// Pour un dossier, webkitRelativePath donne le chemin complet : "contrats/2026/avenant.pdf"
const fileName = file.webkitRelativePath || file.name
const [name_enc, type_enc] = await Promise.all([
encryptStringWithRecipients(fileName, [session_public_key]),
encryptStringWithRecipients(file.type, [session_public_key])
])
await $retyc('/share/{share_id}/file', {
method: 'POST',
path: {share_id},
body: {name_enc, type_enc, original_size: file.size},
})
Quand on envoie un dossier, l'arborescence complète est chiffrée avec le nom. L'API voit passer un fichier, sa taille, et c'est tout.
Des morceaux de 8 Mo, chiffrés un par un
Charger 3 Go en mémoire pour les chiffrer d'un bloc ferait tomber l'onglet bien avant la fin. Le fichier est donc
découpé en morceaux avec Blob.slice(), qui ne lit rien tant qu'on ne le lui demande pas. Chaque morceau est chiffré
séparément, et devient un fichier age complet et autonome :
const chunkCount = Math.ceil(file.size / CHUNK_SIZE) // 8 Mo par défaut
for (let chunkId = 0; chunkId < chunkCount; chunkId++) {
tasks.push((async () => {
// Chiffrer : on ne lit que les 8 Mo de ce morceau
const encryptedBlob = await limitEncrypt(() => {
const start = chunkId * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
return encryptWithRecipient(file.slice(start, end), session_public_key)
})
// Envoyer tout de suite, puis laisser le ramasse-miettes libérer le blob
const formData = new FormData()
formData.append('upload_file', encryptedBlob, 'chunk.age')
await doUpload(file_info, chunkId, formData, onChunkProgress, abortController.signal)
})().catch(err => {
cancelled = true
abortController.abort() // coupe les requêtes encore en vol
throw err
}))
}
await Promise.all(tasks)
Chiffrer chaque morceau à part a trois avantages. La mémoire reste bornée : quelques morceaux en vol, jamais le fichier entier. Les morceaux se chiffrent et partent en parallèle. Et un morceau qui échoue se renvoie seul, sans reprendre le transfert depuis le début. Le prix à payer est un en-tête age par morceau, quelques kilo-octets sur 8 Mo, ce qui reste négligeable.
Pourquoi 8 Mo, et pas 1 ou 100 ? À cause de la mémoire. Un morceau ne se chiffre pas en flux : il est lu entièrement, chiffré, puis gardé sous forme de blob jusqu'à ce que son envoi se termine. Pendant le chiffrement, un morceau coûte à peu près deux fois sa taille, en clair puis chiffré. Avec 8 chiffrements en parallèle, ça représente de l'ordre de 128 Mo, ce qu'un ordinateur portable ordinaire encaisse sans broncher. Le serveur a la même contrainte : il garde chaque morceau reçu en tampon le temps de l'écrire dans le stockage objet, et ces tampons se multiplient par le nombre d'envois simultanés de tous les utilisateurs. Des morceaux plus gros feraient grimper la mémoire des deux côtés. Des morceaux plus petits multiplieraient les requêtes et les en-têtes pour rien. 8 Mo est le compromis que nous avons retenu.
La gestion d'erreur compte autant que le reste. Si un morceau échoue définitivement, le drapeau cancelled empêche les
suivants de démarrer, et l'AbortController interrompt ceux qui étaient déjà en route. Sans ça, un fichier de 3 Go
dont le deuxième morceau échoue continuerait d'envoyer les centaines d'autres pour rien.
Hors du thread principal
Chiffrer 8 Mo n'est pas instantané, et un navigateur qui chiffre sur le thread principal ne répond plus : barre de progression figée, clics ignorés. Le chiffrement tourne donc dans un Web Worker. Comlink évite d'écrire à la main le protocole de messages entre la page et le worker. Côté worker, il suffit d'exposer l'objet :
import * as Comlink from 'comlink'
import {cryptoCore} from '#shared/utils/crypto-core'
Comlink.expose(cryptoCore)
Côté page, on appelle les fonctions du worker comme des fonctions asynchrones ordinaires. Un détail fait la différence :
const encryptWithRecipient = async (input: Blob, recipient: string): Promise<Blob> => {
const data = new Uint8Array(await input.arrayBuffer())
const encrypted = await proxy.encryptChunkWithRecipient(
Comlink.transfer(data, [data.buffer]),
recipient
)
return new Blob([encrypted])
}
Par défaut, postMessage copie les données qu'il envoie au worker. Comlink.transfer() déclare le buffer comme
transférable : sa propriété passe au worker sans copie, et la page ne peut plus y toucher. Sur un fichier de 3 Go,
c'est près de 400 copies de 8 Mo en moins.
Le réseau
Envoyer plusieurs morceaux en parallèle accélère l'upload sur une bonne connexion. Sur une connexion lente, c'est l'inverse. Les envois se partagent la bande passante, et chaque morceau met d'autant plus longtemps à partir. Or chaque envoi a un délai maximal : passé 119 secondes, la requête est abandonnée.
Prenons une connexion à 1 Mbit/s en envoi ce qu'on trouve encore sur de l'ADSL ou une 4G saturée. Un morceau de 8 Mo seul passe en un peu plus d'une minute. Huit morceaux en parallèle se partagent le même débit, et chacun mettrait plus de huit minutes : ils échoueraient tous au bout de deux minutes, et l'upload ne finirait jamais. Sur une fibre, au contraire, un seul morceau à la fois laisserait la plus grande partie du débit inutilisée.
Il n'existe donc pas de bon réglage fixe. Retyc s'adapte à la connexion. Le chiffrement passe par p-limit, 8 morceaux
à la fois au plus. L'envoi passe par un sémaphore adaptatif, qui commence prudemment avec un seul morceau en vol.
À chaque morceau terminé, il mesure le débit, le lisse avec une moyenne mobile pondérée (EWMA), et en déduit combien de
morceaux envoyer en parallèle pour que chacun mette environ 5 secondes, très loin du délai maximal :
const speed = (estimatedByteSize / durationSeconds) * concurrencySnapshot
ewmaSpeed = ewmaSpeed === null
? speed
: ADAPTIVE_EWMA_ALPHA * speed + (1 - ADAPTIVE_EWMA_ALPHA) * ewmaSpeed // alpha = 0.3
const rawTarget = (ewmaSpeed * ADAPTIVE_TARGET_SECONDS) / chunkSize // cible : 5 s par morceau
// +2 au plus par mesure, pour ne pas noyer une connexion lente
semaphore.setTarget(Math.min(rawTarget, semaphore.target + ADAPTIVE_RAMP_UP_STEP))
La montée est volontairement lente, deux morceaux de plus par mesure au maximum, et la cible est plafonnée à 8. Une fibre atteint vite le plafond, une connexion lente reste à un seul morceau en vol, et personne n'a rien à régler. Le plafond protège aussi la mémoire du serveur : même sur la meilleure connexion, un upload n'occupe jamais plus de 8 tampons de réception à la fois. L'objectif est d'aller aussi vite que la connexion le permet, sans jamais échouer sur une connexion lente, et sans dépenser plus de mémoire que nécessaire, ni dans le navigateur ni sur nos serveurs.
Côté serveur : ce que l'API accepte et ce qu'elle refuse
L'API ne déchiffre rien, puisqu'elle n'a aucune clé. Elle vérifie les quotas et écrit chaque morceau tel quel dans le stockage objet. Elle a pourtant un rôle à jouer : refuser tout ce qui affaiblirait le schéma. Chaque clé publique qui entre dans le système passe par ce type Pydantic :
class AgePublicKey(str):
"""Age public key, hybrid post-quantum recipients only (`age1pq1...`).
Classic X25519 recipients (`age1...`) are valid age keys but are refused
everywhere: every identity in the product is generated as a hybrid
post-quantum pair, and a classic key slipping in would silently weaken
the post-quantum guarantee of whatever it protects.
"""
REGEX_PATTERN = r"^age1pq1[0-9a-z]{1000,2500}$"
Le commentaire dit l'essentiel. Une clé age classique est parfaitement valide. age lui-même refuse d'ailleurs de
mélanger une clé classique et une clé post-quantique dans un même fichier, grâce à un label postquantum porté par les
clés hybrides. Mais il suffirait qu'une clé classique entre dans le système, par exemple comme clé d'un utilisateur,
pour que tout ce qu'on chiffre pour elle ne soit protégé que par X25519, et devienne lisible le jour où un ordinateur
quantique saura le casser. La règle est donc posée à l'entrée : une clé qui ne commence pas par age1pq1 est refusée
dès la validation de la requête, avant d'atteindre le moindre service.
Ce que nous voyons quand même
Chiffrer de bout en bout ne rend pas tout invisible, et mieux vaut le dire. Voici ce qui arrive chez nous, et sous quelle forme :
| Donnée | Ce que le serveur reçoit |
|---|---|
| Contenu des fichiers | Chiffré |
| Noms, chemins et types des fichiers | Chiffrés |
| Message accompagnant le transfert | Chiffré |
| Clé de session | Chiffrée pour chaque destinataire |
| Taille des fichiers, nombre de morceaux | En clair |
| Titre du transfert | En clair |
| Adresses e-mail des destinataires | En clair, pour leur envoyer la notification |
| Compte de l'expéditeur, dates, adresses IP | En clair |
Le titre et les adresses restent lisibles parce que le service en a besoin pour fonctionner : afficher la liste de vos transferts, prévenir vos destinataires par e-mail. Si le titre vous paraît sensible, laissez-le vide, ou mettez l'information dans le message, qui est chiffré.
Bonus : changer la serrure sans tout rechiffrer
La clé de session a un dernier avantage, moins visible. Quand un utilisateur fait tourner sa clé personnelle, ses anciens transferts doivent devenir lisibles avec la nouvelle. Rechiffrer des centaines de gigaoctets serait long et coûteux. Il suffit de rechiffrer l'enveloppe : le navigateur ouvre la clé de session avec l'ancienne clé, la chiffre avec la nouvelle, et envoie le résultat.
@router.put("/share/{share_id}/rekey", status_code=status.HTTP_204_NO_CONTENT, operation_id="rekeyShare")
def rekey_share(share: SharePrivateDepend, data: ShareRekeyRequest, session: SessionDep, logger: LoggerDep):
svc_rekey_share(share, data.session_private_key_enc, session, logger)
La requête ne transporte que quelques kilo-octets, même pour un transfert de 3 Go. Les morceaux stockés ne bougent pas.
Pour vérifier par vous-même
Vous n'avez pas à nous croire sur parole pour tout ça. La spécification d'age est publique. Notre CLI, publié sous licence MIT, applique le même schéma en Go : clé de session hybride, enveloppes par destinataire, morceaux chiffrés un par un. Vous pouvez lire son code, ou envoyer un transfert avec et observer ce qui part sur le réseau.
Pour une description complète de l'architecture et de ses limites, le livre blanc est en accès libre. Et si la différence entre « chiffré » et « chiffré de bout en bout » vous semble floue, nous l'avons expliquée dans un article dédié.