@shhhum/xftp-web 0.3.0 → 0.5.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 (54) hide show
  1. package/README.md +15 -83
  2. package/dist/agent.d.ts +4 -6
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +79 -143
  5. package/dist/agent.js.map +1 -1
  6. package/dist/client.d.ts +0 -2
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +1 -6
  9. package/dist/client.js.map +1 -1
  10. package/dist/crypto/digest.d.ts +0 -1
  11. package/dist/crypto/digest.d.ts.map +1 -1
  12. package/dist/crypto/digest.js.map +1 -1
  13. package/dist/crypto/file.d.ts +0 -1
  14. package/dist/crypto/file.d.ts.map +1 -1
  15. package/dist/crypto/identity.d.ts +0 -1
  16. package/dist/crypto/keys.d.ts +0 -1
  17. package/dist/crypto/keys.js +0 -1
  18. package/dist/crypto/keys.js.map +1 -1
  19. package/dist/crypto/padding.d.ts +0 -1
  20. package/dist/crypto/secretbox.d.ts +0 -1
  21. package/dist/download.d.ts +0 -1
  22. package/dist/protocol/address.d.ts +0 -1
  23. package/dist/protocol/chunks.d.ts +0 -1
  24. package/dist/protocol/client.d.ts +0 -1
  25. package/dist/protocol/commands.d.ts +1 -10
  26. package/dist/protocol/commands.js +1 -15
  27. package/dist/protocol/commands.js.map +1 -1
  28. package/dist/protocol/description.d.ts +0 -1
  29. package/dist/protocol/encoding.d.ts +0 -1
  30. package/dist/protocol/handshake.d.ts +0 -1
  31. package/dist/protocol/transmission.d.ts +0 -1
  32. package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
  33. package/dist-web/assets/__vite-browser-external.js +1 -0
  34. package/dist-web/assets/index.css +1 -0
  35. package/dist-web/assets/index.js +1468 -0
  36. package/dist-web/crypto.worker.js +1413 -0
  37. package/dist-web/index.html +15 -0
  38. package/package.json +4 -5
  39. package/src/agent.ts +85 -156
  40. package/src/client.ts +1 -8
  41. package/src/crypto/digest.ts +2 -2
  42. package/src/crypto/keys.ts +0 -1
  43. package/src/protocol/commands.ts +2 -22
  44. package/web/crypto-backend.ts +122 -0
  45. package/web/crypto.worker.ts +313 -0
  46. package/web/download.ts +140 -0
  47. package/web/index.html +15 -0
  48. package/web/main.ts +30 -0
  49. package/web/progress.ts +52 -0
  50. package/web/servers.json +18 -0
  51. package/web/servers.ts +16 -0
  52. package/web/style.css +103 -0
  53. package/web/upload.ts +171 -0
  54. package/src/index.ts +0 -4
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="Content-Security-Policy"
7
+ content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://xftp1.simplex.im:443 https://xftp2.simplex.im:443 https://xftp3.simplex.im:443 https://xftp4.simplex.im:443 https://xftp5.simplex.im:443 https://xftp6.simplex.im:443 https://xftp1.simplexonflux.com:443 https://xftp2.simplexonflux.com:443 https://xftp3.simplexonflux.com:443 https://xftp4.simplexonflux.com:443 https://xftp5.simplexonflux.com:443 https://xftp6.simplexonflux.com:443;">
8
+ <title>SimpleX File Transfer</title>
9
+ <script type="module" crossorigin src="/assets/index.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index.css">
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ </body>
15
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shhhum/xftp-web",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "XFTP file transfer protocol client for web/browser environments",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -9,14 +9,13 @@
9
9
  "directory": "xftp-web"
10
10
  },
11
11
  "type": "module",
12
- "main": "dist/index.js",
13
- "types": "dist/index.d.ts",
14
- "files": ["dist", "src"],
12
+ "main": "dist-web/index.html",
13
+ "files": ["src", "web", "dist", "dist-web"],
15
14
  "publishConfig": {
16
15
  "access": "public"
17
16
  },
