@shhhum/xftp-web 0.4.0 → 0.6.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 (51) hide show
  1. package/README.md +15 -118
  2. package/dist/agent.d.ts +9 -29
  3. package/dist/agent.js +90 -213
  4. package/dist/agent.js.map +1 -1
  5. package/dist/client.d.ts +15 -18
  6. package/dist/client.js +18 -28
  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/padding.d.ts +0 -1
  17. package/dist/crypto/secretbox.d.ts +0 -1
  18. package/dist/download.d.ts +0 -1
  19. package/dist/protocol/address.d.ts +0 -1
  20. package/dist/protocol/chunks.d.ts +0 -1
  21. package/dist/protocol/client.d.ts +0 -1
  22. package/dist/protocol/commands.d.ts +1 -10
  23. package/dist/protocol/commands.js +1 -15
  24. package/dist/protocol/commands.js.map +1 -1
  25. package/dist/protocol/description.d.ts +0 -1
  26. package/dist/protocol/encoding.d.ts +0 -1
  27. package/dist/protocol/handshake.d.ts +0 -1
  28. package/dist/protocol/transmission.d.ts +0 -1
  29. package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
  30. package/dist-web/assets/__vite-browser-external.js +1 -0
  31. package/dist-web/assets/index.css +1 -0
  32. package/dist-web/assets/index.js +1468 -0
  33. package/dist-web/crypto.worker.js +1413 -0
  34. package/dist-web/index.html +15 -0
  35. package/package.json +10 -6
  36. package/src/agent.ts +101 -286
  37. package/src/client.ts +34 -41
  38. package/src/crypto/digest.ts +2 -15
  39. package/src/crypto/file.ts +0 -83
  40. package/src/protocol/commands.ts +2 -22
  41. package/web/crypto-backend.ts +140 -0
  42. package/web/crypto.worker.ts +316 -0
  43. package/web/download.ts +140 -0
  44. package/web/index.html +15 -0
  45. package/web/main.ts +30 -0
  46. package/web/progress.ts +52 -0
  47. package/web/servers.json +18 -0
  48. package/web/servers.ts +12 -0
  49. package/web/style.css +103 -0
  50. package/web/upload.ts +170 -0
  51. 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,9 +222,25 @@ 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
- export async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
243
+ async function connectXFTP(server: XFTPServer, config?: Partial<TransportConfig>): Promise<XFTPClient> {
244
244
  const cfg: TransportConfig = {...DEFAULT_TRANSPORT_CONFIG, ...config}
245
245
  const baseUrl = "https://" + server.host + ":" + server.port
246
246
  const transport = await createTransport(baseUrl, cfg)
@@ -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())
@@ -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.
@@ -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,140 @@
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
+ private ready: Promise<void>
32
+
33
+ constructor() {
34
+ this.worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), {type: 'module'})
35
+ let rejectReady: (e: Error) => void
36
+ this.ready = new Promise((resolve, reject) => {
37
+ rejectReady = reject
38
+ this.worker.onmessage = (e) => {
39
+ if (e.data?.type === 'ready') {
40
+ this.worker.onmessage = (e) => this.handleMessage(e.data)
41
+ resolve()
42
+ } else {
43
+ reject(new Error('Worker: unexpected first message'))
44
+ }
45
+ }
46
+ })
47
+ this.worker.onerror = (e) => {
48
+ rejectReady(new Error('Worker failed to load: ' + e.message))
49
+ for (const p of this.pending.values()) p.reject(new Error('Worker error: ' + e.message))
50
+ this.pending.clear()
51
+ }
52
+ }
53
+
54
+ private handleMessage(msg: {id: number, type: string, [k: string]: any}) {
55
+ if (msg.type === 'progress') {
56
+ this.progressCb?.(msg.done, msg.total)
57
+ return
58
+ }
59
+ const p = this.pending.get(msg.id)
60
+ if (!p) return
61
+ this.pending.delete(msg.id)
62
+ if (msg.type === 'error') {
63
+ p.reject(new Error(msg.message))
64
+ } else {
65
+ p.resolve(msg)
66
+ }
67
+ }
68
+
69
+ private async send(msg: Record<string, any>, transfer?: Transferable[]): Promise<any> {
70
+ await this.ready
71
+ const id = this.nextId++
72
+ return new Promise((resolve, reject) => {
73
+ this.pending.set(id, {resolve, reject})
74
+ this.worker.postMessage({...msg, id}, transfer ?? [])
75
+ })
76
+ }
77
+
78
+ private toTransferable(data: Uint8Array): ArrayBuffer {
79
+ if (data.byteOffset !== 0 || data.byteLength !== data.buffer.byteLength) {
80
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
81
+ }
82
+ return data.buffer as ArrayBuffer
83
+ }
84
+
85
+ async encrypt(data: Uint8Array, fileName: string,
86
+ onProgress?: (done: number, total: number) => void): Promise<EncryptResult> {
87
+ this.progressCb = onProgress ?? null
88
+ const buf = this.toTransferable(data)
89
+ const resp = await this.send({type: 'encrypt', data: buf, fileName}, [buf])
90
+ this.progressCb = null
91
+ return {digest: resp.digest, key: resp.key, nonce: resp.nonce, chunkSizes: resp.chunkSizes}
92
+ }
93
+
94
+ async readChunk(offset: number, size: number): Promise<Uint8Array> {
95
+ const resp = await this.send({type: 'readChunk', offset, size})
96
+ return new Uint8Array(resp.data)
97
+ }
98
+
99
+ async decryptAndStoreChunk(
100
+ dhSecret: Uint8Array, nonce: Uint8Array,
101
+ body: Uint8Array, digest: Uint8Array, chunkNo: number
102
+ ): Promise<void> {
103
+ // Copy arrays to ensure clean ArrayBuffer separation before worker transfer
104
+ // nonce/dhSecret may be subarrays sharing buffer with body
105
+ const dhSecretCopy = new Uint8Array(dhSecret)
106
+ const nonceCopy = new Uint8Array(nonce)
107
+ const digestCopy = new Uint8Array(digest)
108
+ const buf = this.toTransferable(body)
109
+ const hex = (b: Uint8Array | ArrayBuffer, n = 8) => {
110
+ const u = b instanceof ArrayBuffer ? new Uint8Array(b) : b
111
+ return Array.from(u.slice(0, n)).map(x => x.toString(16).padStart(2, '0')).join('')
112
+ }
113
+ 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))}`)
114
+ await this.send(
115
+ {type: 'decryptAndStoreChunk', dhSecret: dhSecretCopy, nonce: nonceCopy, body: buf, chunkDigest: digestCopy, chunkNo},
116
+ [buf]
117
+ )
118
+ }
119
+
120
+ async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
121
+ ): Promise<{header: FileHeader, content: Uint8Array}> {
122
+ const resp = await this.send({
123
+ type: 'verifyAndDecrypt',
124
+ size: params.size, digest: params.digest, key: params.key, nonce: params.nonce
125
+ })
126
+ return {header: resp.header, content: new Uint8Array(resp.content)}
127
+ }
128
+
129
+ async cleanup(): Promise<void> {
130
+ await this.send({type: 'cleanup'})
131
+ this.worker.terminate()
132
+ }
133
+ }
134
+
135
+ export function createCryptoBackend(): CryptoBackend {
136
+ if (typeof Worker === 'undefined') {
137
+ throw new Error('Web Workers required — update your browser')
138
+ }
139
+ return new WorkerBackend()
140
+ }