Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions xftp-web/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
{
"name": "xftp-web",
"version": "0.1.0",
"private": true,
"description": "XFTP file transfer protocol client for web/browser environments",
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "git+https://github.com/simplex-chat/simplexmq.git",
"directory": "xftp-web"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist-web/index.html",
"files": [
"src",
"web",
"dist",
"dist-web"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs && npx playwright install chromium",
"prepublishOnly": "npm run build && npm run build:prod",
"pretest": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs && npx playwright install chromium",
"build": "tsc",
"test": "vitest",
"dev": "npx tsx test/runSetup.ts && vite --mode development",
Expand Down
12 changes: 10 additions & 2 deletions xftp-web/src/crypto/digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ export function sha512(data: Uint8Array): Uint8Array {

// Streaming SHA-512 over multiple chunks -- avoids copying large data into WASM memory at once.
// Internally segments chunks larger than 4MB to limit peak WASM memory usage.
export function sha512Streaming(chunks: Iterable<Uint8Array>): Uint8Array {
export function sha512Streaming(
chunks: Iterable<Uint8Array>,
onProgress?: (done: number, total: number) => void,
totalBytes?: number
): Uint8Array {
const SEG = 4 * 1024 * 1024
const state = sodium.crypto_hash_sha512_init() as unknown as sodium.StateAddress
let done = 0
for (const chunk of chunks) {
for (let off = 0; off < chunk.length; off += SEG) {
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
const end = Math.min(off + SEG, chunk.length)
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, end))
done += end - off
onProgress?.(done, totalBytes ?? done)
}
}
return sodium.crypto_hash_sha512_final(state)
Expand Down
48 changes: 42 additions & 6 deletions xftp-web/src/crypto/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// Operates on in-memory Uint8Array (no file I/O needed for browser).

import {Decoder, concatBytes, encodeInt64, encodeString, decodeString, encodeMaybe, decodeMaybe} from "../protocol/encoding.js"
import {sbInit, sbEncryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js"
import {sbInit, sbEncryptChunk, sbDecryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js"
import {unPadLazy} from "./padding.js"

const AUTH_TAG_SIZE = 16n
const PROGRESS_SEG = 256 * 1024

// -- FileHeader

Expand Down Expand Up @@ -54,14 +56,24 @@ export function encryptFile(
key: Uint8Array,
nonce: Uint8Array,
fileSize: bigint,
encSize: bigint
encSize: bigint,
onProgress?: (done: number, total: number) => void
): Uint8Array {
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 hdr = sbEncryptChunk(state, concatBytes(lenStr, fileHdr))
const encSource = sbEncryptChunk(state, source)
// Process source in segments for progress reporting.
// sbEncryptChunk is streaming — segments produce identical output to a single call.
const encSource = new Uint8Array(source.length)
for (let off = 0; off < source.length; off += PROGRESS_SEG) {
const end = Math.min(off + PROGRESS_SEG, source.length)
const seg = sbEncryptChunk(state, source.subarray(off, end))
encSource.set(seg, off)
onProgress?.(end, source.length)
}
if (source.length === 0) onProgress?.(0, 0)
const padding = new Uint8Array(padLen)
padding.fill(0x23) // '#'
const encPad = sbEncryptChunk(state, padding)
Expand All @@ -82,13 +94,37 @@ export function decryptChunks(
encSize: bigint,
chunks: Uint8Array[],
key: Uint8Array,
nonce: Uint8Array
nonce: Uint8Array,
onProgress?: (done: number, total: number) => void
): {header: FileHeader, content: Uint8Array} {
if (chunks.length === 0) throw new Error("decryptChunks: empty chunks")
const paddedLen = encSize - AUTH_TAG_SIZE
const data = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
const {valid, content} = sbDecryptTailTag(key, nonce, paddedLen, data)
if (!valid) throw new Error("decryptChunks: invalid auth tag")
if (!onProgress) {
const {valid, content} = sbDecryptTailTag(key, nonce, paddedLen, data)
if (!valid) throw new Error("decryptChunks: invalid auth tag")
const {header, rest} = parseFileHeader(content)
return {header, content: rest}
}
// Segmented decrypt for progress reporting.
// sbDecryptChunk is streaming — segments produce identical output to a single call.
const pLen = Number(paddedLen)
const cipher = data.subarray(0, pLen)
const providedTag = data.subarray(pLen)
const state = sbInit(key, nonce)
const plaintext = new Uint8Array(pLen)
for (let off = 0; off < pLen; off += PROGRESS_SEG) {
const end = Math.min(off + PROGRESS_SEG, pLen)
const seg = sbDecryptChunk(state, cipher.subarray(off, end))
plaintext.set(seg, off)
onProgress(end, pLen)
}
if (pLen === 0) onProgress(0, 0)
const computedTag = sbAuth(state)
let diff = providedTag.length === 16 ? 0 : 1
for (let i = 0; i < computedTag.length; i++) diff |= providedTag[i] ^ computedTag[i]
if (diff !== 0) throw new Error("decryptChunks: invalid auth tag")
const content = unPadLazy(plaintext)
const {header, rest} = parseFileHeader(content)
return {header, content: rest}
}
18 changes: 16 additions & 2 deletions xftp-web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ function cspPlugin(servers: string[], isDev: boolean): Plugin {
if (isDev) {
return html.replace(/<meta\s[^>]*?Content-Security-Policy[\s\S]*?>/i, '')
}
return html.replace('__CSP_CONNECT_SRC__', origins)
// Auto-compute SHA-256 hashes for inline scripts (CSP script-src)
const scriptHashes: string[] = []
html.replace(/<script>(.+?)<\/script>/gs, (_m: string, content: string) => {
scriptHashes.push("'sha256-" + createHash('sha256').update(content, 'utf-8').digest('base64') + "'")
return _m
})
return html
.replace('__CSP_CONNECT_SRC__', origins)
.replace('__CSP_SCRIPT_HASHES__', scriptHashes.join(' '))
}
}
}
Expand Down Expand Up @@ -84,6 +92,7 @@ export default defineConfig(({mode}) => {
} : undefined

return {
base: './',
root: 'web',
build: {
outDir: resolve(__dirname, 'dist-web'),
Expand All @@ -92,12 +101,17 @@ export default defineConfig(({mode}) => {
chunkSizeWarningLimit: 1200,
rollupOptions: {
external: ['node:http2', 'url'],
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name][extname]',
},
},
},
server: httpsConfig ? {https: httpsConfig} : {},
preview: {host: true, https: false},
define,
worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url']}},
worker: {format: 'es' as const, rollupOptions: {external: ['node:http2', 'url'], output: {entryFileNames: 'assets/[name].js'}}},
plugins,
}
})
11 changes: 8 additions & 3 deletions xftp-web/web/crypto-backend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {FileHeader} from '../src/crypto/file.js'
import {t} from './i18n.js'

export interface CryptoBackend {
encrypt(data: Uint8Array, fileName: string,
Expand All @@ -9,7 +10,8 @@ export interface CryptoBackend {
dhSecret: Uint8Array, nonce: Uint8Array,
body: Uint8Array, digest: Uint8Array, chunkNo: number
): Promise<void>
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array},
onProgress?: (done: number, total: number) => void
): Promise<{header: FileHeader, content: Uint8Array}>
cleanup(): Promise<void>
}
Expand Down Expand Up @@ -117,12 +119,15 @@ class WorkerBackend implements CryptoBackend {
)
}

async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array},
onProgress?: (done: number, total: number) => void
): Promise<{header: FileHeader, content: Uint8Array}> {
this.progressCb = onProgress ?? null
const resp = await this.send({
type: 'verifyAndDecrypt',
size: params.size, digest: params.digest, key: params.key, nonce: params.nonce
})
this.progressCb = null
return {header: resp.header, content: new Uint8Array(resp.content)}
}

