[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"BLOG_POST_en_receiving-an-encrypted-transfer":3,"BLOG_SURROUND_en_receiving-an-encrypted-transfer":1398},{"id":4,"title":5,"authors":6,"body":9,"category":1378,"date":1379,"description":1380,"extension":1381,"image":1382,"meta":1383,"navigation":90,"path":1384,"seo":1385,"sitemap":1389,"stem":1390,"tags":1391,"__hash__":1397},"blog\u002Fblog\u002Fen\u002Freceiving-an-encrypted-transfer.md","Receiving an encrypted transfer: from the link to the file on your disk",[7],{"name":8},"Emilien Mantel",{"type":10,"value":11,"toc":1369},"minimark",[12,16,19,24,27,30,121,124,128,137,140,143,268,271,274,278,281,368,375,379,382,784,787,790,794,797,806,909,912,919,1063,1079,1082,1092,1095,1098,1175,1178,1181,1184,1188,1194,1336,1339,1342,1346,1354,1365],[13,14,15],"p",{},"You get an email: a colleague has sent you a folder. You click the link, type the passphrase they gave you over the\nphone, and the list of files shows up. One click on \"Download all\", and a few minutes later the files are on your disk.",[13,17,18],{},"From the outside, it looks like any other download. But the server that sent you those files can't read them: it holds\nneither their plaintext content, nor their names, nor a single key. Everything that makes them readable happened in your\nbrowser. This article follows that path in order, from the link to the saved file. The excerpts come from the Retyc\nfrontend (Nuxt, TypeScript) and API (FastAPI). They are shortened for readability, and each one names its source file.",[20,21,23],"h2",{"id":22},"the-link-is-not-always-enough","The link is not always enough",[13,25,26],{},"A transfer link ends up travelling: forwarded to a colleague, pasted into a ticket, previewed by a messaging app. For\nencrypted data, that is not a big deal, since nobody can read it without the key. But handing the encrypted content to\nwhoever holds the link is still pointless, and we don't do it when it isn't needed.",[13,28,29],{},"It all depends on how the sender protected their transfer. With a passphrase, the link is open: the recipient doesn't\nneed an account, the passphrase is what protects the files. Without a passphrase, the transfer was only encrypted for\nspecific Retyc accounts, and the API keeps it for those accounts:",[31,32,38],"pre",{"className":33,"code":34,"filename":35,"language":36,"meta":37,"style":37},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","def assert_share_access(share: ShareModel, current_user: Optional[CurrentUserModel]) -> None:\n    \"\"\"A share without passphrase can only be decrypted by its recipients: restrict its\n    metadata and its chunks to the sender and the recipients, so that holding the link\n    is not enough to harvest the encrypted payload.\n    \"\"\"\n    if share.use_passphrase:\n        return\n\n    if current_user is None:\n        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Authentication required\")\n\n    if current_user.id != share.owner_id and current_user.id not in share.recipients_ids:\n        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=\"You are not a recipient of this share\")\n","app\u002Fapi\u002Fprivate\u002Fshare\u002Fdependencies.py","python","",[39,40,41,49,55,61,67,73,79,85,92,98,104,109,115],"code",{"__ignoreMap":37},[42,43,46],"span",{"class":44,"line":45},"line",1,[42,47,48],{},"def assert_share_access(share: ShareModel, current_user: Optional[CurrentUserModel]) -> None:\n",[42,50,52],{"class":44,"line":51},2,[42,53,54],{},"    \"\"\"A share without passphrase can only be decrypted by its recipients: restrict its\n",[42,56,58],{"class":44,"line":57},3,[42,59,60],{},"    metadata and its chunks to the sender and the recipients, so that holding the link\n",[42,62,64],{"class":44,"line":63},4,[42,65,66],{},"    is not enough to harvest the encrypted payload.\n",[42,68,70],{"class":44,"line":69},5,[42,71,72],{},"    \"\"\"\n",[42,74,76],{"class":44,"line":75},6,[42,77,78],{},"    if share.use_passphrase:\n",[42,80,82],{"class":44,"line":81},7,[42,83,84],{},"        return\n",[42,86,88],{"class":44,"line":87},8,[42,89,91],{"emptyLinePlaceholder":90},true,"\n",[42,93,95],{"class":44,"line":94},9,[42,96,97],{},"    if current_user is None:\n",[42,99,101],{"class":44,"line":100},10,[42,102,103],{},"        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Authentication required\")\n",[42,105,107],{"class":44,"line":106},11,[42,108,91],{"emptyLinePlaceholder":90},[42,110,112],{"class":44,"line":111},12,[42,113,114],{},"    if current_user.id != share.owner_id and current_user.id not in share.recipients_ids:\n",[42,116,118],{"class":44,"line":117},13,[42,119,120],{},"        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=\"You are not a recipient of this share\")\n",[13,122,123],{},"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\nsingle encrypted byte leaves our servers. A transfer that has expired or been disabled by its sender no longer answers\nat all, whatever the account.",[20,125,127],{"id":126},"unlocking-the-transfer","Unlocking the transfer",[13,129,130,131,136],{},"Once access is granted, the browser receives the transfer's description: its title, and several encrypted \"envelopes\"\ncontaining the session key, the one used to encrypt the files. We described how these envelopes are built in\n",[132,133,135],"a",{"href":134},"\u002Fblog\u002Fanatomy-of-an-encrypted-upload","the anatomy of an encrypted upload",". On the recipient's side, the job is to open\nthem.",[13,138,139],{},"If you have an account and the transfer is meant for you, your private key, unlocked in the browser when you logged\nin, opens yours directly. There is nothing to type.",[13,141,142],{},"Without an account, the passphrase does the job, in two steps:",[31,144,149],{"className":145,"code":146,"filename":147,"language":148,"meta":37,"style":37},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F Step 1: the passphrase opens an ephemeral private key (scrypt)\nconst ephemeralPrivateKey = await decryptStringWithPassphrase(\n    shareInfo.value.ephemeral_private_key_enc,\n    data.password\n)\n\n\u002F\u002F Step 2: that ephemeral key opens the transfer's session key\nsessionPrivateKey.value = await decryptStringWithIdentity(\n    shareInfo.value.session_private_key_enc_for_passphrase,\n    ephemeralPrivateKey\n)\n","app\u002Fpages\u002Ftransfer\u002F[slug].vue","ts",[39,150,151,157,182,201,211,216,220,225,244,259,264],{"__ignoreMap":37},[42,152,153],{"class":44,"line":45},[42,154,156],{"class":155},"sHwdD","\u002F\u002F Step 1: the passphrase opens an ephemeral private key (scrypt)\n",[42,158,159,163,167,171,175,179],{"class":44,"line":51},[42,160,162],{"class":161},"spNyl","const",[42,164,166],{"class":165},"sTEyZ"," ephemeralPrivateKey ",[42,168,170],{"class":169},"sMK4o","=",[42,172,174],{"class":173},"s7zQu"," await",[42,176,178],{"class":177},"s2Zo4"," decryptStringWithPassphrase",[42,180,181],{"class":165},"(\n",[42,183,184,187,190,193,195,198],{"class":44,"line":57},[42,185,186],{"class":165},"    shareInfo",[42,188,189],{"class":169},".",[42,191,192],{"class":165},"value",[42,194,189],{"class":169},[42,196,197],{"class":165},"ephemeral_private_key_enc",[42,199,200],{"class":169},",\n",[42,202,203,206,208],{"class":44,"line":63},[42,204,205],{"class":165},"    data",[42,207,189],{"class":169},[42,209,210],{"class":165},"password\n",[42,212,213],{"class":44,"line":69},[42,214,215],{"class":165},")\n",[42,217,218],{"class":44,"line":75},[42,219,91],{"emptyLinePlaceholder":90},[42,221,222],{"class":44,"line":81},[42,223,224],{"class":155},"\u002F\u002F Step 2: that ephemeral key opens the transfer's session key\n",[42,226,227,230,232,235,237,239,242],{"class":44,"line":87},[42,228,229],{"class":165},"sessionPrivateKey",[42,231,189],{"class":169},[42,233,234],{"class":165},"value ",[42,236,170],{"class":169},[42,238,174],{"class":173},[42,240,241],{"class":177}," decryptStringWithIdentity",[42,243,181],{"class":165},[42,245,246,248,250,252,254,257],{"class":44,"line":94},[42,247,186],{"class":165},[42,249,189],{"class":169},[42,251,192],{"class":165},[42,253,189],{"class":169},[42,255,256],{"class":165},"session_private_key_enc_for_passphrase",[42,258,200],{"class":169},[42,260,261],{"class":44,"line":100},[42,262,263],{"class":165},"    ephemeralPrivateKey\n",[42,265,266],{"class":44,"line":106},[42,267,215],{"class":165},[13,269,270],{},"The first step deliberately takes a moment: scrypt is designed to make every attempt expensive, and therefore brute\nforce attacks very slow. A wrong passphrase doesn't produce a wrong result but a clean failure, and the page tells you\nso. In every case, the passphrase is never sent to the server: it is checked only by the fact that it opens the\nenvelope, or doesn't.",[13,272,273],{},"The session key you get never leaves the page. It first decrypts the sender's message, if there is one, then the list\nof files.",[20,275,277],{"id":276},"the-list-of-files","The list of files",[13,279,280],{},"For each file, the server stores an encrypted name, a size and a number of chunks. The browser decrypts the names one\nby one to display the list:",[31,282,285],{"className":145,"code":283,"filename":284,"language":148,"meta":37,"style":37},"for await (const item of fullScanFiles(props.shareId)) {\n    const name_dec = await decryptStringWithIdentity(item.name_enc, props.sessionPrivateKey)\n    \u002F\u002F ...\n}\n","app\u002Fcomponents\u002Ftransfer\u002Fmodal\u002FDownloadAll.vue",[39,286,287,319,358,363],{"__ignoreMap":37},[42,288,289,292,294,297,299,302,305,308,311,313,316],{"class":44,"line":45},[42,290,291],{"class":173},"for",[42,293,174],{"class":173},[42,295,296],{"class":165}," (",[42,298,162],{"class":161},[42,300,301],{"class":165}," item ",[42,303,304],{"class":169},"of",[42,306,307],{"class":177}," fullScanFiles",[42,309,310],{"class":165},"(props",[42,312,189],{"class":169},[42,314,315],{"class":165},"shareId)) ",[42,317,318],{"class":169},"{\n",[42,320,321,324,327,330,332,334,338,341,343,346,349,352,354,356],{"class":44,"line":51},[42,322,323],{"class":161},"    const",[42,325,326],{"class":165}," name_dec",[42,328,329],{"class":169}," =",[42,331,174],{"class":173},[42,333,241],{"class":177},[42,335,337],{"class":336},"swJcz","(",[42,339,340],{"class":165},"item",[42,342,189],{"class":169},[42,344,345],{"class":165},"name_enc",[42,347,348],{"class":169},",",[42,350,351],{"class":165}," props",[42,353,189],{"class":169},[42,355,229],{"class":165},[42,357,215],{"class":336},[42,359,360],{"class":44,"line":57},[42,361,362],{"class":155},"    \u002F\u002F ...\n",[42,364,365],{"class":44,"line":63},[42,366,367],{"class":169},"}\n",[13,369,370,371,374],{},"For a folder sent as is, the decrypted name contains its full path, for example ",[39,372,373],{},"contracts\u002F2026\u002Famendment.pdf",". That\nis what will later allow the original tree to be rebuilt.",[20,376,378],{"id":377},"fetching-and-decrypting-the-content","Fetching and decrypting the content",[13,380,381],{},"Each file was split into chunks when it was sent, each one encrypted separately. The browser requests them from the\nAPI, four at a time by default, and decrypts them in a Web Worker as they arrive. Two limits frame this work: the\nnumber of simultaneous downloads, and the number of chunks kept in memory while they wait for their turn to be written:",[31,383,386],{"className":145,"code":384,"filename":385,"language":148,"meta":37,"style":37},"const LOOKAHEAD = 2 * BATCH_SIZE \u002F\u002F chunks held at once, writing included\n\nfunction startNextChunks(): void {\n    while (!failed && next \u003C chunkCount && inFlight \u003C BATCH_SIZE && held.size \u003C LOOKAHEAD) {\n        const chunk_id = next++\n        inFlight++\n        const chunk = doDownload(file_transfer, chunk_id)\n            .then(res => decryptWithIdentity(res, session_private_key))\n        \u002F\u002F A freed slot immediately starts another download\n        chunk.then(() => {\n            inFlight--;\n            startNextChunks()\n        }, () => {\n            inFlight--;\n            failed = true\n        })\n        held.set(chunk_id, chunk)\n    }\n}\n\nstartNextChunks()\nfor (let chunk_id = 0; chunk_id \u003C chunkCount; chunk_id++) {\n    yield await held.get(chunk_id)! \u002F\u002F handed back in order to whoever writes the file\n    held.delete(chunk_id)\n    startNextChunks()\n}\n","app\u002Fcomposables\u002Fuse-download.ts",[39,387,388,410,414,432,487,502,509,532,564,569,587,595,603,615,622,634,642,664,670,675,680,688,727,754,771,779],{"__ignoreMap":37},[42,389,390,392,395,397,401,404,407],{"class":44,"line":45},[42,391,162],{"class":161},[42,393,394],{"class":165}," LOOKAHEAD ",[42,396,170],{"class":169},[42,398,400],{"class":399},"sbssI"," 2",[42,402,403],{"class":169}," *",[42,405,406],{"class":165}," BATCH_SIZE ",[42,408,409],{"class":155},"\u002F\u002F chunks held at once, writing included\n",[42,411,412],{"class":44,"line":51},[42,413,91],{"emptyLinePlaceholder":90},[42,415,416,419,422,425,429],{"class":44,"line":57},[42,417,418],{"class":161},"function",[42,420,421],{"class":177}," startNextChunks",[42,423,424],{"class":169},"():",[42,426,428],{"class":427},"sBMFI"," void",[42,430,431],{"class":169}," {\n",[42,433,434,437,439,442,445,448,451,454,457,459,462,464,467,469,472,474,477,479,482,485],{"class":44,"line":63},[42,435,436],{"class":173},"    while",[42,438,296],{"class":336},[42,440,441],{"class":169},"!",[42,443,444],{"class":165},"failed",[42,446,447],{"class":169}," &&",[42,449,450],{"class":165}," next",[42,452,453],{"class":169}," \u003C",[42,455,456],{"class":165}," chunkCount",[42,458,447],{"class":169},[42,460,461],{"class":165}," inFlight",[42,463,453],{"class":169},[42,465,466],{"class":165}," BATCH_SIZE",[42,468,447],{"class":169},[42,470,471],{"class":165}," held",[42,473,189],{"class":169},[42,475,476],{"class":165},"size",[42,478,453],{"class":169},[42,480,481],{"class":165}," LOOKAHEAD",[42,483,484],{"class":336},") ",[42,486,318],{"class":169},[42,488,489,492,495,497,499],{"class":44,"line":69},[42,490,491],{"class":161},"        const",[42,493,494],{"class":165}," chunk_id",[42,496,329],{"class":169},[42,498,450],{"class":165},[42,500,501],{"class":169},"++\n",[42,503,504,507],{"class":44,"line":75},[42,505,506],{"class":165},"        inFlight",[42,508,501],{"class":169},[42,510,511,513,516,518,521,523,526,528,530],{"class":44,"line":81},[42,512,491],{"class":161},[42,514,515],{"class":165}," chunk",[42,517,329],{"class":169},[42,519,520],{"class":177}," doDownload",[42,522,337],{"class":336},[42,524,525],{"class":165},"file_transfer",[42,527,348],{"class":169},[42,529,494],{"class":165},[42,531,215],{"class":336},[42,533,534,537,540,542,546,549,552,554,556,558,561],{"class":44,"line":87},[42,535,536],{"class":169},"            .",[42,538,539],{"class":177},"then",[42,541,337],{"class":336},[42,543,545],{"class":544},"sHdIc","res",[42,547,548],{"class":161}," =>",[42,550,551],{"class":177}," decryptWithIdentity",[42,553,337],{"class":336},[42,555,545],{"class":165},[42,557,348],{"class":169},[42,559,560],{"class":165}," session_private_key",[42,562,563],{"class":336},"))\n",[42,565,566],{"class":44,"line":94},[42,567,568],{"class":155},"        \u002F\u002F A freed slot immediately starts another download\n",[42,570,571,574,576,578,580,583,585],{"class":44,"line":100},[42,572,573],{"class":165},"        chunk",[42,575,189],{"class":169},[42,577,539],{"class":177},[42,579,337],{"class":336},[42,581,582],{"class":169},"()",[42,584,548],{"class":161},[42,586,431],{"class":169},[42,588,589,592],{"class":44,"line":106},[42,590,591],{"class":165},"            inFlight",[42,593,594],{"class":169},"--;\n",[42,596,597,600],{"class":44,"line":111},[42,598,599],{"class":177},"            startNextChunks",[42,601,602],{"class":336},"()\n",[42,604,605,608,611,613],{"class":44,"line":117},[42,606,607],{"class":169},"        },",[42,609,610],{"class":169}," ()",[42,612,548],{"class":161},[42,614,431],{"class":169},[42,616,618,620],{"class":44,"line":617},14,[42,619,591],{"class":165},[42,621,594],{"class":169},[42,623,625,628,630],{"class":44,"line":624},15,[42,626,627],{"class":165},"            failed",[42,629,329],{"class":169},[42,631,633],{"class":632},"sfNiH"," true\n",[42,635,637,640],{"class":44,"line":636},16,[42,638,639],{"class":169},"        }",[42,641,215],{"class":336},[42,643,645,648,650,653,655,658,660,662],{"class":44,"line":644},17,[42,646,647],{"class":165},"        held",[42,649,189],{"class":169},[42,651,652],{"class":177},"set",[42,654,337],{"class":336},[42,656,657],{"class":165},"chunk_id",[42,659,348],{"class":169},[42,661,515],{"class":165},[42,663,215],{"class":336},[42,665,667],{"class":44,"line":666},18,[42,668,669],{"class":169},"    }\n",[42,671,673],{"class":44,"line":672},19,[42,674,367],{"class":169},[42,676,678],{"class":44,"line":677},20,[42,679,91],{"emptyLinePlaceholder":90},[42,681,683,686],{"class":44,"line":682},21,[42,684,685],{"class":177},"startNextChunks",[42,687,602],{"class":165},[42,689,691,693,695,698,701,703,706,709,711,714,716,718,720,723,725],{"class":44,"line":690},22,[42,692,291],{"class":173},[42,694,296],{"class":165},[42,696,697],{"class":161},"let",[42,699,700],{"class":165}," chunk_id ",[42,702,170],{"class":169},[42,704,705],{"class":399}," 0",[42,707,708],{"class":169},";",[42,710,700],{"class":165},[42,712,713],{"class":169},"\u003C",[42,715,456],{"class":165},[42,717,708],{"class":169},[42,719,494],{"class":165},[42,721,722],{"class":169},"++",[42,724,484],{"class":165},[42,726,318],{"class":169},[42,728,730,733,735,737,739,742,744,746,749,751],{"class":44,"line":729},23,[42,731,732],{"class":173},"    yield",[42,734,174],{"class":173},[42,736,471],{"class":165},[42,738,189],{"class":169},[42,740,741],{"class":177},"get",[42,743,337],{"class":336},[42,745,657],{"class":165},[42,747,748],{"class":336},")",[42,750,441],{"class":169},[42,752,753],{"class":155}," \u002F\u002F handed back in order to whoever writes the file\n",[42,755,757,760,762,765,767,769],{"class":44,"line":756},24,[42,758,759],{"class":165},"    held",[42,761,189],{"class":169},[42,763,764],{"class":177},"delete",[42,766,337],{"class":336},[42,768,657],{"class":165},[42,770,215],{"class":336},[42,772,774,777],{"class":44,"line":773},25,[42,775,776],{"class":177},"    startNextChunks",[42,778,602],{"class":336},[42,780,782],{"class":44,"line":781},26,[42,783,367],{"class":169},[13,785,786],{},"A slow chunk doesn't hold the others back: while we wait for it, freed slots keep downloading the next ones, which wait\ndecrypted in memory. That head start has a ceiling, though, so that the memory used never depends on the size of the\nfile.",[13,788,789],{},"Decryption also checks integrity. age authenticates the data it encrypts: if a chunk has been tampered with, whether in\nstorage, in transit or by someone with access to our servers, decryption fails instead of producing corrupted data. A\nfile you get is therefore exactly the one that was sent, or nothing.",[20,791,793],{"id":792},"saving-where-browsers-differ","Saving: where browsers differ",[13,795,796],{},"What's left is writing the result to your disk. This is the only step where your browser really changes the\nexperience, and it is worth a closer look.",[13,798,799,800,805],{},"With an ordinary download, the server sends the file and the browser saves it on its own. Here, the file only exists in\nplaintext inside the page, and a web page normally has no right to write to your disk. There are two cases, depending\non whether the browser offers the\n",[801,802,804],"external-link",{"url":803},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FFile_System_API","File System API",":",[31,807,809],{"className":145,"code":808,"filename":385,"language":148,"meta":37,"style":37},"const supportsFSAccess = computed\u003Cboolean>(() => {\n    try {\n        return 'showDirectoryPicker' in window && 'showSaveFilePicker' in window\n    } catch {\n        return false\n    }\n})\n",[39,810,811,839,846,881,891,898,902],{"__ignoreMap":37},[42,812,813,815,818,820,823,825,828,831,833,835,837],{"class":44,"line":45},[42,814,162],{"class":161},[42,816,817],{"class":165}," supportsFSAccess ",[42,819,170],{"class":169},[42,821,822],{"class":177}," computed",[42,824,713],{"class":169},[42,826,827],{"class":427},"boolean",[42,829,830],{"class":169},">",[42,832,337],{"class":165},[42,834,582],{"class":169},[42,836,548],{"class":161},[42,838,431],{"class":169},[42,840,841,844],{"class":44,"line":51},[42,842,843],{"class":173},"    try",[42,845,431],{"class":169},[42,847,848,851,854,858,861,864,867,869,871,874,876,878],{"class":44,"line":57},[42,849,850],{"class":173},"        return",[42,852,853],{"class":169}," '",[42,855,857],{"class":856},"sfazB","showDirectoryPicker",[42,859,860],{"class":169},"'",[42,862,863],{"class":169}," in",[42,865,866],{"class":165}," window",[42,868,447],{"class":169},[42,870,853],{"class":169},[42,872,873],{"class":856},"showSaveFilePicker",[42,875,860],{"class":169},[42,877,863],{"class":169},[42,879,880],{"class":165}," window\n",[42,882,883,886,889],{"class":44,"line":63},[42,884,885],{"class":169},"    }",[42,887,888],{"class":173}," catch",[42,890,431],{"class":169},[42,892,893,895],{"class":44,"line":69},[42,894,850],{"class":173},[42,896,897],{"class":632}," false\n",[42,899,900],{"class":44,"line":75},[42,901,669],{"class":169},[42,903,904,907],{"class":44,"line":81},[42,905,906],{"class":169},"}",[42,908,215],{"class":165},[13,910,911],{},"As of today, only Chromium-based desktop browsers offer it: Chrome, Edge, Opera, Brave. Firefox and Safari don't\nimplement it.",[13,913,914,918],{},[915,916,917],"strong",{},"With the API",", the browser opens your system's save dialog, and you pick a file or a folder. The page then gets the\nright to write in that exact location, and nowhere else, and writes the decrypted data there. For a bulk download, you\npick a folder once, and Retyc recreates the original subfolders inside it before writing the files one by one:",[31,920,922],{"className":145,"code":921,"filename":385,"language":148,"meta":37,"style":37},"const fileHandle = await getFileHandleFromPath(dirHandle, ft.name) \u002F\u002F creates \"contracts\u002F2026\u002F\" if needed\nconst writable = await fileHandle.createWritable()\nfor await (const chunk of getChunkIterator(ft, session_private_key)) {\n    await chunk.stream().pipeTo(writable, {preventClose: true})\n}\nawait writable.close()\n",[39,923,924,954,975,1003,1044,1048],{"__ignoreMap":37},[42,925,926,928,931,933,935,938,941,943,946,948,951],{"class":44,"line":45},[42,927,162],{"class":161},[42,929,930],{"class":165}," fileHandle ",[42,932,170],{"class":169},[42,934,174],{"class":173},[42,936,937],{"class":177}," getFileHandleFromPath",[42,939,940],{"class":165},"(dirHandle",[42,942,348],{"class":169},[42,944,945],{"class":165}," ft",[42,947,189],{"class":169},[42,949,950],{"class":165},"name) ",[42,952,953],{"class":155},"\u002F\u002F creates \"contracts\u002F2026\u002F\" if needed\n",[42,955,956,958,961,963,965,968,970,973],{"class":44,"line":51},[42,957,162],{"class":161},[42,959,960],{"class":165}," writable ",[42,962,170],{"class":169},[42,964,174],{"class":173},[42,966,967],{"class":165}," fileHandle",[42,969,189],{"class":169},[42,971,972],{"class":177},"createWritable",[42,974,602],{"class":165},[42,976,977,979,981,983,985,988,990,993,996,998,1001],{"class":44,"line":57},[42,978,291],{"class":173},[42,980,174],{"class":173},[42,982,296],{"class":165},[42,984,162],{"class":161},[42,986,987],{"class":165}," chunk ",[42,989,304],{"class":169},[42,991,992],{"class":177}," getChunkIterator",[42,994,995],{"class":165},"(ft",[42,997,348],{"class":169},[42,999,1000],{"class":165}," session_private_key)) ",[42,1002,318],{"class":169},[42,1004,1005,1008,1010,1012,1015,1017,1019,1022,1024,1027,1029,1032,1035,1037,1040,1042],{"class":44,"line":63},[42,1006,1007],{"class":173},"    await",[42,1009,515],{"class":165},[42,1011,189],{"class":169},[42,1013,1014],{"class":177},"stream",[42,1016,582],{"class":336},[42,1018,189],{"class":169},[42,1020,1021],{"class":177},"pipeTo",[42,1023,337],{"class":336},[42,1025,1026],{"class":165},"writable",[42,1028,348],{"class":169},[42,1030,1031],{"class":169}," {",[42,1033,1034],{"class":336},"preventClose",[42,1036,805],{"class":169},[42,1038,1039],{"class":632}," true",[42,1041,906],{"class":169},[42,1043,215],{"class":336},[42,1045,1046],{"class":44,"line":69},[42,1047,367],{"class":169},[42,1049,1050,1053,1056,1058,1061],{"class":44,"line":75},[42,1051,1052],{"class":173},"await",[42,1054,1055],{"class":165}," writable",[42,1057,189],{"class":169},[42,1059,1060],{"class":177},"close",[42,1062,602],{"class":165},[1064,1065,1066],"blockquote",{},[13,1067,1068,1069,1072,1073,1075,1076,189],{},"A little anecdote: during the Retyc beta, a user told us they couldn't download a hundred or so files at once from a\ndataroom. After some investigation (we couldn't reproduce it locally), it turned out the browser was raising a\n",[39,1070,1071],{},"SecurityError",". ",[39,1074,857],{}," must be called \"in direct response to a user click\", and the bulk download\ndidn't follow that rule (the call was made after the full scan of the files). This was fixed in version ",[39,1077,1078],{},"1.0.17",[13,1080,1081],{},"Each chunk is written to disk as soon as its turn comes, while the next ones are downloading. At most eight chunks are\nheld at once, whether downloading, waiting or being written: memory usage stays at most around 128 MB (8 chunks x 8 MB\nx 2 versions: plaintext and encrypted), whether the file is 100 MB or 50 GB.",[13,1083,1084,1087,1088,189],{},[915,1085,1086],{},"Without the API",", a page has only one way to produce a file: assemble it entirely in memory, then simulate a click on\na download link. For several files, the browser can't create folders: Retyc packs them into a zip archive, built in the\npage with ",[801,1089,1091],{"url":1090},"https:\u002F\u002Fstuk.github.io\u002Fjszip\u002F","JSZip",[13,1093,1094],{},"This fallback mode has a cost that grows with file size: RAM. Until the file is fully decrypted and assembled, nothing\ncan be written to disk, so the whole content stays in the tab, first as decrypted chunks, then as one large blob that\njoins them. For a bulk download, it gets worse: every file in the transfer piles up before the archive is generated, and\nthe archive itself comes on top of that. A few hundred megabytes go by without you noticing. Several gigabytes, on the\nother hand, can take up most of the memory of an ordinary laptop: the machine slows down, and the browser sometimes ends\nup closing the tab before the end, losing all the work already done. For a large transfer, the fallback mode isn't just\nslower to deliver its files, it may not finish at all.",[13,1096,1097],{},"The difference shows mostly on large volumes:",[1099,1100,1101,1116],"table",{},[1102,1103,1104],"thead",{},[1105,1106,1107,1110,1113],"tr",{},[1108,1109],"th",{},[1108,1111,1112],{},"With the API (Chrome, Edge...)",[1108,1114,1115],{},"Without the API (Firefox, Safari)",[1117,1118,1119,1131,1142,1153,1164],"tbody",{},[1105,1120,1121,1125,1128],{},[1122,1123,1124],"td",{},"Several files",[1122,1126,1127],{},"A folder, tree rebuilt",[1122,1129,1130],{},"A zip archive to extract",[1105,1132,1133,1136,1139],{},[1122,1134,1135],{},"Memory used",[1122,1137,1138],{},"About 128 MB, whatever the size",[1122,1140,1141],{},"The whole file, or the whole transfer plus the archive",[1105,1143,1144,1147,1150],{},[1122,1145,1146],{},"Maximum size of a bulk download",[1122,1148,1149],{},"Available disk space",[1122,1151,1152],{},"4 GB and 65,535 files per archive",[1105,1154,1155,1158,1161],{},[1122,1156,1157],{},"First file available",[1122,1159,1160],{},"As soon as it is written",[1122,1162,1163],{},"Once the whole archive is generated",[1105,1165,1166,1169,1172],{},[1122,1167,1168],{},"Location",[1122,1170,1171],{},"Chosen by you",[1122,1173,1174],{},"\"Downloads\" folder",[13,1176,1177],{},"The 4 GB limit comes from the format: JSZip can't write Zip64, the extension that lifts it. And since the archive has\nto be fully built in memory before being offered, a transfer of several gigabytes can exhaust the tab's memory before\nthe end. That is why the bulk download window selects the \"Modern\" mode when it is available, and points out that zip\nis not recommended for large files.",[13,1179,1180],{},"For a single file download, Retyc automatically uses the File System API if it is available. For a bulk download, the\nRetyc web interface offers the \"Modern\" mode when it is available, and the \"Zip file\" mode otherwise.",[13,1182,1183],{},"If you regularly receive large folders, a Chromium-based browser will spare you these limits. For everything else, zip\nmode does the job just fine. Either way, decryption happens on your machine.",[20,1185,1187],{"id":1186},"a-file-name-is-foreign-data","A file name is foreign data",[13,1189,1190,1191,1193],{},"The path ",[39,1192,373],{}," was chosen by the sender, then encrypted. Once decrypted, it is a string\nsupplied by a third party that is about to decide where a file gets written on your disk. Before writing anything,\nRetyc therefore rejects any path that tries to escape the chosen folder:",[31,1195,1197],{"className":145,"code":1196,"filename":385,"language":148,"meta":37,"style":37},"function assertSafePath(name: string): void {\n    if (name.startsWith('\u002F') || name.split('\u002F').some(seg => seg === '..')) {\n        throw new Error(`Unsafe file path rejected: ${name}`)\n    }\n}\n",[39,1198,1199,1223,1299,1328,1332],{"__ignoreMap":37},[42,1200,1201,1203,1206,1208,1211,1213,1216,1219,1221],{"class":44,"line":45},[42,1202,418],{"class":161},[42,1204,1205],{"class":177}," assertSafePath",[42,1207,337],{"class":169},[42,1209,1210],{"class":544},"name",[42,1212,805],{"class":169},[42,1214,1215],{"class":427}," string",[42,1217,1218],{"class":169},"):",[42,1220,428],{"class":427},[42,1222,431],{"class":169},[42,1224,1225,1228,1230,1232,1234,1237,1239,1241,1244,1246,1248,1251,1254,1256,1259,1261,1263,1265,1267,1269,1271,1274,1276,1279,1281,1284,1287,1289,1292,1294,1297],{"class":44,"line":51},[42,1226,1227],{"class":173},"    if",[42,1229,296],{"class":336},[42,1231,1210],{"class":165},[42,1233,189],{"class":169},[42,1235,1236],{"class":177},"startsWith",[42,1238,337],{"class":336},[42,1240,860],{"class":169},[42,1242,1243],{"class":856},"\u002F",[42,1245,860],{"class":169},[42,1247,484],{"class":336},[42,1249,1250],{"class":169},"||",[42,1252,1253],{"class":165}," name",[42,1255,189],{"class":169},[42,1257,1258],{"class":177},"split",[42,1260,337],{"class":336},[42,1262,860],{"class":169},[42,1264,1243],{"class":856},[42,1266,860],{"class":169},[42,1268,748],{"class":336},[42,1270,189],{"class":169},[42,1272,1273],{"class":177},"some",[42,1275,337],{"class":336},[42,1277,1278],{"class":544},"seg",[42,1280,548],{"class":161},[42,1282,1283],{"class":165}," seg",[42,1285,1286],{"class":169}," ===",[42,1288,853],{"class":169},[42,1290,1291],{"class":856},"..",[42,1293,860],{"class":169},[42,1295,1296],{"class":336},")) ",[42,1298,318],{"class":169},[42,1300,1301,1304,1307,1310,1312,1315,1318,1321,1323,1326],{"class":44,"line":57},[42,1302,1303],{"class":173},"        throw",[42,1305,1306],{"class":169}," new",[42,1308,1309],{"class":177}," Error",[42,1311,337],{"class":336},[42,1313,1314],{"class":169},"`",[42,1316,1317],{"class":856},"Unsafe file path rejected: ",[42,1319,1320],{"class":169},"${",[42,1322,1210],{"class":165},[42,1324,1325],{"class":169},"}`",[42,1327,215],{"class":336},[42,1329,1330],{"class":44,"line":63},[42,1331,669],{"class":169},[42,1333,1334],{"class":44,"line":69},[42,1335,367],{"class":169},[13,1337,1338],{},"The browser API already forbids climbing above the granted folder. We still don't want to rely on a single safeguard\nwhen the data comes from someone else.",[13,1340,1341],{},"For the same reason, the picker only opens in direct response to your click: browsers refuse to let a page decide on\nits own, in the middle of some processing, to write to your disk. The folder is therefore asked for right away, before\nthe files are even listed. You, not the page, decide where it is allowed to write.",[20,1343,1345],{"id":1344},"going-further","Going further",[13,1347,1348,1349,1353],{},"The browser remains a constrained environment. For very large volumes, automated downloads or headless servers, our\n",[801,1350,1352],{"url":1351},"https:\u002F\u002Fgithub.com\u002Fretyc\u002Fretyc-cli","CLI",", released under the MIT license, fetches and decrypts\ntransfers without going through a browser, its pickers or its tab memory limits. It uses the same format, and its code\nis open for anyone to read.",[13,1355,1356,1357,1359,1360,1364],{},"For the other half of the journey, post-quantum keys included,\n",[132,1358,135],{"href":134},"\ndetails what happens before the first byte leaves the sender's machine. And for a complete description of the\narchitecture and its limits, the ",[132,1361,1363],{"href":1362},"\u002Fresources\u002Fwhite-paper","white paper"," is freely available.",[1366,1367,1368],"style",{},"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 .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}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 .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}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 .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}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":37,"searchDepth":51,"depth":51,"links":1370},[1371,1372,1373,1374,1375,1376,1377],{"id":22,"depth":51,"text":23},{"id":126,"depth":51,"text":127},{"id":276,"depth":51,"text":277},{"id":377,"depth":51,"text":378},{"id":792,"depth":51,"text":793},{"id":1186,"depth":51,"text":1187},{"id":1344,"depth":51,"text":1345},"Tech","2026-10-05","Opening the link, unlocking the transfer, decrypting the files, saving them: on the recipient's side, everything happens in the browser. What it does at each step, and why Chrome and Firefox don't offer you the same thing.","md",null,{},"\u002Fblog\u002Fen\u002Freceiving-an-encrypted-transfer",{"title":1386,"description":1387,"ogTitle":5,"ogDescription":1388},"Downloading an end-to-end encrypted file in the browser: what really happens","How Retyc delivers an encrypted transfer to its recipient: access control, passphrase and scrypt, chunked decryption, integrity, saving to disk depending on the browser, memory usage for large files. With the code.","The server can't give you your files back: your browser is the one decrypting them. Here is how, step by step.",{"loc":1384},"blog\u002Fen\u002Freceiving-an-encrypted-transfer",[1392,1393,1394,1395,1396],"end-to-end encryption","download","browser","File System Access API","TypeScript","QlF8reu1aGeLPgfLS164lJoI9MxRmZ3XHuRrooauWbQ",[1382,1399],{"title":1400,"path":1401,"stem":1402,"description":1403,"children":-1},"AI-driven pentest: what we learned","\u002Fblog\u002Fen\u002Fai-pentest","blog\u002Fen\u002Fai-pentest","Through Anthropic's Cyber Verification Portal, we had Retyc pentested by Claude, on our development environment."]