Anatomy of an encrypted upload in the browser
Emilien Mantel
You drop a 3 GB file on the page, pick a recipient, click "Send". The progress bar starts almost right away. In between,
the browser has already generated a key pair, encrypted that key for each recipient, encrypted the file name, and started
cutting the rest into chunks that it encrypts one by one before sending them. The server only ever receives .age
files.
This article follows that path step by step, with the code that does it. The excerpts come from the Retyc front end (Nuxt, TypeScript) and API (FastAPI). They are trimmed for reading, and each one names the file it comes from.
One key pair per transfer
Everything rests on age, a file encryption format with a public
specification, and on its JavaScript implementation, age-encryption. At the start of each transfer, the browser
creates an age identity that will only ever be used for that transfer:
async function generateSessionIdentity()
{
const {generateHybridIdentity, identityToRecipient} = await loadAge()
const identity = await generateHybridIdentity()
return {
public_key: await identityToRecipient(identity),
private_key: identity
}
}
generateHybridIdentity() produces a hybrid pair: it combines X25519, the classic elliptic-curve key exchange, with
ML-KEM-768, the encapsulation mechanism standardized by NIST to withstand quantum computers. To read a file, you have to
break both. The resulting public key starts with age1pq1 and runs to over a thousand characters, against about sixty
for a classic age key. That prefix will come up again later.
Why a key per transfer rather than the sender's own key? Because a transfer has several readers (you, your recipients, sometimes a passphrase), and we only want to encrypt those 3 GB once. The session key encrypts the content. The rest of the work is about handing that key out.
Wrapping the key for each reader
This is envelope encryption. The data is encrypted with one key, and that key is then encrypted for each authorized person. In our code, it takes a few lines:
// You keep access to your own transfer
if (encryptWithMyKey) {
public_keys.push(keyPair.value.public_key)
}
const session_identity = await generateSessionIdentity()
// The session private key, encrypted for every recipient in one go
const session_private_key_enc = await encryptStringWithRecipients(
session_identity.private_key,
public_keys
)
age supports several recipients in a single file: each gets its own entry in the header, and any one of them is enough to recover the file key. A recipient with an account therefore opens the envelope with their private key, which their browser unlocked with their passphrase, without ever sending it in the clear.
A recipient without an account has no public key. For them, the browser creates a second, ephemeral pair, whose private key is protected by the transfer's passphrase:
if (passphrase.length > 0) {
// Ephemeral private key encrypted with the passphrase (scrypt)
const ephemeralKeypair = await createAgeIdentityPair(passphrase)
session_private_key_enc_for_passphrase = await encryptStringWithRecipients(
session_identity.private_key,
[ephemeralKeypair.public_key]
)
}
The passphrase goes through scrypt, a key derivation function that is deliberately slow and memory-hungry. Every guess is expensive, even for someone who got hold of the envelope. The passphrase itself never leaves the browser: you pass it on to your recipient through another channel.
File names too
Encrypted content under a plaintext name like 2026-pentest-application-xyz.pdf already gives a lot away. Before
sending a single byte of content, the browser declares each file to the API with a name and MIME type already encrypted
for the session key:
// For a folder, webkitRelativePath gives the full path: "contracts/2026/amendment.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},
})
When you send a folder, the whole tree is encrypted along with the name. The API sees a file go by, its size, and that is all.
8 MB chunks, encrypted one by one
Loading 3 GB into memory to encrypt it in one go would crash the tab long before the end. So the file is cut into chunks
with Blob.slice(), which reads nothing until asked to. Each chunk is encrypted separately and becomes a complete,
self-contained age file:
const chunkCount = Math.ceil(file.size / CHUNK_SIZE) // 8 MB by default
for (let chunkId = 0; chunkId < chunkCount; chunkId++) {
tasks.push((async () => {
// Encrypt: only this chunk's 8 MB are read
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)
})
// Send right away, then let the garbage collector free the 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() // cancels requests still in flight
throw err
}))
}
await Promise.all(tasks)
Encrypting each chunk on its own has three benefits. Memory stays bounded: a few chunks in flight, never the whole file. Chunks are encrypted and sent in parallel. And a failed chunk is resent on its own, without restarting the transfer from scratch. The cost is one age header per chunk, a few kilobytes out of 8 MB, which is negligible.
Why 8 MB, and not 1 or 100? Because of memory. A chunk isn't encrypted as a stream: it is read in full, encrypted, then kept as a blob until its upload completes. While it's being encrypted, a chunk costs roughly twice its size, once in plaintext and once encrypted. With 8 encryptions in parallel, that comes to around 128 MB, which an ordinary laptop handles without breaking a sweat. The server has the same constraint: it keeps each chunk it receives in a buffer while writing it to object storage, and those buffers multiply with the number of concurrent uploads across all users. Bigger chunks would push memory up on both sides. Smaller chunks would multiply requests and headers for nothing. 8 MB is the trade-off we settled on.
Error handling matters as much as the rest. If a chunk fails for good, the cancelled flag stops the next ones from
starting, and the AbortController interrupts those already on their way. Without it, a 3 GB file whose second chunk
fails would keep sending hundreds of others for nothing.
Off the main thread
Encrypting 8 MB isn't instant, and a browser encrypting on the main thread stops responding: frozen progress bar, ignored clicks. So encryption runs in a Web Worker. Comlink saves us from hand-writing the messaging protocol between the page and the worker. On the worker side, exposing the object is all it takes:
import * as Comlink from 'comlink'
import {cryptoCore} from '#shared/utils/crypto-core'
Comlink.expose(cryptoCore)
On the page side, the worker's functions are called like ordinary async functions. One detail makes the difference:
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])
}
By default, postMessage copies the data it sends to the worker. Comlink.transfer() marks the buffer as
transferable: ownership moves to the worker without a copy, and the page can no longer touch it. On a 3 GB file, that
is close to 400 fewer 8 MB copies.
The network
Sending several chunks in parallel speeds up the upload on a good connection. On a slow one, it does the opposite. Uploads share the bandwidth, and each chunk takes that much longer to get through. And every upload has a time limit: after 119 seconds, the request is dropped.
Take a connection with 1 Mbit/s of upload bandwidth, which you still find on ADSL or a congested 4G link. A single 8 MB chunk gets through in a little over a minute. Eight chunks in parallel share the same bandwidth, and each would take more than eight minutes: they would all fail after two minutes, and the upload would never finish. On fiber, on the other hand, one chunk at a time would leave most of the bandwidth unused.
So there is no good fixed setting. Retyc adapts to the connection. Encryption goes through p-limit, at most 8 chunks at
a time. Uploads go through an adaptive semaphore, which starts cautiously with a single chunk in flight. Each time a
chunk completes, it measures the throughput, smooths it with an exponentially weighted moving average (EWMA), and works
out how many chunks to send in parallel so that each takes about 5 seconds, well clear of the time limit:
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 // target: 5 s per chunk
// +2 at most per measurement, so a slow connection doesn't get swamped
semaphore.setTarget(Math.min(rawTarget, semaphore.target + ADAPTIVE_RAMP_UP_STEP))
The ramp-up is deliberately slow, two more chunks per measurement at most, and the target is capped at 8. Fiber reaches the cap quickly, a slow connection stays at a single chunk in flight, and nobody has anything to tune. The cap also protects server memory: even on the best connection, an upload never takes up more than 8 receive buffers at once. The goal is to go as fast as the connection allows, without ever failing on a slow one, and without spending more memory than needed, either in the browser or on our servers.
Server side: what the API accepts and what it refuses
The API decrypts nothing, since it holds no key. It checks quotas and writes each chunk as is to object storage. It still has a job to do, though: refuse anything that would weaken the scheme. Every public key entering the system goes through this Pydantic type:
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}$"
The docstring says it all. A classic age key is perfectly valid. age itself actually refuses to mix a classic key and a
post-quantum key in the same file, thanks to a postquantum label carried by hybrid keys. But it would only take one
classic key getting into the system, say as a user's key, for everything encrypted to it to be protected by X25519
alone, and to become readable the day a quantum computer can break it. So the rule is enforced at the door: a key that
doesn't start with age1pq1 is refused as soon as the request is validated, before it reaches any service.
What we still see
End-to-end encryption doesn't make everything invisible, and it's better to say so. Here is what reaches us, and in what form:
| Data | What the server receives |
|---|---|
| File contents | Encrypted |
| File names, paths and types | Encrypted |
| Message attached to the transfer | Encrypted |
| Session key | Encrypted for each recipient |
| File sizes, number of chunks | In the clear |
| Transfer title | In the clear |
| Recipients' email addresses | In the clear, to send them the notification |
| Sender's account, dates, IP addresses | In the clear |
The title and the addresses stay readable because the service needs them to work: listing your transfers, notifying your recipients by email. If the title feels sensitive, leave it empty, or put the information in the message, which is encrypted.
Bonus: changing the lock without re-encrypting everything
The session key has one last, less visible benefit. When a user rotates their personal key, their old transfers must become readable with the new one. Re-encrypting hundreds of gigabytes would be slow and costly. Re-encrypting the envelope is enough: the browser opens the session key with the old key, encrypts it with the new one, and sends the result.
@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)
The request carries only a few kilobytes, even for a 3 GB transfer. The stored chunks don't move.
See for yourself
You don't have to take our word for any of this. The age specification is public. Our CLI, released under the MIT license, applies the same scheme in Go: hybrid session key, per-recipient envelopes, chunks encrypted one by one. You can read its code, or send a transfer with it and watch what goes over the wire.
For a full description of the architecture and its limits, the white paper is freely available. And if the difference between "encrypted" and "end-to-end encrypted" feels fuzzy, we covered it in a dedicated article.