[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"BLOG_POST_en_anatomy-of-an-encrypted-upload":3,"BLOG_SURROUND_en_anatomy-of-an-encrypted-upload":1824},{"id":4,"title":5,"authors":6,"body":9,"category":1805,"date":1806,"description":1807,"extension":1808,"image":1809,"meta":1810,"navigation":271,"path":1811,"seo":1812,"sitemap":1816,"stem":1817,"tags":1818,"__hash__":1823},"blog\u002Fblog\u002Fen\u002Fanatomy-of-an-encrypted-upload.md","Anatomy of an encrypted upload in the browser",[7],{"name":8},"Emilien Mantel",{"type":10,"value":11,"toc":1793},"minimark",[12,21,24,29,42,187,202,205,209,212,340,343,346,453,456,460,467,677,680,684,691,1117,1120,1123,1134,1138,1146,1213,1216,1380,1396,1400,1403,1406,1413,1567,1570,1574,1577,1629,1639,1643,1646,1726,1729,1733,1736,1757,1760,1764,1776,1789],[13,14,15,16,20],"p",{},"You drop a 3 GB file on the page, pick a recipient, click \"Send\". The progress bar starts almost right away. In between,\nthe browser has already generated a key pair, encrypted that key for each recipient, encrypted the file name, and started\ncutting the rest into chunks that it encrypts one by one before sending them. The server only ever receives ",[17,18,19],"code",{},".age","\nfiles.",[13,22,23],{},"This article follows that path step by step, with the code that does it. The excerpts come from the Retyc front end\n(Nuxt, TypeScript) and API (FastAPI). They are trimmed for reading, and each one names the file it comes from.",[25,26,28],"h2",{"id":27},"one-key-pair-per-transfer","One key pair per transfer",[13,30,31,32,37,38,41],{},"Everything rests on ",[33,34,36],"external-link",{"url":35},"https:\u002F\u002Fage-encryption.org\u002Fv1","age",", a file encryption format with a public\nspecification, and on its JavaScript implementation, ",[17,39,40],{},"age-encryption",". At the start of each transfer, the browser\ncreates an age identity that will only ever be used for that transfer:",[43,44,50],"pre",{"className":45,"code":46,"filename":47,"language":48,"meta":49,"style":49},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","async function generateSessionIdentity()\n{\n    const {generateHybridIdentity, identityToRecipient} = await loadAge()\n    const identity = await generateHybridIdentity()\n    return {\n        public_key: await identityToRecipient(identity),\n        private_key: identity\n    }\n}\n","app\u002Fshared\u002Futils\u002Fcrypto-core.ts","ts","",[17,51,52,72,78,113,130,139,164,175,181],{"__ignoreMap":49},[53,54,57,61,64,68],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"spNyl","async",[53,62,63],{"class":59}," function",[53,65,67],{"class":66},"s2Zo4"," generateSessionIdentity",[53,69,71],{"class":70},"sMK4o","()\n",[53,73,75],{"class":55,"line":74},2,[53,76,77],{"class":70},"{\n",[53,79,81,84,87,91,94,97,100,103,107,110],{"class":55,"line":80},3,[53,82,83],{"class":59},"    const",[53,85,86],{"class":70}," {",[53,88,90],{"class":89},"sTEyZ","generateHybridIdentity",[53,92,93],{"class":70},",",[53,95,96],{"class":89}," identityToRecipient",[53,98,99],{"class":70},"}",[53,101,102],{"class":70}," =",[53,104,106],{"class":105},"s7zQu"," await",[53,108,109],{"class":66}," loadAge",[53,111,71],{"class":112},"swJcz",[53,114,116,118,121,123,125,128],{"class":55,"line":115},4,[53,117,83],{"class":59},[53,119,120],{"class":89}," identity",[53,122,102],{"class":70},[53,124,106],{"class":105},[53,126,127],{"class":66}," generateHybridIdentity",[53,129,71],{"class":112},[53,131,133,136],{"class":55,"line":132},5,[53,134,135],{"class":105},"    return",[53,137,138],{"class":70}," {\n",[53,140,142,145,148,150,152,155,158,161],{"class":55,"line":141},6,[53,143,144],{"class":112},"        public_key",[53,146,147],{"class":70},":",[53,149,106],{"class":105},[53,151,96],{"class":66},[53,153,154],{"class":112},"(",[53,156,157],{"class":89},"identity",[53,159,160],{"class":112},")",[53,162,163],{"class":70},",\n",[53,165,167,170,172],{"class":55,"line":166},7,[53,168,169],{"class":112},"        private_key",[53,171,147],{"class":70},[53,173,174],{"class":89}," identity\n",[53,176,178],{"class":55,"line":177},8,[53,179,180],{"class":70},"    }\n",[53,182,184],{"class":55,"line":183},9,[53,185,186],{"class":70},"}\n",[13,188,189,192,193,197,198,201],{},[17,190,191],{},"generateHybridIdentity()"," produces a ",[194,195,196],"strong",{},"hybrid"," pair: it combines X25519, the classic elliptic-curve key exchange, with\nML-KEM-768, the encapsulation mechanism standardized by NIST to withstand quantum computers. To read a file, you have to\nbreak both. The resulting public key starts with ",[17,199,200],{},"age1pq1"," and runs to over a thousand characters, against about sixty\nfor a classic age key. That prefix will come up again later.",[13,203,204],{},"Why a key per transfer rather than the sender's own key? Because a transfer has several readers (you, your recipients,\nsometimes a passphrase), and we only want to encrypt those 3 GB once. The session key encrypts the content. The rest of\nthe work is about handing that key out.",[25,206,208],{"id":207},"wrapping-the-key-for-each-reader","Wrapping the key for each reader",[13,210,211],{},"This is envelope encryption. The data is encrypted with one key, and that key is then encrypted for each authorized\nperson. In our code, it takes a few lines:",[43,213,216],{"className":45,"code":214,"filename":215,"language":48,"meta":49,"style":49},"\u002F\u002F You keep access to your own transfer\nif (encryptWithMyKey) {\n    public_keys.push(keyPair.value.public_key)\n}\n\nconst session_identity = await generateSessionIdentity()\n\n\u002F\u002F The session private key, encrypted for every recipient in one go\nconst session_private_key_enc = await encryptStringWithRecipients(\n    session_identity.private_key,\n    public_keys\n)\n","app\u002Fcomposables\u002Fuse-upload-form.ts",[17,217,218,224,234,263,267,273,290,294,299,316,329,335],{"__ignoreMap":49},[53,219,220],{"class":55,"line":56},[53,221,223],{"class":222},"sHwdD","\u002F\u002F You keep access to your own transfer\n",[53,225,226,229,232],{"class":55,"line":74},[53,227,228],{"class":105},"if",[53,230,231],{"class":89}," (encryptWithMyKey) ",[53,233,77],{"class":70},[53,235,236,239,242,245,247,250,252,255,257,260],{"class":55,"line":80},[53,237,238],{"class":89},"    public_keys",[53,240,241],{"class":70},".",[53,243,244],{"class":66},"push",[53,246,154],{"class":112},[53,248,249],{"class":89},"keyPair",[53,251,241],{"class":70},[53,253,254],{"class":89},"value",[53,256,241],{"class":70},[53,258,259],{"class":89},"public_key",[53,261,262],{"class":112},")\n",[53,264,265],{"class":55,"line":115},[53,266,186],{"class":70},[53,268,269],{"class":55,"line":132},[53,270,272],{"emptyLinePlaceholder":271},true,"\n",[53,274,275,278,281,284,286,288],{"class":55,"line":141},[53,276,277],{"class":59},"const",[53,279,280],{"class":89}," session_identity ",[53,282,283],{"class":70},"=",[53,285,106],{"class":105},[53,287,67],{"class":66},[53,289,71],{"class":89},[53,291,292],{"class":55,"line":166},[53,293,272],{"emptyLinePlaceholder":271},[53,295,296],{"class":55,"line":177},[53,297,298],{"class":222},"\u002F\u002F The session private key, encrypted for every recipient in one go\n",[53,300,301,303,306,308,310,313],{"class":55,"line":183},[53,302,277],{"class":59},[53,304,305],{"class":89}," session_private_key_enc ",[53,307,283],{"class":70},[53,309,106],{"class":105},[53,311,312],{"class":66}," encryptStringWithRecipients",[53,314,315],{"class":89},"(\n",[53,317,319,322,324,327],{"class":55,"line":318},10,[53,320,321],{"class":89},"    session_identity",[53,323,241],{"class":70},[53,325,326],{"class":89},"private_key",[53,328,163],{"class":70},[53,330,332],{"class":55,"line":331},11,[53,333,334],{"class":89},"    public_keys\n",[53,336,338],{"class":55,"line":337},12,[53,339,262],{"class":89},[13,341,342],{},"age supports several recipients in a single file: each gets its own entry in the header, and any one of them is enough\nto recover the file key. A recipient with an account therefore opens the envelope with their private key, which their\nbrowser unlocked with their passphrase, without ever sending it in the clear.",[13,344,345],{},"A recipient without an account has no public key. For them, the browser creates a second, ephemeral pair, whose private\nkey is protected by the transfer's passphrase:",[43,347,349],{"className":45,"code":348,"filename":215,"language":48,"meta":49,"style":49},"if (passphrase.length > 0) {\n    \u002F\u002F Ephemeral private key encrypted with the passphrase (scrypt)\n    const ephemeralKeypair = await createAgeIdentityPair(passphrase)\n\n    session_private_key_enc_for_passphrase = await encryptStringWithRecipients(\n        session_identity.private_key,\n        [ephemeralKeypair.public_key]\n    )\n}\n",[17,350,351,375,380,401,405,418,429,444,449],{"__ignoreMap":49},[53,352,353,355,358,360,363,366,370,373],{"class":55,"line":56},[53,354,228],{"class":105},[53,356,357],{"class":89}," (passphrase",[53,359,241],{"class":70},[53,361,362],{"class":89},"length ",[53,364,365],{"class":70},">",[53,367,369],{"class":368},"sbssI"," 0",[53,371,372],{"class":89},") ",[53,374,77],{"class":70},[53,376,377],{"class":55,"line":74},[53,378,379],{"class":222},"    \u002F\u002F Ephemeral private key encrypted with the passphrase (scrypt)\n",[53,381,382,384,387,389,391,394,396,399],{"class":55,"line":80},[53,383,83],{"class":59},[53,385,386],{"class":89}," ephemeralKeypair",[53,388,102],{"class":70},[53,390,106],{"class":105},[53,392,393],{"class":66}," createAgeIdentityPair",[53,395,154],{"class":112},[53,397,398],{"class":89},"passphrase",[53,400,262],{"class":112},[53,402,403],{"class":55,"line":115},[53,404,272],{"emptyLinePlaceholder":271},[53,406,407,410,412,414,416],{"class":55,"line":132},[53,408,409],{"class":89},"    session_private_key_enc_for_passphrase",[53,411,102],{"class":70},[53,413,106],{"class":105},[53,415,312],{"class":66},[53,417,315],{"class":112},[53,419,420,423,425,427],{"class":55,"line":141},[53,421,422],{"class":89},"        session_identity",[53,424,241],{"class":70},[53,426,326],{"class":89},[53,428,163],{"class":70},[53,430,431,434,437,439,441],{"class":55,"line":166},[53,432,433],{"class":112},"        [",[53,435,436],{"class":89},"ephemeralKeypair",[53,438,241],{"class":70},[53,440,259],{"class":89},[53,442,443],{"class":112},"]\n",[53,445,446],{"class":55,"line":177},[53,447,448],{"class":112},"    )\n",[53,450,451],{"class":55,"line":183},[53,452,186],{"class":70},[13,454,455],{},"The passphrase goes through scrypt, a key derivation function that is deliberately slow and memory-hungry. Every guess\nis expensive, even for someone who got hold of the envelope. The passphrase itself never leaves the browser: you pass it\non to your recipient through another channel.",[25,457,459],{"id":458},"file-names-too","File names too",[13,461,462,463,466],{},"Encrypted content under a plaintext name like ",[17,464,465],{},"2026-pentest-application-xyz.pdf"," already gives a lot away. Before\nsending a single byte of content, the browser declares each file to the API with a name and MIME type already encrypted\nfor the session key:",[43,468,471],{"className":45,"code":469,"filename":470,"language":48,"meta":49,"style":49},"\u002F\u002F For a folder, webkitRelativePath gives the full path: \"contracts\u002F2026\u002Famendment.pdf\"\nconst fileName = file.webkitRelativePath || file.name\n\nconst [name_enc, type_enc] = await Promise.all([\n    encryptStringWithRecipients(fileName, [session_public_key]),\n    encryptStringWithRecipients(file.type, [session_public_key])\n])\n\nawait $retyc('\u002Fshare\u002F{share_id}\u002Ffile', {\n    method: 'POST',\n    path: {share_id},\n    body: {name_enc, type_enc, original_size: file.size},\n})\n","app\u002Fcomposables\u002Fuse-share.ts",[17,472,473,478,505,509,543,558,575,580,584,607,624,639,670],{"__ignoreMap":49},[53,474,475],{"class":55,"line":56},[53,476,477],{"class":222},"\u002F\u002F For a folder, webkitRelativePath gives the full path: \"contracts\u002F2026\u002Famendment.pdf\"\n",[53,479,480,482,485,487,490,492,495,498,500,502],{"class":55,"line":74},[53,481,277],{"class":59},[53,483,484],{"class":89}," fileName ",[53,486,283],{"class":70},[53,488,489],{"class":89}," file",[53,491,241],{"class":70},[53,493,494],{"class":89},"webkitRelativePath ",[53,496,497],{"class":70},"||",[53,499,489],{"class":89},[53,501,241],{"class":70},[53,503,504],{"class":89},"name\n",[53,506,507],{"class":55,"line":80},[53,508,272],{"emptyLinePlaceholder":271},[53,510,511,513,516,519,521,524,527,529,531,535,537,540],{"class":55,"line":115},[53,512,277],{"class":59},[53,514,515],{"class":70}," [",[53,517,518],{"class":89},"name_enc",[53,520,93],{"class":70},[53,522,523],{"class":89}," type_enc",[53,525,526],{"class":70},"]",[53,528,102],{"class":70},[53,530,106],{"class":105},[53,532,534],{"class":533},"sBMFI"," Promise",[53,536,241],{"class":70},[53,538,539],{"class":66},"all",[53,541,542],{"class":89},"([\n",[53,544,545,548,551,553,556],{"class":55,"line":132},[53,546,547],{"class":66},"    encryptStringWithRecipients",[53,549,550],{"class":89},"(fileName",[53,552,93],{"class":70},[53,554,555],{"class":89}," [session_public_key])",[53,557,163],{"class":70},[53,559,560,562,565,567,570,572],{"class":55,"line":141},[53,561,547],{"class":66},[53,563,564],{"class":89},"(file",[53,566,241],{"class":70},[53,568,569],{"class":89},"type",[53,571,93],{"class":70},[53,573,574],{"class":89}," [session_public_key])\n",[53,576,577],{"class":55,"line":166},[53,578,579],{"class":89},"])\n",[53,581,582],{"class":55,"line":177},[53,583,272],{"emptyLinePlaceholder":271},[53,585,586,589,592,594,597,601,603,605],{"class":55,"line":183},[53,587,588],{"class":105},"await",[53,590,591],{"class":66}," $retyc",[53,593,154],{"class":89},[53,595,596],{"class":70},"'",[53,598,600],{"class":599},"sfazB","\u002Fshare\u002F{share_id}\u002Ffile",[53,602,596],{"class":70},[53,604,93],{"class":70},[53,606,138],{"class":70},[53,608,609,612,614,617,620,622],{"class":55,"line":318},[53,610,611],{"class":112},"    method",[53,613,147],{"class":70},[53,615,616],{"class":70}," '",[53,618,619],{"class":599},"POST",[53,621,596],{"class":70},[53,623,163],{"class":70},[53,625,626,629,631,633,636],{"class":55,"line":331},[53,627,628],{"class":112},"    path",[53,630,147],{"class":70},[53,632,86],{"class":70},[53,634,635],{"class":89},"share_id",[53,637,638],{"class":70},"},\n",[53,640,641,644,646,648,650,652,654,656,659,661,663,665,668],{"class":55,"line":337},[53,642,643],{"class":112},"    body",[53,645,147],{"class":70},[53,647,86],{"class":70},[53,649,518],{"class":89},[53,651,93],{"class":70},[53,653,523],{"class":89},[53,655,93],{"class":70},[53,657,658],{"class":112}," original_size",[53,660,147],{"class":70},[53,662,489],{"class":89},[53,664,241],{"class":70},[53,666,667],{"class":89},"size",[53,669,638],{"class":70},[53,671,673,675],{"class":55,"line":672},13,[53,674,99],{"class":70},[53,676,262],{"class":89},[13,678,679],{},"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\nis all.",[25,681,683],{"id":682},"_8-mb-chunks-encrypted-one-by-one","8 MB chunks, encrypted one by one",[13,685,686,687,690],{},"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\nwith ",[17,688,689],{},"Blob.slice()",", which reads nothing until asked to. Each chunk is encrypted separately and becomes a complete,\nself-contained age file:",[43,692,695],{"className":45,"code":693,"filename":694,"language":48,"meta":49,"style":49},"const chunkCount = Math.ceil(file.size \u002F CHUNK_SIZE) \u002F\u002F 8 MB by default\n\nfor (let chunkId = 0; chunkId \u003C chunkCount; chunkId++) {\n    tasks.push((async () => {\n        \u002F\u002F Encrypt: only this chunk's 8 MB are read\n        const encryptedBlob = await limitEncrypt(() => {\n            const start = chunkId * CHUNK_SIZE\n            const end = Math.min(start + CHUNK_SIZE, file.size)\n            return encryptWithRecipient(file.slice(start, end), session_public_key)\n        })\n\n        \u002F\u002F Send right away, then let the garbage collector free the blob\n        const formData = new FormData()\n        formData.append('upload_file', encryptedBlob, 'chunk.age')\n        await doUpload(file_info, chunkId, formData, onChunkProgress, abortController.signal)\n    })().catch(err => {\n        cancelled = true\n        abortController.abort() \u002F\u002F cancels requests still in flight\n        throw err\n    }))\n}\n\nawait Promise.all(tasks)\n","app\u002Fcomposables\u002Fuse-upload.ts",[17,696,697,730,734,775,797,802,826,844,881,916,923,927,932,949,984,1023,1047,1059,1076,1085,1093,1098,1103],{"__ignoreMap":49},[53,698,699,701,704,706,709,711,714,716,718,721,724,727],{"class":55,"line":56},[53,700,277],{"class":59},[53,702,703],{"class":89}," chunkCount ",[53,705,283],{"class":70},[53,707,708],{"class":89}," Math",[53,710,241],{"class":70},[53,712,713],{"class":66},"ceil",[53,715,564],{"class":89},[53,717,241],{"class":70},[53,719,720],{"class":89},"size ",[53,722,723],{"class":70},"\u002F",[53,725,726],{"class":89}," CHUNK_SIZE) ",[53,728,729],{"class":222},"\u002F\u002F 8 MB by default\n",[53,731,732],{"class":55,"line":74},[53,733,272],{"emptyLinePlaceholder":271},[53,735,736,739,742,745,748,750,752,755,757,760,763,765,768,771,773],{"class":55,"line":80},[53,737,738],{"class":105},"for",[53,740,741],{"class":89}," (",[53,743,744],{"class":59},"let",[53,746,747],{"class":89}," chunkId ",[53,749,283],{"class":70},[53,751,369],{"class":368},[53,753,754],{"class":70},";",[53,756,747],{"class":89},[53,758,759],{"class":70},"\u003C",[53,761,762],{"class":89}," chunkCount",[53,764,754],{"class":70},[53,766,767],{"class":89}," chunkId",[53,769,770],{"class":70},"++",[53,772,372],{"class":89},[53,774,77],{"class":70},[53,776,777,780,782,784,787,789,792,795],{"class":55,"line":115},[53,778,779],{"class":89},"    tasks",[53,781,241],{"class":70},[53,783,244],{"class":66},[53,785,786],{"class":112},"((",[53,788,60],{"class":59},[53,790,791],{"class":70}," ()",[53,793,794],{"class":59}," =>",[53,796,138],{"class":70},[53,798,799],{"class":55,"line":132},[53,800,801],{"class":222},"        \u002F\u002F Encrypt: only this chunk's 8 MB are read\n",[53,803,804,807,810,812,814,817,819,822,824],{"class":55,"line":141},[53,805,806],{"class":59},"        const",[53,808,809],{"class":89}," encryptedBlob",[53,811,102],{"class":70},[53,813,106],{"class":105},[53,815,816],{"class":66}," limitEncrypt",[53,818,154],{"class":112},[53,820,821],{"class":70},"()",[53,823,794],{"class":59},[53,825,138],{"class":70},[53,827,828,831,834,836,838,841],{"class":55,"line":166},[53,829,830],{"class":59},"            const",[53,832,833],{"class":89}," start",[53,835,102],{"class":70},[53,837,767],{"class":89},[53,839,840],{"class":70}," *",[53,842,843],{"class":89}," CHUNK_SIZE\n",[53,845,846,848,851,853,855,857,860,862,865,868,871,873,875,877,879],{"class":55,"line":177},[53,847,830],{"class":59},[53,849,850],{"class":89}," end",[53,852,102],{"class":70},[53,854,708],{"class":89},[53,856,241],{"class":70},[53,858,859],{"class":66},"min",[53,861,154],{"class":112},[53,863,864],{"class":89},"start",[53,866,867],{"class":70}," +",[53,869,870],{"class":89}," CHUNK_SIZE",[53,872,93],{"class":70},[53,874,489],{"class":89},[53,876,241],{"class":70},[53,878,667],{"class":89},[53,880,262],{"class":112},[53,882,883,886,889,891,894,896,899,901,903,905,907,909,911,914],{"class":55,"line":183},[53,884,885],{"class":105},"            return",[53,887,888],{"class":66}," encryptWithRecipient",[53,890,154],{"class":112},[53,892,893],{"class":89},"file",[53,895,241],{"class":70},[53,897,898],{"class":66},"slice",[53,900,154],{"class":112},[53,902,864],{"class":89},[53,904,93],{"class":70},[53,906,850],{"class":89},[53,908,160],{"class":112},[53,910,93],{"class":70},[53,912,913],{"class":89}," session_public_key",[53,915,262],{"class":112},[53,917,918,921],{"class":55,"line":318},[53,919,920],{"class":70},"        }",[53,922,262],{"class":112},[53,924,925],{"class":55,"line":331},[53,926,272],{"emptyLinePlaceholder":271},[53,928,929],{"class":55,"line":337},[53,930,931],{"class":222},"        \u002F\u002F Send right away, then let the garbage collector free the blob\n",[53,933,934,936,939,941,944,947],{"class":55,"line":672},[53,935,806],{"class":59},[53,937,938],{"class":89}," formData",[53,940,102],{"class":70},[53,942,943],{"class":70}," new",[53,945,946],{"class":66}," FormData",[53,948,71],{"class":112},[53,950,952,955,957,960,962,964,967,969,971,973,975,977,980,982],{"class":55,"line":951},14,[53,953,954],{"class":89},"        formData",[53,956,241],{"class":70},[53,958,959],{"class":66},"append",[53,961,154],{"class":112},[53,963,596],{"class":70},[53,965,966],{"class":599},"upload_file",[53,968,596],{"class":70},[53,970,93],{"class":70},[53,972,809],{"class":89},[53,974,93],{"class":70},[53,976,616],{"class":70},[53,978,979],{"class":599},"chunk.age",[53,981,596],{"class":70},[53,983,262],{"class":112},[53,985,987,990,993,995,998,1000,1002,1004,1006,1008,1011,1013,1016,1018,1021],{"class":55,"line":986},15,[53,988,989],{"class":105},"        await",[53,991,992],{"class":66}," doUpload",[53,994,154],{"class":112},[53,996,997],{"class":89},"file_info",[53,999,93],{"class":70},[53,1001,767],{"class":89},[53,1003,93],{"class":70},[53,1005,938],{"class":89},[53,1007,93],{"class":70},[53,1009,1010],{"class":89}," onChunkProgress",[53,1012,93],{"class":70},[53,1014,1015],{"class":89}," abortController",[53,1017,241],{"class":70},[53,1019,1020],{"class":89},"signal",[53,1022,262],{"class":112},[53,1024,1026,1029,1032,1034,1037,1039,1043,1045],{"class":55,"line":1025},16,[53,1027,1028],{"class":70},"    }",[53,1030,1031],{"class":112},")()",[53,1033,241],{"class":70},[53,1035,1036],{"class":66},"catch",[53,1038,154],{"class":112},[53,1040,1042],{"class":1041},"sHdIc","err",[53,1044,794],{"class":59},[53,1046,138],{"class":70},[53,1048,1050,1053,1055],{"class":55,"line":1049},17,[53,1051,1052],{"class":89},"        cancelled",[53,1054,102],{"class":70},[53,1056,1058],{"class":1057},"sfNiH"," true\n",[53,1060,1062,1065,1067,1070,1073],{"class":55,"line":1061},18,[53,1063,1064],{"class":89},"        abortController",[53,1066,241],{"class":70},[53,1068,1069],{"class":66},"abort",[53,1071,1072],{"class":112},"() ",[53,1074,1075],{"class":222},"\u002F\u002F cancels requests still in flight\n",[53,1077,1079,1082],{"class":55,"line":1078},19,[53,1080,1081],{"class":105},"        throw",[53,1083,1084],{"class":89}," err\n",[53,1086,1088,1090],{"class":55,"line":1087},20,[53,1089,1028],{"class":70},[53,1091,1092],{"class":112},"))\n",[53,1094,1096],{"class":55,"line":1095},21,[53,1097,186],{"class":70},[53,1099,1101],{"class":55,"line":1100},22,[53,1102,272],{"emptyLinePlaceholder":271},[53,1104,1106,1108,1110,1112,1114],{"class":55,"line":1105},23,[53,1107,588],{"class":105},[53,1109,534],{"class":533},[53,1111,241],{"class":70},[53,1113,539],{"class":66},[53,1115,1116],{"class":89},"(tasks)\n",[13,1118,1119],{},"Encrypting each chunk on its own has three benefits. Memory stays bounded: a few chunks in flight, never the whole file.\nChunks are encrypted and sent in parallel. And a failed chunk is resent on its own, without restarting the transfer from\nscratch. The cost is one age header per chunk, a few kilobytes out of 8 MB, which is negligible.",[13,1121,1122],{},"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\nkept as a blob until its upload completes. While it's being encrypted, a chunk costs roughly twice its size, once in\nplaintext and once encrypted. With 8 encryptions in parallel, that comes to around 128 MB, which an ordinary laptop\nhandles without breaking a sweat. The server has the same constraint: it keeps each chunk it receives in a buffer while\nwriting it to object storage, and those buffers multiply with the number of concurrent uploads across all users. Bigger\nchunks would push memory up on both sides. Smaller chunks would multiply requests and headers for nothing. 8 MB is the\ntrade-off we settled on.",[13,1124,1125,1126,1129,1130,1133],{},"Error handling matters as much as the rest. If a chunk fails for good, the ",[17,1127,1128],{},"cancelled"," flag stops the next ones from\nstarting, and the ",[17,1131,1132],{},"AbortController"," interrupts those already on their way. Without it, a 3 GB file whose second chunk\nfails would keep sending hundreds of others for nothing.",[25,1135,1137],{"id":1136},"off-the-main-thread","Off the main thread",[13,1139,1140,1141,1145],{},"Encrypting 8 MB isn't instant, and a browser encrypting on the main thread stops responding: frozen progress bar,\nignored clicks. So encryption runs in a Web Worker.\n",[33,1142,1144],{"url":1143},"https:\u002F\u002Fgithub.com\u002FGoogleChromeLabs\u002Fcomlink","Comlink"," saves us from hand-writing the messaging\nprotocol between the page and the worker. On the worker side, exposing the object is all it takes:",[43,1147,1150],{"className":45,"code":1148,"filename":1149,"language":48,"meta":49,"style":49},"import * as Comlink from 'comlink'\nimport {cryptoCore} from '#shared\u002Futils\u002Fcrypto-core'\n\nComlink.expose(cryptoCore)\n","app\u002Fworkers\u002Fcrypto.worker.ts",[17,1151,1152,1176,1197,1201],{"__ignoreMap":49},[53,1153,1154,1157,1159,1162,1165,1168,1170,1173],{"class":55,"line":56},[53,1155,1156],{"class":105},"import",[53,1158,840],{"class":70},[53,1160,1161],{"class":105}," as",[53,1163,1164],{"class":89}," Comlink ",[53,1166,1167],{"class":105},"from",[53,1169,616],{"class":70},[53,1171,1172],{"class":599},"comlink",[53,1174,1175],{"class":70},"'\n",[53,1177,1178,1180,1182,1185,1187,1190,1192,1195],{"class":55,"line":74},[53,1179,1156],{"class":105},[53,1181,86],{"class":70},[53,1183,1184],{"class":89},"cryptoCore",[53,1186,99],{"class":70},[53,1188,1189],{"class":105}," from",[53,1191,616],{"class":70},[53,1193,1194],{"class":599},"#shared\u002Futils\u002Fcrypto-core",[53,1196,1175],{"class":70},[53,1198,1199],{"class":55,"line":80},[53,1200,272],{"emptyLinePlaceholder":271},[53,1202,1203,1205,1207,1210],{"class":55,"line":115},[53,1204,1144],{"class":89},[53,1206,241],{"class":70},[53,1208,1209],{"class":66},"expose",[53,1211,1212],{"class":89},"(cryptoCore)\n",[13,1214,1215],{},"On the page side, the worker's functions are called like ordinary async functions. One detail makes the difference:",[43,1217,1220],{"className":45,"code":1218,"filename":1219,"language":48,"meta":49,"style":49},"const encryptWithRecipient = async (input: Blob, recipient: string): Promise\u003CBlob> => {\n    const data = new Uint8Array(await input.arrayBuffer())\n    const encrypted = await proxy.encryptChunkWithRecipient(\n        Comlink.transfer(data, [data.buffer]),\n        recipient\n    )\n    return new Blob([encrypted])\n}\n","app\u002Fcomposables\u002Fuse-age-crypto.ts",[17,1221,1222,1270,1299,1320,1351,1356,1360,1376],{"__ignoreMap":49},[53,1223,1224,1226,1229,1231,1234,1236,1239,1241,1244,1246,1249,1251,1254,1257,1259,1261,1264,1266,1268],{"class":55,"line":56},[53,1225,277],{"class":59},[53,1227,1228],{"class":89}," encryptWithRecipient ",[53,1230,283],{"class":70},[53,1232,1233],{"class":59}," async",[53,1235,741],{"class":70},[53,1237,1238],{"class":1041},"input",[53,1240,147],{"class":70},[53,1242,1243],{"class":533}," Blob",[53,1245,93],{"class":70},[53,1247,1248],{"class":1041}," recipient",[53,1250,147],{"class":70},[53,1252,1253],{"class":533}," string",[53,1255,1256],{"class":70},"):",[53,1258,534],{"class":533},[53,1260,759],{"class":70},[53,1262,1263],{"class":533},"Blob",[53,1265,365],{"class":70},[53,1267,794],{"class":59},[53,1269,138],{"class":70},[53,1271,1272,1274,1277,1279,1281,1284,1286,1288,1291,1293,1296],{"class":55,"line":74},[53,1273,83],{"class":59},[53,1275,1276],{"class":89}," data",[53,1278,102],{"class":70},[53,1280,943],{"class":70},[53,1282,1283],{"class":66}," Uint8Array",[53,1285,154],{"class":112},[53,1287,588],{"class":105},[53,1289,1290],{"class":89}," input",[53,1292,241],{"class":70},[53,1294,1295],{"class":66},"arrayBuffer",[53,1297,1298],{"class":112},"())\n",[53,1300,1301,1303,1306,1308,1310,1313,1315,1318],{"class":55,"line":80},[53,1302,83],{"class":59},[53,1304,1305],{"class":89}," encrypted",[53,1307,102],{"class":70},[53,1309,106],{"class":105},[53,1311,1312],{"class":89}," proxy",[53,1314,241],{"class":70},[53,1316,1317],{"class":66},"encryptChunkWithRecipient",[53,1319,315],{"class":112},[53,1321,1322,1325,1327,1330,1332,1335,1337,1339,1341,1343,1346,1349],{"class":55,"line":115},[53,1323,1324],{"class":89},"        Comlink",[53,1326,241],{"class":70},[53,1328,1329],{"class":66},"transfer",[53,1331,154],{"class":112},[53,1333,1334],{"class":89},"data",[53,1336,93],{"class":70},[53,1338,515],{"class":112},[53,1340,1334],{"class":89},[53,1342,241],{"class":70},[53,1344,1345],{"class":89},"buffer",[53,1347,1348],{"class":112},"])",[53,1350,163],{"class":70},[53,1352,1353],{"class":55,"line":132},[53,1354,1355],{"class":89},"        recipient\n",[53,1357,1358],{"class":55,"line":141},[53,1359,448],{"class":112},[53,1361,1362,1364,1366,1368,1371,1374],{"class":55,"line":166},[53,1363,135],{"class":105},[53,1365,943],{"class":70},[53,1367,1243],{"class":66},[53,1369,1370],{"class":112},"([",[53,1372,1373],{"class":89},"encrypted",[53,1375,579],{"class":112},[53,1377,1378],{"class":55,"line":177},[53,1379,186],{"class":70},[13,1381,1382,1383,1386,1387,1390,1391,1395],{},"By default, ",[17,1384,1385],{},"postMessage"," copies the data it sends to the worker. ",[17,1388,1389],{},"Comlink.transfer()"," marks the buffer as\n",[1392,1393,1394],"em",{},"transferable",": ownership moves to the worker without a copy, and the page can no longer touch it. On a 3 GB file, that\nis close to 400 fewer 8 MB copies.",[25,1397,1399],{"id":1398},"the-network","The network",[13,1401,1402],{},"Sending several chunks in parallel speeds up the upload on a good connection. On a slow one, it does the opposite.\nUploads share the bandwidth, and each chunk takes that much longer to get through. And every upload has a time limit:\nafter 119 seconds, the request is dropped.",[13,1404,1405],{},"Take a connection with 1 Mbit\u002Fs of upload bandwidth, which you still find on ADSL or a congested 4G link. A single 8 MB\nchunk gets through in a little over a minute. Eight chunks in parallel share the same bandwidth, and each would take more\nthan eight minutes: they would all fail after two minutes, and the upload would never finish. On fiber, on the other\nhand, one chunk at a time would leave most of the bandwidth unused.",[13,1407,1408,1409,1412],{},"So there is no good fixed setting. Retyc adapts to the connection. Encryption goes through ",[17,1410,1411],{},"p-limit",", at most 8 chunks at\na time. Uploads go through an adaptive semaphore, which starts cautiously with a single chunk in flight. Each time a\nchunk completes, it measures the throughput, smooths it with an exponentially weighted moving average (EWMA), and works\nout how many chunks to send in parallel so that each takes about 5 seconds, well clear of the time limit:",[43,1414,1416],{"className":45,"code":1415,"filename":694,"language":48,"meta":49,"style":49},"const speed = (estimatedByteSize \u002F durationSeconds) * concurrencySnapshot\newmaSpeed = ewmaSpeed === null\n    ? speed\n    : ADAPTIVE_EWMA_ALPHA * speed + (1 - ADAPTIVE_EWMA_ALPHA) * ewmaSpeed \u002F\u002F alpha = 0.3\n\nconst rawTarget = (ewmaSpeed * ADAPTIVE_TARGET_SECONDS) \u002F chunkSize \u002F\u002F target: 5 s per chunk\n\u002F\u002F +2 at most per measurement, so a slow connection doesn't get swamped\nsemaphore.setTarget(Math.min(rawTarget, semaphore.target + ADAPTIVE_RAMP_UP_STEP))\n",[17,1417,1418,1441,1457,1465,1498,1502,1527,1532],{"__ignoreMap":49},[53,1419,1420,1422,1425,1427,1430,1432,1435,1438],{"class":55,"line":56},[53,1421,277],{"class":59},[53,1423,1424],{"class":89}," speed ",[53,1426,283],{"class":70},[53,1428,1429],{"class":89}," (estimatedByteSize ",[53,1431,723],{"class":70},[53,1433,1434],{"class":89}," durationSeconds) ",[53,1436,1437],{"class":70},"*",[53,1439,1440],{"class":89}," concurrencySnapshot\n",[53,1442,1443,1446,1448,1451,1454],{"class":55,"line":74},[53,1444,1445],{"class":89},"ewmaSpeed ",[53,1447,283],{"class":70},[53,1449,1450],{"class":89}," ewmaSpeed ",[53,1452,1453],{"class":70},"===",[53,1455,1456],{"class":70}," null\n",[53,1458,1459,1462],{"class":55,"line":80},[53,1460,1461],{"class":70},"    ?",[53,1463,1464],{"class":89}," speed\n",[53,1466,1467,1470,1473,1475,1477,1480,1482,1485,1488,1491,1493,1495],{"class":55,"line":115},[53,1468,1469],{"class":70},"    :",[53,1471,1472],{"class":89}," ADAPTIVE_EWMA_ALPHA ",[53,1474,1437],{"class":70},[53,1476,1424],{"class":89},[53,1478,1479],{"class":70},"+",[53,1481,741],{"class":89},[53,1483,1484],{"class":368},"1",[53,1486,1487],{"class":70}," -",[53,1489,1490],{"class":89}," ADAPTIVE_EWMA_ALPHA) ",[53,1492,1437],{"class":70},[53,1494,1450],{"class":89},[53,1496,1497],{"class":222},"\u002F\u002F alpha = 0.3\n",[53,1499,1500],{"class":55,"line":132},[53,1501,272],{"emptyLinePlaceholder":271},[53,1503,1504,1506,1509,1511,1514,1516,1519,1521,1524],{"class":55,"line":141},[53,1505,277],{"class":59},[53,1507,1508],{"class":89}," rawTarget ",[53,1510,283],{"class":70},[53,1512,1513],{"class":89}," (ewmaSpeed ",[53,1515,1437],{"class":70},[53,1517,1518],{"class":89}," ADAPTIVE_TARGET_SECONDS) ",[53,1520,723],{"class":70},[53,1522,1523],{"class":89}," chunkSize ",[53,1525,1526],{"class":222},"\u002F\u002F target: 5 s per chunk\n",[53,1528,1529],{"class":55,"line":166},[53,1530,1531],{"class":222},"\u002F\u002F +2 at most per measurement, so a slow connection doesn't get swamped\n",[53,1533,1534,1537,1539,1542,1545,1547,1549,1552,1554,1557,1559,1562,1564],{"class":55,"line":177},[53,1535,1536],{"class":89},"semaphore",[53,1538,241],{"class":70},[53,1540,1541],{"class":66},"setTarget",[53,1543,1544],{"class":89},"(Math",[53,1546,241],{"class":70},[53,1548,859],{"class":66},[53,1550,1551],{"class":89},"(rawTarget",[53,1553,93],{"class":70},[53,1555,1556],{"class":89}," semaphore",[53,1558,241],{"class":70},[53,1560,1561],{"class":89},"target ",[53,1563,1479],{"class":70},[53,1565,1566],{"class":89}," ADAPTIVE_RAMP_UP_STEP))\n",[13,1568,1569],{},"The ramp-up is deliberately slow, two more chunks per measurement at most, and the target is capped at 8. Fiber reaches\nthe cap quickly, a slow connection stays at a single chunk in flight, and nobody has anything to tune. The cap also\nprotects server memory: even on the best connection, an upload never takes up more than 8 receive buffers at once. The\ngoal is to go as fast as the connection allows, without ever failing on a slow one, and without spending more memory\nthan needed, either in the browser or on our servers.",[25,1571,1573],{"id":1572},"server-side-what-the-api-accepts-and-what-it-refuses","Server side: what the API accepts and what it refuses",[13,1575,1576],{},"The API decrypts nothing, since it holds no key. It checks quotas and writes each chunk as is to object storage. It still\nhas a job to do, though: refuse anything that would weaken the scheme. Every public key entering the system goes through\nthis Pydantic type:",[43,1578,1583],{"className":1579,"code":1580,"filename":1581,"language":1582,"meta":49,"style":49},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","class AgePublicKey(str):\n    \"\"\"Age public key, hybrid post-quantum recipients only (`age1pq1...`).\n\n    Classic X25519 recipients (`age1...`) are valid age keys but are refused\n    everywhere: every identity in the product is generated as a hybrid\n    post-quantum pair, and a classic key slipping in would silently weaken\n    the post-quantum guarantee of whatever it protects.\n    \"\"\"\n    REGEX_PATTERN = r\"^age1pq1[0-9a-z]{1000,2500}$\"\n","app\u002Fmodel\u002Ftypes\u002Fage_public_key.py","python",[17,1584,1585,1590,1595,1599,1604,1609,1614,1619,1624],{"__ignoreMap":49},[53,1586,1587],{"class":55,"line":56},[53,1588,1589],{},"class AgePublicKey(str):\n",[53,1591,1592],{"class":55,"line":74},[53,1593,1594],{},"    \"\"\"Age public key, hybrid post-quantum recipients only (`age1pq1...`).\n",[53,1596,1597],{"class":55,"line":80},[53,1598,272],{"emptyLinePlaceholder":271},[53,1600,1601],{"class":55,"line":115},[53,1602,1603],{},"    Classic X25519 recipients (`age1...`) are valid age keys but are refused\n",[53,1605,1606],{"class":55,"line":132},[53,1607,1608],{},"    everywhere: every identity in the product is generated as a hybrid\n",[53,1610,1611],{"class":55,"line":141},[53,1612,1613],{},"    post-quantum pair, and a classic key slipping in would silently weaken\n",[53,1615,1616],{"class":55,"line":166},[53,1617,1618],{},"    the post-quantum guarantee of whatever it protects.\n",[53,1620,1621],{"class":55,"line":177},[53,1622,1623],{},"    \"\"\"\n",[53,1625,1626],{"class":55,"line":183},[53,1627,1628],{},"    REGEX_PATTERN = r\"^age1pq1[0-9a-z]{1000,2500}$\"\n",[13,1630,1631,1632,1635,1636,1638],{},"The docstring says it all. A classic age key is perfectly valid. age itself actually refuses to mix a classic key and a\npost-quantum key in the same file, thanks to a ",[17,1633,1634],{},"postquantum"," label carried by hybrid keys. But it would only take one\nclassic key getting into the system, say as a user's key, for everything encrypted to it to be protected by X25519\nalone, and to become readable the day a quantum computer can break it. So the rule is enforced at the door: a key that\ndoesn't start with ",[17,1637,200],{}," is refused as soon as the request is validated, before it reaches any service.",[25,1640,1642],{"id":1641},"what-we-still-see","What we still see",[13,1644,1645],{},"End-to-end encryption doesn't make everything invisible, and it's better to say so. Here is what reaches us, and in what\nform:",[1647,1648,1649,1662],"table",{},[1650,1651,1652],"thead",{},[1653,1654,1655,1659],"tr",{},[1656,1657,1658],"th",{},"Data",[1656,1660,1661],{},"What the server receives",[1663,1664,1665,1674,1681,1688,1696,1704,1711,1719],"tbody",{},[1653,1666,1667,1671],{},[1668,1669,1670],"td",{},"File contents",[1668,1672,1673],{},"Encrypted",[1653,1675,1676,1679],{},[1668,1677,1678],{},"File names, paths and types",[1668,1680,1673],{},[1653,1682,1683,1686],{},[1668,1684,1685],{},"Message attached to the transfer",[1668,1687,1673],{},[1653,1689,1690,1693],{},[1668,1691,1692],{},"Session key",[1668,1694,1695],{},"Encrypted for each recipient",[1653,1697,1698,1701],{},[1668,1699,1700],{},"File sizes, number of chunks",[1668,1702,1703],{},"In the clear",[1653,1705,1706,1709],{},[1668,1707,1708],{},"Transfer title",[1668,1710,1703],{},[1653,1712,1713,1716],{},[1668,1714,1715],{},"Recipients' email addresses",[1668,1717,1718],{},"In the clear, to send them the notification",[1653,1720,1721,1724],{},[1668,1722,1723],{},"Sender's account, dates, IP addresses",[1668,1725,1703],{},[13,1727,1728],{},"The title and the addresses stay readable because the service needs them to work: listing your transfers, notifying your\nrecipients by email. If the title feels sensitive, leave it empty, or put the information in the message, which is\nencrypted.",[25,1730,1732],{"id":1731},"bonus-changing-the-lock-without-re-encrypting-everything","Bonus: changing the lock without re-encrypting everything",[13,1734,1735],{},"The session key has one last, less visible benefit. When a user rotates their personal key, their old transfers must\nbecome readable with the new one. Re-encrypting hundreds of gigabytes would be slow and costly. Re-encrypting the\nenvelope is enough: the browser opens the session key with the old key, encrypts it with the new one, and sends the\nresult.",[43,1737,1740],{"className":1579,"code":1738,"filename":1739,"language":1582,"meta":49,"style":49},"@router.put(\"\u002Fshare\u002F{share_id}\u002Frekey\", status_code=status.HTTP_204_NO_CONTENT, operation_id=\"rekeyShare\")\ndef rekey_share(share: SharePrivateDepend, data: ShareRekeyRequest, session: SessionDep, logger: LoggerDep):\n    svc_rekey_share(share, data.session_private_key_enc, session, logger)\n","app\u002Fapi\u002Fprivate\u002Fshare\u002Frouter.py",[17,1741,1742,1747,1752],{"__ignoreMap":49},[53,1743,1744],{"class":55,"line":56},[53,1745,1746],{},"@router.put(\"\u002Fshare\u002F{share_id}\u002Frekey\", status_code=status.HTTP_204_NO_CONTENT, operation_id=\"rekeyShare\")\n",[53,1748,1749],{"class":55,"line":74},[53,1750,1751],{},"def rekey_share(share: SharePrivateDepend, data: ShareRekeyRequest, session: SessionDep, logger: LoggerDep):\n",[53,1753,1754],{"class":55,"line":80},[53,1755,1756],{},"    svc_rekey_share(share, data.session_private_key_enc, session, logger)\n",[13,1758,1759],{},"The request carries only a few kilobytes, even for a 3 GB transfer. The stored chunks don't move.",[25,1761,1763],{"id":1762},"see-for-yourself","See for yourself",[13,1765,1766,1767,1770,1771,1775],{},"You don't have to take our word for any of this.\nThe ",[33,1768,1769],{"url":35},"age specification"," is public.\nOur ",[33,1772,1774],{"url":1773},"https:\u002F\u002Fgithub.com\u002Fretyc\u002Fretyc-cli","CLI",", released under the MIT license, applies the same\nscheme in Go: hybrid session key, per-recipient envelopes, chunks encrypted one by one. You can read its code, or send a\ntransfer with it and watch what goes over the wire.",[13,1777,1778,1779,1784,1785,241],{},"For a full description of the architecture and its limits, the ",[1780,1781,1783],"a",{"href":1782},"\u002Fresources\u002Fwhite-paper","white paper"," is freely\navailable. And if the difference between \"encrypted\" and \"end-to-end encrypted\" feels fuzzy, we covered it in\n",[1780,1786,1788],{"href":1787},"\u002Fblog\u002Fencrypted-does-not-mean-end-to-end","a dedicated article",[1790,1791,1792],"style",{},"html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}",{"title":49,"searchDepth":74,"depth":74,"links":1794},[1795,1796,1797,1798,1799,1800,1801,1802,1803,1804],{"id":27,"depth":74,"text":28},{"id":207,"depth":74,"text":208},{"id":458,"depth":74,"text":459},{"id":682,"depth":74,"text":683},{"id":1136,"depth":74,"text":1137},{"id":1398,"depth":74,"text":1399},{"id":1572,"depth":74,"text":1573},{"id":1641,"depth":74,"text":1642},{"id":1731,"depth":74,"text":1732},{"id":1762,"depth":74,"text":1763},"Tech","2026-09-25","A post-quantum key pair per transfer, 8 MB chunks encrypted one by one in a Web Worker, an API that refuses classic keys. What the browser does between the moment you drop a file and the moment the last byte leaves, with the code to show for it.","md",null,{},"\u002Fblog\u002Fen\u002Fanatomy-of-an-encrypted-upload",{"title":1813,"description":1814,"ogTitle":5,"ogDescription":1815},"End-to-end encryption in the browser: anatomy of an upload (age, ML-KEM, Web Worker)","How Retyc encrypts a file in the browser before sending it: hybrid ML-KEM-768 + X25519 session key, envelope encryption, chunking, Web Worker and Comlink, server-side validation. With the code.","Post-quantum keys, 8 MB chunks, a Web Worker: what happens before the first byte leaves your machine.",{"loc":1811},"blog\u002Fen\u002Fanatomy-of-an-encrypted-upload",[1819,36,1820,1821,1822],"end-to-end encryption","post-quantum","Web Worker","TypeScript","oRnAG4Eu9vZsarL7WHTfSl92p94ppsx6b_l5Wxh4Nqs",[1809,1825],{"title":1826,"path":1827,"stem":1828,"description":1829,"children":-1},"Sending a file through a free transfer service: what you are really handing over","\u002Fblog\u002Fen\u002Ffile-transfer-without-end-to-end-encryption","blog\u002Fen\u002Ffile-transfer-without-end-to-end-encryption","Drop a file, type an address, click \"Send\". Consumer file transfer services have become a reflex at work. Without end-to-end encryption, they see everything that goes through them. Five professional situations to understand what that means."]