From 260380486ac5831fdfcfd4b3eeb950d6bac80434 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 2 Feb 2026 22:05:04 +0000 Subject: [PATCH] xftp-web client functions, fix transmission encoding --- rfcs/2026-01-30-send-file-page.md | 314 +++++++++++++------------- tests/XFTPWebTests.hs | 96 +++++++- xftp-web/src/client.ts | 191 ++++++++++++++++ xftp-web/src/protocol/transmission.ts | 39 ++-- 4 files changed, 449 insertions(+), 191 deletions(-) create mode 100644 xftp-web/src/client.ts diff --git a/rfcs/2026-01-30-send-file-page.md b/rfcs/2026-01-30-send-file-page.md index 28556b3d0..0e35d4499 100644 --- a/rfcs/2026-01-30-send-file-page.md +++ b/rfcs/2026-01-30-send-file-page.md @@ -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. diff --git a/tests/XFTPWebTests.hs b/tests/XFTPWebTests.hs index 3dec93270..f2b091fcb 100644 --- a/tests/XFTPWebTests.hs +++ b/tests/XFTPWebTests.hs @@ -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] diff --git a/xftp-web/src/client.ts b/xftp-web/src/client.ts new file mode 100644 index 000000000..5dcbb1a76 --- /dev/null +++ b/xftp-web/src/client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/xftp-web/src/protocol/transmission.ts b/xftp-web/src/protocol/transmission.ts index 6e7f95453..75d00890b 100644 --- a/xftp-web/src/protocol/transmission.ts +++ b/xftp-web/src/protocol/transmission.ts @@ -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} }