xftp-web client functions, fix transmission encoding

This commit is contained in:
Evgeny Poberezkin
2026-02-02 22:05:04 +00:00
parent 64089834f3
commit 260380486a
4 changed files with 449 additions and 191 deletions
+155 -159
View File
@@ -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.1212.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.112.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
View File
@@ -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]
+191
View File
@@ -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()
}
+15 -24
View File
@@ -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}
}