18
17
  "scripts": {
19
- "prepublishOnly": "npm run build",
18
+ "prepublishOnly": "npm run build && npm run build:prod",
20
19
  "pretest": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs && npx playwright install chromium",
21
20
  "build": "tsc",
22
21
  "test": "vitest",
package/src/agent.ts CHANGED
@@ -15,12 +15,10 @@ import {
15
15
  } from "./protocol/description.js"
16
16
  import type {FileInfo} from "./protocol/commands.js"
17
17
  import {
18
- createXFTPChunk, addXFTPRecipients, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw,
19
- deleteXFTPChunk, ackXFTPChunk, type XFTPClientAgent
18
+ createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw,
19
+ deleteXFTPChunk, type XFTPClientAgent
20
20
  } from "./client.js"
21
- export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent, type TransportConfig,
22
- XFTPRetriableError, XFTPPermanentError, isRetriable, categorizeError, humanReadableMessage,
23
- ackXFTPChunk, addXFTPRecipients} from "./client.js"
21
+ export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent, type TransportConfig} from "./client.js"
24
22
  import {processDownloadedFile, decryptReceivedChunk} from "./download.js"
25
23
  import type {XFTPServer} from "./protocol/address.js"
26
24
  import {formatXFTPServer, parseXFTPServer} from "./protocol/address.js"
@@ -32,7 +30,8 @@ interface SentChunk {
32
30
  chunkNo: number
33
31
  senderId: Uint8Array
34
32
  senderKey: Uint8Array // 64B libsodium Ed25519 private key
35
- recipients: {recipientId: Uint8Array, recipientKey: Uint8Array}[]
33
+ recipientId: Uint8Array
34
+ recipientKey: Uint8Array // 64B libsodium Ed25519 private key
36
35
  chunkSize: number
37
36
  digest: Uint8Array // SHA-256
38
37
  server: XFTPServer
@@ -50,7 +49,7 @@ export interface EncryptedFileInfo extends EncryptedFileMetadata {
50
49
  }
51
50
 
52
51
  export interface UploadResult {
53
- rcvDescriptions: FileDescription[]
52
+ rcvDescription: FileDescription
54
53
  sndDescription: FileDescription
55
54
  uri: string // base64url-encoded compressed YAML (no leading #)
56
55
  }
@@ -96,24 +95,20 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr
96
95
  }
97
96
 
98
97
  const DEFAULT_REDIRECT_THRESHOLD = 400
99
- const MAX_RECIPIENTS_PER_REQUEST = 200
100
98
 
101
99
  export interface UploadOptions {
102
100
  onProgress?: (uploaded: number, total: number) => void
103
101
  redirectThreshold?: number
104
102
  readChunk?: (offset: number, size: number) => Promise<Uint8Array>
105
- auth?: Uint8Array
106
- numRecipients?: number
107
103
  }
108
104
 
