diff --git a/xftp-web/README.md b/xftp-web/README.md index 7c63e824b..b7154ee71 100644 --- a/xftp-web/README.md +++ b/xftp-web/README.md @@ -28,7 +28,7 @@ const servers = [ parseXFTPServer("xftp://server2..."), parseXFTPServer("xftp://server3..."), ] -const encrypted = encryptFileForUpload(fileBytes, "photo.jpg") +const encrypted = await encryptFileForUpload(fileBytes, "photo.jpg") const {rcvDescriptions, sndDescription, uri} = await uploadFile(agent, servers, encrypted, { onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`), }) diff --git a/xftp-web/src/agent.ts b/xftp-web/src/agent.ts index 509acecef..8704065a1 100644 --- a/xftp-web/src/agent.ts +++ b/xftp-web/src/agent.ts @@ -4,7 +4,7 @@ // file descriptions, and DEFLATE-compressed URI encoding. import pako from "pako" -import {encryptFile, encodeFileHeader} from "./crypto/file.js" +import {encryptFileAsync, encodeFileHeader} from "./crypto/file.js" import {generateEd25519KeyPair, encodePubKeyEd25519, encodePrivKeyEd25519, decodePrivKeyEd25519, ed25519KeyPairFromSeed} from "./crypto/keys.js" import {sha512Streaming} from "./crypto/digest.js" import {prepareChunkSizes, prepareChunkSpecs, getChunkDigest, fileSizeLen, authTagSize} from "./protocol/chunks.js" @@ -79,7 +79,11 @@ export function decodeDescriptionURI(fragment: string): FileDescription { // -- Upload -export function encryptFileForUpload(source: Uint8Array, fileName: string): EncryptedFileInfo { +export async function encryptFileForUpload( + source: Uint8Array, + fileName: string, + onProgress?: (done: number, total: number) => void +): Promise { const key = new Uint8Array(32) const nonce = new Uint8Array(24) crypto.getRandomValues(key) @@ -89,7 +93,7 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr const payloadSize = Number(fileSize) + fileSizeLen + authTagSize const chunkSizes = prepareChunkSizes(payloadSize) const encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0)) - const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize) + const encData = await encryptFileAsync(source, fileHdr, key, nonce, fileSize, encSize, onProgress) const digest = sha512Streaming([encData]) console.log(`[AGENT-DBG] encrypt: encData.len=${encData.length} digest=${_dbgHex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`) return {encData, digest, key, nonce, chunkSizes} @@ -229,7 +233,7 @@ async function uploadRedirectDescription( ): Promise { const yaml = encodeFileDescription(innerFd) const yamlBytes = new TextEncoder().encode(yaml) - const enc = encryptFileForUpload(yamlBytes, "") + const enc = await encryptFileForUpload(yamlBytes, "") const specs = prepareChunkSpecs(enc.chunkSizes) const chunkJobs = specs.map((spec, i) => ({ diff --git a/xftp-web/src/crypto/file.ts b/xftp-web/src/crypto/file.ts index 0088a680b..a6468a3f7 100644 --- a/xftp-web/src/crypto/file.ts +++ b/xftp-web/src/crypto/file.ts @@ -69,6 +69,48 @@ export function encryptFile( return concatBytes(hdr, encSource, encPad, tag) } +// Async variant: encrypts source in 64KB slices, yielding between each to avoid blocking the main thread. +// Produces identical output to encryptFile. +const ENCRYPT_SLICE = 65536 + +export async function encryptFileAsync( + source: Uint8Array, + fileHdr: Uint8Array, + key: Uint8Array, + nonce: Uint8Array, + fileSize: bigint, + encSize: bigint, + onProgress?: (done: number, total: number) => void +): Promise { + const state = sbInit(key, nonce) + const lenStr = encodeInt64(fileSize) + const padLen = Number(encSize - AUTH_TAG_SIZE - fileSize - 8n) + if (padLen < 0) throw new Error("encryptFile: encSize too small") + const totalOut = Number(encSize) + const out = new Uint8Array(totalOut) + let outOff = 0 + // Header (small, no yield needed) + const hdr = sbEncryptChunk(state, concatBytes(lenStr, fileHdr)) + out.set(hdr, outOff); outOff += hdr.length + // Source in 64KB slices, yielding between each + for (let off = 0; off < source.length; off += ENCRYPT_SLICE) { + const end = Math.min(off + ENCRYPT_SLICE, source.length) + const enc = sbEncryptChunk(state, source.subarray(off, end)) + out.set(enc, outOff); outOff += enc.length + onProgress?.(end, source.length) + await new Promise(r => setTimeout(r, 0)) + } + // Padding (small, no yield needed) + const padding = new Uint8Array(padLen) + padding.fill(0x23) + const encPad = sbEncryptChunk(state, padding) + out.set(encPad, outOff); outOff += encPad.length + // Auth tag + const tag = sbAuth(state) + out.set(tag, outOff) + return out +} + // -- Decryption (FileTransfer.Crypto:decryptChunks) // Decrypt one or more XFTP chunks into a FileHeader and file content.