@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.
- package/README.md +15 -83
- package/dist/agent.d.ts +4 -6
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +79 -143
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +0 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -6
- package/dist/client.js.map +1 -1
- package/dist/crypto/digest.d.ts +0 -1
- package/dist/crypto/digest.d.ts.map +1 -1
- package/dist/crypto/digest.js.map +1 -1
- package/dist/crypto/file.d.ts +0 -1
- package/dist/crypto/file.d.ts.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 +85 -156
- package/src/client.ts +1 -8
- package/src/crypto/digest.ts +2 -2
- 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
|
@@ -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
|
+
"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.
|
|
13
|
-
"
|
|
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,
|
|
19
|
-
deleteXFTPChunk,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
server: XFTPServer,
|
|
112
108
|
encrypted: EncryptedFileMetadata,
|
|
113
109
|
options?: UploadOptions
|
|
114
110
|
): Promise<UploadResult> {
|
|
115
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
uri = encodeDescriptionURI(redirected)
|
|
153
|
+
finalRcvDescription = await uploadRedirectDescription(agent, server, rcvDescription)
|
|
154
|
+
uri = encodeDescriptionURI(finalRcvDescription)
|
|
192
155
|
}
|
|
193
|
-
return {
|
|
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.
|
|
217
|
-
replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
287
|
-
replicaKey: encodePrivKeyEd25519(c.
|
|
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
|
|
382
|
-
if (!
|
|
383
|
-
|
|
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
|
|
417
|
-
if (!
|
|
418
|
-
|
|
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,
|
|
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)
|
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 {
|
|
@@ -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)))
|
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
|
+
}
|