update TS client to pad hellos

This commit is contained in:
Evgeny @ SimpleX Chat
2026-02-10 14:11:05 +00:00
parent 6f11e2a648
commit 25c08ecc7f
9 changed files with 46 additions and 89 deletions
+1 -1
View File
@@ -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
},
})
+2 -21
View File
@@ -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<void> {
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
}
+2 -9
View File
@@ -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<XFTPClient> {
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<void> {
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<void> {
const corrId = new Uint8Array(0)
const block = encodeTransmission(c.sessionId, corrId, new Uint8Array(0), encodePING())
-2
View File
@@ -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
+7 -13
View File
@@ -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)
+4 -3
View File
@@ -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
+16 -24
View File
@@ -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()
}
+7 -5
View File
@@ -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)
+7 -11
View File
@@ -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'