@shhhum/xftp-web 0.4.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 -118
  2. package/dist/agent.d.ts +9 -29
  3. package/dist/agent.js +98 -238
  4. package/dist/agent.js.map +1 -1
  5. package/dist/client.d.ts +16 -17
  6. package/dist/client.js +21 -27
  7. package/dist/client.js.map +1 -1
  8. package/dist/crypto/digest.d.ts +0 -5
  9. package/dist/crypto/digest.js +0 -10
  10. package/dist/crypto/digest.js.map +1 -1
  11. package/dist/crypto/file.d.ts +0 -12
  12. package/dist/crypto/file.js +0 -48
  13. package/dist/crypto/file.js.map +1 -1
  14. package/dist/crypto/identity.d.ts +0 -1
  15. package/dist/crypto/keys.d.ts +0 -1
  16. package/dist/crypto/keys.js +0 -1
  17. package/dist/crypto/keys.js.map +1 -1
  18. package/dist/crypto/padding.d.ts +0 -1
  19. package/dist/crypto/secretbox.d.ts +0 -1
  20. package/dist/download.d.ts +0 -1
  21. package/dist/protocol/address.d.ts +0 -1
  22. package/dist/protocol/chunks.d.ts +0 -1
  23. package/dist/protocol/client.d.ts +0 -1
  24. package/dist/protocol/commands.d.ts +1 -10
  25. package/dist/protocol/commands.js +1 -15
  26. package/dist/protocol/commands.js.map +1 -1
  27. package/dist/protocol/description.d.ts +0 -1
  28. package/dist/protocol/encoding.d.ts +0 -1
  29. package/dist/protocol/handshake.d.ts +0 -1
  30. package/dist/protocol/transmission.d.ts +0 -1
  31. package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
  32. package/dist-web/assets/__vite-browser-external.js +1 -0
  33. package/dist-web/assets/index.css +1 -0
  34. package/dist-web/assets/index.js +1468 -0
  35. package/dist-web/crypto.worker.js +1413 -0
  36. package/dist-web/index.html +15 -0
  37. package/package.json +4 -5
  38. package/src/agent.ts +108 -310
  39. package/src/client.ts +38 -40
  40. package/src/crypto/digest.ts +2 -15
  41. package/src/crypto/file.ts +0 -83
  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
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"
@@ -172,33 +172,17 @@ interface ServerConnection {
172
172
  queue: Promise<void> // tail of sequential command chain
173
173
  }
174
174
 