Expand All @@ -134,7 +139,7 @@ class WorkerBackend implements CryptoBackend {

export function createCryptoBackend(): CryptoBackend {
if (typeof Worker === 'undefined') {
throw new Error('Web Workers required update your browser')
throw new Error(t('workersRequired', 'Web Workers required \u2014 update your browser'))
}
return new WorkerBackend()
}
46 changes: 29 additions & 17 deletions xftp-web/web/crypto.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,18 @@ async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) {
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
const chunkSizes = prepareChunkSizes(payloadSize)
const encSize = BigInt(chunkSizes.reduce((a: number, b: number) => a + b, 0))
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)
const encDataLen = Number(encSize)
const total = source.length + encDataLen * 2 // encrypt + hash + write

self.postMessage({id, type: 'progress', done: 50, total: 100})
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize, (done) => {
self.postMessage({id, type: 'progress', done, total})
})

const digest = sha512Streaming([encData])
const digest = sha512Streaming([encData], (done) => {
self.postMessage({id, type: 'progress', done: source.length + done, total})
}, encDataLen)
console.log(`[WORKER-DBG] encrypt: encData.len=${encData.length} digest=${_whex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`)

self.postMessage({id, type: 'progress', done: 80, total: 100})

// Write to OPFS
const dir = await getSessionDir()
const fileHandle = await dir.getFileHandle('upload.bin', {create: true})
Expand All @@ -70,7 +73,7 @@ async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) {
// Reopen as persistent read handle
uploadReadHandle = await fileHandle.createSyncAccessHandle()

self.postMessage({id, type: 'progress', done: 100, total: 100})
self.postMessage({id, type: 'progress', done: total, total})
self.postMessage({id, type: 'encrypted', digest, key, nonce, chunkSizes})
}

