mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-06 23:40:18 +00:00
xftp-web client functions, fix transmission encoding
This commit is contained in:
+155
-159
@@ -112,30 +112,24 @@ These estimates are preliminary and may be incorrect.
|
||||
```
|
||||
xftp-web/src/ # Separate npm project (see §12.19)
|
||||
├── protocol/
|
||||
│ ├── encoding.ts # Binary encoding/decoding matching Haskell Encoding module
|
||||
│ ├── commands.ts # XFTP command types (FNEW, FPUT, FGET, etc.)
|
||||
│ ├── responses.ts # XFTP response types (FRSndIds, FRFile, etc.)
|
||||
│ └── transmission.ts # Transmission framing, signing, padding
|
||||
│ ├── encoding.ts # Binary encoding/decoding ← Simplex.Messaging.Encoding ✓
|
||||
│ ├── commands.ts # XFTP commands + responses ← Simplex.FileTransfer.Protocol ✓
|
||||
│ ├── transmission.ts # Transmission framing, signing, padding ✓
|
||||
│ ├── handshake.ts # XFTP handshake (standard + web) ← FileTransfer.Transport ✓
|
||||
│ ├── address.ts # XFTP server address parser ← Simplex.Messaging.Protocol ✓
|
||||
│ ├── chunks.ts # Chunk sizes + splitting ← FileTransfer.Chunks + Client.hs ✓
|
||||
│ ├── client.ts # Transport crypto (cbAuthenticate, transit encrypt/decrypt) ✓
|
||||
│ └── description.ts # Types, YAML, validation, base64url ← FileTransfer.Description ✓
|
||||
├── crypto/
|
||||
│ ├── secretbox.ts # XSalsa20-Poly1305 streaming encryption/decryption
|
||||
│ ├── file.ts # File-level encryption/decryption (encryptFile, decryptChunks)
|
||||
│ ├── keys.ts # Ed25519, X25519 key generation and operations
|
||||
│ ├── digest.ts # SHA-256/SHA-512 hashing
|
||||
│ └── padding.ts # Block padding/unpadding (2-byte length prefix + '#' fill)
|
||||
├── transport/
|
||||
│ ├── client.ts # HTTP/2 client via fetch(), streaming body
|
||||
│ ├── handshake.ts # XFTP handshake (standard + web variant)
|
||||
│ └── cors.ts # CORS-aware request handling
|
||||
├── description/
|
||||
│ ├── types.ts # FileDescription, FileChunk, FileChunkReplica types
|
||||
│ ├── yaml.ts # YAML serialization/deserialization
|
||||
│ ├── uri.ts # URL encoding/decoding with compression
|
||||
│ └── validation.ts # Description validation (sequential chunks, size match)
|
||||
├── agent/
|
||||
│ ├── upload.ts # Full upload orchestration
|
||||
│ ├── download.ts # Full download orchestration
|
||||
│ └── chunking.ts # File splitting, chunk size selection
|
||||
└── index.ts # Public API
|
||||
│ ├── secretbox.ts # XSalsa20-Poly1305 streaming encryption/decryption ✓
|
||||
│ ├── file.ts # File-level encryption/decryption (encryptFile, decryptChunks) ✓
|
||||
│ ├── keys.ts # Ed25519, X25519, Ed448 key generation and operations ✓
|
||||
│ ├── digest.ts # SHA-256/SHA-512 hashing ✓
|
||||
│ ├── padding.ts # Block padding/unpadding (2-byte length prefix + '#' fill) ✓
|
||||
│ └── identity.ts # Web handshake identity proof verification (Ed25519/Ed448) ✓
|
||||
├── download.ts # Download helper functions (DH, transit-decrypt, file-decrypt) ✓
|
||||
├── client.ts # HTTP/2 XFTP client ← Simplex.FileTransfer.Client
|
||||
└── agent.ts # Upload/download orchestration + URI ← FileTransfer.Client.Main
|
||||
```
|
||||
|
||||
### 5.2 Binary Encoding
|
||||
@@ -471,47 +465,59 @@ The web page should display a brief, non-technical security summary explaining t
|
||||
|
||||
## 9. Implementation Plan
|
||||
|
||||
### Phase 1: TypeScript XFTP Client Core
|
||||
### Phase 1: TypeScript XFTP Building Blocks — DONE
|
||||
|
||||
**Goal:** A Node.js-runnable XFTP client that can upload and download files against a real Haskell XFTP server.
|
||||
**Goal:** All per-function building blocks implemented and tested via Haskell-driven unit tests.
|
||||
|
||||
1. **Binary encoding module** — Implement `Encoding` equivalent: length-prefixed bytestrings, padding, SMP-style encoding for all XFTP types.
|
||||
2. **Crypto module** — Wrapper around libsodium.js for: key generation (Ed25519, X25519), signing, XSalsa20-Poly1305 streaming encryption/decryption, SHA-256/SHA-512 hashing.
|
||||
3. **Protocol module** — XFTP command encoding (FNEW, FADD, FPUT, FDEL, FGET, FACK, PING) and response decoding (FRSndIds, FRRcvIds, FRFile, FROk, FRErr, FRPong). Transmission framing with signing and padding.
|
||||
4. **Transport module** — HTTP/2 client using `fetch()` (Node.js 18+ built-in or `undici`). Handshake implementation (standard XFTP handshake first, web variant later).
|
||||
5. **File description module** — YAML serialization/deserialization matching Haskell's `StrEncoding` for `FileDescription`. Validation.
|
||||
6. **Agent module** — Upload orchestration (encrypt → chunk → register → upload → build description). Download orchestration (parse description → download → transit-decrypt → file-decrypt).
|
||||
**Completed** (164 tests passing across 16 test groups):
|
||||
1. Binary encoding (protocol/encoding.ts) — 23 tests
|
||||
2. Crypto: secretbox, keys, file, padding, digest (crypto/*.ts) — 72 tests
|
||||
3. Protocol: commands, transmission (protocol/commands.ts, transmission.ts) — 40 tests
|
||||
4. Handshake encoding/decoding (protocol/handshake.ts) — 18 tests
|
||||
5. Identity proof verification (crypto/identity.ts) — 15 tests
|
||||
6. File descriptions: types, YAML, validation (protocol/description.ts) — 13 tests
|
||||
7. Chunk sizing: prepareChunkSizes, singleChunkSize, etc. (protocol/chunks.ts) — 4 tests
|
||||
8. Transport crypto: cbAuthenticate/cbVerify, transit encrypt/decrypt (protocol/client.ts) — 10 tests
|
||||
9. Server address parsing (protocol/address.ts) — 3 tests
|
||||
10. Download helpers: DH, transit-decrypt, file-decrypt (download.ts) — 11 tests
|
||||
|
||||
### Phase 2: Integration Testing
|
||||
### Phase 2: XFTP Server Changes — DONE
|
||||
|
||||
**Goal:** XFTP servers support web client connections.
|
||||
|
||||
**Completed** (7 Haskell integration tests passing):
|
||||
1. SNI certificate switching — `TLSServerCredential` mechanism for XFTP
|
||||
2. CORS headers — OPTIONS handler + CORS response headers
|
||||
3. Web handshake — challenge-response identity proof (Ed25519 + Ed448)
|
||||
4. Integration tests — Ed25519 and Ed448 web handshake round-trips
|
||||
|
||||
### Phase 3: HTTP/2 Client + Agent Orchestration
|
||||
|
||||
**Goal:** Complete XFTP client that can upload and download files against a real Haskell XFTP server.
|
||||
|
||||
1. **`client.ts`** ← `Simplex.FileTransfer.Client` — HTTP/2 client via `fetch()` / `node:http2`: connect + handshake, sendCommand, createChunk, uploadChunk, downloadChunk, deleteChunk, ackChunk, ping.
|
||||
2. **`agent.ts`** ← `Simplex.FileTransfer.Client.Main` — Upload orchestration (encrypt → chunk → register → upload → build description), download orchestration (parse → download → verify → decrypt → ack), URL encoding with DEFLATE compression (§4.1).
|
||||
|
||||
### Phase 4: Integration Testing
|
||||
|
||||
**Goal:** Prove the TypeScript client is wire-compatible with the Haskell server.
|
||||
|
||||
1. **Test harness** — Node.js test (`xftp-web/test/integration.test.ts`) spawns `xftp-server` and `xftp` CLI as subprocesses.
|
||||
1. **Test harness** — Haskell-driven tests in `XFTPWebTests.hs` (same pattern as per-function tests).
|
||||
2. **Upload test** — TypeScript uploads file → Haskell client downloads it → verify contents match.
|
||||
3. **Download test** — Haskell client uploads file → TypeScript downloads it → verify contents match.
|
||||
4. **Round-trip test** — TypeScript upload → TypeScript download → verify.
|
||||
5. **Edge cases** — Single chunk, many chunks, exactly-sized chunks, redirect descriptions.
|
||||
|
||||
### Phase 3: XFTP Server Changes
|
||||
|
||||
**Goal:** XFTP servers support web client connections.
|
||||
|
||||
1. **SNI certificate switching** — Port SMP server's `TLSServerCredential` mechanism to XFTP server.
|
||||
2. **CORS headers** — Add OPTIONS handler and CORS response headers when Origin is present.
|
||||
3. **Web handshake** — Detect web client (SNI-based), include identity proof (cert chain + signed challenge) in handshake response.
|
||||
4. **Configuration** — Add `[WEB]` section to `file-server.ini` for HTTPS cert paths and web mode toggle.
|
||||
|
||||
### Phase 4: Web Page
|
||||
### Phase 5: Web Page
|
||||
|
||||
**Goal:** Static HTML page with upload/download UX.
|
||||
|
||||
1. **Bundle TypeScript** — Compile to ES module bundle with libsodium.js WASM included.
|
||||
2. **Upload UI** — Drag-drop zone, file picker, progress circle, link display.
|
||||
3. **Download UI** — Parse URL, show file info, download button, progress circle.
|
||||
4. **URL encoding** — DEFLATE compression + base64url for file description in hash fragment.
|
||||
5. **App install CTA** — Banner/messaging promoting SimpleX app for larger files.
|
||||
4. **App install CTA** — Banner/messaging promoting SimpleX app for larger files.
|
||||
|
||||
### Phase 5: Server-Hosted Page (Optional)
|
||||
### Phase 6: Server-Hosted Page (Optional)
|
||||
|
||||
**Goal:** XFTP servers can optionally serve the web page themselves.
|
||||
|
||||
@@ -880,118 +886,115 @@ XFTP handshake types and encoding.
|
||||
| `XFTP_VERSION_RANGE` | `supportedFileServerVRange` | 101 | Version 1..3 |
|
||||
| `CURRENT_XFTP_VERSION` | `currentXFTPVersion` | 98 | Version 3 |
|
||||
|
||||
### 12.10 `transport/client.ts` ← `Simplex/FileTransfer/Client.hs`
|
||||
### 12.10 `protocol/client.ts` ← `Simplex/FileTransfer/Client.hs` (crypto primitives) — DONE
|
||||
|
||||
HTTP/2 client, command sending, chunk upload/download.
|
||||
Transport-level crypto for command authentication and chunk encryption/decryption.
|
||||
|
||||
| TypeScript function | Haskell function | Line | Description |
|
||||
| TypeScript function | Haskell function | Description | Status |
|
||||
|---|---|---|---|
|
||||
| `connectXFTP(server, config)` | `getXFTPClient` | 111 | Establish HTTP/2 connection + handshake |
|
||||
| `xftpHandshakeV1(vRange, keyHash, http2)` | `xftpClientHandshakeV1` | 137 | Two-stage handshake over HTTP/2 |
|
||||
| `sendCommand(client, key, fileId, cmd, chunk?)` | `sendXFTPCommand` | 199 | Encode + sign + send + parse response |
|
||||
| `createChunk(client, key, info, rcvKeys, auth?)` | `createXFTPChunk` | 231 | FNEW → (senderId, recipientIds) |
|
||||
| `addRecipients(client, key, fileId, rcvKeys)` | `addXFTPRecipients` | 243 | FADD → recipientIds |
|
||||
| `uploadChunk(client, key, fileId, spec)` | `uploadXFTPChunk` | 249 | FPUT with streaming body |
|
||||
| `downloadChunk(rng, client, key, fileId, spec)` | `downloadXFTPChunk` | 253 | FGET → transit-decrypt → save |
|
||||
| `deleteChunk(client, key, senderId)` | `deleteXFTPChunk` | 285 | FDEL |
|
||||
| `ackChunk(client, key, recipientId)` | `ackXFTPChunk` | 288 | FACK |
|
||||
| `ping(client)` | `pingXFTP` | 291 | PING → PONG |
|
||||
| `cbAuthenticate(peerPub, ownPriv, nonce, msg)` | `C.cbAuthenticate` | 80-byte crypto_box authenticator | ✓ |
|
||||
| `cbVerify(peerPub, ownPriv, nonce, auth, msg)` | `C.cbVerify` | Verify authenticator | ✓ |
|
||||
| `encryptTransportChunk(dhSecret, nonce, plain)` | `sendEncFile` | Encrypt chunk (tag appended) | ✓ |
|
||||
| `decryptTransportChunk(dhSecret, nonce, enc)` | `receiveEncFile` | Decrypt chunk (tag verified) | ✓ |
|
||||
|
||||
### 12.11 `agent/chunking.ts` ← `Simplex/FileTransfer/Client.hs` + `Simplex/FileTransfer/Chunks.hs`
|
||||
### 12.11 `protocol/chunks.ts` ← `Simplex/FileTransfer/Chunks.hs` + `Client.hs` — DONE
|
||||
|
||||
Chunk size selection and file splitting.
|
||||
|
||||
| TypeScript function/constant | Haskell function/constant | File | Line | Description |
|
||||
|---|---|---|---|---|
|
||||
| `CHUNK_SIZE_64K` | `chunkSize0` | Chunks.hs | 9 | 65536 |
|
||||
| `CHUNK_SIZE_256K` | `chunkSize1` | Chunks.hs | 13 | 262144 |
|
||||
| `CHUNK_SIZE_1M` | `chunkSize2` | Chunks.hs | 17 | 1048576 |
|
||||
| `CHUNK_SIZE_4M` | `chunkSize3` | Chunks.hs | 21 | 4194304 |
|
||||
| `SERVER_CHUNK_SIZES` | `serverChunkSizes` | Chunks.hs | 5 | `[64K, 256K, 1M, 4M]` |
|
||||
| `singleChunkSize(fileSize)` | `singleChunkSize` | Client.hs | 315 | Smallest chunk size ≥ fileSize, or Nothing |
|
||||
| `prepareChunkSizes(fileSize)` | `prepareChunkSizes` | Client.hs | 321 | Split file into chunk sizes |
|
||||
| `prepareChunkSpecs(path, sizes)` | `prepareChunkSpecs` | Client.hs | 338 | Create offset-based chunk specs |
|
||||
| `getChunkDigest(spec)` | `getChunkDigest` | Client.hs | 346 | SHA-256 of chunk data |
|
||||
| TypeScript function/constant | Haskell equivalent | Status |
|
||||
|---|---|---|
|
||||
| `chunkSize0..3` | `chunkSize0..3` (Chunks.hs) | ✓ |
|
||||
| `serverChunkSizes` | `serverChunkSizes` | ✓ |
|
||||
| `prepareChunkSizes(size)` | `prepareChunkSizes` (Client.hs:322) | ✓ |
|
||||
| `singleChunkSize(size)` | `singleChunkSize` (Client.hs:316) | ✓ |
|
||||
| `prepareChunkSpecs(sizes)` | `prepareChunkSpecs` (Client.hs:339) | ✓ |
|
||||
| `getChunkDigest(chunk)` | `getChunkDigest` (Client.hs:347) | ✓ |
|
||||
|
||||
### 12.12 `description/types.ts` ← `Simplex/FileTransfer/Description.hs`
|
||||
### 12.12–12.14 `protocol/description.ts` ← `Simplex/FileTransfer/Description.hs` — DONE
|
||||
|
||||
File description types matching the YAML format.
|
||||
Types, YAML encode/decode, base64url, FileSize, replica grouping/folding, validation — all in one file.
|
||||
|
||||
| TypeScript type | Haskell type | Line | Description |
|
||||
|---|---|---|---|
|
||||
| `FileDescription` | `FileDescription p` | 81 | `{party, size, digest, key, nonce, chunkSize, chunks, redirect?}` |
|
||||
| `RedirectFileInfo` | `RedirectFileInfo` | 93 | `{size, digest}` |
|
||||
| `FileDigest` | `FileDigest` | 114 | Newtype over ByteString (base64url encoded in YAML via `StrEncoding`) |
|
||||
| `FileSize` | `FileSize a` | 186 | Newtype wrapper; human-readable `StrEncoding` ("26mb", "8mb", "100kb", "1gb") |
|
||||
| `FileChunk` | `FileChunk` | 132 | `{chunkNo, chunkSize, digest, replicas}` |
|
||||
| `FileChunkReplica` | `FileChunkReplica` | 140 | `{server, replicaId, replicaKey}` |
|
||||
| `ChunkReplicaId` | `ChunkReplicaId` | 147 | Newtype over XFTPFileId |
|
||||
| `FileDescriptionURI` | `FileDescriptionURI` | 243 | `{scheme, description, clientData?}` — Haskell format (`simplex:/file#/?desc=...`); web client uses different URL format (§4.1) |
|
||||
| TypeScript function/type | Haskell equivalent | Status |
|
||||
|---|---|---|
|
||||
| `FileDescription`, `FileChunk`, `FileChunkReplica`, `RedirectFileInfo` | Matching record types | ✓ |
|
||||
| `base64urlEncode/Decode` | `strEncode`/`strDecode` for `ByteString` | ✓ |
|
||||
| `encodeFileSize/decodeFileSize` | `StrEncoding (FileSize a)` | ✓ |
|
||||
| `encodeFileDescription(fd)` | `encodeFileDescription` (line 230) | ✓ |
|
||||
| `decodeFileDescription(yaml)` | `decodeFileDescription` (line 356) | ✓ |
|
||||
| `validateFileDescription(fd)` | `validateFileDescription` (line 221) | ✓ |
|
||||
| `fdSeparator` | `fdSeparator` (line 111) | ✓ |
|
||||
| Internal: `unfoldChunksToReplicas`, `foldReplicasToChunks`, `encodeFileReplicas` | Matching functions | ✓ |
|
||||
|
||||
**Size constants:**
|
||||
- `qrSizeLimit` (line 269): 1002 bytes
|
||||
- `maxFileSize` (line 273): 1 GB
|
||||
- `fileSizeLen` (line 283): 8 bytes
|
||||
### 12.15 `client.ts` ← `Simplex/FileTransfer/Client.hs` (HTTP/2 operations)
|
||||
|
||||
### 12.13 `description/yaml.ts` ← `Simplex/FileTransfer/Description.hs`
|
||||
|
||||
YAML serialization via `Data.Yaml` (aeson) through intermediate `YAMLFileDescription` type (line 158).
|
||||
HTTP/2 XFTP client using `node:http2` (Node.js) or `fetch()` (browser). Transpilation of `Client.hs` network operations.
|
||||
|
||||
| TypeScript function | Haskell function | Line | Description |
|
||||
|---|---|---|---|
|
||||
| `encodeFileDescription(desc)` | `encodeFileDescription` | 230 | `FileDescription` → `YAMLFileDescription` → YAML bytes |
|
||||
| `decodeFileDescription(yaml)` | `strDecode` instance | — | YAML bytes → `YAMLFileDescription` → `FileDescription` |
|
||||
| `fileDescriptionURI(desc)` | `fileDescriptionURI` | 252 | Wrap in URI format |
|
||||
| `connectXFTP(server, config)` | `getXFTPClient` | 111 | HTTP/2 connect + handshake → XFTPClient state |
|
||||
| `sendXFTPCommand(client, key, fileId, cmd, chunk?)` | `sendXFTPCommand` | 200 | Encode auth transmission + POST + parse response |
|
||||
| `createXFTPChunk(client, spKey, info, rcvKeys, auth?)` | `createXFTPChunk` | 232 | FNEW → (SenderId, RecipientId[]) |
|
||||
| `addXFTPRecipients(client, spKey, fileId, rcvKeys)` | `addXFTPRecipients` | 244 | FADD → RecipientId[] |
|
||||
| `uploadXFTPChunk(client, spKey, fileId, chunkData)` | `uploadXFTPChunk` | 250 | FPUT with streaming body |
|
||||
| `downloadXFTPChunk(client, rpKey, fileId, chunkSize)` | `downloadXFTPChunk` | 254 | FGET → DH → transit-decrypt → Uint8Array |
|
||||
| `deleteXFTPChunk(client, spKey, senderId)` | `deleteXFTPChunk` | 286 | FDEL |
|
||||
| `ackXFTPChunk(client, rpKey, recipientId)` | `ackXFTPChunk` | 289 | FACK |
|
||||
| `pingXFTP(client)` | `pingXFTP` | 292 | PING → FRPong |
|
||||
|
||||
**Intermediate YAML types:**
|
||||
- `YAMLFileDescription` (line 158): `{party, size :: String, digest, key, nonce, chunkSize :: String, replicas :: [YAMLServerReplicas], redirect :: Maybe RedirectFileInfo}` — `size` and `chunkSize` use human-readable `StrEncoding` format.
|
||||
- `YAMLServerReplicas` (line 170): `{server :: XFTPServer, chunks :: [String]}` — replicas grouped by server.
|
||||
- Binary fields (`digest`, `key`, `nonce`) are base64url-encoded via `StrEncoding` / `strToJSON`.
|
||||
- Chunk replica string format: `chunkNo:replicaId:replicaKey[:digest][:chunkSize]`
|
||||
**XFTPClient state** (returned by `connectXFTP`):
|
||||
- HTTP/2 session (node: `ClientHttp2Session`, browser: base URL for fetch)
|
||||
- `thParams`: `{sessionId, blockSize, thVersion, thAuth}` from handshake
|
||||
- Server address for reconnection
|
||||
|
||||
### 12.14 `description/validation.ts` ← `Simplex/FileTransfer/Description.hs`
|
||||
**sendXFTPCommand wire format:**
|
||||
1. `xftpEncodeAuthTransmission(thParams, pKey, (corrId, fId, cmd))` → padded 16KB block
|
||||
2. POST to "/" with body = block + optional chunk data (streaming)
|
||||
3. Response: read 16KB `bodyHead`, decode via `xftpDecodeTClient`
|
||||
4. For FGET: response also has streaming body (encrypted chunk)
|
||||
|
||||
### 12.16 `agent.ts` ← `Simplex/FileTransfer/Client/Main.hs`
|
||||
|
||||
Upload/download orchestration and URL encoding. Combines what the RFC originally split across `agent/upload.ts`, `agent/download.ts`, and `description/uri.ts`.
|
||||
|
||||
**Upload functions:**
|
||||
|
||||
| TypeScript function | Haskell function | Line | Description |
|
||||
|---|---|---|---|
|
||||
| `validateFileDescription(desc)` | `validateFileDescription` | 221 | Check sequential chunk numbers and total size match |
|
||||
|
||||
### 12.15 `agent/upload.ts` ← `Simplex/FileTransfer/Client/Main.hs`
|
||||
|
||||
Upload orchestration — the top-level flow.
|
||||
|
||||
| TypeScript function | Haskell function | Line | Description |
|
||||
|---|---|---|---|
|
||||
| `encryptFileForUpload(file)` | `encryptFileForUpload` | 264 | Generate key/nonce, encrypt, compute digest, split |
|
||||
| `uploadFile(chunks, servers)` | `uploadFile` | 285 | Parallel upload (8 concurrent) |
|
||||
| `uploadFileChunk(agent, chunk, server)` | `uploadFileChunk` | 301 | FNEW + FPUT for one chunk |
|
||||
| `createRcvFileDescriptions(desc, sentChunks)` | `createRcvFileDescriptions` | 329 | Build recipient descriptions |
|
||||
| `encryptFileForUpload(file, fileName)` | `encryptFileForUpload` | 264 | key/nonce → encrypt → digest → chunk specs |
|
||||
| `uploadFile(client, chunkSpecs, servers, numRcps)` | `uploadFile` | 285 | Parallel upload (up to 16 concurrent) |
|
||||
| `uploadFileChunk(client, chunkNo, spec, server)` | `uploadFileChunk` | 301 | FNEW + FPUT for one chunk |
|
||||
| `createRcvFileDescriptions(fd, sentChunks)` | `createRcvFileDescriptions` | 329 | Build per-recipient descriptions |
|
||||
| `createSndFileDescription(fd, sentChunks)` | `createSndFileDescription` | 361 | Build sender (deletion) description |
|
||||
|
||||
**Upload call sequence** (`cliSendFileOpts`, line 243):
|
||||
1. `encryptFileForUpload` (line 264) — `C.randomSbKey` + `C.randomCbNonce` → `encryptFile` → `sha512Hash` digest → `prepareChunkSpecs`
|
||||
2. `uploadFile` (line 285) — `pooledForConcurrentlyN 16 chunks uploadFileChunk`
|
||||
3. `uploadFileChunk` (line 301) — `getChunkDigest` (line 306) → `createXFTPChunk` → `uploadXFTPChunk`
|
||||
4. `createRcvFileDescriptions` (line 329) — assembles `FileDescription 'FRecipient` from sent chunks
|
||||
5. `writeFileDescriptions` (line 376) — serializes to YAML files
|
||||
1. `encryptFileForUpload` — `randomSbKey` + `randomCbNonce` → `encryptFile` → `sha512Hash` digest → `prepareChunkSpecs`
|
||||
2. `uploadFile` — for each chunk: generate sender/recipient key pairs, `createXFTPChunk`, `uploadXFTPChunk`
|
||||
3. `createRcvFileDescriptions` — assemble `FileDescription` per recipient from sent chunks
|
||||
4. `createSndFileDescription` — assemble sender description with deletion keys
|
||||
|
||||
### 12.16 `agent/download.ts` ← `Simplex/FileTransfer/Client/Main.hs`
|
||||
|
||||
Download orchestration — the top-level flow.
|
||||
**Download functions:**
|
||||
|
||||
| TypeScript function | Haskell function | Line | Description |
|
||||
|---|---|---|---|
|
||||
| `downloadFile(description)` | `cliReceiveFile` | 388 | Full download flow |
|
||||
| `downloadFileChunk(rng, agent, path, size, chunk)` | `downloadFileChunk` | 418 | FGET + transit-decrypt one chunk |
|
||||
| `ackFileChunk(agent, chunk)` | `acknowledgeFileChunk` | 440 | FACK one chunk |
|
||||
| `downloadFile(description)` | `cliReceiveFile` | 388 | Full download: parse → download → verify → decrypt |
|
||||
| `downloadFileChunk(client, chunk)` | `downloadFileChunk` | 418 | FGET + transit-decrypt one chunk |
|
||||
| `ackFileChunk(client, chunk)` | `acknowledgeFileChunk` | 440 | FACK one chunk |
|
||||
| `deleteFile(description)` | `cliDeleteFile` | 455 | FDEL for all chunks |
|
||||
|
||||
**Download call sequence** (`cliReceiveFile`, line 388):
|
||||
1. Parse and validate `FileDescription` from YAML
|
||||
2. Group chunks by server: `groupAllOn srv chunks` (line 402, local `srv` helper extracts first replica's server)
|
||||
3. Parallel download: `pooledForConcurrentlyN 16 srvChunks downloadFileChunk`
|
||||
4. `downloadFileChunk` (line 418) — calls `downloadXFTPChunk` (`Client.hs:253`) which does FGET → DH → transit-decrypt
|
||||
5. `readChunks` (`Crypto.hs:113`) — concatenate chunk files
|
||||
6. Verify file digest (SHA-512)
|
||||
7. `decryptChunks` (`Crypto.hs:57`) — file-level decrypt with auth tag verification
|
||||
8. Parallel acknowledge: `acknowledgeFileChunk` → `ackXFTPChunk`
|
||||
2. Group chunks by server
|
||||
3. Parallel download: `downloadXFTPChunk` per chunk (up to 16 concurrent)
|
||||
4. Verify file digest (SHA-512) over concatenated encrypted chunks
|
||||
5. `decryptChunks` — file-level decrypt with auth tag verification
|
||||
6. Parallel acknowledge: `ackXFTPChunk` per chunk
|
||||
|
||||
**URL encoding (§4.1):**
|
||||
|
||||
| TypeScript function | Description |
|
||||
|---|---|
|
||||
| `encodeDescriptionURI(fd)` | DEFLATE compress YAML → base64url → URL hash fragment |
|
||||
| `decodeDescriptionURI(url)` | Parse hash fragment → base64url decode → inflate → YAML parse |
|
||||
|
||||
### 12.17 Transit Encryption Detail ← `Simplex/FileTransfer/Client.hs:253-275`
|
||||
|
||||
@@ -1039,33 +1042,25 @@ Download orchestration — the top-level flow.
|
||||
xftp-web/ # Separate npm project
|
||||
├── src/
|
||||
│ ├── protocol/
|
||||
│ │ ├── encoding.ts # ← Simplex.Messaging.Encoding
|
||||
│ │ ├── commands.ts # ← Simplex.FileTransfer.Protocol (commands)
|
||||
│ │ ├── responses.ts # ← Simplex.FileTransfer.Protocol (responses)
|
||||
│ │ └── transmission.ts # ← Simplex.FileTransfer.Protocol (framing)
|
||||
│ │ ├── encoding.ts # ← Simplex.Messaging.Encoding ✓
|
||||
│ │ ├── commands.ts # ← Simplex.FileTransfer.Protocol (commands+responses) ✓
|
||||
│ │ ├── transmission.ts # ← Simplex.FileTransfer.Protocol (framing) ✓
|
||||
│ │ ├── handshake.ts # ← Simplex.FileTransfer.Transport (handshake) ✓
|
||||
│ │ ├── address.ts # ← Simplex.Messaging.Protocol (server address) ✓
|
||||
│ │ ├── chunks.ts # ← Simplex.FileTransfer.Chunks + Client.hs (sizing) ✓
|
||||
│ │ ├── client.ts # ← Transport crypto (cbAuth, transit encrypt/decrypt) ✓
|
||||
│ │ └── description.ts # ← Simplex.FileTransfer.Description (types+yaml+val) ✓
|
||||
│ ├── crypto/
|
||||
│ │ ├── secretbox.ts # ← Simplex.Messaging.Crypto + Crypto.Lazy
|
||||
│ │ ├── file.ts # ← Simplex.FileTransfer.Crypto
|
||||
│ │ ├── keys.ts # ← Simplex.Messaging.Crypto (keys, sign, DH)
|
||||
│ │ ├── digest.ts # ← Simplex.Messaging.Crypto (sha256, sha512)
|
||||
│ │ └── padding.ts # ← Simplex.Messaging.Crypto (pad/unPad)
|
||||
│ ├── transport/
|
||||
│ │ ├── client.ts # ← Simplex.FileTransfer.Client
|
||||
│ │ ├── handshake.ts # ← Simplex.FileTransfer.Transport
|
||||
│ │ └── cors.ts # CORS-aware request handling
|
||||
│ ├── description/
|
||||
│ │ ├── types.ts # ← Simplex.FileTransfer.Description (types)
|
||||
│ │ ├── yaml.ts # ← Simplex.FileTransfer.Description (encoding)
|
||||
│ │ ├── uri.ts # ← URL encoding/decoding with compression (§4.1)
|
||||
│ │ └── validation.ts # ← Simplex.FileTransfer.Description (validation)
|
||||
│ ├── agent/
|
||||
│ │ ├── upload.ts # ← Simplex.FileTransfer.Client.Main (upload)
|
||||
│ │ ├── download.ts # ← Simplex.FileTransfer.Client.Main (download)
|
||||
│ │ └── chunking.ts # ← Simplex.FileTransfer.Client + Chunks
|
||||
│ └── index.ts # Public API
|
||||
├── test/
|
||||
│ └── integration.test.ts # TS-driven: spawns xftp-server, full round-trips
|
||||
├── web/ # Browser UI (Phase 4)
|
||||
│ │ ├── secretbox.ts # ← Simplex.Messaging.Crypto + Crypto.Lazy ✓
|
||||
│ │ ├── file.ts # ← Simplex.FileTransfer.Crypto ✓
|
||||
│ │ ├── keys.ts # ← Simplex.Messaging.Crypto (Ed25519/X25519/Ed448) ✓
|
||||
│ │ ├── digest.ts # ← Simplex.Messaging.Crypto (sha256, sha512) ✓
|
||||
│ │ ├── padding.ts # ← Simplex.Messaging.Crypto (pad/unPad) ✓
|
||||
│ │ └── identity.ts # ← Web handshake identity proof (Ed25519/Ed448) ✓
|
||||
│ ├── download.ts # Download helpers (DH, transit-decrypt, file-decrypt) ✓
|
||||
│ ├── client.ts # ← Simplex.FileTransfer.Client (HTTP/2 operations)
|
||||
│ └── agent.ts # ← Simplex.FileTransfer.Client.Main (orchestration)
|
||||
├── web/ # Browser UI (Phase 5)
|
||||
│ ├── index.html
|
||||
│ ├── upload.ts
|
||||
│ ├── download.ts
|
||||
@@ -1074,12 +1069,13 @@ xftp-web/ # Separate npm project
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**Haskell per-function tests (in simplexmq repo):**
|
||||
**Haskell tests (in simplexmq repo):**
|
||||
```
|
||||
tests/
|
||||
└── XFTPWebTests.hs # Haskell-driven: calls each TS function via node,
|
||||
# compares output with Haskell function (see §10.1)
|
||||
# ~100 test cases, one per row in §12.1–12.17 tables
|
||||
├── XFTPWebTests.hs # Haskell-driven: calls each TS function via node,
|
||||
│ # compares output with Haskell function (see §10.1)
|
||||
│ # 164 test cases across 16 test groups
|
||||
└── fixtures/ed25519/ # Ed25519 test certs for web handshake integration tests
|
||||
```
|
||||
|
||||
No fixture files, no TS test harness for unit tests. The Haskell test file IS the test — it calls both Haskell and TypeScript functions directly and compares outputs. TS-side integration tests (`test/integration.test.ts`) are separate and only run after all per-function tests pass.
|
||||
|
||||
+88
-8
@@ -31,7 +31,7 @@ import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
|
||||
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
|
||||
import System.Directory (doesDirectoryExist)
|
||||
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive)
|
||||
import System.Exit (ExitCode (..))
|
||||
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)
|
||||
import Test.Hspec hiding (fit, it)
|
||||
@@ -1361,12 +1361,14 @@ tsTransmissionTests = describe "protocol/transmission" $ do
|
||||
|
||||
describe "transmission encoding" $ do
|
||||
it "encodeTransmission unsigned (PING)" $ do
|
||||
let corrId = "abc" :: B.ByteString
|
||||
let sessionId = B.pack [201 .. 232]
|
||||
corrId = "abc" :: B.ByteString
|
||||
entityId = "" :: B.ByteString
|
||||
cmdBytes = "PING" :: B.ByteString
|
||||
tInner = smpEncode corrId <> smpEncode entityId <> cmdBytes
|
||||
-- implySessId = False: sessionId on wire
|
||||
tWire = smpEncode sessionId <> smpEncode corrId <> smpEncode entityId <> cmdBytes
|
||||
authenticator = smpEncode ("" :: B.ByteString)
|
||||
encoded = authenticator <> tInner
|
||||
encoded = authenticator <> tWire
|
||||
batch = B.singleton 1 <> smpEncode (Large encoded)
|
||||
expected = either (error . show) id $ C.pad batch 16384
|
||||
tsResult <-
|
||||
@@ -1374,6 +1376,8 @@ tsTransmissionTests = describe "protocol/transmission" $ do
|
||||
impTx
|
||||
<> jsOut
|
||||
( "Tx.encodeTransmission("
|
||||
<> jsUint8 sessionId
|
||||
<> ", "
|
||||
<> jsUint8 corrId
|
||||
<> ", "
|
||||
<> jsUint8 entityId
|
||||
@@ -1396,7 +1400,8 @@ tsTransmissionTests = describe "protocol/transmission" $ do
|
||||
sig = Ed25519.sign sk pk tForAuth
|
||||
rawSig = BA.convert sig :: B.ByteString
|
||||
authenticator = smpEncode rawSig
|
||||
encoded = authenticator <> tInner
|
||||
-- implySessId = False: tToSend = tForAuth (sessionId on wire)
|
||||
encoded = authenticator <> tForAuth
|
||||
batch = B.singleton 1 <> smpEncode (Large encoded)
|
||||
expected = either (error . show) id $ C.pad batch 16384
|
||||
tsResult <-
|
||||
@@ -1419,18 +1424,22 @@ tsTransmissionTests = describe "protocol/transmission" $ do
|
||||
tsResult `shouldBe` expected
|
||||
|
||||
it "decodeTransmission" $ do
|
||||
let corrId = "r01" :: B.ByteString
|
||||
let sessionId = B.pack [201 .. 232]
|
||||
corrId = "r01" :: B.ByteString
|
||||
entityId = B.pack [1 .. 16]
|
||||
cmdBytes = "OK" :: B.ByteString
|
||||
tInner = smpEncode corrId <> smpEncode entityId <> cmdBytes
|
||||
-- implySessId = False: sessionId on wire
|
||||
tWire = smpEncode sessionId <> smpEncode corrId <> smpEncode entityId <> cmdBytes
|
||||
authenticator = smpEncode ("" :: B.ByteString)
|
||||
encoded = authenticator <> tInner
|
||||
encoded = authenticator <> tWire
|
||||
batch = B.singleton 1 <> smpEncode (Large encoded)
|
||||
block = either (error . show) id $ C.pad batch 256
|
||||
tsResult <-
|
||||
callNode $
|
||||
impTx
|
||||
<> "const t = Tx.decodeTransmission("
|
||||
<> jsUint8 sessionId
|
||||
<> ", "
|
||||
<> jsUint8 block
|
||||
<> ");"
|
||||
<> jsOut "E.concatBytes(t.corrId, t.entityId, t.command)"
|
||||
@@ -2790,6 +2799,10 @@ tsIntegrationTests = describe "integration" $ do
|
||||
webHandshakeTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
||||
it "web handshake with Ed448 identity verification" $
|
||||
webHandshakeTest testXFTPServerConfigSNI "tests/fixtures/ca.crt"
|
||||
it "connectXFTP + pingXFTP" $
|
||||
pingTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
||||
it "full round-trip: create, upload, download, ack, addRecipients, delete" $
|
||||
fullRoundTripTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
|
||||
|
||||
webHandshakeTest :: XFTPServerConfig -> FilePath -> Expectation
|
||||
webHandshakeTest cfg caFile = do
|
||||
@@ -2831,3 +2844,70 @@ webHandshakeTest cfg caFile = do
|
||||
\client.close();"
|
||||
<> jsOut "new Uint8Array([idOk ? 1 : 0, ack.length === 0 ? 1 : 0])"
|
||||
result `shouldBe` B.pack [1, 1]
|
||||
|
||||
pingTest :: XFTPServerConfig -> FilePath -> Expectation
|
||||
pingTest cfg caFile = do
|
||||
withXFTPServerCfg cfg $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint caFile
|
||||
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
||||
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
||||
result <-
|
||||
callNode $
|
||||
"import sodium from 'libsodium-wrappers-sumo';\
|
||||
\import * as Addr from './dist/protocol/address.js';\
|
||||
\import {connectXFTP, pingXFTP, closeXFTP} from './dist/client.js';\
|
||||
\await sodium.ready;\
|
||||
\const server = Addr.parseXFTPServer('"
|
||||
<> addr
|
||||
<> "');\
|
||||
\const c = await connectXFTP(server);\
|
||||
\await pingXFTP(c);\
|
||||
\closeXFTP(c);"
|
||||
<> jsOut "new Uint8Array([1])"
|
||||
result `shouldBe` B.pack [1]
|
||||
|
||||
fullRoundTripTest :: XFTPServerConfig -> FilePath -> Expectation
|
||||
fullRoundTripTest cfg caFile = do
|
||||
createDirectoryIfMissing False "tests/tmp/xftp-server-files"
|
||||
withXFTPServerCfg cfg $ \_ -> do
|
||||
Fingerprint fp <- loadFileFingerprint caFile
|
||||
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
|
||||
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
|
||||
result <-
|
||||
callNode $
|
||||
"import sodium from 'libsodium-wrappers-sumo';\
|
||||
\import crypto from 'node:crypto';\
|
||||
\import * as Addr from './dist/protocol/address.js';\
|
||||
\import * as K from './dist/crypto/keys.js';\
|
||||
\import {sha256} from './dist/crypto/digest.js';\
|
||||
\import {connectXFTP, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk,\
|
||||
\ ackXFTPChunk, addXFTPRecipients, deleteXFTPChunk, closeXFTP} from './dist/client.js';\
|
||||
\await sodium.ready;\
|
||||
\const server = Addr.parseXFTPServer('"
|
||||
<> addr
|
||||
<> "');\
|
||||
\const c = await connectXFTP(server);\
|
||||
\const sndKp = K.generateEd25519KeyPair();\
|
||||
\const rcvKp1 = K.generateEd25519KeyPair();\
|
||||
\const rcvKp2 = K.generateEd25519KeyPair();\
|
||||
\const chunkData = new Uint8Array(crypto.randomBytes(65536));\
|
||||
\const digest = sha256(chunkData);\
|
||||
\const file = {\
|
||||
\ sndKey: K.encodePubKeyEd25519(sndKp.publicKey),\
|
||||
\ size: chunkData.length,\
|
||||
\ digest\
|
||||
\};\
|
||||
\const rcvKeys = [K.encodePubKeyEd25519(rcvKp1.publicKey)];\
|
||||
\const {senderId, recipientIds} = await createXFTPChunk(c, sndKp.privateKey, file, rcvKeys, null);\
|
||||
\await uploadXFTPChunk(c, sndKp.privateKey, senderId, chunkData);\
|
||||
\const dl1 = await downloadXFTPChunk(c, rcvKp1.privateKey, recipientIds[0], digest);\
|
||||
\const match1 = dl1.length === chunkData.length && dl1.every((b, i) => b === chunkData[i]);\
|
||||
\await ackXFTPChunk(c, rcvKp1.privateKey, recipientIds[0]);\
|
||||
\const newIds = await addXFTPRecipients(c, sndKp.privateKey, senderId,\
|
||||
\ [K.encodePubKeyEd25519(rcvKp2.publicKey)]);\
|
||||
\const dl2 = await downloadXFTPChunk(c, rcvKp2.privateKey, newIds[0], digest);\
|
||||
\const match2 = dl2.length === chunkData.length && dl2.every((b, i) => b === chunkData[i]);\
|
||||
\await deleteXFTPChunk(c, sndKp.privateKey, senderId);\
|
||||
\closeXFTP(c);"
|
||||
<> jsOut "new Uint8Array([match1 ? 1 : 0, match2 ? 1 : 0])"
|
||||
result `shouldBe` B.pack [1, 1]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// XFTP HTTP/2 client — Simplex.FileTransfer.Client
|
||||
//
|
||||
// Connects to XFTP server via HTTP/2, performs web handshake,
|
||||
// sends authenticated commands, receives responses.
|
||||
|
||||
import http2 from "node:http2"
|
||||
import crypto from "node:crypto"
|
||||
import {
|
||||
encodeAuthTransmission, encodeTransmission, decodeTransmission,
|
||||
XFTP_BLOCK_SIZE, initialXFTPVersion, currentXFTPVersion
|
||||
} from "./protocol/transmission.js"
|
||||
import {
|
||||
encodeClientHello, encodeClientHandshake, decodeServerHandshake,
|
||||
compatibleVRange
|
||||
} from "./protocol/handshake.js"
|
||||
import {verifyIdentityProof} from "./crypto/identity.js"
|
||||
import {generateX25519KeyPair, encodePubKeyX25519, dh} from "./crypto/keys.js"
|
||||
import {
|
||||
encodeFNEW, encodeFADD, encodeFPUT, encodeFGET, encodeFDEL, encodeFACK, encodePING,
|
||||
decodeResponse, type FileResponse, type FileInfo
|
||||
} from "./protocol/commands.js"
|
||||
import {decryptReceivedChunk} from "./download.js"
|
||||
import type {XFTPServer} from "./protocol/address.js"
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface XFTPClient {
|
||||
session: http2.ClientHttp2Session
|
||||
sessionId: Uint8Array
|
||||
xftpVersion: number
|
||||
}
|
||||
|
||||
// ── HTTP/2 helpers ────────────────────────────────────────────────
|
||||
|
||||
function h2Request(
|
||||
session: http2.ClientHttp2Session,
|
||||
body: Uint8Array,
|
||||
extraBody?: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = session.request({":method": "POST", ":path": "/"})
|
||||
const chunks: Buffer[] = []
|
||||
stream.on("data", (d: Buffer) => chunks.push(d))
|
||||
stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
||||
stream.on("error", reject)
|
||||
if (extraBody) {
|
||||
stream.write(Buffer.from(body))
|
||||
stream.end(Buffer.from(extraBody))
|
||||
} else {
|
||||
stream.end(Buffer.from(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readBody(stream: http2.ClientHttp2Stream): Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
stream.on("data", (d: Buffer) => chunks.push(d))
|
||||
stream.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
|
||||
stream.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Connect + handshake ───────────────────────────────────────────
|
||||
|
||||
export async function connectXFTP(server: XFTPServer): Promise<XFTPClient> {
|
||||
const session = http2.connect(
|
||||
"https://" + server.host + ":" + server.port,
|
||||
{rejectUnauthorized: false}
|
||||
)
|
||||
|
||||
// Step 1: send client hello with web challenge
|
||||
const challenge = new Uint8Array(crypto.randomBytes(32))
|
||||
const s1 = session.request({":method": "POST", ":path": "/"})
|
||||
s1.end(Buffer.from(encodeClientHello({webChallenge: challenge})))
|
||||
const shsBody = await readBody(s1)
|
||||
|
||||
// Step 2: decode + verify server handshake
|
||||
const hs = decodeServerHandshake(shsBody)
|
||||
if (!hs.webIdentityProof) throw new Error("connectXFTP: no web identity proof")
|
||||
const idOk = verifyIdentityProof({
|
||||
certChainDer: hs.certChainDer,
|
||||
signedKeyDer: hs.signedKeyDer,
|
||||
sigBytes: hs.webIdentityProof,
|
||||
challenge,
|
||||
sessionId: hs.sessionId,
|
||||
keyHash: server.keyHash
|
||||
})
|
||||
if (!idOk) throw new Error("connectXFTP: identity verification failed")
|
||||
|
||||
// Step 3: version negotiation
|
||||
const vr = compatibleVRange(hs.xftpVersionRange, {minVersion: initialXFTPVersion, maxVersion: currentXFTPVersion})
|
||||
if (!vr) throw new Error("connectXFTP: incompatible version")
|
||||
const xftpVersion = vr.maxVersion
|
||||
|
||||
// Step 4: send client handshake
|
||||
const s2 = session.request({":method": "POST", ":path": "/"})
|
||||
s2.end(Buffer.from(encodeClientHandshake({xftpVersion, keyHash: server.keyHash})))
|
||||
const ack = await readBody(s2)
|
||||
if (ack.length !== 0) throw new Error("connectXFTP: non-empty handshake ack")
|
||||
|
||||
return {session, sessionId: hs.sessionId, xftpVersion}
|
||||
}
|
||||
|
||||
// ── Send command ──────────────────────────────────────────────────
|
||||
|
||||
async function sendXFTPCommand(
|
||||
client: XFTPClient,
|
||||
privateKey: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmdBytes: Uint8Array,
|
||||
chunkData?: Uint8Array
|
||||
): Promise<{response: FileResponse, body: Uint8Array}> {
|
||||
const corrId = new Uint8Array(0)
|
||||
const block = encodeAuthTransmission(client.sessionId, corrId, entityId, cmdBytes, privateKey)
|
||||
const fullResp = await h2Request(client.session, block, chunkData)
|
||||
if (fullResp.length < XFTP_BLOCK_SIZE) throw new Error("sendXFTPCommand: response too short")
|
||||
const respBlock = fullResp.subarray(0, XFTP_BLOCK_SIZE)
|
||||
const body = fullResp.subarray(XFTP_BLOCK_SIZE)
|
||||
const {command} = decodeTransmission(client.sessionId, respBlock)
|
||||
const response = decodeResponse(command)
|
||||
if (response.type === "FRErr") throw new Error("XFTP error: " + response.err.type)
|
||||
return {response, body}
|
||||
}
|
||||
|
||||
// ── Command wrappers ──────────────────────────────────────────────
|
||||
|
||||
export async function createXFTPChunk(
|
||||
c: XFTPClient, spKey: Uint8Array, file: FileInfo,
|
||||
rcvKeys: Uint8Array[], auth: Uint8Array | null = null
|
||||
): Promise<{senderId: Uint8Array, recipientIds: Uint8Array[]}> {
|
||||
const {response} = await sendXFTPCommand(c, spKey, new Uint8Array(0), encodeFNEW(file, rcvKeys, auth))
|
||||
if (response.type !== "FRSndIds") throw new Error("unexpected response: " + response.type)
|
||||
return {senderId: response.senderId, recipientIds: response.recipientIds}
|
||||
}
|
||||
|
||||
export async function addXFTPRecipients(
|
||||
c: XFTPClient, spKey: Uint8Array, fId: Uint8Array, rcvKeys: Uint8Array[]
|
||||
): Promise<Uint8Array[]> {
|
||||
const {response} = await sendXFTPCommand(c, spKey, fId, encodeFADD(rcvKeys))
|
||||
if (response.type !== "FRRcvIds") throw new Error("unexpected response: " + response.type)
|
||||
return response.recipientIds
|
||||
}
|
||||
|
||||
export async function uploadXFTPChunk(
|
||||
c: XFTPClient, spKey: Uint8Array, fId: Uint8Array, chunkData: Uint8Array
|
||||
): Promise<void> {
|
||||
const {response} = await sendXFTPCommand(c, spKey, fId, encodeFPUT(), chunkData)
|
||||
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
|
||||
}
|
||||
|
||||
export async function downloadXFTPChunk(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const {publicKey, privateKey} = generateX25519KeyPair()
|
||||
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
|
||||
const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd)
|
||||
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
|
||||
const dhSecret = dh(response.rcvDhKey, privateKey)
|
||||
return decryptReceivedChunk(dhSecret, response.nonce, body, digest ?? null)
|
||||
}
|
||||
|
||||
export async function deleteXFTPChunk(
|
||||
c: XFTPClient, spKey: Uint8Array, sId: Uint8Array
|
||||
): Promise<void> {
|
||||
const {response} = await sendXFTPCommand(c, spKey, sId, encodeFDEL())
|
||||
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
|
||||
}
|
||||
|
||||
export async function ackXFTPChunk(
|
||||
c: XFTPClient, rpKey: Uint8Array, rId: Uint8Array
|
||||
): Promise<void> {
|
||||
const {response} = await sendXFTPCommand(c, rpKey, rId, encodeFACK())
|
||||
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
|
||||
}
|
||||
|
||||
export async function pingXFTP(c: XFTPClient): Promise<void> {
|
||||
const corrId = new Uint8Array(0)
|
||||
const block = encodeTransmission(c.sessionId, corrId, new Uint8Array(0), encodePING())
|
||||
const fullResp = await h2Request(c.session, block)
|
||||
if (fullResp.length < XFTP_BLOCK_SIZE) throw new Error("pingXFTP: response too short")
|
||||
const {command} = decodeTransmission(c.sessionId, fullResp.subarray(0, XFTP_BLOCK_SIZE))
|
||||
const response = decodeResponse(command)
|
||||
if (response.type !== "FRPong") throw new Error("unexpected response: " + response.type)
|
||||
}
|
||||
|
||||
// ── Close ─────────────────────────────────────────────────────────
|
||||
|
||||
export function closeXFTP(c: XFTPClient): void {
|
||||
c.session.close()
|
||||
}
|
||||
@@ -43,13 +43,8 @@ export function blockUnpad(block: Uint8Array): Uint8Array {
|
||||
// ── Transmission encoding (client -> server) ──────────────────────
|
||||
|
||||
// Encode an authenticated XFTP command as a padded block.
|
||||
// Matches xftpEncodeAuthTransmission (implySessId = True).
|
||||
//
|
||||
// sessionId: TLS session ID (typically 32 bytes)
|
||||
// corrId: correlation ID (ByteString)
|
||||
// entityId: file entity ID (ByteString, empty for FNEW/PING)
|
||||
// cmdBytes: encoded command (from encodeFNEW, encodeFGET, etc.)
|
||||
// privateKey: Ed25519 private key (64-byte libsodium format)
|
||||
// Matches xftpEncodeAuthTransmission with implySessId = False:
|
||||
// sessionId is included in both signed data AND wire data.
|
||||
export function encodeAuthTransmission(
|
||||
sessionId: Uint8Array,
|
||||
corrId: Uint8Array,
|
||||
@@ -59,29 +54,25 @@ export function encodeAuthTransmission(
|
||||
): Uint8Array {
|
||||
// t' = encodeTransmission_ v t = smpEncode (corrId, entityId) <> cmdBytes
|
||||
const tInner = concatBytes(encodeBytes(corrId), encodeBytes(entityId), cmdBytes)
|
||||
// tForAuth = smpEncode sessionId <> t' (implySessId = True)
|
||||
// tForAuth = smpEncode sessionId <> t'
|
||||
const tForAuth = concatBytes(encodeBytes(sessionId), tInner)
|
||||
// Ed25519 sign (nonce ignored for Ed25519 in Haskell sign')
|
||||
const signature = sign(privateKey, tForAuth)
|
||||
// tEncodeAuth False (Just (TASignature sig, Nothing)) = smpEncode (signatureBytes sig)
|
||||
const authenticator = encodeBytes(signature)
|
||||
// tEncode False (auth, tToSend) = authenticator <> tToSend
|
||||
// tToSend = t' (since implySessId = True, no sessionId in wire)
|
||||
const encoded = concatBytes(authenticator, tInner)
|
||||
// tEncodeBatch1 False = \x01 + encodeLarge(encoded)
|
||||
// implySessId = False: tToSend = tForAuth (sessionId on wire)
|
||||
const encoded = concatBytes(authenticator, tForAuth)
|
||||
const batch = concatBytes(new Uint8Array([1]), encodeLarge(encoded))
|
||||
// pad to blockSize
|
||||
return blockPad(batch)
|
||||
}
|
||||
|
||||
// Encode an unsigned XFTP command (e.g. PING) as a padded block.
|
||||
// Matches xftpEncodeTransmission (implySessId = True).
|
||||
// Matches xftpEncodeTransmission with implySessId = False: sessionId on wire.
|
||||
export function encodeTransmission(
|
||||
sessionId: Uint8Array,
|
||||
corrId: Uint8Array,
|
||||
entityId: Uint8Array,
|
||||
cmdBytes: Uint8Array
|
||||
): Uint8Array {
|
||||
const tInner = concatBytes(encodeBytes(corrId), encodeBytes(entityId), cmdBytes)
|
||||
const tInner = concatBytes(encodeBytes(sessionId), encodeBytes(corrId), encodeBytes(entityId), cmdBytes)
|
||||
// No auth: tEncodeAuth False Nothing = smpEncode B.empty = \x00
|
||||
const authenticator = encodeBytes(new Uint8Array(0))
|
||||
const encoded = concatBytes(authenticator, tInner)
|
||||
@@ -99,23 +90,23 @@ export interface DecodedTransmission {
|
||||
|
||||
// Decode a server response block into raw parts.
|
||||
// Call decodeResponse(command) from commands.ts to parse the response.
|
||||
// Matches xftpDecodeTClient (implySessId = True).
|
||||
export function decodeTransmission(block: Uint8Array): DecodedTransmission {
|
||||
// unPad
|
||||
// Matches xftpDecodeTClient with implySessId = False: reads and verifies sessionId from wire.
|
||||
export function decodeTransmission(sessionId: Uint8Array, block: Uint8Array): DecodedTransmission {
|
||||
const raw = blockUnpad(block)
|
||||
const d = new Decoder(raw)
|
||||
// Read batch count (must be 1)
|
||||
const count = d.anyByte()
|
||||
if (count !== 1) throw new Error("decodeTransmission: expected batch count 1, got " + count)
|
||||
// Read Large-encoded transmission
|
||||
const transmission = decodeLarge(d)
|
||||
const td = new Decoder(transmission)
|
||||
// Skip authenticator (server responses have empty auth)
|
||||
decodeBytes(td)
|
||||
// Read corrId and entityId
|
||||
// implySessId = False: read sessionId from wire and verify
|
||||
const sessId = decodeBytes(td)
|
||||
if (sessId.length !== sessionId.length || !sessId.every((b, i) => b === sessionId[i])) {
|
||||
throw new Error("decodeTransmission: session ID mismatch")
|
||||
}
|
||||
const corrId = decodeBytes(td)
|
||||
const entityId = decodeBytes(td)
|
||||
// Remaining bytes are the response command
|
||||
const command = td.takeAll()
|
||||
return {corrId, entityId, command}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user