@shhhum/xftp-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +47 -0
  2. package/dist/agent.d.ts +46 -0
  3. package/dist/agent.js +273 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/client.d.ts +63 -0
  6. package/dist/client.js +353 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/crypto/digest.d.ts +3 -0
  9. package/dist/crypto/digest.js +23 -0
  10. package/dist/crypto/digest.js.map +1 -0
  11. package/dist/crypto/file.d.ts +14 -0
  12. package/dist/crypto/file.js +68 -0
  13. package/dist/crypto/file.js.map +1 -0
  14. package/dist/crypto/identity.d.ts +10 -0
  15. package/dist/crypto/identity.js +98 -0
  16. package/dist/crypto/identity.js.map +1 -0
  17. package/dist/crypto/keys.d.ts +27 -0
  18. package/dist/crypto/keys.js +138 -0
  19. package/dist/crypto/keys.js.map +1 -0
  20. package/dist/crypto/padding.d.ts +8 -0
  21. package/dist/crypto/padding.js +60 -0
  22. package/dist/crypto/padding.js.map +1 -0
  23. package/dist/crypto/secretbox.d.ts +22 -0
  24. package/dist/crypto/secretbox.js +195 -0
  25. package/dist/crypto/secretbox.js.map +1 -0
  26. package/dist/download.d.ts +9 -0
  27. package/dist/download.js +60 -0
  28. package/dist/download.js.map +1 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +4 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/protocol/address.d.ts +7 -0
  33. package/dist/protocol/address.js +50 -0
  34. package/dist/protocol/address.js.map +1 -0
  35. package/dist/protocol/chunks.d.ts +15 -0
  36. package/dist/protocol/chunks.js +75 -0
  37. package/dist/protocol/chunks.js.map +1 -0
  38. package/dist/protocol/client.d.ts +9 -0
  39. package/dist/protocol/client.js +69 -0
  40. package/dist/protocol/client.js.map +1 -0
  41. package/dist/protocol/commands.d.ts +68 -0
  42. package/dist/protocol/commands.js +115 -0
  43. package/dist/protocol/commands.js.map +1 -0
  44. package/dist/protocol/description.d.ts +37 -0
  45. package/dist/protocol/description.js +317 -0
  46. package/dist/protocol/description.js.map +1 -0
  47. package/dist/protocol/encoding.d.ts +34 -0
  48. package/dist/protocol/encoding.js +197 -0
  49. package/dist/protocol/encoding.js.map +1 -0
  50. package/dist/protocol/handshake.d.ts +47 -0
  51. package/dist/protocol/handshake.js +158 -0
  52. package/dist/protocol/handshake.js.map +1 -0
  53. package/dist/protocol/transmission.d.ts +15 -0
  54. package/dist/protocol/transmission.js +84 -0
  55. package/dist/protocol/transmission.js.map +1 -0
  56. package/package.json +40 -0
  57. package/src/agent.ts +372 -0
  58. package/src/client.ts +448 -0
  59. package/src/crypto/digest.ts +26 -0
  60. package/src/crypto/file.ts +94 -0
  61. package/src/crypto/identity.ts +112 -0
  62. package/src/crypto/keys.ts +172 -0
  63. package/src/crypto/padding.ts +61 -0
  64. package/src/crypto/secretbox.ts +219 -0
  65. package/src/download.ts +76 -0
  66. package/src/index.ts +4 -0
  67. package/src/protocol/address.ts +54 -0
  68. package/src/protocol/chunks.ts +86 -0
  69. package/src/protocol/client.ts +95 -0
  70. package/src/protocol/commands.ts +157 -0
  71. package/src/protocol/description.ts +363 -0
  72. package/src/protocol/encoding.ts +224 -0
  73. package/src/protocol/handshake.ts +220 -0
  74. package/src/protocol/transmission.ts +113 -0
