From 25c08ecc7ffc85a5e23f51902e832d1e5cb36a35 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:11:05 +0000 Subject: [PATCH] update TS client to pad hellos --- xftp-web/playwright.config.ts | 2 +- xftp-web/src/agent.ts | 23 ++--------------- xftp-web/src/client.ts | 11 ++------ xftp-web/src/protocol/commands.ts | 2 -- xftp-web/src/protocol/encoding.ts | 20 ++++++--------- xftp-web/src/protocol/handshake.ts | 7 +++--- xftp-web/test/globalSetup.ts | 40 ++++++++++++------------------ xftp-web/test/page.spec.ts | 12 +++++---- xftp-web/web/download.ts | 18 ++++++-------- 9 files changed, 46 insertions(+), 89 deletions(-) diff --git a/xftp-web/playwright.config.ts b/xftp-web/playwright.config.ts index 99ebbb4ea..bdfd579f8 100644 --- a/xftp-web/playwright.config.ts +++ b/xftp-web/playwright.config.ts @@ -22,6 +22,6 @@ export default defineConfig({ // Run setup script first (starts XFTP server + proxy), then build, then preview command: 'npx tsx test/runSetup.ts && npx vite build --mode development && npx vite preview --mode development', url: 'http://localhost:4173', - reuseExistingServer: !process.env.CI + reuseExistingServer: false }, }) diff --git a/xftp-web/src/agent.ts b/xftp-web/src/agent.ts index 3edfbe209..41d99abc7 100644 --- a/xftp-web/src/agent.ts +++ b/xftp-web/src/agent.ts @@ -16,7 +16,7 @@ import { import type {FileInfo} from "./protocol/commands.js" import { getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw, - ackXFTPChunk, deleteXFTPChunk, type XFTPClientAgent + deleteXFTPChunk, type XFTPClientAgent } from "./client.js" export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent} from "./client.js" import {processDownloadedFile, decryptReceivedChunk} from "./download.js" @@ -302,21 +302,6 @@ export async function downloadFileRaw( return resolvedFd } -export async function ackFileChunks( - agent: XFTPClientAgent, fd: FileDescription -): Promise { - for (const chunk of fd.chunks) { - const replica = chunk.replicas[0] - if (!replica) continue - try { - const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) - const seed = decodePrivKeyEd25519(replica.replicaKey) - const kp = ed25519KeyPairFromSeed(seed) - await ackXFTPChunk(client, kp.privateKey, replica.replicaId) - } catch (_) {} - } -} - export async function downloadFile( agent: XFTPClientAgent, fd: FileDescription, @@ -332,9 +317,7 @@ export async function downloadFile( if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch") const digest = sha512(combined) if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch") - const result = processDownloadedFile(resolvedFd, chunks) - await ackFileChunks(agent, resolvedFd) - return result + return processDownloadedFile(resolvedFd, chunks) } async function resolveRedirect( @@ -363,8 +346,6 @@ async function resolveRedirect( if (innerErr) throw new Error("resolveRedirect: inner description invalid: " + innerErr) if (innerFd.size !== fd.redirect!.size) throw new Error("resolveRedirect: redirect size mismatch") if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("resolveRedirect: redirect digest mismatch") - // ACK redirect chunks (best-effort) - await ackFileChunks(agent, fd) return innerFd } diff --git a/xftp-web/src/client.ts b/xftp-web/src/client.ts index d4c2550ea..f3424b935 100644 --- a/xftp-web/src/client.ts +++ b/xftp-web/src/client.ts @@ -16,7 +16,7 @@ import { import {verifyIdentityProof} from "./crypto/identity.js" import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js" import { - encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodeFACK, encodePING, + encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodePING, decodeResponse, type FileResponse, type FileInfo } from "./protocol/commands.js" import {decryptReceivedChunk} from "./download.js" @@ -159,7 +159,7 @@ export async function connectXFTP(server: XFTPServer): Promise { const xftpVersion = vr.maxVersion // Step 4: send client handshake - const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash})) + const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash}), {"xftp-handshake": "1"}) if (ack.length !== 0) throw new Error("connectXFTP: non-empty handshake ack") return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport} @@ -248,13 +248,6 @@ export async function deleteXFTPChunk( if (response.type !== "FROk") throw new Error("unexpected response: " + response.type) } -export async function ackXFTPChunk( - c: XFTPClient, rpKey: Uint8Array, rId: Uint8Array -): Promise { - const {response} = await sendXFTPCommand(c, rpKey, rId, encodeFACK()) - if (response.type !== "FROk") throw new Error("unexpected response: " + response.type) -} - export async function pingXFTP(c: XFTPClient): Promise { const corrId = new Uint8Array(0) const block = encodeTransmission(c.sessionId, corrId, new Uint8Array(0), encodePING()) diff --git a/xftp-web/src/protocol/commands.ts b/xftp-web/src/protocol/commands.ts index 636abbc54..3ca43541f 100644 --- a/xftp-web/src/protocol/commands.ts +++ b/xftp-web/src/protocol/commands.ts @@ -77,8 +77,6 @@ export function encodeFGET(rcvDhKey: Uint8Array): Uint8Array { return concatBytes(ascii("FGET"), SPACE, encodeBytes(rcvDhKey)) } -export function encodeFACK(): Uint8Array { return ascii("FACK") } - export function encodePING(): Uint8Array { return ascii("PING") } // -- Response decoding diff --git a/xftp-web/src/protocol/encoding.ts b/xftp-web/src/protocol/encoding.ts index 2bbfc97fc..aec31f63d 100644 --- a/xftp-web/src/protocol/encoding.ts +++ b/xftp-web/src/protocol/encoding.ts @@ -157,24 +157,18 @@ export function decodeBool(d: Decoder): boolean { throw new Error("decodeBool: invalid tag " + byte) } -// -- String: encode as ByteString via Latin-1 (Encoding.hs:159) -// Haskell's B.pack converts String (list of Char) to ByteString using Latin-1. +// -- String/Text: encode as UTF-8 ByteString (Encoding.hs) +// Matches Haskell's Encoding Text instance: encodeUtf8/decodeUtf8. + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() export function encodeString(s: string): Uint8Array { - const bytes = new Uint8Array(s.length) - for (let i = 0; i < s.length; i++) { - bytes[i] = s.charCodeAt(i) & 0xFF - } - return encodeBytes(bytes) + return encodeBytes(textEncoder.encode(s)) } export function decodeString(d: Decoder): string { - const bs = decodeBytes(d) - let s = "" - for (let i = 0; i < bs.length; i++) { - s += String.fromCharCode(bs[i]) - } - return s + return textDecoder.decode(decodeBytes(d)) } // -- Maybe: '0' for Nothing, '1' + encoded value for Just (Encoding.hs:114) diff --git a/xftp-web/src/protocol/handshake.ts b/xftp-web/src/protocol/handshake.ts index 92223536c..84f936c56 100644 --- a/xftp-web/src/protocol/handshake.ts +++ b/xftp-web/src/protocol/handshake.ts @@ -48,10 +48,11 @@ export interface XFTPClientHello { webChallenge: Uint8Array | null // 32 random bytes for web handshake, or null for standard } -// Encode client hello (NOT padded -- sent as raw POST body). -// Wire format: smpEncode (Maybe ByteString) +// Encode client hello (padded to XFTP_BLOCK_SIZE for web clients). +// Wire format: smpEncode (Maybe ByteString), padded when webChallenge present export function encodeClientHello(hello: XFTPClientHello): Uint8Array { - return encodeMaybe(encodeBytes, hello.webChallenge) + const body = encodeMaybe(encodeBytes, hello.webChallenge) + return hello.webChallenge ? blockPad(body, XFTP_BLOCK_SIZE) : body } // -- Client handshake diff --git a/xftp-web/test/globalSetup.ts b/xftp-web/test/globalSetup.ts index d305e1197..6e1983988 100644 --- a/xftp-web/test/globalSetup.ts +++ b/xftp-web/test/globalSetup.ts @@ -3,7 +3,7 @@ import {createHash} from 'crypto' import {createConnection, createServer} from 'net' import {resolve, join, dirname} from 'path' import {fileURLToPath} from 'url' -import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync} from 'fs' +import {readFileSync, mkdtempSync, writeFileSync, copyFileSync, existsSync, unlinkSync, openSync} from 'fs' import {tmpdir} from 'os' const __filename = fileURLToPath(import.meta.url) @@ -34,22 +34,16 @@ let server: ChildProcess | null = null let isOwner = false async function setup() { - // Check if an xftp-server is already running from a previous test - if (existsSync(SERVER_PID_FILE) && existsSync(PORT_FILE)) { - const serverPid = parseInt(readFileSync(SERVER_PID_FILE, 'utf-8').trim(), 10) - const port = parseInt(readFileSync(PORT_FILE, 'utf-8').trim(), 10) + // Kill any stale server from a previous run + if (existsSync(SERVER_PID_FILE)) { try { - process.kill(serverPid, 0) // check if server process exists - // Server is alive — wait for it to be ready and reuse - await waitForPort(port) - console.log('[runSetup] Reusing existing xftp-server on port', port) - return - } catch (_) { - // Server is dead — clean up stale files - try { unlinkSync(LOCK_FILE) } catch (_) {} - try { unlinkSync(SERVER_PID_FILE) } catch (_) {} - try { unlinkSync(PORT_FILE) } catch (_) {} - } + const serverPid = parseInt(readFileSync(SERVER_PID_FILE, 'utf-8').trim(), 10) + process.kill(serverPid, 'SIGTERM') + await new Promise(r => setTimeout(r, 500)) + } catch (_) {} + try { unlinkSync(LOCK_FILE) } catch (_) {} + try { unlinkSync(SERVER_PID_FILE) } catch (_) {} + try { unlinkSync(PORT_FILE) } catch (_) {} } // Find a free port dynamically @@ -98,6 +92,11 @@ key: ${join(fixtures, 'web.key')} // Resolve binary path once (avoids cabal rebuild check on every run) const serverBin = execSync('cabal -v0 list-bin xftp-server', {encoding: 'utf-8'}).trim() + // Redirect server stderr to file so logs survive after setup exits + const serverLogPath = join(tmpdir(), 'xftp-test-server.log') + const stderrFd = openSync(serverLogPath, 'w') + console.log('[runSetup] Server log:', serverLogPath) + // Spawn xftp-server as detached process so runSetup.ts can exit server = spawn(serverBin, ['start'], { env: { @@ -105,23 +104,16 @@ key: ${join(fixtures, 'web.key')} XFTP_SERVER_CFG_PATH: cfgDir, XFTP_SERVER_LOG_PATH: logDir }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'ignore', stderrFd], detached: true }) - server.stderr?.on('data', (data: Buffer) => { - console.error('[xftp-server]', data.toString()) - }) - // Poll-connect until the server is actually listening await waitForServerReady(server, xftpPort) // Store server PID for teardown writeFileSync(SERVER_PID_FILE, String(server.pid)) - // Detach stdio so the setup process can exit - server.stdout?.destroy() - server.stderr?.destroy() server.unref() } diff --git a/xftp-web/test/page.spec.ts b/xftp-web/test/page.spec.ts index 09febba48..5b2d3ce66 100644 --- a/xftp-web/test/page.spec.ts +++ b/xftp-web/test/page.spec.ts @@ -194,7 +194,8 @@ test.describe('Edge Cases', () => { await downloadPage.gotoWithLink(link) const download = await downloadPage.clickDownload() - expect(download.suggestedFilename()).toBe(fileName) + // Browser download attribute uses encodeURIComponent for non-ASCII filenames + expect(download.suggestedFilename()).toBe(encodeURIComponent(fileName)) }) test('upload and download file with spaces', async ({uploadPage, downloadPage}) => { @@ -206,7 +207,8 @@ test.describe('Edge Cases', () => { await downloadPage.gotoWithLink(link) const download = await downloadPage.clickDownload() - expect(download.suggestedFilename()).toBe(fileName) + // Browser download attribute uses encodeURIComponent for the filename + expect(download.suggestedFilename()).toBe(encodeURIComponent(fileName)) }) test('filename with path separators is sanitized', async ({uploadPage, downloadPage}) => { @@ -245,8 +247,8 @@ test.describe('Edge Cases', () => { // Intercept and abort server requests after encryption starts await uploadPage.page.route('**/*', route => { const url = route.request().url() - // Only abort XFTP server requests, not the web page - if (url.includes(':443') && route.request().method() !== 'GET') { + // Only abort XFTP server requests (HTTPS), not the web page (HTTP) + if (url.startsWith('https://') && route.request().method() !== 'GET') { route.abort('failed') } else { route.continue() @@ -267,7 +269,7 @@ test.describe('Edge Cases', () => { const link = await upload.waitForShareLink() const hash = upload.getHashFromLink(link) - // Open two tabs and download concurrently + // Open two tabs and download concurrently (shared HTTP/2 connection) const page2 = await context.newPage() const page3 = await context.newPage() const dl2 = new DownloadPage(page2) diff --git a/xftp-web/web/download.ts b/xftp-web/web/download.ts index 6145ea001..d54091414 100644 --- a/xftp-web/web/download.ts +++ b/xftp-web/web/download.ts @@ -2,7 +2,7 @@ import {createCryptoBackend} from './crypto-backend.js' import {createProgressRing} from './progress.js' import { newXFTPAgent, closeXFTPAgent, - decodeDescriptionURI, downloadFileRaw, ackFileChunks + decodeDescriptionURI, downloadFileRaw } from '../src/agent.js' export function initDownload(app: HTMLElement, hash: string) { @@ -71,17 +71,13 @@ export function initDownload(app: HTMLElement, hash: string) { try { const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => { - console.error(`[MAIN THREAD] chunkNo=${raw.chunkNo} dhSecret=${Array.from(raw.dhSecret).map(b => b.toString(16).padStart(2, '0')).join('')}`) - console.error(`[MAIN THREAD] chunkNo=${raw.chunkNo} nonce=${Array.from(raw.nonce).map(b => b.toString(16).padStart(2, '0')).join('')}`) - console.error(`[MAIN THREAD] chunkNo=${raw.chunkNo} body.length=${raw.body.length} first16=${Array.from(raw.body.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('')} last16=${Array.from(raw.body.slice(-16)).map(b => b.toString(16).padStart(2, '0')).join('')}`) await backend.decryptAndStoreChunk( raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo ) }, { onProgress: (downloaded, total) => { ring.update(downloaded / total * 0.8) - }, - concurrency: 1 // Disabled concurrency for debugging + } }) statusText.textContent = 'Decrypting…' @@ -96,18 +92,18 @@ export function initDownload(app: HTMLElement, hash: string) { ring.update(0.95) - // ACK (best-effort) - ackFileChunks(agent, resolvedFd).catch(() => {}) - // Sanitize filename and trigger browser save const fileName = sanitizeFileName(header.fileName) const blob = new Blob([content.buffer as ArrayBuffer]) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = fileName + a.download = encodeURIComponent(fileName) + a.style.display = 'none' + document.body.appendChild(a) a.click() - URL.revokeObjectURL(url) + document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) ring.update(1) statusText.textContent = 'Download complete'