Recevoir un transfert chiffré : du lien au fichier sur votre disque
Emilien Mantel
Vous recevez un e-mail : un collègue vous a envoyé un dossier. Vous cliquez sur le lien, vous saisissez la passphrase qu'il vous a transmise par téléphone, et la liste des fichiers apparaît. Un clic sur « Tout télécharger », et quelques minutes plus tard les fichiers sont sur votre disque.
Vu de l'extérieur, c'est un téléchargement comme un autre. Mais le serveur qui vous a servi ces fichiers ne sait pas les lire : il ne détient ni leur contenu en clair, ni leurs noms, ni la moindre clé. Tout ce qui les rend lisibles s'est passé dans votre navigateur. Cet article suit ce trajet dans l'ordre, du lien jusqu'au fichier enregistré. 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.
Le lien ne suffit pas toujours
Un lien de transfert finit par circuler : transféré à un collègue, collé dans un ticket, affiché en aperçu par une messagerie. Pour les données chiffrées, ce n'est pas grave, puisque personne ne peut les lire sans la clé. Mais distribuer le contenu chiffré à quiconque tient le lien reste inutile, et nous ne le faisons pas quand ce n'est pas nécessaire.
Tout dépend de la façon dont l'expéditeur a protégé son transfert. Avec une passphrase, le lien est ouvert : le destinataire n'a pas besoin de compte, c'est la passphrase qui protège les fichiers. Sans passphrase, le transfert n'a été chiffré que pour des comptes Retyc précis, et l'API le réserve à ces comptes :
def assert_share_access(share: ShareModel, current_user: Optional[CurrentUserModel]) -> None:
"""A share without passphrase can only be decrypted by its recipients: restrict its
metadata and its chunks to the sender and the recipients, so that holding the link
is not enough to harvest the encrypted payload.
"""
if share.use_passphrase:
return
if current_user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
if current_user.id != share.owner_id and current_user.id not in share.recipients_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a recipient of this share")
Un visiteur non connecté est invité à se connecter, un compte qui n'est pas destinataire reçoit un refus. Dans les deux cas, aucun octet chiffré ne sort de nos serveurs. Un transfert expiré ou désactivé par son expéditeur ne répond plus du tout, quel que soit le compte.
Déverrouiller le transfert
Une fois l'accès accordé, le navigateur reçoit la description du transfert : son titre, et plusieurs « enveloppes » chiffrées contenant la clé de session, celle qui a servi à chiffrer les fichiers. Nous avons décrit comment ces enveloppes sont fabriquées dans l'anatomie d'un upload chiffré. Côté destinataire, il s'agit de les ouvrir.
Si vous avez un compte et que le transfert vous est destiné, votre clé privée, déverrouillée dans le navigateur à la connexion, ouvre directement la sienne. Vous n'avez rien à saisir.
Sans compte, c'est la passphrase qui sert, en deux temps :
// Étape 1 : la passphrase ouvre une clé privée éphémère (scrypt)
const ephemeralPrivateKey = await decryptStringWithPassphrase(
shareInfo.value.ephemeral_private_key_enc,
data.password
)
// Étape 2 : cette clé éphémère ouvre la clé de session du transfert
sessionPrivateKey.value = await decryptStringWithIdentity(
shareInfo.value.session_private_key_enc_for_passphrase,
ephemeralPrivateKey
)
La première étape prend volontairement un peu de temps : scrypt est conçu pour rendre chaque essai coûteux, et donc les attaques par force brute très lentes. Une passphrase erronée ne produit pas un résultat faux mais un échec net, et la page vous le dit. Dans tous les cas, la passphrase n'est jamais envoyée au serveur : elle est vérifiée par le seul fait qu'elle ouvre, ou non, l'enveloppe.
La clé de session obtenue ne quitte pas la page. Elle sert d'abord à déchiffrer le message éventuel de l'expéditeur, puis la liste des fichiers.
La liste des fichiers
Le serveur stocke pour chaque fichier un nom chiffré, une taille et un nombre de morceaux. Le navigateur déchiffre les noms un à un pour afficher la liste :
for await (const item of fullScanFiles(props.shareId)) {
const name_dec = await decryptStringWithIdentity(item.name_enc, props.sessionPrivateKey)
// ...
}
Pour un dossier envoyé tel quel, le nom déchiffré contient son chemin complet, par exemple contrats/2026/avenant.pdf.
C'est ce qui permettra plus loin de reconstituer l'arborescence d'origine.
Récupérer et déchiffrer le contenu
Chaque fichier a été découpé en morceaux à l'envoi, chacun chiffré séparément. Le navigateur les demande à l'API, quatre à la fois par défaut, et les déchiffre dans un Web Worker au fur et à mesure qu'ils arrivent. Deux limites encadrent ce travail : le nombre de téléchargements simultanés, et le nombre de morceaux gardés en mémoire en attendant leur tour d'écriture :
const LOOKAHEAD = 2 * BATCH_SIZE // morceaux gardés à la fois, écriture comprise
function startNextChunks(): void {
while (!failed && next < chunkCount && inFlight < BATCH_SIZE && held.size < LOOKAHEAD) {
const chunk_id = next++
inFlight++
const chunk = doDownload(file_transfer, chunk_id)
.then(res => decryptWithIdentity(res, session_private_key))
// Un créneau libéré relance aussitôt un téléchargement
chunk.then(() => {
inFlight--;
startNextChunks()
}, () => {
inFlight--;
failed = true
})
held.set(chunk_id, chunk)
}
}
startNextChunks()
for (let chunk_id = 0; chunk_id < chunkCount; chunk_id++) {
yield await held.get(chunk_id)! // remis dans l'ordre à celui qui écrit le fichier
held.delete(chunk_id)
startNextChunks()
}
Un morceau lent ne bloque pas les autres : pendant qu'on l'attend, les créneaux libérés continuent de télécharger les suivants, qui patientent déchiffrés en mémoire. Cette avance a toutefois un plafond, pour que la mémoire occupée ne dépende jamais de la taille du fichier.
Le déchiffrement vérifie aussi l'intégrité. age authentifie les données qu'il chiffre : si un morceau a été modifié, que ce soit sur le stockage, en transit ou par quelqu'un qui aurait accès à nos serveurs, le déchiffrement échoue au lieu de produire des données corrompues. Un fichier que vous obtenez est donc exactement celui qui a été envoyé, ou rien.
L'enregistrement : là où les navigateurs diffèrent
Il reste à écrire le résultat sur votre disque. C'est la seule étape où votre navigateur change vraiment l'expérience, et elle mérite qu'on s'y arrête.
Sur un téléchargement ordinaire, le serveur envoie le fichier et le navigateur l'enregistre de lui-même. Ici, le fichier n'existe en clair que dans la page, et une page web n'a normalement aucun droit d'écrire sur votre disque. Deux cas se présentent, selon que le navigateur propose ou non l'API File System :
const supportsFSAccess = computed<boolean>(() => {
try {
return 'showDirectoryPicker' in window && 'showSaveFilePicker' in window
} catch {
return false
}
})
À ce jour, seuls les navigateurs basés sur Chromium sur ordinateur la proposent : Chrome, Edge, Opera, Brave. Firefox et Safari ne l'implémentent pas.
Avec l'API, le navigateur ouvre la fenêtre d'enregistrement de votre système, et vous choisissez un fichier ou un dossier. La page reçoit alors le droit d'écrire à cet endroit précis, et nulle part ailleurs, puis y écrit les données déchiffrées. Pour un téléchargement groupé, vous choisissez un dossier une seule fois, et Retyc y recrée les sous-dossiers d'origine avant d'y écrire les fichiers un par un :
const fileHandle = await getFileHandleFromPath(dirHandle, ft.name) // crée "contrats/2026/" si besoin
const writable = await fileHandle.createWritable()
for await (const chunk of getChunkIterator(ft, session_private_key)) {
await chunk.stream().pipeTo(writable, {preventClose: true})
}
await writable.close()
Petite anecdote, lors de la bêta de Retyc, un utilisateur nous a signalé qu'il ne pouvait pas télécharger une centaine de fichiers d'un coup depuis une dataroom. Après investigation (non reproductible en local), il s'est avéré que le navigateur remontait une
SecurityError. Dans la fonctionshowDirectoryPickerdoit être appelée "en réponse directe à un clic de l'utilisateur", et le téléchargement groupé ne respectait pas cette règle (l'appel était fait après le scan complet des fichiers). Ce point a été corrigé dans la version1.0.17.
Chaque morceau est écrit sur le disque dès que son tour arrive, pendant que les suivants se téléchargent. Au plus huit morceaux sont gardés à la fois, en cours de téléchargement, en attente ou en cours d'écriture : la mémoire occupée reste au plus de l'ordre de 128 Mo (8 chunks x 8 Mo x 2 versions : en clair et chiffré), que le fichier fasse 100 Mo ou 50 Go.
Sans l'API, une page n'a qu'un moyen de produire un fichier : l'assembler entièrement en mémoire, puis simuler un clic sur un lien de téléchargement. Pour plusieurs fichiers, le navigateur ne sait pas créer de dossiers : Retyc les emballe dans une archive zip, construite dans la page avec JSZip.
Ce mode de repli a un coût qui grandit avec la taille des fichiers : la mémoire vive. Tant que le fichier n'est pas entièrement déchiffré et assemblé, rien ne peut être écrit sur le disque, si bien que tout le contenu reste dans l'onglet, sous forme de morceaux déchiffrés puis d'un grand blob qui les réunit. Pour un téléchargement groupé, c'est pire : tous les fichiers du transfert s'accumulent avant que l'archive soit générée, et l'archive elle-même vient s'ajouter par-dessus. Quelques centaines de mégaoctets passent sans que vous le remarquiez. Plusieurs gigaoctets, en revanche, peuvent occuper l'essentiel de la mémoire d'un ordinateur portable ordinaire : la machine ralentit, et le navigateur finit parfois par fermer l'onglet avant la fin, en perdant tout le travail déjà fait. Pour un gros transfert, le mode de repli n'est donc pas seulement plus lent à livrer ses fichiers, il peut ne pas aboutir du tout.
La différence se sent surtout sur les gros volumes :
| Avec l'API (Chrome, Edge...) | Sans l'API (Firefox, Safari) | |
|---|---|---|
| Plusieurs fichiers | Un dossier, arborescence recréée | Une archive zip à décompresser |
| Mémoire utilisée | Environ 128 Mo, quelle que soit la taille | Tout le fichier, ou tout le transfert plus l'archive |
| Taille maximale d'un envoi groupé | Espace disque disponible | 4 Go et 65 535 fichiers par archive |
| Premier fichier disponible | Dès qu'il est écrit | Une fois toute l'archive générée |
| Emplacement | Choisi par vous | Dossier « Téléchargements » |
La limite de 4 Go vient du format : JSZip ne sait pas écrire Zip64, l'extension qui permet de la dépasser. Et comme l'archive doit être entièrement construite en mémoire avant d'être proposée, un transfert de plusieurs gigaoctets peut épuiser la mémoire de l'onglet avant la fin. C'est pourquoi la fenêtre de téléchargement groupé sélectionne le mode « Moderne » quand il est disponible, et signale que le zip n'est pas recommandé pour les gros fichiers.
Pour le téléchargement d'un seul fichier, Retyc choisit automatiquement l'utilisation de l'API File System si elle est disponible. Tandis que pour un téléchargement groupé, l'interface web de Retyc propose le mode "Moderne" quand il est disponible ou le mode « Zip » le cas échéant.
Si vous recevez régulièrement des dossiers volumineux, un navigateur basé sur Chromium vous évitera ces limites. Pour le reste, le mode zip fait très bien l'affaire. Dans les deux cas, le déchiffrement a lieu sur votre machine.
Un nom de fichier est une donnée étrangère
Le chemin contrats/2026/avenant.pdf a été choisi par l'expéditeur, puis chiffré. Une fois déchiffré, c'est une chaîne
fournie par un tiers qui va décider où un fichier est écrit sur votre disque. Avant d'écrire quoi que ce soit, Retyc
refuse donc tout chemin qui tenterait de sortir du dossier choisi :
function assertSafePath(name: string): void {
if (name.startsWith('/') || name.split('/').some(seg => seg === '..')) {
throw new Error(`Unsafe file path rejected: ${name}`)
}
}
L'API du navigateur interdit déjà de remonter au-dessus du dossier accordé. Nous ne voulons pas pour autant dépendre d'un seul garde-fou quand la donnée vient de quelqu'un d'autre.
Pour la même raison, la fenêtre de sélection ne s'ouvre qu'en réponse directe à votre clic : les navigateurs refusent qu'une page décide seule, au milieu d'un traitement, d'écrire sur votre disque. Le dossier vous est donc demandé tout de suite, avant même de lister les fichiers. C'est vous, et pas la page, qui décidez de l'endroit où elle a le droit d'écrire.
Pour aller plus loin
Le navigateur reste un environnement contraint. Pour les très gros volumes, les téléchargements automatisés ou les serveurs sans interface graphique, notre CLI, publié sous licence MIT, récupère et déchiffre les transferts sans passer par un navigateur, ni par ses fenêtres de sélection ou ses limites de mémoire d'onglet. Il applique le même format, et son code est lisible par tous.
Pour l'autre moitié du trajet, clés post-quantiques comprises, l'anatomie d'un upload chiffré détaille ce qui se passe avant que le premier octet ne quitte la machine de l'expéditeur. Et pour une description complète de l'architecture et de ses limites, le livre blanc est en accès libre.