Expand Down Expand Up @@ -155,12 +158,16 @@ async function handleVerifyAndDecrypt(
// Read chunks — from memory (fallback) or OPFS
const chunks: Uint8Array[] = []
let totalSize = 0
const total = size * 3 // read + hash + decrypt (byte-based progress)
let done = 0
if (useMemory) {
const sorted = [...memoryChunks.entries()].sort((a, b) => a[0] - b[0])
for (const [chunkNo, data] of sorted) {
console.log(`[WORKER-DBG] verify memory chunk=${chunkNo} size=${data.length}`)
chunks.push(data)
totalSize += data.length
done += data.length
self.postMessage({id, type: 'progress', done, total})
}
} else {
// Close write handle, reopen as read
Expand All @@ -180,6 +187,8 @@ async function handleVerifyAndDecrypt(
console.log(`[WORKER-DBG] verify read chunk=${chunkNo} offset=${meta.offset} size=${meta.size} bytesRead=${bytesRead} [0..8]=${_whex(buf)} [-8..]=${_whex(buf.slice(-8))}`)
chunks.push(buf)
totalSize += meta.size
done += meta.size
self.postMessage({id, type: 'progress', done, total})
}
readHandle.close()
}
Expand All @@ -189,27 +198,27 @@ async function handleVerifyAndDecrypt(
return
}

// Compute per-chunk SHA-512 incrementally to find divergence point
// Compute SHA-512 with byte-level progress
const hashSEG = 4 * 1024 * 1024
const state = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
const SEG = 4 * 1024 * 1024
for (let off = 0; off < chunk.length; off += SEG) {
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
for (let off = 0; off < chunk.length; off += hashSEG) {
const end = Math.min(off + hashSEG, chunk.length)
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, end))
done += end - off
self.postMessage({id, type: 'progress', done, total})
}
}
const actualDigest = sodium.crypto_hash_sha512_final(state)
if (!digestEqual(actualDigest, digest)) {
console.error(`[WORKER-DBG] DIGEST MISMATCH: expected=${_whex(digest, 64)} actual=${_whex(actualDigest, 64)} chunks=${chunks.length} totalSize=${totalSize}`)
// Log per-chunk incremental hash to find divergence
const state2 = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
const SEG = 4 * 1024 * 1024
for (let off = 0; off < chunk.length; off += SEG) {
sodium.crypto_hash_sha512_update(state2, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
for (let off = 0; off < chunk.length; off += hashSEG) {
sodium.crypto_hash_sha512_update(state2, chunk.subarray(off, Math.min(off + hashSEG, chunk.length)))
}
// snapshot incremental hash (create temp copy of state)
const chunkDigest = sha512Streaming([chunk])
console.error(`[WORKER-DBG] chunk[${i}] size=${chunk.length} sha512=${_whex(chunkDigest, 32)}… [0..8]=${_whex(chunk)} [-8..]=${_whex(chunk.slice(-8))}`)
}
Expand All @@ -218,8 +227,11 @@ async function handleVerifyAndDecrypt(
}
console.log(`[WORKER-DBG] verify: digest OK`)

// File-level decrypt
const result = decryptChunks(BigInt(size), chunks, key, nonce)
// File-level decrypt with byte-level progress
const result = decryptChunks(BigInt(size), chunks, key, nonce, (d) => {
self.postMessage({id, type: 'progress', done: size * 2 + d, total})
})
self.postMessage({id, type: 'progress', done: total, total})

// Clean up download state
if (!useMemory) {
Expand Down
Loading
Loading