mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-05-25 12:04:32 +00:00
update TS client to pad hellos
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user