109
105
  export async function uploadFile(
110
106
  agent: XFTPClientAgent,
111
- servers: XFTPServer[],
107
+ server: XFTPServer,
112
108
  encrypted: EncryptedFileMetadata,
113
109
  options?: UploadOptions
114
110
  ): Promise<UploadResult> {
115
- if (servers.length === 0) throw new Error("uploadFile: servers list is empty")
116
- const {onProgress, redirectThreshold, readChunk: readChunkOpt, auth, numRecipients = 1} = options ?? {}
111
+ const {onProgress, redirectThreshold, readChunk: readChunkOpt} = options ?? {}
117
112
  const readChunk: (offset: number, size: number) => Promise<Uint8Array> = readChunkOpt
118
113
  ? readChunkOpt
119
114
  : ('encData' in encrypted
@@ -121,81 +116,48 @@ export async function uploadFile(
121
116
  : () => { throw new Error("uploadFile: readChunk required when encData is absent") })
122
117
  const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)
123
118
  const specs = prepareChunkSpecs(encrypted.chunkSizes)
124
-
125
- // Pre-assign servers and group by server (matching Haskell groupAllOn)
126
- const chunkJobs = specs.map((spec, i) => ({
127
- index: i,
128
- spec,
129
- server: servers[Math.floor(Math.random() * servers.length)]
130
- }))
131
- const byServer = new Map<string, typeof chunkJobs>()
132
- for (const job of chunkJobs) {
133
- const key = formatXFTPServer(job.server)
134
- if (!byServer.has(key)) byServer.set(key, [])
135
- byServer.get(key)!.push(job)
136
- }
137
-
138
- // Upload groups in parallel, sequential within each group
139
- const sentChunks: SentChunk[] = new Array(specs.length)
119
+ const sentChunks: SentChunk[] = []
140
120
  let uploaded = 0
141
- await Promise.all([...byServer.values()].map(async (jobs) => {
142
- for (const {index, spec, server} of jobs) {
143
- const chunkNo = index + 1
144
- const sndKp = generateEd25519KeyPair()
145
- const rcvKps = Array.from({length: numRecipients}, () => generateEd25519KeyPair())
146
- const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize)
147
- const chunkDigest = getChunkDigest(chunkData)
148
- console.log(`[AGENT-DBG] upload chunk=${chunkNo} offset=${spec.chunkOffset} size=${spec.chunkSize} digest=${_dbgHex(chunkDigest, 32)} data[0..8]=${_dbgHex(chunkData)} data[-8..]=${_dbgHex(chunkData.slice(-8))}`)
149
- const fileInfo: FileInfo = {
150
- sndKey: encodePubKeyEd25519(sndKp.publicKey),
151
- size: spec.chunkSize,
152
- digest: chunkDigest
153
- }
154
- const firstBatch = Math.min(numRecipients, MAX_RECIPIENTS_PER_REQUEST)
155
- const firstBatchKeys = rcvKps.slice(0, firstBatch).map(kp => encodePubKeyEd25519(kp.publicKey))
156
- const {senderId, recipientIds: firstIds} = await createXFTPChunk(
157
- agent, server, sndKp.privateKey, fileInfo, firstBatchKeys, auth ?? null
158
- )
159
- const allRecipientIds = [...firstIds]
160
- let added = firstBatch
161
- while (added < numRecipients) {
162
- const batchSize = Math.min(numRecipients - added, MAX_RECIPIENTS_PER_REQUEST)
163
- const batchKeys = rcvKps.slice(added, added + batchSize).map(kp => encodePubKeyEd25519(kp.publicKey))
164
- const moreIds = await addXFTPRecipients(agent, server, sndKp.privateKey, senderId, batchKeys)
165
- allRecipientIds.push(...moreIds)
166
- added += batchSize
167
- }
168
- await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData)
169
- sentChunks[index] = {
170
- chunkNo, senderId, senderKey: sndKp.privateKey,
171
- recipients: allRecipientIds.map((rid, ri) => ({
172
- recipientId: rid, recipientKey: rcvKps[ri].privateKey
173
- })),
174
- chunkSize: spec.chunkSize, digest: chunkDigest, server
175
- }
176
- uploaded += spec.chunkSize
177
- onProgress?.(uploaded, total)
121
+ for (let i = 0; i < specs.length; i++) {
122
+ const spec = specs[i]
123
+ const chunkNo = i + 1
124
+ const sndKp = generateEd25519KeyPair()
125
+ const rcvKp = generateEd25519KeyPair()
126
+ const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize)
127
+ const chunkDigest = getChunkDigest(chunkData)
128
+ console.log(`[AGENT-DBG] upload chunk=${chunkNo} offset=${spec.chunkOffset} size=${spec.chunkSize} digest=${_dbgHex(chunkDigest, 32)} data[0..8]=${_dbgHex(chunkData)} data[-8..]=${_dbgHex(chunkData.slice(-8))}`)
129
+ const fileInfo: FileInfo = {
130
+ sndKey: encodePubKeyEd25519(sndKp.publicKey),
131
+ size: spec.chunkSize,
132
+ digest: chunkDigest
178
133
  }
179
- }))
180
-
181
- const rcvDescriptions = Array.from({length: numRecipients}, (_, ri) =>
182
- buildDescription("recipient", ri, encrypted, sentChunks)
183
- )
184
- const sndDescription = buildDescription("sender", 0, encrypted, sentChunks)
185
- let uri = encodeDescriptionURI(rcvDescriptions[0])
186
- let finalRcvDescriptions = rcvDescriptions
134
+ const rcvKeysForChunk = [encodePubKeyEd25519(rcvKp.publicKey)]
135
+ const {senderId, recipientIds} = await createXFTPChunk(
136
+ agent, server, sndKp.privateKey, fileInfo, rcvKeysForChunk
137
+ )
138
+ await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData)
139
+ sentChunks.push({
140
+ chunkNo, senderId, senderKey: sndKp.privateKey,
141
+ recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
142
+ chunkSize: spec.chunkSize, digest: chunkDigest, server
143
+ })
144
+ uploaded += spec.chunkSize
145
+ onProgress?.(uploaded, total)
146
+ }
147
+ const rcvDescription = buildDescription("recipient", encrypted, sentChunks)
148
+ const sndDescription = buildDescription("sender", encrypted, sentChunks)
149
+ let uri = encodeDescriptionURI(rcvDescription)
150
+ let finalRcvDescription = rcvDescription
187
151
  const threshold = redirectThreshold ?? DEFAULT_REDIRECT_THRESHOLD
