Receiving an encrypted transfer: from the link to the file on your disk
Emilien Mantel
You get an email: a colleague has sent you a folder. You click the link, type the passphrase they gave you over the phone, and the list of files shows up. One click on "Download all", and a few minutes later the files are on your disk.
From the outside, it looks like any other download. But the server that sent you those files can't read them: it holds neither their plaintext content, nor their names, nor a single key. Everything that makes them readable happened in your browser. This article follows that path in order, from the link to the saved file. The excerpts come from the Retyc frontend (Nuxt, TypeScript) and API (FastAPI). They are shortened for readability, and each one names its source file.
The link is not always enough
A transfer link ends up travelling: forwarded to a colleague, pasted into a ticket, previewed by a messaging app. For encrypted data, that is not a big deal, since nobody can read it without the key. But handing the encrypted content to whoever holds the link is still pointless, and we don't do it when it isn't needed.
It all depends on how the sender protected their transfer. With a passphrase, the link is open: the recipient doesn't need an account, the passphrase is what protects the files. Without a passphrase, the transfer was only encrypted for specific Retyc accounts, and the API keeps it for those accounts:
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")
A visitor who isn't logged in is asked to log in, an account that isn't a recipient is turned away. Either way, not a single encrypted byte leaves our servers. A transfer that has expired or been disabled by its sender no longer answers at all, whatever the account.
Unlocking the transfer
Once access is granted, the browser receives the transfer's description: its title, and several encrypted "envelopes" containing the session key, the one used to encrypt the files. We described how these envelopes are built in the anatomy of an encrypted upload. On the recipient's side, the job is to open them.
If you have an account and the transfer is meant for you, your private key, unlocked in the browser when you logged in, opens yours directly. There is nothing to type.
Without an account, the passphrase does the job, in two steps:
// Step 1: the passphrase opens an ephemeral private key (scrypt)
const ephemeralPrivateKey = await decryptStringWithPassphrase(
shareInfo.value.ephemeral_private_key_enc,
data.password
)
// Step 2: that ephemeral key opens the transfer's session key
sessionPrivateKey.value = await decryptStringWithIdentity(
shareInfo.value.session_private_key_enc_for_passphrase,
ephemeralPrivateKey
)
The first step deliberately takes a moment: scrypt is designed to make every attempt expensive, and therefore brute force attacks very slow. A wrong passphrase doesn't produce a wrong result but a clean failure, and the page tells you so. In every case, the passphrase is never sent to the server: it is checked only by the fact that it opens the envelope, or doesn't.
The session key you get never leaves the page. It first decrypts the sender's message, if there is one, then the list of files.
The list of files
For each file, the server stores an encrypted name, a size and a number of chunks. The browser decrypts the names one by one to display the list:
for await (const item of fullScanFiles(props.shareId)) {
const name_dec = await decryptStringWithIdentity(item.name_enc, props.sessionPrivateKey)
// ...
}
For a folder sent as is, the decrypted name contains its full path, for example contracts/2026/amendment.pdf. That
is what will later allow the original tree to be rebuilt.
Fetching and decrypting the content
Each file was split into chunks when it was sent, each one encrypted separately. The browser requests them from the API, four at a time by default, and decrypts them in a Web Worker as they arrive. Two limits frame this work: the number of simultaneous downloads, and the number of chunks kept in memory while they wait for their turn to be written:
const LOOKAHEAD = 2 * BATCH_SIZE // chunks held at once, writing included
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))
// A freed slot immediately starts another download
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)! // handed back in order to whoever writes the file
held.delete(chunk_id)
startNextChunks()
}
A slow chunk doesn't hold the others back: while we wait for it, freed slots keep downloading the next ones, which wait decrypted in memory. That head start has a ceiling, though, so that the memory used never depends on the size of the file.
Decryption also checks integrity. age authenticates the data it encrypts: if a chunk has been tampered with, whether in storage, in transit or by someone with access to our servers, decryption fails instead of producing corrupted data. A file you get is therefore exactly the one that was sent, or nothing.
Saving: where browsers differ
What's left is writing the result to your disk. This is the only step where your browser really changes the experience, and it is worth a closer look.
With an ordinary download, the server sends the file and the browser saves it on its own. Here, the file only exists in plaintext inside the page, and a web page normally has no right to write to your disk. There are two cases, depending on whether the browser offers the File System API:
const supportsFSAccess = computed<boolean>(() => {
try {
return 'showDirectoryPicker' in window && 'showSaveFilePicker' in window
} catch {
return false
}
})
As of today, only Chromium-based desktop browsers offer it: Chrome, Edge, Opera, Brave. Firefox and Safari don't implement it.
With the API, the browser opens your system's save dialog, and you pick a file or a folder. The page then gets the right to write in that exact location, and nowhere else, and writes the decrypted data there. For a bulk download, you pick a folder once, and Retyc recreates the original subfolders inside it before writing the files one by one:
const fileHandle = await getFileHandleFromPath(dirHandle, ft.name) // creates "contracts/2026/" if needed
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()
A little anecdote: during the Retyc beta, a user told us they couldn't download a hundred or so files at once from a dataroom. After some investigation (we couldn't reproduce it locally), it turned out the browser was raising a
SecurityError.showDirectoryPickermust be called "in direct response to a user click", and the bulk download didn't follow that rule (the call was made after the full scan of the files). This was fixed in version1.0.17.
Each chunk is written to disk as soon as its turn comes, while the next ones are downloading. At most eight chunks are held at once, whether downloading, waiting or being written: memory usage stays at most around 128 MB (8 chunks x 8 MB x 2 versions: plaintext and encrypted), whether the file is 100 MB or 50 GB.
Without the API, a page has only one way to produce a file: assemble it entirely in memory, then simulate a click on a download link. For several files, the browser can't create folders: Retyc packs them into a zip archive, built in the page with JSZip.
This fallback mode has a cost that grows with file size: RAM. Until the file is fully decrypted and assembled, nothing can be written to disk, so the whole content stays in the tab, first as decrypted chunks, then as one large blob that joins them. For a bulk download, it gets worse: every file in the transfer piles up before the archive is generated, and the archive itself comes on top of that. A few hundred megabytes go by without you noticing. Several gigabytes, on the other hand, can take up most of the memory of an ordinary laptop: the machine slows down, and the browser sometimes ends up closing the tab before the end, losing all the work already done. For a large transfer, the fallback mode isn't just slower to deliver its files, it may not finish at all.
The difference shows mostly on large volumes:
| With the API (Chrome, Edge...) | Without the API (Firefox, Safari) | |
|---|---|---|
| Several files | A folder, tree rebuilt | A zip archive to extract |
| Memory used | About 128 MB, whatever the size | The whole file, or the whole transfer plus the archive |
| Maximum size of a bulk download | Available disk space | 4 GB and 65,535 files per archive |
| First file available | As soon as it is written | Once the whole archive is generated |
| Location | Chosen by you | "Downloads" folder |
The 4 GB limit comes from the format: JSZip can't write Zip64, the extension that lifts it. And since the archive has to be fully built in memory before being offered, a transfer of several gigabytes can exhaust the tab's memory before the end. That is why the bulk download window selects the "Modern" mode when it is available, and points out that zip is not recommended for large files.
For a single file download, Retyc automatically uses the File System API if it is available. For a bulk download, the Retyc web interface offers the "Modern" mode when it is available, and the "Zip file" mode otherwise.
If you regularly receive large folders, a Chromium-based browser will spare you these limits. For everything else, zip mode does the job just fine. Either way, decryption happens on your machine.
A file name is foreign data
The path contracts/2026/amendment.pdf was chosen by the sender, then encrypted. Once decrypted, it is a string
supplied by a third party that is about to decide where a file gets written on your disk. Before writing anything,
Retyc therefore rejects any path that tries to escape the chosen folder:
function assertSafePath(name: string): void {
if (name.startsWith('/') || name.split('/').some(seg => seg === '..')) {
throw new Error(`Unsafe file path rejected: ${name}`)
}
}
The browser API already forbids climbing above the granted folder. We still don't want to rely on a single safeguard when the data comes from someone else.
For the same reason, the picker only opens in direct response to your click: browsers refuse to let a page decide on its own, in the middle of some processing, to write to your disk. The folder is therefore asked for right away, before the files are even listed. You, not the page, decide where it is allowed to write.
Going further
The browser remains a constrained environment. For very large volumes, automated downloads or headless servers, our CLI, released under the MIT license, fetches and decrypts transfers without going through a browser, its pickers or its tab memory limits. It uses the same format, and its code is open for anyone to read.
For the other half of the journey, post-quantum keys included, the anatomy of an encrypted upload details what happens before the first byte leaves the sender's machine. And for a complete description of the architecture and its limits, the white paper is freely available.