package/src/client.ts ADDED
@@ -0,0 +1,448 @@
1
+ // XFTP HTTP/2 client -- Simplex.FileTransfer.Client
2
+ //
3
+ // Connects to XFTP server via HTTP/2, performs web handshake,
4
+ // sends authenticated commands, receives responses.
5
+ //
6
+ // Uses node:http2 in Node.js (tests), fetch() in browsers.
7
+
8
+ import {
9
+ encodeAuthTransmission, encodeTransmission, decodeTransmission,
10
+ XFTP_BLOCK_SIZE, initialXFTPVersion, currentXFTPVersion
11
+ } from "./protocol/transmission.js"
12
+ import {
13
+ encodeClientHello, encodeClientHandshake, decodeServerHandshake,
14
+ compatibleVRange
15
+ } from "./protocol/handshake.js"
16
+ import {verifyIdentityProof} from "./crypto/identity.js"
17
+ import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js"
18
+ import {
19
+ encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodePING,
20
+ decodeResponse, type FileResponse, type FileInfo, type XFTPErrorType
21
+ } from "./protocol/commands.js"
22
+ import {decryptReceivedChunk} from "./download.js"
23
+ import type {XFTPServer} from "./protocol/address.js"
24
+ import {formatXFTPServer} from "./protocol/address.js"
25
+ import {concatBytes} from "./protocol/encoding.js"
26
+ import {blockUnpad} from "./protocol/transmission.js"
27
+
28
+ // -- Error types
29
+
30
+ export class XFTPRetriableError extends Error {
31
+ constructor(public readonly errorType: string) {
32
+ super(humanReadableMessage(errorType))
33
+ this.name = "XFTPRetriableError"
34
+ }
35
+ }
36
+
37
+ export class XFTPPermanentError extends Error {
38
+ constructor(public readonly errorType: string, message: string) {
39
+ super(message)
40
+ this.name = "XFTPPermanentError"
41
+ }
42
+ }
43
+
44
+ export function isRetriable(e: unknown): boolean {
45
+ if (e instanceof XFTPRetriableError) return true
46
+ if (e instanceof XFTPPermanentError) return false
47
+ if (e instanceof TypeError) return true // fetch network error
48
+ if (e instanceof Error && e.name === "AbortError") return true // timeout
49
+ return false
50
+ }
51
+
52
+ export function categorizeError(e: unknown): Error {
53
+ if (e instanceof XFTPRetriableError || e instanceof XFTPPermanentError) return e
54
+ if (e instanceof TypeError) return new XFTPRetriableError("NETWORK")
55
+ if (e instanceof Error && e.name === "AbortError") return new XFTPRetriableError("TIMEOUT")
56
+ return e instanceof Error ? e : new Error(String(e))
57
+ }
58
+
59
+ export function humanReadableMessage(errorType: string | XFTPErrorType): string {
60
+ const t = typeof errorType === "string" ? errorType : errorType.type
61
+ switch (t) {
62
+ case "SESSION": return "Session expired, reconnecting..."
63
+ case "HANDSHAKE": return "Connection interrupted, reconnecting..."
64
+ case "NETWORK": return "Network error, retrying..."
65
+ case "TIMEOUT": return "Server timeout, retrying..."
66
+ case "AUTH": return "File is invalid, expired, or has been removed"
67
+ case "NO_FILE": return "File not found — it may have expired"
68
+ case "SIZE": return "File size exceeds server limit"
69
+ case "QUOTA": return "Server storage quota exceeded"
70
+ case "BLOCKED": return "File has been blocked by server"
71
+ case "DIGEST": return "File integrity check failed"
72
+ case "INTERNAL": return "Server internal error"
73
+ case "CMD": return "Protocol error"
74
+ default: return "Server error: " + t
75
+ }
76
+ }
77
+
78
+ // -- Types
79
+
80
+ export interface XFTPClient {
81
+ baseUrl: string
82
+ sessionId: Uint8Array
83
+ xftpVersion: number
84
+ transport: Transport
85
+ }
86
+
87
+ export interface TransportConfig {
88
+ timeoutMs: number // default 30000 (30s), lower for tests
89
+ }
90
+
91
+ const DEFAULT_TRANSPORT_CONFIG: TransportConfig = {timeoutMs: 30000}
92
+
93
+ interface Transport {
94
+ post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array>
95
+ close(): void
96
+ }
97
+
98
+ // -- Transport implementations
99
+
100
+ const isNode = typeof globalThis.process !== "undefined" && globalThis.process.versions?.node
101
+
102
+ // In development mode, use HTTP proxy to avoid self-signed cert issues in browser
103
+ // __XFTP_PROXY_PORT__ is injected by vite build (null in production)
104
+ declare const __XFTP_PROXY_PORT__: string | null
105
+
106
+ async function createTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
107
+ if (isNode) {
108
+ return createNodeTransport(baseUrl, config)
109
+ } else {
110
+ return createBrowserTransport(baseUrl, config)
111
+ }
112
+ }
113
+
114
+ async function createNodeTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
115
+ const http2 = await import("node:http2")
116
+ const session = http2.connect(baseUrl, {rejectUnauthorized: false})
117
+ return {
118
+ async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
119
+ return new Promise((resolve, reject) => {
120
+ const req = session.request({":method": "POST", ":path": "/", ...headers})
121
+ req.setTimeout(config.timeoutMs, () => {
122
+ req.close()
123
+ reject(Object.assign(new Error("Request timeout"), {name: "AbortError"}))
124
+ })
125
+ const chunks: Buffer[] = []
126
+ req.on("data", (chunk: Buffer) => chunks.push(chunk))
127
+ req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
128
+ req.on("error", reject)
129
+ req.end(Buffer.from(body))
130
+ })
131
+ },
132
+ close() {
133
+ session.close()
134
+ }
135
+ }
136
+ }
137
+
138
+ function createBrowserTransport(baseUrl: string, config: TransportConfig): Transport {
139
+ // In dev mode, route through /xftp-proxy to avoid self-signed cert rejection
140
+ // __XFTP_PROXY_PORT__ is 'proxy' in dev mode (uses relative path), null in production
141
+ const effectiveUrl = typeof __XFTP_PROXY_PORT__ !== 'undefined' && __XFTP_PROXY_PORT__
142
+ ? '/xftp-proxy'
143
+ : baseUrl
144
+ return {
145
+ async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
146
+ const controller = new AbortController()
147
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs)
148
+ try {
149
+ const resp = await fetch(effectiveUrl, {
150
+ method: "POST",
151
+ headers,
152
+ body,
153
+ signal: controller.signal
154
+ })
155
+ if (!resp.ok) {
156
+ console.error('[XFTP] fetch %s failed: %d %s', effectiveUrl, resp.status, resp.statusText)
157
+ throw new Error(`Server request failed: ${resp.status} ${resp.statusText}`)
158
+ }
159
+ return new Uint8Array(await resp.arrayBuffer())
160
+ } finally {
161
+ clearTimeout(timer)
162
+ }
163
+ },
164
+ close() {}
165
+ }
166
+ }
167
+
168
+ // -- Client agent (connection pool with Promise-based lock)
169
+
170
+ interface ServerConnection {
171
+ client: Promise<XFTPClient> // resolves to connected client; replaced on reconnect
172
+ queue: Promise<void> // tail of sequential command chain
173
+ }
174
+
175
+ export interface XFTPClientAgent {
176
+ connections: Map<string, ServerConnection>
177
+ /** @internal Injectable for testing — defaults to connectXFTP */
178
+ _connectFn: (server: XFTPServer) => Promise<XFTPClient>
179
+ }
180
+
181
+ export function newXFTPAgent(): XFTPClientAgent {
182
+ return {connections: new Map(), _connectFn: connectXFTP}
183
+ }
184
+
185
+ export function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
186
+ const key = formatXFTPServer(server)
187
+ let conn = agent.connections.get(key)
188
+ if (!conn) {
189
+ const p = agent._connectFn(server)
190
+ conn = {client: p, queue: Promise.resolve()}
191
+ agent.connections.set(key, conn)
192
+ p.catch(() => {
193
+ const cur = agent.connections.get(key)
194
+ if (cur && cur.client === p) agent.connections.delete(key)
195
+ })
196
+ }
197
+ return conn.client
198
+ }
199
+
200
+ export function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
201
+ const key = formatXFTPServer(server)
202
+ const old = agent.connections.get(key)
203
+ old?.client.then(c => c.transport.close(), () => {})
204
+ const p = agent._connectFn(server)
205
+ const conn: ServerConnection = {client: p, queue: old?.queue ?? Promise.resolve()}
206
+ agent.connections.set(key, conn)
207
+ p.catch(() => {
208
+ const cur = agent.connections.get(key)
209
+ if (cur && cur.client === p) agent.connections.delete(key)
210
+ })
211
+ return p
212
+ }
213
+
214
+ export function removeStaleConnection(
215
+ agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
216
+ ): void {
217
+ const key = formatXFTPServer(server)
218
+ const conn = agent.connections.get(key)
219
+ if (conn && conn.client === failedP) {
220
+ agent.connections.delete(key)
221
+ failedP.then(c => c.transport.close(), () => {})
222
+ }
223
+ }
224
+
225
+ export function closeXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): void {
226
+ const key = formatXFTPServer(server)
227
+ const conn = agent.connections.get(key)
228
+ if (conn) {
229
+ agent.connections.delete(key)
230
+ conn.client.then(c => c.transport.close(), () => {})
231
+ }
232
+ }
233
+
234
+ export function closeXFTPAgent(agent: XFTPClientAgent): void {
235
+ for (const conn of agent.connections.values()) {
236
+ conn.client.then(c => c.transport.close(), () => {})
237
+ }
238
+ agent.connections.clear()
239
+ }
240
+
241
+ // -- Connect + handshake
242
+
243
+ export async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
244
+ const cfg: TransportConfig = {...DEFAULT_TRANSPORT_CONFIG, ...config}
245
+ const baseUrl = "https://" + server.host + ":" + server.port
246
+ const transport = await createTransport(baseUrl, cfg)
247
+
248
+ try {
249
+ // Step 1: send client hello with web challenge
250
+ const challenge = new Uint8Array(32)
251
+ crypto.getRandomValues(challenge)
252
+ const clientHelloBytes = encodeClientHello({webChallenge: challenge})
253
+ const shsBody = await transport.post(clientHelloBytes, {"xftp-web-hello": "1"})
254
+
255
+ // Step 2: decode + verify server handshake
256
+ const hs = decodeServerHandshake(shsBody)
257
+ if (!hs.webIdentityProof) {
258
+ console.error('[XFTP] Server did not provide web identity proof')
259
+ throw new Error("Server did not provide web identity proof")
260
+ }
261
+ const idOk = verifyIdentityProof({
262
+ certChainDer: hs.certChainDer,
263
+ signedKeyDer: hs.signedKeyDer,
264
+ sigBytes: hs.webIdentityProof,
265
+ challenge,
266
+ sessionId: hs.sessionId,
267
+ keyHash: server.keyHash
268
+ })
269
+ if (!idOk) {
270
+ console.error('[XFTP] Server identity verification failed')
271
+ throw new Error("Server identity verification failed")
272
+ }
273
+
274
+ // Step 3: version negotiation
275
+ const vr = compatibleVRange(hs.xftpVersionRange, {minVersion: initialXFTPVersion, maxVersion: currentXFTPVersion})
276
+ if (!vr) {
277
+ console.error('[XFTP] Incompatible server version: %o', hs.xftpVersionRange)
278
+ throw new Error("Incompatible server version")
279
+ }
280
+ const xftpVersion = vr.maxVersion
281
+
282
+ // Step 4: send client handshake
283
+ const ack = await transport.post(encodeClientHandshake({xftpVersion, keyHash: server.keyHash}), {"xftp-handshake": "1"})
284
+ if (ack.length !== 0) {
285
+ console.error('[XFTP] Non-empty handshake ack (%d bytes)', ack.length)
286
+ throw new Error("Server handshake failed")
287
+ }
288
+
289
+ return {baseUrl, sessionId: hs.sessionId, xftpVersion, transport}
290
+ } catch (e) {
291
+ console.error('[XFTP] Connection to %s failed:', baseUrl, e)
292
+ transport.close()
293
+ throw e
294
+ }
295
+ }
296
+
297
+ // -- Send command (single attempt, no retry)
298
+
299
+ async function sendXFTPCommandOnce(
300
+ client: XFTPClient,
301
+ privateKey: Uint8Array,
302
+ entityId: Uint8Array,
303
+ cmdBytes: Uint8Array,
304
+ chunkData?: Uint8Array
305
+ ): Promise<{response: FileResponse, body: Uint8Array}> {
306
+ const corrId = new Uint8Array(0)
307
+ const block = encodeAuthTransmission(client.sessionId, corrId, entityId, cmdBytes, privateKey)
308
+ const reqBody = chunkData ? concatBytes(block, chunkData) : block
309
+ const fullResp = await client.transport.post(reqBody)
310
+ console.log(`[XFTP-DBG] sendOnce: fullResp.length=${fullResp.length} entityId=${_hex(entityId)} cmdTag=${cmdBytes[0]}`)
311
+ if (fullResp.length < XFTP_BLOCK_SIZE) {
312
+ console.error('[XFTP] Response too short: %d bytes (expected >= %d)', fullResp.length, XFTP_BLOCK_SIZE)
313
+ throw new Error("Server response too short")
314
+ }
315
+ const respBlock = fullResp.subarray(0, XFTP_BLOCK_SIZE)
316
+ const body = fullResp.subarray(XFTP_BLOCK_SIZE)
317
+ console.log(`[XFTP-DBG] sendOnce: body.length=${body.length} body.byteOffset=${body.byteOffset} body.buffer.byteLength=${body.buffer.byteLength}`)
318
+ // Detect padded error strings (HANDSHAKE, SESSION) before decodeTransmission
319
+ const raw = blockUnpad(respBlock)
320
+ if (raw.length < 20) {
321
+ const text = new TextDecoder().decode(raw)
322
+ if (/^[A-Z_]+$/.test(text)) {
323
+ throw new XFTPRetriableError(text)
324
+ }
325
+ }
326
+ const {command} = decodeTransmission(client.sessionId, respBlock)
327
+ const response = decodeResponse(command)
328
+ if (response.type === "FRErr") {
329
+ const err = response.err
330
+ if (err.type === "SESSION" || err.type === "HANDSHAKE") {
331
+ throw new XFTPRetriableError(err.type)
332
+ }
333
+ throw new XFTPPermanentError(err.type, humanReadableMessage(err))
334
+ }
335
+ return {response, body}
336
+ }
337
+
338
+ function _hex(b: Uint8Array, n = 8): string {
339
+ return Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, '0')).join('')
340
+ }
341
+
342
+ // -- Send command (with retry + reconnect)
343
+
344
+ export async function sendXFTPCommand(
345
+ agent: XFTPClientAgent,
346
+ server: XFTPServer,
347
+ privateKey: Uint8Array,
348
+ entityId: Uint8Array,
349
+ cmdBytes: Uint8Array,
350
+ chunkData?: Uint8Array,
351
+ maxRetries: number = 3
352
+ ): Promise<{response: FileResponse, body: Uint8Array}> {
353
+ let clientP = getXFTPServerClient(agent, server)
354
+ let client = await clientP
355
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
356
+ try {
357
+ if (attempt > 1) console.log(`[XFTP-DBG] sendCmd: retry attempt=${attempt}/${maxRetries}`)
358
+ return await sendXFTPCommandOnce(client, privateKey, entityId, cmdBytes, chunkData)
359
+ } catch (e) {
360
+ console.log(`[XFTP-DBG] sendCmd: attempt=${attempt} failed: ${e instanceof Error ? e.message : String(e)} retriable=${isRetriable(e)}`)
361
+ if (!isRetriable(e)) {
362
+ throw categorizeError(e)
363
+ }
364
+ if (attempt === maxRetries) {
365
+ removeStaleConnection(agent, server, clientP)
366
+ throw categorizeError(e)
367
+ }
368
+ clientP = reconnectClient(agent, server)
369
+ client = await clientP
370
+ }
371
+ }
372
+ throw new Error("unreachable")
373
+ }
374
+
375
+ // -- Command wrappers
376
+
377
+ export async function createXFTPChunk(
378
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, file: FileInfo,
379
+ rcvKeys: Uint8Array[], auth: Uint8Array | null = null
380
+ ): Promise<{senderId: Uint8Array, recipientIds: Uint8Array[]}> {
381
+ const {response} = await sendXFTPCommand(agent, server, spKey, new Uint8Array(0), encodeFNEW(file, rcvKeys, auth))
382
+ if (response.type !== "FRSndIds") throw new Error("unexpected response: " + response.type)
383
+ return {senderId: response.senderId, recipientIds: response.recipientIds}
384
+ }
385
+
386
+ export async function addXFTPRecipients(
387
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
388
+ ): Promise<Uint8Array[]> {
389
+ const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFADD(rcvKeys))
390
+ if (response.type !== "FRRcvIds") throw new Error("unexpected response: " + response.type)
391
+ return response.recipientIds
392
+ }
393
+
394
+ export async function uploadXFTPChunk(
395
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
396
+ ): Promise<void> {
397
+ const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFPUT(), chunkData)
398
+ if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
399
+ }
400
+
401
+ export interface RawChunkResponse {
402
+ dhSecret: Uint8Array
403
+ nonce: Uint8Array
404
+ body: Uint8Array
405
+ }
406
+
407
+ export async function downloadXFTPChunkRaw(
408
+ agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array
409
+ ): Promise<RawChunkResponse> {
410
+ const {publicKey, privateKey} = generateX25519KeyPair()
411
+ const cmd = encodeFGET(encodePubKeyX25519(publicKey))
412
+ const {response, body} = await sendXFTPCommand(agent, server, rpKey, fId, cmd)
413
+ if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
414
+ const dhSecret = dh(response.rcvDhKey, privateKey)
415
+ console.log(`[XFTP-DBG] dlChunkRaw: body.length=${body.length} nonce=${_hex(response.nonce, 24)} dhSecret=${_hex(dhSecret)} body[0..8]=${_hex(body)} body[-8..]=${_hex(body.slice(-8))}`)
416
+ return {dhSecret, nonce: response.nonce, body}
417
+ }
418
+
419
+ export async function downloadXFTPChunk(
420
+ agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
421
+ ): Promise<Uint8Array> {
422
+ const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(agent, server, rpKey, fId)
423
+ return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
424
+ }
425
+
426
+ export async function deleteXFTPChunk(
427
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, sId: Uint8Array
428
+ ): Promise<void> {
429
+ const {response} = await sendXFTPCommand(agent, server, spKey, sId, encodeFDEL())
430
+ if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
431
+ }
432
+
433
+ export async function pingXFTP(agent: XFTPClientAgent, server: XFTPServer): Promise<void> {
434
+ const client = await getXFTPServerClient(agent, server)
435
+ const corrId = new Uint8Array(0)
436
+ const block = encodeTransmission(client.sessionId, corrId, new Uint8Array(0), encodePING())
437
+ const fullResp = await client.transport.post(block)
438
+ if (fullResp.length < XFTP_BLOCK_SIZE) throw new Error("pingXFTP: response too short")
439
+ const {command} = decodeTransmission(client.sessionId, fullResp.subarray(0, XFTP_BLOCK_SIZE))
440
+ const response = decodeResponse(command)
441
+ if (response.type !== "FRPong") throw new Error("unexpected response: " + response.type)
442
+ }
443
+
444
+ // -- Close
445
+
446
+ export function closeXFTP(c: XFTPClient): void {
447
+ c.transport.close()
448
+ }
@@ -0,0 +1,26 @@
1
+ // Cryptographic hash functions matching Simplex.Messaging.Crypto (sha256Hash, sha512Hash).
2
+
3
+ import sodium, {type StateAddress} from "libsodium-wrappers-sumo"
4
+
5
+ // SHA-256 digest (32 bytes) -- Crypto.hs:1006
6
+ export function sha256(data: Uint8Array): Uint8Array {
7
+ return sodium.crypto_hash_sha256(data)
8
+ }
9
+
10
+ // SHA-512 digest (64 bytes) -- Crypto.hs:1011
11
+ export function sha512(data: Uint8Array): Uint8Array {
12
+ return sodium.crypto_hash_sha512(data)
13
+ }
14
+
15
+ // Streaming SHA-512 over multiple chunks -- avoids copying large data into WASM memory at once.
16
+ // Internally segments chunks larger than 4MB to limit peak WASM memory usage.
17
+ export function sha512Streaming(chunks: Iterable<Uint8Array>): Uint8Array {
18
+ const SEG = 4 * 1024 * 1024
19
+ const state = sodium.crypto_hash_sha512_init() as unknown as StateAddress
20
+ for (const chunk of chunks) {
21
+ for (let off = 0; off < chunk.length; off += SEG) {
22
+ sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
23
+ }
24
+ }
25
+ return sodium.crypto_hash_sha512_final(state)
26
+ }
@@ -0,0 +1,94 @@
1
+ // File-level encryption/decryption matching Simplex.FileTransfer.Crypto.
2
+ // Operates on in-memory Uint8Array (no file I/O needed for browser).
3
+
4
+ import {Decoder, concatBytes, encodeInt64, encodeString, decodeString, encodeMaybe, decodeMaybe} from "../protocol/encoding.js"
5
+ import {sbInit, sbEncryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js"
6
+
7
+ const AUTH_TAG_SIZE = 16n
8
+
9
+ // -- FileHeader
10
+
11
+ export interface FileHeader {
12
+ fileName: string
13
+ fileExtra: string | null
14
+ }
15
+
16
+ // Encoding matches Haskell: smpEncode (fileName, fileExtra)
17
+ // = smpEncode fileName <> smpEncode fileExtra
18
+ // = encodeString(fileName) + encodeMaybe(encodeString, fileExtra)
19
+ export function encodeFileHeader(hdr: FileHeader): Uint8Array {
20
+ return concatBytes(
21
+ encodeString(hdr.fileName),
22
+ encodeMaybe(encodeString, hdr.fileExtra)
23
+ )
24
+ }
25
+
26
+ // Parse FileHeader from decrypted content (first 1024 bytes examined).
27
+ // Returns the parsed header and remaining bytes (file content).
28
+ export function parseFileHeader(data: Uint8Array): {header: FileHeader, rest: Uint8Array} {
29
+ const hdrLen = Math.min(1024, data.length)
30
+ const d = new Decoder(data.subarray(0, hdrLen))
31
+ const fileName = decodeString(d)
32
+ const fileExtra = decodeMaybe(decodeString, d)
33
+ const consumed = d.offset()
34
+ return {
35
+ header: {fileName, fileExtra},
36
+ rest: data.subarray(consumed)
37
+ }
38
+ }
39
+
40
+ // -- Encryption (FileTransfer.Crypto:encryptFile)
41
+
42
+ // Encrypt file content with streaming XSalsa20-Poly1305.
43
+ // Output format: encrypted(Int64 fileSize | fileHdr | source | '#' padding) | 16-byte auth tag
44
+ //
45
+ // source -- raw file content
46
+ // fileHdr -- pre-encoded FileHeader bytes (from encodeFileHeader)
47
+ // key -- 32-byte symmetric key
48
+ // nonce -- 24-byte nonce
49
+ // fileSize -- BigInt(fileHdr.length + source.length)
50
+ // encSize -- total output size (including 16-byte auth tag)
51
+ export function encryptFile(
52
+ source: Uint8Array,
53
+ fileHdr: Uint8Array,
54
+ key: Uint8Array,
55
+ nonce: Uint8Array,
56
+ fileSize: bigint,
57
+ encSize: bigint
58
+ ): Uint8Array {
59
+ const state = sbInit(key, nonce)
60
+ const lenStr = encodeInt64(fileSize)
61
+ const padLen = Number(encSize - AUTH_TAG_SIZE - fileSize - 8n)
62
+ if (padLen < 0) throw new Error("encryptFile: encSize too small")
63
+ const hdr = sbEncryptChunk(state, concatBytes(lenStr, fileHdr))
64
+ const encSource = sbEncryptChunk(state, source)
65
+ const padding = new Uint8Array(padLen)
66
+ padding.fill(0x23) // '#'
67
+ const encPad = sbEncryptChunk(state, padding)
68
+ const tag = sbAuth(state)
69
+ return concatBytes(hdr, encSource, encPad, tag)
70
+ }
71
+
72
+ // -- Decryption (FileTransfer.Crypto:decryptChunks)
73
+
74
+ // Decrypt one or more XFTP chunks into a FileHeader and file content.
75
+ // Chunks are concatenated, then decrypted as a single stream.
76
+ //
77
+ // encSize -- total encrypted size (including 16-byte auth tag)
78
+ // chunks -- downloaded XFTP chunk data (concatenated = full encrypted file)
79
+ // key -- 32-byte symmetric key
80
+ // nonce -- 24-byte nonce
81
+ export function decryptChunks(
82
+ encSize: bigint,
83
+ chunks: Uint8Array[],
84
+ key: Uint8Array,
85
+ nonce: Uint8Array
86
+ ): {header: FileHeader, content: Uint8Array} {
87
+ if (chunks.length === 0) throw new Error("decryptChunks: empty chunks")
88
+ const paddedLen = encSize - AUTH_TAG_SIZE
89
+ const data = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
90
+ const {valid, content} = sbDecryptTailTag(key, nonce, paddedLen, data)
91
+ if (!valid) throw new Error("decryptChunks: invalid auth tag")
92
+ const {header, rest} = parseFileHeader(content)
93
+ return {header, content: rest}
94
+ }
@@ -0,0 +1,112 @@
1
+ // Web handshake identity proof verification.
2
+ //
3
+ // Verifies server identity in the XFTP web handshake using the certificate
4
+ // chain from the protocol handshake (independent of TLS certificates).
5
+ // Ed25519 via libsodium, Ed448 via @noble/curves.
6
+
7
+ import {Decoder, concatBytes} from "../protocol/encoding.js"
8
+ import {sha256} from "./digest.js"
9
+ import {verify, decodePubKeyEd25519, verifyEd448, decodePubKeyEd448} from "./keys.js"
10
+ import {chainIdCaCerts, extractSignedKey} from "../protocol/handshake.js"
11
+
12
+ // -- ASN.1 DER helpers (minimal, for X.509 parsing)
13
+
14
+ function derLen(d: Decoder): number {
15
+ const first = d.anyByte()
16
+ if (first < 0x80) return first
17
+ const n = first & 0x7f
18
+ if (n === 0 || n > 4) throw new Error("DER: unsupported length encoding")
19
+ let len = 0
20
+ for (let i = 0; i < n; i++) len = (len << 8) | d.anyByte()
21
+ return len
22
+ }
23
+
24
+ function derSkip(d: Decoder): void {
25
+ d.anyByte()
26
+ d.take(derLen(d))
27
+ }
28
+
29
+ function derReadElement(d: Decoder): Uint8Array {
30
+ const start = d.offset()
31
+ d.anyByte()
32
+ d.take(derLen(d))
33
+ return d.buf.subarray(start, d.offset())
34
+ }
35
+
36
+ // -- X.509 certificate public key extraction
37
+
38
+ // Extract SubjectPublicKeyInfo DER from a full X.509 certificate DER.
39
+ // Navigates: Certificate -> TBSCertificate -> skip version, serialNumber,
40
+ // signatureAlg, issuer, validity, subject -> SubjectPublicKeyInfo.
41
+ export function extractCertPublicKeyInfo(certDer: Uint8Array): Uint8Array {
42
+ const d = new Decoder(certDer)
43
+ if (d.anyByte() !== 0x30) throw new Error("X.509: expected Certificate SEQUENCE")
44
+ derLen(d)
45
+ if (d.anyByte() !== 0x30) throw new Error("X.509: expected TBSCertificate SEQUENCE")
46
+ derLen(d)
47
+ if (d.buf[d.offset()] === 0xa0) derSkip(d) // version [0] EXPLICIT (optional)
48
+ derSkip(d) // serialNumber
49
+ derSkip(d) // signature AlgorithmIdentifier
50
+ derSkip(d) // issuer
51
+ derSkip(d) // validity
52
+ derSkip(d) // subject
53
+ return derReadElement(d) // SubjectPublicKeyInfo
54
+ }
55
+
56
+ // Detect certificate key algorithm from SPKI DER prefix.
57
+ // Ed25519 OID 1.3.101.112: byte 8 = 0x70, SPKI = 44 bytes
58
+ // Ed448 OID 1.3.101.113: byte 8 = 0x71, SPKI = 69 bytes
59
+ type CertKeyAlgorithm = 'ed25519' | 'ed448'
60
+
61
+ function detectKeyAlgorithm(spki: Uint8Array): CertKeyAlgorithm {
62
+ if (spki.length === 44 && spki[8] === 0x70) return 'ed25519'
63
+ if (spki.length === 69 && spki[8] === 0x71) return 'ed448'
64
+ throw new Error("unsupported certificate key algorithm")
65
+ }
66
+
67
+ // Extract raw public key from SPKI DER, auto-detecting Ed25519 or Ed448.
68
+ function extractCertRawKey(spki: Uint8Array): {key: Uint8Array, alg: CertKeyAlgorithm} {
69
+ const alg = detectKeyAlgorithm(spki)
70
+ const key = alg === 'ed25519' ? decodePubKeyEd25519(spki) : decodePubKeyEd448(spki)
71
+ return {key, alg}
72
+ }
73
+
74
+ // Verify signature using the appropriate algorithm.
75
+ function verifySig(alg: CertKeyAlgorithm, key: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean {
76
+ return alg === 'ed25519' ? verify(key, sig, msg) : verifyEd448(key, sig, msg)
77
+ }
78
+
79
+ // -- Identity proof verification
80
+
81
+ export interface IdentityVerification {
82
+ certChainDer: Uint8Array[]
83
+ signedKeyDer: Uint8Array
84
+ sigBytes: Uint8Array
85
+ challenge: Uint8Array
86
+ sessionId: Uint8Array
87
+ keyHash: Uint8Array
88
+ }
89
+
90
+ // Verify server identity proof from XFTP web handshake.
91
+ // 1. Certificate chain has valid structure (2-4 certs)
92
+ // 2. SHA-256(idCert) matches expected keyHash
93
+ // 3. Challenge signature valid: verify(leafKey, sigBytes, challenge || sessionId)
94
+ // 4. DH key signature valid: verify(leafKey, signedKey.signature, signedKey.objectDer)
95
+ export function verifyIdentityProof(v: IdentityVerification): boolean {
96
+ const cc = chainIdCaCerts(v.certChainDer)
97
+ if (cc.type !== 'valid') return false
98
+ const fp = sha256(cc.idCert)
99
+ if (!constantTimeEqual(fp, v.keyHash)) return false
100
+ const spki = extractCertPublicKeyInfo(cc.leafCert)
101
+ const {key, alg} = extractCertRawKey(spki)
102
+ if (!verifySig(alg, key, v.sigBytes, concatBytes(v.challenge, v.sessionId))) return false
103
+ const sk = extractSignedKey(v.signedKeyDer)
104
+ return verifySig(alg, key, sk.signature, sk.objectDer)
105
+ }
106
+
107
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
108
+ if (a.length !== b.length) return false
109
+ let diff = 0
110
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
111
+ return diff === 0
112
+ }