188
152
  if (uri.length > threshold && sentChunks.length > 1) {
189
- const redirected = await uploadRedirectDescription(agent, servers, rcvDescriptions[0], auth)
190
- finalRcvDescriptions = [redirected, ...rcvDescriptions.slice(1)]
191
- uri = encodeDescriptionURI(redirected)
153
+ finalRcvDescription = await uploadRedirectDescription(agent, server, rcvDescription)
154
+ uri = encodeDescriptionURI(finalRcvDescription)
192
155
  }
193
- return {rcvDescriptions: finalRcvDescriptions, sndDescription, uri}
156
+ return {rcvDescription: finalRcvDescription, sndDescription, uri}
194
157
  }
195
158
 
196
159
  function buildDescription(
197
160
  party: "recipient" | "sender",
198
- recipientIndex: number,
199
161
  enc: EncryptedFileMetadata,
200
162
  chunks: SentChunk[]
201
163
  ): FileDescription {
@@ -213,8 +175,8 @@ function buildDescription(
213
175
  digest: c.digest,
214
176
  replicas: [{
215
177
  server: formatXFTPServer(c.server),
216
- replicaId: party === "recipient" ? c.recipients[recipientIndex].recipientId : c.senderId,
217
- replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.recipients[recipientIndex].recipientKey : c.senderKey)
178
+ replicaId: party === "recipient" ? c.recipientId : c.senderId,
179
+ replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.recipientKey : c.senderKey)
218
180
  }]
219
181
  })),
220
182
  redirect: null
@@ -223,53 +185,37 @@ function buildDescription(
223
185
 
224
186
  async function uploadRedirectDescription(
225
187
  agent: XFTPClientAgent,
226
- servers: XFTPServer[],
227
- innerFd: FileDescription,
228
- auth?: Uint8Array
188
+ server: XFTPServer,
189
+ innerFd: FileDescription
229
190
  ): Promise<FileDescription> {
230
191
  const yaml = encodeFileDescription(innerFd)
231
192
  const yamlBytes = new TextEncoder().encode(yaml)
232
193
  const enc = encryptFileForUpload(yamlBytes, "")
233
194
  const specs = prepareChunkSpecs(enc.chunkSizes)
234
-
235
- const chunkJobs = specs.map((spec, i) => ({
236
- index: i,
237
- spec,
238
- server: servers[Math.floor(Math.random() * servers.length)]
239
- }))
240
- const byServer = new Map<string, typeof chunkJobs>()
241
- for (const job of chunkJobs) {
242
- const key = formatXFTPServer(job.server)
243
- if (!byServer.has(key)) byServer.set(key, [])
244
- byServer.get(key)!.push(job)
245
- }
246
-
247
- const sentChunks: SentChunk[] = new Array(specs.length)
248
- await Promise.all([...byServer.values()].map(async (jobs) => {
249
- for (const {index, spec, server} of jobs) {
250
- const chunkNo = index + 1
251
- const sndKp = generateEd25519KeyPair()
252
- const rcvKp = generateEd25519KeyPair()
253
- const chunkData = enc.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
254
- const chunkDigest = getChunkDigest(chunkData)
255
- const fileInfo: FileInfo = {
256
- sndKey: encodePubKeyEd25519(sndKp.publicKey),
257
- size: spec.chunkSize,
258
- digest: chunkDigest
259
- }
260
- const rcvKeysForChunk = [encodePubKeyEd25519(rcvKp.publicKey)]
261
- const {senderId, recipientIds} = await createXFTPChunk(
262
- agent, server, sndKp.privateKey, fileInfo, rcvKeysForChunk, auth ?? null
263
- )
264
- await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData)
265
- sentChunks[index] = {
266
- chunkNo, senderId, senderKey: sndKp.privateKey,
267
- recipients: [{recipientId: recipientIds[0], recipientKey: rcvKp.privateKey}],
268
- chunkSize: spec.chunkSize, digest: chunkDigest, server
269
- }
195
+ const sentChunks: SentChunk[] = []
196
+ for (let i = 0; i < specs.length; i++) {
197
+ const spec = specs[i]
198
+ const chunkNo = i + 1
199
+ const sndKp = generateEd25519KeyPair()
200
+ const rcvKp = generateEd25519KeyPair()
201
+ const chunkData = enc.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
202
+ const chunkDigest = getChunkDigest(chunkData)
203
+ const fileInfo: FileInfo = {
204
+ sndKey: encodePubKeyEd25519(sndKp.publicKey),
205
+ size: spec.chunkSize,
206
+ digest: chunkDigest
270
207
  }
271
- }))
272
-
208
+ const rcvKeysForChunk = [encodePubKeyEd25519(rcvKp.publicKey)]
209
+ const {senderId, recipientIds} = await createXFTPChunk(
210
+ agent, server, sndKp.privateKey, fileInfo, rcvKeysForChunk
211
+ )
212
+ await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData)
213
+ sentChunks.push({
214
+ chunkNo, senderId, senderKey: sndKp.privateKey,
215
+ recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
216
+ chunkSize: spec.chunkSize, digest: chunkDigest, server
217
+ })
218
+ }
273
219
  return {
274
220
  party: "recipient",
275
221
  size: enc.chunkSizes.reduce((a, b) => a + b, 0),
@@ -283,8 +229,8 @@ async function uploadRedirectDescription(
283
229
  digest: c.digest,
284
230
  replicas: [{
285
231
  server: formatXFTPServer(c.server),
286
- replicaId: c.recipients[0].recipientId,
287
- replicaKey: encodePrivKeyEd25519(c.recipients[0].recipientKey)
232
+ replicaId: c.recipientId,
233
+ replicaKey: encodePrivKeyEd25519(c.recipientKey)
288
234
  }]
289
235
  })),
