@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.
- package/README.md +15 -118
- package/dist/agent.d.ts +9 -29
- package/dist/agent.js +90 -213
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +15 -18
- package/dist/client.js +18 -28
- 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/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 +10 -6
- package/src/agent.ts +101 -286
- package/src/client.ts +34 -41
- package/src/crypto/digest.ts +2 -15
- package/src/crypto/file.ts +0 -83
- package/src/protocol/commands.ts +2 -22
- package/web/crypto-backend.ts +140 -0
- package/web/crypto.worker.ts +316 -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 +12 -0
- package/web/style.css +103 -0
- package/web/upload.ts +170 -0
- package/src/index.ts +0 -4
package/README.md
CHANGED
|
@@ -2,116 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Browser-compatible XFTP file transfer client in TypeScript.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install xftp-web
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
```typescript
|
|
14
|
-
import {
|
|
15
|
-
XFTPAgent,
|
|
16
|
-
parseXFTPServer,
|
|
17
|
-
sendFile, receiveFile, deleteFile,
|
|
18
|
-
XFTPRetriableError, XFTPPermanentError, isRetriable,
|
|
19
|
-
} from "xftp-web"
|
|
20
|
-
|
|
21
|
-
// Create agent (manages connections)
|
|
22
|
-
const agent = new XFTPAgent()
|
|
23
|
-
|
|
24
|
-
const servers = [
|
|
25
|
-
parseXFTPServer("xftp://server1..."),
|
|
26
|
-
parseXFTPServer("xftp://server2..."),
|
|
27
|
-
parseXFTPServer("xftp://server3..."),
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
// Upload (from Uint8Array)
|
|
31
|
-
const {rcvDescriptions, sndDescription, uri} = await sendFile(
|
|
32
|
-
agent, servers, fileBytes, "photo.jpg",
|
|
33
|
-
{onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`)}
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
// Upload (streaming — constant memory, no full-file buffer)
|
|
37
|
-
const file = inputEl.files[0]
|
|
38
|
-
const result = await sendFile(
|
|
39
|
-
agent, servers, file.stream(), file.size, file.name,
|
|
40
|
-
{onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`)}
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
// Download
|
|
44
|
-
const {header, content} = await receiveFile(agent, uri, {
|
|
45
|
-
onProgress: (downloaded, total) => console.log(`${downloaded}/${total}`)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
// Delete (requires sender description from upload)
|
|
49
|
-
await deleteFile(agent, sndDescription)
|
|
50
|
-
|
|
51
|
-
// Cleanup
|
|
52
|
-
agent.close()
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
### Advanced usage
|
|
56
|
-
|
|
57
|
-
For streaming encryption (avoids buffering the full encrypted file) or worker-based uploads:
|
|
58
|
-
|
|
59
|
-
```typescript
|
|
60
|
-
import {
|
|
61
|
-
encryptFileForUpload, uploadFile, downloadFile,
|
|
62
|
-
decodeDescriptionURI,
|
|
63
|
-
} from "xftp-web"
|
|
64
|
-
|
|
65
|
-
// Streaming encryption — encrypted slices emitted via callback
|
|
66
|
-
const metadata = await encryptFileForUpload(fileBytes, "photo.jpg", {
|
|
67
|
-
onSlice: (data) => { /* write to OPFS, IndexedDB, etc. */ },
|
|
68
|
-
onProgress: (done, total) => {},
|
|
69
|
-
})
|
|
70
|
-
// metadata has {digest, key, nonce, chunkSizes} but no encData
|
|
71
|
-
|
|
72
|
-
// Upload with custom chunk reader (e.g. reading from OPFS)
|
|
73
|
-
const result = await uploadFile(agent, servers, metadata, {
|
|
74
|
-
readChunk: (offset, size) => readFromStorage(offset, size),
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
// Download with FileDescription object
|
|
78
|
-
const fd = decodeDescriptionURI(uri)
|
|
79
|
-
const {header, content} = await downloadFile(agent, fd)
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
### Upload options
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
await sendFile(agent, servers, fileBytes, "photo.jpg", {
|
|
86
|
-
onProgress: (uploaded, total) => {}, // progress callback
|
|
87
|
-
auth: basicAuthBytes, // BasicAuth for auth-required servers
|
|
88
|
-
numRecipients: 3, // multiple independent download credentials (default: 1)
|
|
89
|
-
})
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Error handling
|
|
93
|
-
|
|
94
|
-
```typescript
|
|
95
|
-
try {
|
|
96
|
-
await sendFile(agent, servers, fileBytes, "photo.jpg")
|
|
97
|
-
} catch (e) {
|
|
98
|
-
if (e instanceof XFTPRetriableError) {
|
|
99
|
-
// Network/timeout/session errors — safe to retry
|
|
100
|
-
} else if (e instanceof XFTPPermanentError) {
|
|
101
|
-
// AUTH, NO_FILE, BLOCKED, etc. — do not retry
|
|
102
|
-
}
|
|
103
|
-
// or use: isRetriable(e)
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
## Development
|
|
108
|
-
|
|
109
|
-
### Prerequisites
|
|
5
|
+
## Prerequisites
|
|
110
6
|
|
|
111
7
|
- Haskell toolchain with `cabal` (to build `xftp-server`)
|
|
112
8
|
- Node.js 20+
|
|
9
|
+
- Chromium system dependencies (see below)
|
|
113
10
|
|
|
114
|
-
|
|
11
|
+
## Setup
|
|
115
12
|
|
|
116
13
|
```bash
|
|
117
14
|
# Build the XFTP server binary (from repo root)
|
|
@@ -120,31 +17,31 @@ cabal build xftp-server
|
|
|
120
17
|
# Install JS dependencies
|
|
121
18
|
cd xftp-web
|
|
122
19
|
npm install
|
|
20
|
+
|
|
21
|
+
# Install Chromium for Playwright (browser tests)
|
|
22
|
+
npx playwright install chromium
|
|
123
23
|
```
|
|
124
24
|
|
|
125
|
-
|
|
25
|
+
If Chromium fails to launch due to missing system libraries, install them with:
|
|
126
26
|
|
|
127
27
|
```bash
|
|
128
|
-
|
|
28
|
+
# Requires root
|
|
29
|
+
npx playwright install-deps chromium
|
|
129
30
|
```
|
|
130
31
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
If Chromium fails to launch due to missing system libraries:
|
|
32
|
+
## Running tests
|
|
134
33
|
|
|
135
34
|
```bash
|
|
136
|
-
#
|
|
137
|
-
|
|
35
|
+
# Browser round-trip test (vitest + Playwright headless Chromium)
|
|
36
|
+
npm run test
|
|
138
37
|
```
|
|
139
38
|
|
|
140
|
-
|
|
39
|
+
The browser test automatically starts an `xftp-server` instance on port 7000 via `globalSetup`, using certs from `tests/fixtures/`.
|
|
40
|
+
|
|
41
|
+
## Build
|
|
141
42
|
|
|
142
43
|
```bash
|
|
143
44
|
npm run build
|
|
144
45
|
```
|
|
145
46
|
|
|
146
47
|
Output goes to `dist/`.
|
|
147
|
-
|
|
148
|
-
## License
|
|
149
|
-
|
|
150
|
-
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html)
|
package/dist/agent.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
export { prepareEncryption, type EncryptionParams } from "./crypto/file.js";
|
|
2
1
|
import { type FileDescription } from "./protocol/description.js";
|
|
3
|
-
import {
|
|
4
|
-
export {
|
|
2
|
+
import { type XFTPClientAgent } from "./client.js";
|
|
3
|
+
export { newXFTPAgent, closeXFTPAgent, type XFTPClientAgent, type TransportConfig } from "./client.js";
|
|
5
4
|
import type { XFTPServer } from "./protocol/address.js";
|
|
6
5
|
import type { FileHeader } from "./crypto/file.js";
|
|
7
6
|
export interface EncryptedFileMetadata {
|
|
@@ -14,7 +13,7 @@ export interface EncryptedFileInfo extends EncryptedFileMetadata {
|
|
|
14
13
|
encData: Uint8Array;
|
|
15
14
|
}
|
|
16
15
|
export interface UploadResult {
|
|
17
|
-
|
|
16
|
+
rcvDescription: FileDescription;
|
|
18
17
|
sndDescription: FileDescription;
|
|
19
18
|
uri: string;
|
|
20
19
|
}
|
|
@@ -24,29 +23,13 @@ export interface DownloadResult {
|
|
|
24
23
|
}
|
|
25
24
|
export declare function encodeDescriptionURI(fd: FileDescription): string;
|
|
26
25
|
export declare function decodeDescriptionURI(fragment: string): FileDescription;
|
|
27
|
-
export
|
|
28
|
-
onProgress?: (done: number, total: number) => void;
|
|
29
|
-
onSlice?: (data: Uint8Array) => void | Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
export declare function encryptFileForUpload(source: Uint8Array, fileName: string, options: EncryptForUploadOptions & {
|
|
32
|
-
onSlice: NonNullable<EncryptForUploadOptions['onSlice']>;
|
|
33
|
-
}): Promise<EncryptedFileMetadata>;
|
|
34
|
-
export declare function encryptFileForUpload(source: Uint8Array, fileName: string, options?: EncryptForUploadOptions): Promise<EncryptedFileInfo>;
|
|
26
|
+
export declare function encryptFileForUpload(source: Uint8Array, fileName: string): EncryptedFileInfo;
|
|
35
27
|
export interface UploadOptions {
|
|
36
28
|
onProgress?: (uploaded: number, total: number) => void;
|
|
37
29
|
redirectThreshold?: number;
|
|
38
30
|
readChunk?: (offset: number, size: number) => Promise<Uint8Array>;
|
|
39
|
-
auth?: Uint8Array;
|
|
40
|
-
numRecipients?: number;
|
|
41
|
-
}
|
|
42
|
-
export declare function uploadFile(agent: XFTPAgent, servers: XFTPServer[], encrypted: EncryptedFileMetadata, options?: UploadOptions): Promise<UploadResult>;
|
|
43
|
-
export interface SendFileOptions {
|
|
44
|
-
onProgress?: (uploaded: number, total: number) => void;
|
|
45
|
-
auth?: Uint8Array;
|
|
46
|
-
numRecipients?: number;
|
|
47
31
|
}
|
|
48
|
-
export declare function
|
|
49
|
-
export declare function sendFile(agent: XFTPAgent, servers: XFTPServer[], source: AsyncIterable<Uint8Array>, sourceSize: number, fileName: string, options?: SendFileOptions): Promise<UploadResult>;
|
|
32
|
+
export declare function uploadFile(agent: XFTPClientAgent, servers: XFTPServer[], encrypted: EncryptedFileMetadata, options?: UploadOptions): Promise<UploadResult>;
|
|
50
33
|
export interface RawDownloadedChunk {
|
|
51
34
|
chunkNo: number;
|
|
52
35
|
dhSecret: Uint8Array;
|
|
@@ -56,11 +39,8 @@ export interface RawDownloadedChunk {
|
|
|
56
39
|
}
|
|
57
40
|
export interface DownloadRawOptions {
|
|
58
41
|
onProgress?: (downloaded: number, total: number) => void;
|
|
42
|
+
concurrency?: number;
|
|
59
43
|
}
|
|
60
|
-
export declare function downloadFileRaw(agent:
|
|
61
|
-
export declare function downloadFile(agent:
|
|
62
|
-
export declare function
|
|
63
|
-
onProgress?: (downloaded: number, total: number) => void;
|
|
64
|
-
}): Promise<DownloadResult>;
|
|
65
|
-
export declare function deleteFile(agent: XFTPAgent, sndDescription: FileDescription): Promise<void>;
|
|
66
|
-
//# sourceMappingURL=agent.d.ts.map
|
|
44
|
+
export declare function downloadFileRaw(agent: XFTPClientAgent, fd: FileDescription, onRawChunk: (chunk: RawDownloadedChunk) => Promise<void>, options?: DownloadRawOptions): Promise<FileDescription>;
|
|
45
|
+
export declare function downloadFile(agent: XFTPClientAgent, fd: FileDescription, onProgress?: (downloaded: number, total: number) => void): Promise<DownloadResult>;
|
|
46
|
+
export declare function deleteFile(agent: XFTPClientAgent, sndDescription: FileDescription): Promise<void>;
|
package/dist/agent.js
CHANGED
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
// Combines all building blocks: encryption, chunking, XFTP client commands,
|
|
4
4
|
// file descriptions, and DEFLATE-compressed URI encoding.
|
|
5
5
|
import pako from "pako";
|
|
6
|
-
import {
|
|
7
|
-
import { sbInit, sbEncryptChunk, sbAuth } from "./crypto/secretbox.js";
|
|
8
|
-
import { concatBytes, encodeInt64 } from "./protocol/encoding.js";
|
|
9
|
-
export { prepareEncryption } from "./crypto/file.js";
|
|
6
|
+
import { encryptFile, encodeFileHeader } from "./crypto/file.js";
|
|
10
7
|
import { generateEd25519KeyPair, encodePubKeyEd25519, encodePrivKeyEd25519, decodePrivKeyEd25519, ed25519KeyPairFromSeed } from "./crypto/keys.js";
|
|
11
|
-
import { sha512Streaming
|
|
12
|
-
import { prepareChunkSpecs, getChunkDigest } from "./protocol/chunks.js";
|
|
8
|
+
import { sha512Streaming } from "./crypto/digest.js";
|
|
9
|
+
import { prepareChunkSizes, prepareChunkSpecs, getChunkDigest, fileSizeLen, authTagSize } from "./protocol/chunks.js";
|
|
13
10
|
import { encodeFileDescription, decodeFileDescription, validateFileDescription, base64urlEncode, base64urlDecode } from "./protocol/description.js";
|
|
14
|
-
import { createXFTPChunk,
|
|
15
|
-
export {
|
|
11
|
+
import { createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw, deleteXFTPChunk } from "./client.js";
|
|
12
|
+
export { newXFTPAgent, closeXFTPAgent } from "./client.js";
|
|
16
13
|
import { processDownloadedFile, decryptReceivedChunk } from "./download.js";
|
|
17
14
|
import { formatXFTPServer, parseXFTPServer } from "./protocol/address.js";
|
|
18
15
|
// -- URI encoding/decoding (RFC section 4.1: DEFLATE + base64url)
|
|
@@ -30,61 +27,27 @@ export function decodeDescriptionURI(fragment) {
|
|
|
30
27
|
throw new Error("decodeDescriptionURI: " + err);
|
|
31
28
|
return fd;
|
|
32
29
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log(`[AGENT-DBG] encrypt: encData.len=${encData.length} digest=${_dbgHex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`);
|
|
49
|
-
return { encData, digest, key, nonce, chunkSizes };
|
|
50
|
-
}
|
|
30
|
+
// -- Upload
|
|
31
|
+
export function encryptFileForUpload(source, fileName) {
|
|
32
|
+
const key = new Uint8Array(32);
|
|
33
|
+
const nonce = new Uint8Array(24);
|
|
34
|
+
crypto.getRandomValues(key);
|
|
35
|
+
crypto.getRandomValues(nonce);
|
|
36
|
+
const fileHdr = encodeFileHeader({ fileName, fileExtra: null });
|
|
37
|
+
const fileSize = BigInt(fileHdr.length + source.length);
|
|
38
|
+
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize;
|
|
39
|
+
const chunkSizes = prepareChunkSizes(payloadSize);
|
|
40
|
+
const encSize = BigInt(chunkSizes.reduce((a, b) => a + b, 0));
|
|
41
|
+
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize);
|
|
42
|
+
const digest = sha512Streaming([encData]);
|
|
43
|
+
console.log(`[AGENT-DBG] encrypt: encData.len=${encData.length} digest=${_dbgHex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`);
|
|
44
|
+
return { encData, digest, key, nonce, chunkSizes };
|
|
51
45
|
}
|
|
52
46
|
const DEFAULT_REDIRECT_THRESHOLD = 400;
|
|
53
|
-
const MAX_RECIPIENTS_PER_REQUEST = 200; // each key is ~46 bytes; 200 keys fit within 16KB XFTP block
|
|
54
|
-
async function uploadSingleChunk(agent, server, chunkNo, chunkData, chunkSize, numRecipients, auth) {
|
|
55
|
-
const sndKp = generateEd25519KeyPair();
|
|
56
|
-
const rcvKps = Array.from({ length: numRecipients }, () => generateEd25519KeyPair());
|
|
57
|
-
const chunkDigest = getChunkDigest(chunkData);
|
|
58
|
-
const fileInfo = {
|
|
59
|
-
sndKey: encodePubKeyEd25519(sndKp.publicKey),
|
|
60
|
-
size: chunkSize,
|
|
61
|
-
digest: chunkDigest
|
|
62
|
-
};
|
|
63
|
-
const firstBatch = Math.min(numRecipients, MAX_RECIPIENTS_PER_REQUEST);
|
|
64
|
-
const firstBatchKeys = rcvKps.slice(0, firstBatch).map(kp => encodePubKeyEd25519(kp.publicKey));
|
|
65
|
-
const { senderId, recipientIds: firstIds } = await createXFTPChunk(agent, server, sndKp.privateKey, fileInfo, firstBatchKeys, auth);
|
|
66
|
-
const allRecipientIds = [...firstIds];
|
|
67
|
-
let added = firstBatch;
|
|
68
|
-
while (added < numRecipients) {
|
|
69
|
-
const batchSize = Math.min(numRecipients - added, MAX_RECIPIENTS_PER_REQUEST);
|
|
70
|
-
const batchKeys = rcvKps.slice(added, added + batchSize).map(kp => encodePubKeyEd25519(kp.publicKey));
|
|
71
|
-
const moreIds = await addXFTPRecipients(agent, server, sndKp.privateKey, senderId, batchKeys);
|
|
72
|
-
allRecipientIds.push(...moreIds);
|
|
73
|
-
added += batchSize;
|
|
74
|
-
}
|
|
75
|
-
await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData);
|
|
76
|
-
return {
|
|
77
|
-
chunkNo, senderId, senderKey: sndKp.privateKey,
|
|
78
|
-
recipients: allRecipientIds.map((rid, ri) => ({
|
|
79
|
-
recipientId: rid, recipientKey: rcvKps[ri].privateKey
|
|
80
|
-
})),
|
|
81
|
-
chunkSize, digest: chunkDigest, server
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
47
|
export async function uploadFile(agent, servers, encrypted, options) {
|
|
85
48
|
if (servers.length === 0)
|
|
86
49
|
throw new Error("uploadFile: servers list is empty");
|
|
87
|
-
const { onProgress, redirectThreshold, readChunk: readChunkOpt
|
|
50
|
+
const { onProgress, redirectThreshold, readChunk: readChunkOpt } = options ?? {};
|
|
88
51
|
const readChunk = readChunkOpt
|
|
89
52
|
? readChunkOpt
|
|
90
53
|
: ('encData' in encrypted
|
|
@@ -92,7 +55,7 @@ export async function uploadFile(agent, servers, encrypted, options) {
|
|
|
92
55
|
: () => { throw new Error("uploadFile: readChunk required when encData is absent"); });
|
|
93
56
|
const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0);
|
|
94
57
|
const specs = prepareChunkSpecs(encrypted.chunkSizes);
|
|
95
|
-
// Pre-assign servers and group by server
|
|
58
|
+
// Pre-assign servers and group by server for parallel upload
|
|
96
59
|
const chunkJobs = specs.map((spec, i) => ({
|
|
97
60
|
index: i,
|
|
98
61
|
spec,
|
|
@@ -111,104 +74,39 @@ export async function uploadFile(agent, servers, encrypted, options) {
|
|
|
111
74
|
await Promise.all([...byServer.values()].map(async (jobs) => {
|
|
112
75
|
for (const { index, spec, server } of jobs) {
|
|
113
76
|
const chunkNo = index + 1;
|
|
77
|
+
const sndKp = generateEd25519KeyPair();
|
|
78
|
+
const rcvKp = generateEd25519KeyPair();
|
|
114
79
|
const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize);
|
|
115
|
-
|
|
80
|
+
const chunkDigest = getChunkDigest(chunkData);
|
|
81
|
+
const fileInfo = {
|
|
82
|
+
sndKey: encodePubKeyEd25519(sndKp.publicKey),
|
|
83
|
+
size: spec.chunkSize,
|
|
84
|
+
digest: chunkDigest
|
|
85
|
+
};
|
|
86
|
+
const rcvKeysForChunk = [encodePubKeyEd25519(rcvKp.publicKey)];
|
|
87
|
+
const { senderId, recipientIds } = await createXFTPChunk(agent, server, sndKp.privateKey, fileInfo, rcvKeysForChunk);
|
|
88
|
+
await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData);
|
|
89
|
+
sentChunks[index] = {
|
|
90
|
+
chunkNo, senderId, senderKey: sndKp.privateKey,
|
|
91
|
+
recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
|
|
92
|
+
chunkSize: spec.chunkSize, digest: chunkDigest, server
|
|
93
|
+
};
|
|
116
94
|
uploaded += spec.chunkSize;
|
|
117
95
|
onProgress?.(uploaded, total);
|
|
118
96
|
}
|
|
119
97
|
}));
|
|
120
|
-
const
|
|
121
|
-
const sndDescription = buildDescription("sender",
|
|
122
|
-
let uri = encodeDescriptionURI(
|
|
123
|
-
let
|
|
98
|
+
const rcvDescription = buildDescription("recipient", encrypted, sentChunks);
|
|
99
|
+
const sndDescription = buildDescription("sender", encrypted, sentChunks);
|
|
100
|
+
let uri = encodeDescriptionURI(rcvDescription);
|
|
101
|
+
let finalRcvDescription = rcvDescription;
|
|
124
102
|
const threshold = redirectThreshold ?? DEFAULT_REDIRECT_THRESHOLD;
|
|
125
103
|
if (uri.length > threshold && sentChunks.length > 1) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
uri = encodeDescriptionURI(redirected);
|
|
129
|
-
}
|
|
130
|
-
return { rcvDescriptions: finalRcvDescriptions, sndDescription, uri };
|
|
131
|
-
}
|
|
132
|
-
export async function sendFile(agent, servers, source, fileNameOrSize, fileNameOrOptions, maybeOptions) {
|
|
133
|
-
let sourceSize, fileName, options;
|
|
134
|
-
if (source instanceof Uint8Array) {
|
|
135
|
-
sourceSize = source.length;
|
|
136
|
-
fileName = fileNameOrSize;
|
|
137
|
-
options = fileNameOrOptions;
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
sourceSize = fileNameOrSize;
|
|
141
|
-
fileName = fileNameOrOptions;
|
|
142
|
-
options = maybeOptions;
|
|
143
|
-
}
|
|
144
|
-
if (servers.length === 0)
|
|
145
|
-
throw new Error("sendFile: servers list is empty");
|
|
146
|
-
const { onProgress, auth, numRecipients = 1 } = options ?? {};
|
|
147
|
-
const params = prepareEncryption(sourceSize, fileName);
|
|
148
|
-
const specs = prepareChunkSpecs(params.chunkSizes);
|
|
149
|
-
const total = params.chunkSizes.reduce((a, b) => a + b, 0);
|
|
150
|
-
const encState = sbInit(params.key, params.nonce);
|
|
151
|
-
const hashState = sha512Init();
|
|
152
|
-
const sentChunks = new Array(specs.length);
|
|
153
|
-
let specIdx = 0, chunkOff = 0, uploaded = 0;
|
|
154
|
-
let chunkBuf = new Uint8Array(specs[0].chunkSize);
|
|
155
|
-
async function flushChunk() {
|
|
156
|
-
const server = servers[Math.floor(Math.random() * servers.length)];
|
|
157
|
-
sentChunks[specIdx] = await uploadSingleChunk(agent, server, specIdx + 1, chunkBuf, specs[specIdx].chunkSize, numRecipients, auth ?? null);
|
|
158
|
-
uploaded += specs[specIdx].chunkSize;
|
|
159
|
-
onProgress?.(uploaded, total);
|
|
160
|
-
specIdx++;
|
|
161
|
-
if (specIdx < specs.length) {
|
|
162
|
-
chunkBuf = new Uint8Array(specs[specIdx].chunkSize);
|
|
163
|
-
chunkOff = 0;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
async function feedEncrypted(data) {
|
|
167
|
-
sha512Update(hashState, data);
|
|
168
|
-
let off = 0;
|
|
169
|
-
while (off < data.length) {
|
|
170
|
-
const space = specs[specIdx].chunkSize - chunkOff;
|
|
171
|
-
const n = Math.min(space, data.length - off);
|
|
172
|
-
chunkBuf.set(data.subarray(off, off + n), chunkOff);
|
|
173
|
-
chunkOff += n;
|
|
174
|
-
off += n;
|
|
175
|
-
if (chunkOff === specs[specIdx].chunkSize)
|
|
176
|
-
await flushChunk();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
await feedEncrypted(sbEncryptChunk(encState, concatBytes(encodeInt64(params.fileSize), params.fileHdr)));
|
|
180
|
-
const SLICE = 65536;
|
|
181
|
-
if (source instanceof Uint8Array) {
|
|
182
|
-
for (let off = 0; off < source.length; off += SLICE) {
|
|
183
|
-
await feedEncrypted(sbEncryptChunk(encState, source.subarray(off, Math.min(off + SLICE, source.length))));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
for await (const chunk of source) {
|
|
188
|
-
for (let off = 0; off < chunk.length; off += SLICE) {
|
|
189
|
-
await feedEncrypted(sbEncryptChunk(encState, chunk.subarray(off, Math.min(off + SLICE, chunk.length))));
|
|
190
|
-
}
|
|
191
|
-
}
|
|
104
|
+
finalRcvDescription = await uploadRedirectDescription(agent, servers, rcvDescription);
|
|
105
|
+
uri = encodeDescriptionURI(finalRcvDescription);
|
|
192
106
|
}
|
|
193
|
-
|
|
194
|
-
const padding = new Uint8Array(padLen);
|
|
195
|
-
padding.fill(0x23);
|
|
196
|
-
await feedEncrypted(sbEncryptChunk(encState, padding));
|
|
197
|
-
await feedEncrypted(sbAuth(encState));
|
|
198
|
-
const digest = sha512Final(hashState);
|
|
199
|
-
const encrypted = { digest, key: params.key, nonce: params.nonce, chunkSizes: params.chunkSizes };
|
|
200
|
-
const rcvDescriptions = Array.from({ length: numRecipients }, (_, ri) => buildDescription("recipient", ri, encrypted, sentChunks));
|
|
201
|
-
const sndDescription = buildDescription("sender", 0, encrypted, sentChunks);
|
|
202
|
-
let uri = encodeDescriptionURI(rcvDescriptions[0]);
|
|
203
|
-
let finalRcvDescriptions = rcvDescriptions;
|
|
204
|
-
if (uri.length > DEFAULT_REDIRECT_THRESHOLD && sentChunks.length > 1) {
|
|
205
|
-
const redirected = await uploadRedirectDescription(agent, servers, rcvDescriptions[0], auth);
|
|
206
|
-
finalRcvDescriptions = [redirected, ...rcvDescriptions.slice(1)];
|
|
207
|
-
uri = encodeDescriptionURI(redirected);
|
|
208
|
-
}
|
|
209
|
-
return { rcvDescriptions: finalRcvDescriptions, sndDescription, uri };
|
|
107
|
+
return { rcvDescription: finalRcvDescription, sndDescription, uri };
|
|
210
108
|
}
|
|
211
|
-
function buildDescription(party,
|
|
109
|
+
function buildDescription(party, enc, chunks) {
|
|
212
110
|
const defChunkSize = enc.chunkSizes[0];
|
|
213
111
|
return {
|
|
214
112
|
party,
|
|
@@ -223,38 +121,41 @@ function buildDescription(party, recipientIndex, enc, chunks) {
|
|
|
223
121
|
digest: c.digest,
|
|
224
122
|
replicas: [{
|
|
225
123
|
server: formatXFTPServer(c.server),
|
|
226
|
-
replicaId: party === "recipient" ? c.
|
|
227
|
-
replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.
|
|
124
|
+
replicaId: party === "recipient" ? c.recipientId : c.senderId,
|
|
125
|
+
replicaKey: encodePrivKeyEd25519(party === "recipient" ? c.recipientKey : c.senderKey)
|
|
228
126
|
}]
|
|
229
127
|
})),
|
|
230
128
|
redirect: null
|
|
231
129
|
};
|
|
232
130
|
}
|
|
233
|
-
async function uploadRedirectDescription(agent, servers, innerFd
|
|
131
|
+
async function uploadRedirectDescription(agent, servers, innerFd) {
|
|
234
132
|
const yaml = encodeFileDescription(innerFd);
|
|
235
133
|
const yamlBytes = new TextEncoder().encode(yaml);
|
|
236
|
-
const enc =
|
|
134
|
+
const enc = encryptFileForUpload(yamlBytes, "");
|
|
237
135
|
const specs = prepareChunkSpecs(enc.chunkSizes);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
spec
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
136
|
+
const sentChunks = [];
|
|
137
|
+
for (let i = 0; i < specs.length; i++) {
|
|
138
|
+
const spec = specs[i];
|
|
139
|
+
const chunkNo = i + 1;
|
|
140
|
+
const server = servers[Math.floor(Math.random() * servers.length)];
|
|
141
|
+
const sndKp = generateEd25519KeyPair();
|
|
142
|
+
const rcvKp = generateEd25519KeyPair();
|
|
143
|
+
const chunkData = enc.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize);
|
|
144
|
+
const chunkDigest = getChunkDigest(chunkData);
|
|
145
|
+
const fileInfo = {
|
|
146
|
+
sndKey: encodePubKeyEd25519(sndKp.publicKey),
|
|
147
|
+
size: spec.chunkSize,
|
|
148
|
+
digest: chunkDigest
|
|
149
|
+
};
|
|
150
|
+
const rcvKeysForChunk = [encodePubKeyEd25519(rcvKp.publicKey)];
|
|
151
|
+
const { senderId, recipientIds } = await createXFTPChunk(agent, server, sndKp.privateKey, fileInfo, rcvKeysForChunk);
|
|
152
|
+
await uploadXFTPChunk(agent, server, sndKp.privateKey, senderId, chunkData);
|
|
153
|
+
sentChunks.push({
|
|
154
|
+
chunkNo, senderId, senderKey: sndKp.privateKey,
|
|
155
|
+
recipientId: recipientIds[0], recipientKey: rcvKp.privateKey,
|
|
156
|
+
chunkSize: spec.chunkSize, digest: chunkDigest, server
|
|
157
|
+
});
|
|
249
158
|
}
|
|
250
|
-
const sentChunks = new Array(specs.length);
|
|
251
|
-
await Promise.all([...byServer.values()].map(async (jobs) => {
|
|
252
|
-
for (const { index, spec, server } of jobs) {
|
|
253
|
-
const chunkNo = index + 1;
|
|
254
|
-
const chunkData = enc.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize);
|
|
255
|
-
sentChunks[index] = await uploadSingleChunk(agent, server, chunkNo, chunkData, spec.chunkSize, 1, auth ?? null);
|
|
256
|
-
}
|
|
257
|
-
}));
|
|
258
159
|
return {
|
|
259
160
|
party: "recipient",
|
|
260
161
|
size: enc.chunkSizes.reduce((a, b) => a + b, 0),
|
|
@@ -268,8 +169,8 @@ async function uploadRedirectDescription(agent, servers, innerFd, auth) {
|
|
|
268
169
|
digest: c.digest,
|
|
269
170
|
replicas: [{
|
|
270
171
|
server: formatXFTPServer(c.server),
|
|
271
|
-
replicaId: c.
|
|
272
|
-
replicaKey: encodePrivKeyEd25519(c.
|
|
172
|
+
replicaId: c.recipientId,
|
|
173
|
+
replicaKey: encodePrivKeyEd25519(c.recipientKey)
|
|
273
174
|
}]
|
|
274
175
|
})),
|
|
275
176
|
redirect: { size: innerFd.size, digest: innerFd.digest }
|
|
@@ -279,7 +180,7 @@ export async function downloadFileRaw(agent, fd, onRawChunk, options) {
|
|
|
279
180
|
const err = validateFileDescription(fd);
|
|
280
181
|
if (err)
|
|
281
182
|
throw new Error("downloadFileRaw: " + err);
|
|
282
|
-
const { onProgress } = options ?? {};
|
|
183
|
+
const { onProgress, concurrency = 1 } = options ?? {};
|
|
283
184
|
// Resolve redirect on main thread (redirect data is small)
|
|
284
185
|
if (fd.redirect !== null) {
|
|
285
186
|
console.log(`[AGENT-DBG] resolving redirect: outer size=${fd.size} chunks=${fd.chunks.length}`);
|
|
@@ -313,7 +214,6 @@ export async function downloadFileRaw(agent, fd, onRawChunk, options) {
|
|
|
313
214
|
body: raw.body,
|
|
314
215
|
digest: chunk.digest
|
|
315
216
|
});
|
|
316
|
-
await ackXFTPChunk(agent, server, kp.privateKey, replica.replicaId);
|
|
317
217
|
downloaded += chunk.chunkSize;
|
|
318
218
|
onProgress?.(downloaded, resolvedFd.size);
|
|
319
219
|
}
|
|
@@ -333,32 +233,18 @@ export async function downloadFile(agent, fd, onProgress) {
|
|
|
333
233
|
throw new Error("downloadFile: file digest mismatch");
|
|
334
234
|
return processDownloadedFile(resolvedFd, chunks);
|
|
335
235
|
}
|
|
336
|
-
export async function receiveFile(agent, uri, options) {
|
|
337
|
-
const fd = decodeDescriptionURI(uri);
|
|
338
|
-
return downloadFile(agent, fd, options?.onProgress);
|
|
339
|
-
}
|
|
340
236
|
async function resolveRedirect(agent, fd) {
|
|
341
237
|
const plaintextChunks = new Array(fd.chunks.length);
|
|
342
|
-
const byServer = new Map();
|
|
343
238
|
for (const chunk of fd.chunks) {
|
|
344
|
-
const
|
|
345
|
-
if (!
|
|
346
|
-
|
|
347
|
-
|
|
239
|
+
const replica = chunk.replicas[0];
|
|
240
|
+
if (!replica)
|
|
241
|
+
throw new Error("resolveRedirect: chunk has no replicas");
|
|
242
|
+
const server = parseXFTPServer(replica.server);
|
|
243
|
+
const seed = decodePrivKeyEd25519(replica.replicaKey);
|
|
244
|
+
const kp = ed25519KeyPairFromSeed(seed);
|
|
245
|
+
const data = await downloadXFTPChunk(agent, server, kp.privateKey, replica.replicaId, chunk.digest);
|
|
246
|
+
plaintextChunks[chunk.chunkNo - 1] = data;
|
|
348
247
|
}
|
|
349
|
-
await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
|
|
350
|
-
const server = parseXFTPServer(srv);
|
|
351
|
-
for (const chunk of chunks) {
|
|
352
|
-
const replica = chunk.replicas[0];
|
|
353
|
-
if (!replica)
|
|
354
|
-
throw new Error("resolveRedirect: chunk has no replicas");
|
|
355
|
-
const seed = decodePrivKeyEd25519(replica.replicaKey);
|
|
356
|
-
const kp = ed25519KeyPairFromSeed(seed);
|
|
357
|
-
const data = await downloadXFTPChunk(agent, server, kp.privateKey, replica.replicaId, chunk.digest);
|
|
358
|
-
plaintextChunks[chunk.chunkNo - 1] = data;
|
|
359
|
-
await ackXFTPChunk(agent, server, kp.privateKey, replica.replicaId);
|
|
360
|
-
}
|
|
361
|
-
}));
|
|
362
248
|
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0);
|
|
363
249
|
if (totalSize !== fd.size)
|
|
364
250
|
throw new Error("resolveRedirect: redirect file size mismatch");
|
|
@@ -379,24 +265,15 @@ async function resolveRedirect(agent, fd) {
|
|
|
379
265
|
}
|
|
380
266
|
// -- Delete
|
|
381
267
|
export async function deleteFile(agent, sndDescription) {
|
|
382
|
-
const byServer = new Map();
|
|
383
268
|
for (const chunk of sndDescription.chunks) {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
386
|
-
|
|
387
|
-
|
|
269
|
+
const replica = chunk.replicas[0];
|
|
270
|
+
if (!replica)
|
|
271
|
+
throw new Error("deleteFile: chunk has no replicas");
|
|
272
|
+
const server = parseXFTPServer(replica.server);
|
|
273
|
+
const seed = decodePrivKeyEd25519(replica.replicaKey);
|
|
274
|
+
const kp = ed25519KeyPairFromSeed(seed);
|
|
275
|
+
await deleteXFTPChunk(agent, server, kp.privateKey, replica.replicaId);
|
|
388
276
|
}
|
|
389
|
-
await Promise.all([...byServer.entries()].map(async ([srv, chunks]) => {
|
|
390
|
-
const server = parseXFTPServer(srv);
|
|
391
|
-
for (const chunk of chunks) {
|
|
392
|
-
const replica = chunk.replicas[0];
|
|
393
|
-
if (!replica)
|
|
394
|
-
throw new Error("deleteFile: chunk has no replicas");
|
|
395
|
-
const seed = decodePrivKeyEd25519(replica.replicaKey);
|
|
396
|
-
const kp = ed25519KeyPairFromSeed(seed);
|
|
397
|
-
await deleteXFTPChunk(agent, server, kp.privateKey, replica.replicaId);
|
|
398
|
-
}
|
|
399
|
-
}));
|
|
400
277
|
}
|
|
401
278
|
// -- Internal
|
|
402
279
|
function _dbgHex(b, n = 8) {
|