Files
simplexmq/xftp-web/src/client.ts
T
2026-06-06 09:04:26 +01:00

438 lines
16 KiB
TypeScript

// XFTP HTTP/2 client -- Simplex.FileTransfer.Client
//
// Connects to XFTP server via HTTP/2, performs web handshake,
// sends authenticated commands, receives responses.
//
// Uses node:http2 in Node.js (tests), fetch() in browsers.
import {
encodeAuthTransmission, encodeTransmission, decodeTransmission,
XFTP_BLOCK_SIZE, initialXFTPVersion, currentXFTPVersion
} from "./protocol/transmission.js"
import {
encodeClientHello, encodeClientHandshake, decodeServerHandshake,
compatibleVRange
} from "./protocol/handshake.js"
import {verifyIdentityProof} from "./crypto/identity.js"
import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js"
import {
encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodePING,
decodeResponse, type FileResponse, type FileInfo, type XFTPErrorType
} from "./protocol/commands.js"
import {decryptReceivedChunk} from "./download.js"
import type {XFTPServer} from "./protocol/address.js"
import {formatXFTPServer} from "./protocol/address.js"
import {concatBytes} from "./protocol/encoding.js"
import {blockUnpad} from "./protocol/transmission.js"
// -- Error types
export class XFTPRetriableError extends Error {
constructor(public readonly errorType: string) {
super(humanReadableMessage(errorType))
this.name = "XFTPRetriableError"
}
}
export class XFTPPermanentError extends Error {
constructor(public readonly errorType: string, message: string) {
super(message)
this.name = "XFTPPermanentError"
}
}
export function isRetriable(e: unknown): boolean {
if (e instanceof XFTPRetriableError) return true
if (e instanceof XFTPPermanentError) return false
if (e instanceof TypeError) return true // fetch network error
if (e instanceof Error && e.name === "AbortError") return true // timeout
return false
}
export function categorizeError(e: unknown): Error {
if (e instanceof XFTPRetriableError || e instanceof XFTPPermanentError) return e
if (e instanceof TypeError) return new XFTPRetriableError("NETWORK")
if (e instanceof Error && e.name === "AbortError") return new XFTPRetriableError("TIMEOUT")
return e instanceof Error ? e : new Error(String(e))
}
export function humanReadableMessage(errorType: string | XFTPErrorType): string {
const t = typeof errorType === "string" ? errorType : errorType.type
switch (t) {
case "SESSION": return "Session expired, reconnecting..."
case "HANDSHAKE": return "Connection interrupted, reconnecting..."
case "NETWORK": return "Network error, retrying..."
case "TIMEOUT": return "Server timeout, retrying..."
case "AUTH": return "File is invalid, expired, or has been removed"
case "NO_FILE": return "File not found — it may have expired"
case "SIZE": return "File size exceeds server limit"
case "QUOTA": return "Server storage quota exceeded"
case "BLOCKED": return "File has been blocked by server"
case "DIGEST": return "File integrity check failed"
case "INTERNAL": return "Server internal error"
case "CMD": return "Protocol error"
default: return "Server error: " + t
}
}
// -- Types
export interface XFTPClient {
baseUrl: string
sessionId: Uint8Array
xftpVersion: number
transport: Transport
}
export interface TransportConfig {
timeoutMs: number // default 30000 (30s), lower for tests
}
const DEFAULT_TRANSPORT_CONFIG: TransportConfig = {timeoutMs: 30000}
interface Transport {
post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array>
close(): void
}
// -- Transport implementations
const isNode = typeof (globalThis as any).process !== "undefined" && (globalThis as any).process.versions?.node
// In development mode, use HTTP proxy to avoid self-signed cert issues in browser
// __XFTP_PROXY_PORT__ is injected by vite build (null in production)
declare const __XFTP_PROXY_PORT__: string | null
async function createTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
if (isNode) {
return createNodeTransport(baseUrl, config)
} else {
return createBrowserTransport(baseUrl, config)
}
}
async function createNodeTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
// @ts-ignore node:http2 unavailable in browser tsconfig
const http2: any = await import("node:http2")
const session = http2.connect(baseUrl, {rejectUnauthorized: false})
return {
async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const req = session.request({":method": "POST", ":path": "/", ...headers})
req.setTimeout(config.timeoutMs, () => {
req.close()
reject(Object.assign(new Error("Request timeout"), {name: "AbortError"}))
})
const chunks: any[] = []
req.on("data", (chunk: any) => chunks.push(chunk))
req.on("end", () => {
const B = (globalThis as any).Buffer
resolve(new Uint8Array(B.concat(chunks)))
})
req.on("error", reject)
req.end(body)
})
},
close() {
session.close()
}
}
}
function createBrowserTransport(baseUrl: string, config: TransportConfig): Transport {
// In dev mode, route through /xftp-proxy to avoid self-signed cert rejection
// __XFTP_PROXY_PORT__ is 'proxy' in dev mode (uses relative path), null in production
const effectiveUrl = typeof __XFTP_PROXY_PORT__ !== 'undefined' && __XFTP_PROXY_PORT__
? '/xftp-proxy'
: baseUrl
return {
async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), config.timeoutMs)
try {
const resp = await fetch(effectiveUrl, {
method: "POST",
headers,
body: body as any,
signal: controller.signal
})
if (!resp.ok) {
console.error('[XFTP] fetch %s failed: %d %s', effectiveUrl, resp.status, resp.statusText)
throw new Error(`Server request failed: ${resp.status} ${resp.statusText}`)
}
return new Uint8Array(await resp.arrayBuffer())
} finally {
clearTimeout(timer)
}
},
close() {}
}
}
// -- Client agent (connection pool with Promise-based lock)
interface ServerConnection {
client: Promise<XFTPClient> // resolves to connected client; replaced on reconnect
queue: Promise<void> // tail of sequential command chain
}
export interface XFTPClientAgent {
connections: Map<string, ServerConnection>
/** @internal Injectable for testing — defaults to connectXFTP */
_connectFn: (server: XFTPServer) => Promise<XFTPClient>
}
export function newXFTPAgent(): XFTPClientAgent {
return {connections: new Map(), _connectFn: connectXFTP}
}
export function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
const key = formatXFTPServer(server)
let conn = agent.connections.get(key)
if (!conn) {
const p = agent._connectFn(server)
conn = {client: p, queue: Promise.resolve()}
agent.connections.set(key, conn)
p.catch(() => {
const cur = agent.connections.get(key)
if (cur && cur.client === p) agent.connections.delete(key)
})
}
return conn.client
}
export function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
const key = formatXFTPServer(server)
const old = agent.connections.get(key)
old?.client.then(c => c.transport.close(), () => {})
const p = agent._connectFn(server)
const conn: ServerConnection = {client: p, queue: old?.queue ?? Promise.resolve()}
agent.connections.set(key, conn)
p.catch(() => {
const cur = agent.connections.get(key)
if (cur && cur.client === p) agent.connections.delete(key)
})
return p
}
export function removeStaleConnection(
agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
): void {
const key = formatXFTPServer(server)
const conn = agent.connections.get(key)
if (conn && conn.client === failedP) {
agent.connections.delete(key)
failedP.then(c => c.transport.close(), () => {})
}
}
export function closeXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): void {
const key = formatXFTPServer(server)
const conn = agent.connections.get(key)
if (conn) {
agent.connections.delete(key)
conn.client.then(c => c.transport.close(), () => {})
}
}
export function closeXFTPAgent(agent: XFTPClientAgent): void {
for (const conn of agent.connections.values()) {
conn.client.then(c => c.transport.close(), () => {})
}
agent.connections.clear()
}
// -- Connect + handshake
async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
const cfg: TransportConfig = {...DEFAULT_TRANSPORT_CONFIG, ...config}
const baseUrl = "https://" + server.host + ":" + server.port
const transport = await createTransport(baseUrl, cfg)
try {
// Step 1: send client hello with web challenge
const challenge = new Uint8Array(32)
crypto.getRandomValues(challenge)
const clientHelloBytes = encodeClientHello({webChallenge: challenge})
const shsBody = await transport.post(clientHelloBytes, {"xftp-web-hello": "1"})
// Step 2: decode + verify server handshake
const hs = decodeServerHandshake(shsBody)
if (!hs.webIdentityProof) {
console.error('[XFTP] Server did not provide web identity proof')
throw new Error("Server did not provide web identity proof")
}
const idOk = verifyIdentityProof({
certChainDer: hs.certChainDer,
signedKeyDer: hs.signedKeyDer,
sigBytes: hs.webIdentityProof,
challenge,
sessionId: hs.sessionId,
keyHash: server.keyHash
})
if (!idOk) {
console.error('[XFTP] Server identity verification failed')
throw new Error("Server identity verification failed")
}
// Step 3: version negotiation
const vr = compatibleVRange(hs.xftpVersionRange, {minVersion: initialXFTPVersion, maxVersion: currentXFTPVersion})
if (!vr) {
console.error('[XFTP] Incompatible server version: %o', hs.xftpVersionRange)
throw new Error("Incompatible server version")
}
const xftpVersion = vr.maxVersion
// Step 4: send client handshake
const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash}), {"xftp-handshake": "1"})
if (ack.length !== 0) {
console.error('[XFTP] Non-empty handshake ack (%d bytes)', ack.length)
throw new Error("Server handshake failed")
}
return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport}
} catch (e) {
console.error('[XFTP] Connection to %s failed:', baseUrl, e)
transport.close()
throw e
}
}
// -- Send command (single attempt, no retry)
async function sendXFTPCommandOnce(
client: XFTPClient,
privateKey: Uint8Array,
entityId: Uint8Array,
cmdBytes: Uint8Array,
chunkData?: Uint8Array
): Promise<{response: FileResponse, body: Uint8Array}> {
const corrId = new Uint8Array(0)
const block = encodeAuthTransmission(client.sessionId, corrId, entityId, cmdBytes, privateKey)
const reqBody = chunkData ? concatBytes(block, chunkData) : block
const fullResp = await client.transport.post(reqBody)
if (fullResp.length < XFTP_BLOCK_SIZE) {
console.error('[XFTP] Response too short: %d bytes (expected >= %d)', fullResp.length, XFTP_BLOCK_SIZE)
throw new Error("Server response too short")
}
const respBlock = fullResp.subarray(0, XFTP_BLOCK_SIZE)
const body = fullResp.subarray(XFTP_BLOCK_SIZE)
// Detect padded error strings (HANDSHAKE, SESSION) before decodeTransmission
const raw = blockUnpad(respBlock)
if (raw.length < 20) {
const text = new TextDecoder().decode(raw)
if (/^[A-Z_]+$/.test(text)) {
throw new XFTPRetriableError(text)
}
}
const {command} = decodeTransmission(client.sessionId, respBlock)
const response = decodeResponse(command)
if (response.type === "FRErr") {
const err = response.err
if (err.type === "SESSION" || err.type === "HANDSHAKE") {
throw new XFTPRetriableError(err.type)
}
throw new XFTPPermanentError(err.type, humanReadableMessage(err))
}
return {response, body}
}
// -- Send command (with retry + reconnect)
export async function sendXFTPCommand(
agent: XFTPClientAgent,
server: XFTPServer,
privateKey: Uint8Array,
entityId: Uint8Array,
cmdBytes: Uint8Array,
chunkData?: Uint8Array,
maxRetries: number = 3
): Promise<{response: FileResponse, body: Uint8Array}> {
let clientP = getXFTPServerClient(agent, server)
let client = await clientP
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await sendXFTPCommandOnce(client, privateKey, entityId, cmdBytes, chunkData)
} catch (e) {
if (!isRetriable(e)) {
throw categorizeError(e)
}
if (attempt === maxRetries) {
removeStaleConnection(agent, server, clientP)
throw categorizeError(e)
}
clientP = reconnectClient(agent, server)
client = await clientP
}
}
throw new Error("unreachable")
}
// -- Command wrappers
export async function createXFTPChunk(
agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, file: FileInfo,
rcvKeys: Uint8Array[], auth: Uint8Array | null = null
): Promise<{senderId: Uint8Array, recipientIds: Uint8Array[]}> {
const {response} = await sendXFTPCommand(agent, server, spKey, new Uint8Array(0), encodeFNEW(file, rcvKeys, auth))
if (response.type !== "FRSndIds") throw new Error("unexpected response: " + response.type)
return {senderId: response.senderId, recipientIds: response.recipientIds}
}
export async function addXFTPRecipients(
agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
): Promise<Uint8Array[]> {
const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFADD(rcvKeys))
if (response.type !== "FRRcvIds") throw new Error("unexpected response: " + response.type)
return response.recipientIds
}
export async function uploadXFTPChunk(
agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
): Promise<void> {
const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFPUT(), chunkData)
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
}
export interface RawChunkResponse {
dhSecret: Uint8Array
nonce: Uint8Array
body: Uint8Array
}
export async function downloadXFTPChunkRaw(
agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array
): Promise<RawChunkResponse> {
const {publicKey, privateKey} = generateX25519KeyPair()
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
const {response, body} = await sendXFTPCommand(agent, server, rpKey, fId, cmd)
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
const dhSecret = dh(response.rcvDhKey, privateKey)
return {dhSecret, nonce: response.nonce, body}
}
export async function downloadXFTPChunk(
agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
): Promise<Uint8Array> {
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(agent, server, rpKey, fId)
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
}
export async function deleteXFTPChunk(
agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, sId: Uint8Array
): Promise<void> {
const {response} = await sendXFTPCommand(agent, server, spKey, sId, encodeFDEL())
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
}
export async function pingXFTP(agent: XFTPClientAgent, server: XFTPServer): Promise<void> {
const client = await getXFTPServerClient(agent, server)
const corrId = new Uint8Array(0)
const block = encodeTransmission(client.sessionId, corrId, new Uint8Array(0), encodePING())
const fullResp = await client.transport.post(block)
if (fullResp.length < XFTP_BLOCK_SIZE) throw new Error("pingXFTP: response too short")
const {command} = decodeTransmission(client.sessionId, fullResp.subarray(0, XFTP_BLOCK_SIZE))
const response = decodeResponse(command)
if (response.type !== "FRPong") throw new Error("unexpected response: " + response.type)
}