290
236
  redirect: {size: innerFd.size, digest: innerFd.digest}
@@ -303,6 +249,7 @@ export interface RawDownloadedChunk {
303
249
 
304
250
  export interface DownloadRawOptions {
305
251
  onProgress?: (downloaded: number, total: number) => void
252
+ concurrency?: number
306
253
  }
307
254
 
308
255
  export async function downloadFileRaw(
@@ -313,7 +260,7 @@ export async function downloadFileRaw(
313
260
  ): Promise<FileDescription> {
314
261
  const err = validateFileDescription(fd)
315
262
  if (err) throw new Error("downloadFileRaw: " + err)
316
- const {onProgress} = options ?? {}
263
+ const {onProgress, concurrency = 1} = options ?? {}
317
264
  // Resolve redirect on main thread (redirect data is small)
318
265
  if (fd.redirect !== null) {
319
266
  console.log(`[AGENT-DBG] resolving redirect: outer size=${fd.size} chunks=${fd.chunks.length}`)
@@ -345,7 +292,6 @@ export async function downloadFileRaw(
345
292
  body: raw.body,
346
293
  digest: chunk.digest
347
294
  })
348
- await ackXFTPChunk(agent, server, kp.privateKey, replica.replicaId)
349
295
  downloaded += chunk.chunkSize
350
296
  onProgress?.(downloaded, resolvedFd.size)
351
297
  }
@@ -376,24 +322,15 @@ async function resolveRedirect(
376
322
  fd: FileDescription
377
323
  ): Promise<FileDescription> {
378
324
  const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
379
- const byServer = new Map<string, typeof fd.chunks>()
380
325
  for (const chunk of fd.chunks) {
381
- const srv = chunk.replicas[0]?.server ?? ""
382
- if (!byServer.has(srv)) byServer.set(srv, [])
383
- byServer.get(srv)!.push(chunk)
326
+ const replica = chunk.replicas[0]
327
+ if (!replica) throw new Error("resolveRedirect: chunk has no replicas")
328
+ const server = parseXFTPServer(replica.server)
329
+ const seed = decodePrivKeyEd25519(replica.replicaKey)
330
+ const kp = ed25519KeyPairFromSeed(seed)
331
+ const data = await downloadXFTPChunk(agent, server, kp.privateKey, replica.replicaId, chunk.digest)
332
+ plaintextChunks[chunk.chunkNo - 1] = data
384
333
  }
385
- await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
386
- const server = parseXFTPServer(srv)
387
- for (const chunk of chunks) {
388
- const replica = chunk.replicas[0]
389
- if (!replica) throw new Error("resolveRedirect: chunk has no replicas")
390
- const seed = decodePrivKeyEd25519(replica.replicaKey)
391
- const kp = ed25519KeyPairFromSeed(seed)
392
- const data = await downloadXFTPChunk(agent, server, kp.privateKey, replica.replicaId, chunk.digest)
393
- plaintextChunks[chunk.chunkNo - 1] = data
394
- await ackXFTPChunk(agent, server, kp.privateKey, replica.replicaId)
395
- }
396
- }))
397
334
  const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
