@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.
- package/README.md +15 -118
- package/dist/agent.d.ts +9 -29
- package/dist/agent.js +98 -238
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +16 -17
- package/dist/client.js +21 -27
- package/dist/client.js.map +1 -1
- package/dist/crypto/digest.d.ts +0 -5
- package/dist/crypto/digest.js +0 -10
- package/dist/crypto/digest.js.map +1 -1
- package/dist/crypto/file.d.ts +0 -12
- package/dist/crypto/file.js +0 -48
- package/dist/crypto/file.js.map +1 -1
- package/dist/crypto/identity.d.ts +0 -1
- package/dist/crypto/keys.d.ts +0 -1
- package/dist/crypto/keys.js +0 -1
- package/dist/crypto/keys.js.map +1 -1
- package/dist/crypto/padding.d.ts +0 -1
- package/dist/crypto/secretbox.d.ts +0 -1
- package/dist/download.d.ts +0 -1
- package/dist/protocol/address.d.ts +0 -1
- package/dist/protocol/chunks.d.ts +0 -1
- package/dist/protocol/client.d.ts +0 -1
- package/dist/protocol/commands.d.ts +1 -10
- package/dist/protocol/commands.js +1 -15
- package/dist/protocol/commands.js.map +1 -1
- package/dist/protocol/description.d.ts +0 -1
- package/dist/protocol/encoding.d.ts +0 -1
- package/dist/protocol/handshake.d.ts +0 -1
- package/dist/protocol/transmission.d.ts +0 -1
- package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
- package/dist-web/assets/__vite-browser-external.js +1 -0
- package/dist-web/assets/index.css +1 -0
- package/dist-web/assets/index.js +1468 -0
- package/dist-web/crypto.worker.js +1413 -0
- package/dist-web/index.html +15 -0
- package/package.json +4 -5
- package/src/agent.ts +108 -310
- package/src/client.ts +38 -40
- package/src/crypto/digest.ts +2 -15
- package/src/crypto/file.ts +0 -83
- package/src/crypto/keys.ts +0 -1
- package/src/protocol/commands.ts +2 -22
- package/web/crypto-backend.ts +122 -0
- package/web/crypto.worker.ts +313 -0
- package/web/download.ts +140 -0
- package/web/index.html +15 -0
- package/web/main.ts +30 -0
- package/web/progress.ts +52 -0
- package/web/servers.json +18 -0
- package/web/servers.ts +16 -0
- package/web/style.css +103 -0
- package/web/upload.ts +171 -0
- 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,
|
|
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
|
|
176
|
-
connections
|
|
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
|
-
|
|
181
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/src/crypto/digest.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Cryptographic hash functions matching Simplex.Messaging.Crypto (sha256Hash, sha512Hash).
|
|
2
2
|
|
|
3
|
-
import sodium
|
|
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)))
|
package/src/crypto/file.ts
CHANGED
|
@@ -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.
|
package/src/crypto/keys.ts
CHANGED
|
@@ -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"
|
package/src/protocol/commands.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
}
|