175
- export class XFTPAgent {
176
- connections = new Map<string, ServerConnection>()
175
+ export interface XFTPClientAgent {
176
+ connections: Map<string, ServerConnection>
177
177
  /** @internal Injectable for testing — defaults to connectXFTP */
178
178
  _connectFn: (server: XFTPServer) => Promise<XFTPClient>
179
+ }
179
180
 
180
- constructor(connectFn?: (server: XFTPServer) => Promise<XFTPClient>) {
181
- this._connectFn = connectFn ?? connectXFTP
182
- }
183
-
184
- closeServer(server: XFTPServer): void {
185
- const key = formatXFTPServer(server)
186
- const conn = this.connections.get(key)
187
- if (conn) {
188
- this.connections.delete(key)
189
- conn.client.then(c => c.transport.close(), () => {})
190
- }
191
- }
192
-
193
- close(): void {
194
- for (const conn of this.connections.values()) {
195
- conn.client.then(c => c.transport.close(), () => {})
196
- }
197
- this.connections.clear()
198
- }
181
+ export function newXFTPAgent(): XFTPClientAgent {
182
+ return {connections: new Map(), _connectFn: connectXFTP}
199
183
  }
200
184
 
201
- export function getXFTPServerClient(agent: XFTPAgent, server: XFTPServer): Promise<XFTPClient> {
185
+ export function getXFTPServerClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
202
186
  const key = formatXFTPServer(server)
203
187
  let conn = agent.connections.get(key)
204
188
  if (!conn) {
@@ -213,7 +197,7 @@ export function getXFTPServerClient(agent: XFTPAgent, server: XFTPServer): Promi
213
197
  return conn.client
214
198
  }
215
199
 
216
- export function reconnectClient(agent: XFTPAgent, server: XFTPServer): Promise<XFTPClient> {
200
+ export function reconnectClient(agent: XFTPClientAgent, server: XFTPServer): Promise<XFTPClient> {
217
201
  const key = formatXFTPServer(server)
218
202
  const old = agent.connections.get(key)
219
203
  old?.client.then(c => c.transport.close(), () => {})
@@ -228,7 +212,7 @@ export function reconnectClient(agent: XFTPAgent, server: XFTPServer): Promise<X
228
212
  }
229
213
 
230
214
  export function removeStaleConnection(
231
- agent: XFTPAgent, server: XFTPServer, failedP: Promise<XFTPClient>
215
+ agent: XFTPClientAgent, server: XFTPServer, failedP: Promise<XFTPClient>
232
216
  ): void {
233
217
  const key = formatXFTPServer(server)
234
218
  const conn = agent.connections.get(key)
@@ -238,6 +222,22 @@ export function removeStaleConnection(
238
222
  }
239
223
  }
240
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
241
  // -- Connect + handshake
242
242
 
243
243
  export async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
@@ -342,7 +342,7 @@ function _hex(b: Uint8Array, n = 8): string {
342
342
  // -- Send command (with retry + reconnect)
343
343
 
344
344
  export async function sendXFTPCommand(
345
- agent: XFTPAgent,
345
+ agent: XFTPClientAgent,
346
346
  server: XFTPServer,
347
347
  privateKey: Uint8Array,
348
348
  entityId: Uint8Array,
@@ -375,7 +375,7 @@ export async function sendXFTPCommand(
375
375
  // -- Command wrappers
376
376
 
377
377
  export async function createXFTPChunk(
378
- agent: XFTPAgent, server: XFTPServer, spKey: Uint8Array, file: FileInfo,
378
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, file: FileInfo,
379
379
  rcvKeys: Uint8Array[], auth: Uint8Array | null = null
380
380
  ): Promise<{senderId: Uint8Array, recipientIds: Uint8Array[]}> {
381
381
  const {response} = await sendXFTPCommand(agent, server, spKey, new Uint8Array(0), encodeFNEW(file, rcvKeys, auth))
@@ -384,7 +384,7 @@ export async function createXFTPChunk(
384
384
  }
385
385
 
386
386
  export async function addXFTPRecipients(
387
- agent: XFTPAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
387
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
388
388
  ): Promise<Uint8Array[]> {
389
389
  const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFADD(rcvKeys))
390
390
  if (response.type !== "FRRcvIds") throw new Error("unexpected response: " + response.type)
@@ -392,7 +392,7 @@ export async function addXFTPRecipients(
392
392
  }
393
393
 
394
394
  export async function uploadXFTPChunk(
395
- agent: XFTPAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
395
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
396
396
  ): Promise<void> {
397
397
  const {response} = await sendXFTPCommand(agent, server, spKey, fId, encodeFPUT(), chunkData)
398
398
  if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
@@ -405,7 +405,7 @@ export interface RawChunkResponse {
405
405
  }
406
406
 
407
407
  export async function downloadXFTPChunkRaw(
408
- agent: XFTPAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array
408
+ agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array
409
409
  ): Promise<RawChunkResponse> {
410
410
  const {publicKey, privateKey} = generateX25519KeyPair()
411
411
  const cmd = encodeFGET(encodePubKeyX25519(publicKey))
@@ -417,27 +417,20 @@ export async function downloadXFTPChunkRaw(
417
417
  }
418
418
 
419
419
  export async function downloadXFTPChunk(
420
- agent: XFTPAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
420
+ agent: XFTPClientAgent, server: XFTPServer, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
421
421
  ): Promise<Uint8Array> {
422
422
  const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(agent, server, rpKey, fId)
423
423
  return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
424
424
  }
425
425
 
426
426
  export async function deleteXFTPChunk(
427
- agent: XFTPAgent, server: XFTPServer, spKey: Uint8Array, sId: Uint8Array
427
+ agent: XFTPClientAgent, server: XFTPServer, spKey: Uint8Array, sId: Uint8Array
428
428
  ): Promise<void> {
429
429
  const {response} = await sendXFTPCommand(agent, server, spKey, sId, encodeFDEL())
430
430
  if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
431
431
  }
432
432
 
433
- export async function ackXFTPChunk(
434
- agent: XFTPAgent, 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
- export async function pingXFTP(agent: XFTPAgent, server: XFTPServer): Promise<void> {
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)
443
436
  const block = encodeTransmission(client.sessionId, corrId, new Uint8Array(0), encodePING())
@@ -448,3 +441,8 @@ export async function pingXFTP(agent: XFTPAgent, server: XFTPServer): Promise<vo
448
441
  if (response.type !== "FRPong") throw new Error("unexpected response: " + response.type)
449
442
  }
450
443
 
444
+ // -- Close
445
+
446
+ export function closeXFTP(c: XFTPClient): void {
447
+ c.transport.close()
448
+ }
@@ -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 {
@@ -12,24 +12,11 @@ export function sha512(data: Uint8Array): Uint8Array {
12
12
  return sodium.crypto_hash_sha512(data)
13
13
  }
14
14
 
15
- // Incremental SHA-512 — for computing digest during streaming encryption.
16
- export function sha512Init(): StateAddress {
17
- return sodium.crypto_hash_sha512_init() as unknown as StateAddress
18
- }
19
-
20
- export function sha512Update(state: StateAddress, data: Uint8Array): void {
21
- sodium.crypto_hash_sha512_update(state, data)
22
- }
23
-
24
- export function sha512Final(state: StateAddress): Uint8Array {
25
- return sodium.crypto_hash_sha512_final(state)
26
- }
27
-
28
15
  // Streaming SHA-512 over multiple chunks -- avoids copying large data into WASM memory at once.
29
16
  // Internally segments chunks larger than 4MB to limit peak WASM memory usage.
30
17
  export function sha512Streaming(chunks: Iterable<Uint8Array>): Uint8Array {
31
18
  const SEG = 4 * 1024 * 1024
32
- const state = sodium.crypto_hash_sha512_init() as unknown as StateAddress
19
+ const state = sodium.crypto_hash_sha512_init() as unknown as sodium.StateAddress
33
20
  for (const chunk of chunks) {
34
21
  for (let off = 0; off < chunk.length; off += SEG) {
35
22
  sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
@@ -3,7 +3,6 @@
3
3
 
4
4
  import {Decoder, concatBytes, encodeInt64, encodeString, decodeString, encodeMaybe, decodeMaybe} from "../protocol/encoding.js"
5
5
  import {sbInit, sbEncryptChunk, sbDecryptTailTag, sbAuth} from "./secretbox.js"
6
- import {prepareChunkSizes, fileSizeLen, authTagSize} from "../protocol/chunks.js"
7
6
 
8
7
  const AUTH_TAG_SIZE = 16n
9
8
 
@@ -70,88 +69,6 @@ export function encryptFile(
70
69
  return concatBytes(hdr, encSource, encPad, tag)
71
70
  }
72
71
 
73
- // Async variant: encrypts source in 64KB slices, yielding between each to avoid blocking the main thread.
74
- // Produces identical output to encryptFile.
75
- // When onSlice is provided, encrypted data is streamed to the callback instead of buffered.
76
- const ENCRYPT_SLICE = 65536
77
-
78
- export async function encryptFileAsync(
79
- source: Uint8Array, fileHdr: Uint8Array,
80
- key: Uint8Array, nonce: Uint8Array,
81
- fileSize: bigint, encSize: bigint,
82
- onProgress?: (done: number, total: number) => void
83
- ): Promise<Uint8Array>
84
- export async function encryptFileAsync(
85
- source: Uint8Array, fileHdr: Uint8Array,
86
- key: Uint8Array, nonce: Uint8Array,
87
- fileSize: bigint, encSize: bigint,
88
- onProgress: ((done: number, total: number) => void) | undefined,
89
- onSlice: (data: Uint8Array) => void | Promise<void>
90
- ): Promise<void>
91
- export async function encryptFileAsync(
92
- source: Uint8Array,
93
- fileHdr: Uint8Array,
94
- key: Uint8Array,
95
- nonce: Uint8Array,
96
- fileSize: bigint,
97
- encSize: bigint,
98
- onProgress?: (done: number, total: number) => void,
99
- onSlice?: (data: Uint8Array) => void | Promise<void>
100
- ): Promise<Uint8Array | void> {
101
- const state = sbInit(key, nonce)
102
- const lenStr = encodeInt64(fileSize)
103
- const padLen = Number(encSize - AUTH_TAG_SIZE - fileSize - 8n)
104
- if (padLen < 0) throw new Error("encryptFile: encSize too small")
105
- const out = onSlice ? null : new Uint8Array(Number(encSize))
106
- let outOff = 0
107
-
108
- async function emit(data: Uint8Array) {
109
- if (onSlice) {
110
- await onSlice(data)
111
- } else {
112
- out!.set(data, outOff)
113
- outOff += data.length
114
- }
115
- }
116
-
117
- await emit(sbEncryptChunk(state, concatBytes(lenStr, fileHdr)))
118
- for (let off = 0; off < source.length; off += ENCRYPT_SLICE) {
119
- const end = Math.min(off + ENCRYPT_SLICE, source.length)
120
- await emit(sbEncryptChunk(state, source.subarray(off, end)))
121
- onProgress?.(end, source.length)
122
- await new Promise<void>(r => setTimeout(r, 0))
123
- }
124
- const padding = new Uint8Array(padLen)
125
- padding.fill(0x23)
126
- await emit(sbEncryptChunk(state, padding))
127
- await emit(sbAuth(state))
128
- if (out) return out
129
- }
130
-
131
- // -- Encryption preparation (key gen + chunk sizing)
132
-
133
- export interface EncryptionParams {
134
- fileHdr: Uint8Array
135
- key: Uint8Array
136
- nonce: Uint8Array
137
- fileSize: bigint
138
- encSize: bigint
139
- chunkSizes: number[]
140
- }
141
-
142
- export function prepareEncryption(sourceSize: number, fileName: string): EncryptionParams {
143
- const key = new Uint8Array(32)
144
- const nonce = new Uint8Array(24)
145
- crypto.getRandomValues(key)
146
- crypto.getRandomValues(nonce)
147
- const fileHdr = encodeFileHeader({fileName, fileExtra: null})
148
- const fileSize = BigInt(fileHdr.length + sourceSize)
149
- const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
150
- const chunkSizes = prepareChunkSizes(payloadSize)
151
- const encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0))
152
- return {fileHdr, key, nonce, fileSize, encSize, chunkSizes}
153
- }
154
-
155
72
  // -- Decryption (FileTransfer.Crypto:decryptChunks)
156
73
 
157
74
  // Decrypt one or more XFTP chunks into a FileHeader and file content.
@@ -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
+ }