398
335
  if (totalSize !== fd.size) throw new Error("resolveRedirect: redirect file size mismatch")
399
336
  const digest = sha512Streaming(plaintextChunks)
@@ -411,22 +348,14 @@ async function resolveRedirect(
411
348
  // -- Delete
412
349
 
413
350
  export async function deleteFile(agent: XFTPClientAgent, sndDescription: FileDescription): Promise<void> {
414
- const byServer = new Map<string, typeof sndDescription.chunks>()
415
351
  for (const chunk of sndDescription.chunks) {
416
- const srv = chunk.replicas[0]?.server ?? ""
417
- if (!byServer.has(srv)) byServer.set(srv, [])
418
- byServer.get(srv)!.push(chunk)
352
+ const replica = chunk.replicas[0]
353
+ if (!replica) throw new Error("deleteFile: chunk has no replicas")
354
+ const server = parseXFTPServer(replica.server)
355
+ const seed = decodePrivKeyEd25519(replica.replicaKey)
356
+ const kp = ed25519KeyPairFromSeed(seed)
357
+ await deleteXFTPChunk(agent, server, kp.privateKey, replica.replicaId)
419
358
  }
420
- await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
421
- const server = parseXFTPServer(srv)
422
- for (const chunk of chunks) {
423
- const replica = chunk.replicas[0]
424
- if (!replica) throw new Error("deleteFile: chunk has no replicas")
425
- const seed = decodePrivKeyEd25519(replica.replicaKey)
426
- const kp = ed25519KeyPairFromSeed(seed)
427
- await deleteXFTPChunk(agent, server, kp.privateKey, replica.replicaId)
428
- }
429
- }))
430
359
  }
431
360
 
432
361
  // -- Internal
package/src/client.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import {verifyIdentityProof} from "./crypto/identity.js"
17
17
  import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js"
18
18
  import {
19
- encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFACK, encodeFDEL, encodePING,
19
+ encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodePING,
20
20
  decodeResponse, type FileResponse, type FileInfo, type XFTPErrorType
21
21
  } from "./protocol/commands.js"
22
22
  import {decryptReceivedChunk} from "./download.js"
@@ -430,13 +430,6 @@ export async function deleteXFTPChunk(
430
430
  if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
431
431
  }
432
432
 
433
- export async function ackXFTPChunk(
434
- agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, rId: Uint8Array
435
- ): Promise<void> {
436
- const {response} = await sendXFTPCommand(agent, server, rpKey, rId, encodeFACK())
437
- if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
438
- }
439
-
440
433
  export async function pingXFTP(agent: XFTPClientAgent, server: XFTPServer): Promise<void> {
441
434
  const client = await getXFTPServerClient(agent, server)
442
435
  const corrId = new Uint8Array(0)
@@ -1,6 +1,6 @@
1
1
  // Cryptographic hash functions matching Simplex.Messaging.Crypto (sha256Hash, sha512Hash).
2
2
 
3
- import sodium, {type StateAddress} from "libsodium-wrappers-sumo"
3
+ import sodium from "libsodium-wrappers-sumo"
4
4
 
5
5
  // SHA-256 digest (32 bytes) -- Crypto.hs:1006
6
6
  export function sha256(data: Uint8Array): Uint8Array {
@@ -16,7 +16,7 @@ export function sha512(data: Uint8Array): Uint8Array {
16
16
  // Internally segments chunks larger than 4MB to limit peak WASM memory usage.
17
17
  export function sha512Streaming(chunks: Iterable<Uint8Array>): Uint8Array {
18
18
  const SEG = 4 * 1024 * 1024
19
- const state = sodium.crypto_hash_sha512_init() as unknown as StateAddress
19
+ const state = sodium.crypto_hash_sha512_init() as unknown as sodium.StateAddress
20
20
  for (const chunk of chunks) {
21
21
  for (let off = 0; off < chunk.length; off += SEG) {
22
22
  sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
@@ -1,7 +1,6 @@
1
1
  // Key generation, signing, DH -- Simplex.Messaging.Crypto (Ed25519/X25519/Ed448 functions).
2
2
 
3
3
  import sodium from "libsodium-wrappers-sumo"
4
- await sodium.ready
5
4
  import {ed448} from "@noble/curves/ed448"
6
5
  import {sha256} from "./digest.js"
7
6
  import {concatBytes} from "../protocol/encoding.js"
@@ -22,18 +22,11 @@ export interface FileInfo {
22
22
 
23
23
  export type CommandError = "UNKNOWN" | "SYNTAX" | "PROHIBITED" | "NO_AUTH" | "HAS_AUTH" | "NO_ENTITY"
24
24
 
25
- export type BlockingReason = "spam" | "content"
26
-
27
- export interface BlockingInfo {
28
- reason: BlockingReason
29
- notice: {ttl: number | null} | null
30
- }
31
-
32
25
  export type XFTPErrorType =
33
26
  | {type: "BLOCK"} | {type: "SESSION"} | {type: "HANDSHAKE"}
34
27
  | {type: "CMD", cmdErr: CommandError}
35
28
  | {type: "AUTH"}
36
- | {type: "BLOCKED", blockInfo: BlockingInfo}
29
+ | {type: "BLOCKED", blockInfo: string}
37
30
  | {type: "SIZE"} | {type: "QUOTA"} | {type: "DIGEST"} | {type: "CRYPTO"}
38
31
  | {type: "NO_FILE"} | {type: "HAS_FILE"} | {type: "FILE_IO"}
39
32
  | {type: "TIMEOUT"} | {type: "INTERNAL"}
@@ -80,8 +73,6 @@ export function encodeFPUT(): Uint8Array { return ascii("FPUT") }
80
73
 
81
74
  export function encodeFDEL(): Uint8Array { return ascii("FDEL") }
82
75
 
83
- export function encodeFACK(): Uint8Array { return ascii("FACK") }
84
-
85
76
  export function encodeFGET(rcvDhKey: Uint8Array): Uint8Array {
86
77
  return concatBytes(ascii("FGET"), SPACE, encodeBytes(rcvDhKey))
87
78
  }
@@ -111,17 +102,6 @@ function decodeCommandError(s: string): CommandError {
111
102
  throw new Error("bad CommandError: " + s)
112
103
  }
113
104
 
114
- function decodeBlockingInfo(s: string): BlockingInfo {
115
- const noticeIdx = s.indexOf(",notice=")
116
- const reasonPart = noticeIdx >= 0 ? s.slice(0, noticeIdx) : s
117
- const reason: BlockingReason = reasonPart === "reason=spam" ? "spam" : "content"
118
- let notice: {ttl: number | null} | null = null
119
- if (noticeIdx >= 0) {
120
- try { notice = JSON.parse(s.slice(noticeIdx + 8)) } catch {}
121
- }
122
- return {reason, notice}
123
- }
124
-
125
105
  export function decodeXFTPError(d: Decoder): XFTPErrorType {
126
106
  const s = readTag(d)
127
107
  switch (s) {
@@ -135,7 +115,7 @@ export function decodeXFTPError(d: Decoder): XFTPErrorType {
135
115
  const rest = d.takeAll()
136
116
  let info = ""
137
117
  for (let i = 0; i < rest.length; i++) info += String.fromCharCode(rest[i])
138
- return {type: "BLOCKED", blockInfo: decodeBlockingInfo(info)}
118
+ return {type: "BLOCKED", blockInfo: info}
139
119
  }
140
120
  case "SIZE": return {type: "SIZE"}
141
121
  case "QUOTA": return {type: "QUOTA"}
@@ -0,0 +1,122 @@
1
+ import type {FileHeader} from '../src/crypto/file.js'
2
+
3
+ export interface CryptoBackend {
4
+ encrypt(data: Uint8Array, fileName: string,
5
+ onProgress?: (done: number, total: number) => void
6
+ ): Promise<EncryptResult>
7
+ readChunk(offset: number, size: number): Promise<Uint8Array>
8
+ decryptAndStoreChunk(
9
+ dhSecret: Uint8Array, nonce: Uint8Array,
10
+ body: Uint8Array, digest: Uint8Array, chunkNo: number
11
+ ): Promise<void>
12
+ verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
13
+ ): Promise<{header: FileHeader, content: Uint8Array}>
14
+ cleanup(): Promise<void>
15
+ }
16
+
17
+ export interface EncryptResult {
18
+ digest: Uint8Array
19
+ key: Uint8Array
20
+ nonce: Uint8Array
21
+ chunkSizes: number[]
22
+ }
23
+
24
+ type PendingRequest = {resolve: (value: any) => void, reject: (reason: any) => void}
25
+
26
+ class WorkerBackend implements CryptoBackend {
27
+ private worker: Worker
28
+ private pending = new Map<number, PendingRequest>()
29
+ private nextId = 1
30
+ private progressCb: ((done: number, total: number) => void) | null = null
31
+
32
+ constructor() {
33
+ this.worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), {type: 'module'})
34
+ this.worker.onmessage = (e) => this.handleMessage(e.data)
35
+ }
36
+
37
+ private handleMessage(msg: {id: number, type: string, [k: string]: any}) {
38
+ if (msg.type === 'progress') {
39
+ this.progressCb?.(msg.done, msg.total)
40
+ return
41
+ }
42
+ const p = this.pending.get(msg.id)
43
+ if (!p) return
44
+ this.pending.delete(msg.id)
45
+ if (msg.type === 'error') {
46
+ p.reject(new Error(msg.message))
47
+ } else {
48
+ p.resolve(msg)
49
+ }
50
+ }
51
+
52
+ private send(msg: Record<string, any>, transfer?: Transferable[]): Promise<any> {
53
+ const id = this.nextId++
54
+ return new Promise((resolve, reject) => {
55
+ this.pending.set(id, {resolve, reject})
56
+ this.worker.postMessage({...msg, id}, transfer ?? [])
57
+ })
58
+ }
59
+
60
+ private toTransferable(data: Uint8Array): ArrayBuffer {
61
+ if (data.byteOffset !== 0 || data.byteLength !== data.buffer.byteLength) {
62
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
63
+ }
64
+ return data.buffer as ArrayBuffer
65
+ }
66
+
67
+ async encrypt(data: Uint8Array, fileName: string,
68
+ onProgress?: (done: number, total: number) => void): Promise<EncryptResult> {
69
+ this.progressCb = onProgress ?? null
70
+ const buf = this.toTransferable(data)
71
+ const resp = await this.send({type: 'encrypt', data: buf, fileName}, [buf])
72
+ this.progressCb = null
73
+ return {digest: resp.digest, key: resp.key, nonce: resp.nonce, chunkSizes: resp.chunkSizes}
74
+ }
75
+
76
+ async readChunk(offset: number, size: number): Promise<Uint8Array> {
77
+ const resp = await this.send({type: 'readChunk', offset, size})
78
+ return new Uint8Array(resp.data)
79
+ }
80
+
81
+ async decryptAndStoreChunk(
82
+ dhSecret: Uint8Array, nonce: Uint8Array,
83
+ body: Uint8Array, digest: Uint8Array, chunkNo: number
84
+ ): Promise<void> {
85
+ // Copy arrays to ensure clean ArrayBuffer separation before worker transfer
86
+ // nonce/dhSecret may be subarrays sharing buffer with body
87
+ const dhSecretCopy = new Uint8Array(dhSecret)
88
+ const nonceCopy = new Uint8Array(nonce)
89
+ const digestCopy = new Uint8Array(digest)
90
+ const buf = this.toTransferable(body)
91
+ const hex = (b: Uint8Array | ArrayBuffer, n = 8) => {
92
+ const u = b instanceof ArrayBuffer ? new Uint8Array(b) : b
93
+ return Array.from(u.slice(0, n)).map(x => x.toString(16).padStart(2, '0')).join('')
94
+ }
95
+ console.log(`[BACKEND-DBG] chunk=${chunkNo} body.len=${body.length} body.byteOff=${body.byteOffset} buf.byteLen=${buf.byteLength} nonce=${hex(nonceCopy, 24)} dhSecret=${hex(dhSecretCopy)} digest=${hex(digestCopy, 32)} buf[0..8]=${hex(buf)} body[-8..]=${hex(body.slice(-8))}`)
96
+ await this.send(
97
+ {type: 'decryptAndStoreChunk', dhSecret: dhSecretCopy, nonce: nonceCopy, body: buf, chunkDigest: digestCopy, chunkNo},
98
+ [buf]
99
+ )
100
+ }
101
+
102
+ async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
103
+ ): Promise<{header: FileHeader, content: Uint8Array}> {
104
+ const resp = await this.send({
105
+ type: 'verifyAndDecrypt',
106
+ size: params.size, digest: params.digest, key: params.key, nonce: params.nonce
107
+ })
108
+ return {header: resp.header, content: new Uint8Array(resp.content)}
109
+ }
110
+
111
+ async cleanup(): Promise<void> {
112
+ await this.send({type: 'cleanup'})
113
+ this.worker.terminate()
114
+ }
115
+ }
116
+
117
+ export function createCryptoBackend(): CryptoBackend {
118
+ if (typeof Worker === 'undefined') {
119
+ throw new Error('Web Workers required — update your browser')
120
+ }
121
+ return new WorkerBackend()
122
+ }