smp web: spike (#1745)

* smp web: initial setup

* encoding/decoding of LGET/LNK

* SMP over websocket handshake works

* parse short connection links

* hkdf for short links

* refactor hkdf

* decrypt link data

* fetch link data via websocket

* signed challenge in web handshake

* test: skip smp-web tests when TS not built

* block encryption

* gitignore

* parse FixedLinkData

---------

Co-authored-by: Evgeny @ SimpleX Chat <[email protected]>
This commit is contained in:
Evgeny
2026-05-11 18:24:54 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent 3536a156d1
commit 3f40febe60
21 changed files with 1904 additions and 22 deletions
+299
View File
@@ -0,0 +1,299 @@
# SMP Agent for Browser — Web Widget Infrastructure
## 1. Problem & Goal
The SimpleX web widget needs to create duplex connections, send and receive encrypted messages, and handle the full SMP agent lifecycle — all running in the browser. This requires a TypeScript implementation of the SMP protocol stack: encoding, transport, client, and agent layers, mirroring the Haskell implementation in simplexmq.
This document covers the protocol infrastructure that lives in the simplexmq repository (`smp-web/`). The widget UI and chat-layer semantics (contact addresses, business addresses, group links) live in simplex-chat.
## 2. Architecture
Four layers, mirroring the Haskell codebase:
```
┌─────────────────────────────────────────────────────────────┐
│ Agent Layer │
│ Duplex connections, X3DH key agreement, double ratchet, │
│ message delivery, queue rotation, connection lifecycle │
├─────────────────────────────────────────────────────────────┤
│ Client Layer │
│ Connection pool (per server), command/response correlation, │
│ reconnection, backoff │
├─────────────────────────────────────────────────────────────┤
│ Transport Layer │
│ WebSocket, SMP handshake, block framing (16384 bytes), │
│ block encryption (X25519 DH + SbChainKeys) │
├─────────────────────────────────────────────────────────────┤
│ Protocol Layer │
│ SMP commands (NEW, KEY, SUB, SEND, ACK, etc.), │
│ binary encoding, transmission format │
├─────────────────────────────────────────────────────────────┤
│ Shared (from xftp-web) │
│ encoding.ts, secretbox.ts, padding.ts, keys.ts, digest.ts │
└─────────────────────────────────────────────────────────────┘
│
▼ WebSocket (TLS via browser)
┌───────────────┐
│ SMP Server │
│ (SNI → Warp │
│ → WS upgrade)│
└───────────────┘
```
### Core Principle: Mirror Haskell Structure
TypeScript code mirrors the Haskell module hierarchy as closely as possible. Each Haskell module has a corresponding TypeScript file, placed in the same relative path. Functions keep the same names. This enables:
- Easy cross-reference between codebases
- Sync as protocol evolves
- Code review by people who know the Haskell side
- Byte-for-byte testing of corresponding functions
### File Structure
```
smp-web/
├── src/
│ ├── protocol.ts ← SMP commands, transmission format
│ ├── protocol/
│ │ └── types.ts ← protocol types
│ ├── version.ts ← version range negotiation
│ ├── transport.ts ← handshake, block framing, THandle
│ ├── transport/
│ │ └── websockets.ts ← WebSocket connection
│ ├── client.ts ← connection pool, correlation, reconnect
│ ├── crypto/
│ │ ├── ratchet.ts ← double ratchet
│ │ └── shortLink.ts ← HKDF, link data decrypt
│ └── agent/
│ ├── protocol.ts ← connection types, link data parsing
│ └── client.ts ← connection lifecycle, message delivery
├── package.json
└── tsconfig.json
```
Encoding and crypto primitives are imported directly from xftp-web (npm dependency). New files are only created where SMP-specific logic is needed.
### Haskell Module → TypeScript File Mapping
| Haskell Module | TypeScript File | Source |
|---|---|---|
| `Simplex.Messaging.Encoding` | xftp-web `protocol/encoding.ts` | import directly |
| `Simplex.Messaging.Crypto` | xftp-web `crypto/*` | import directly |
| `Simplex.Messaging.Protocol` | `protocol.ts` | new |
| `Simplex.Messaging.Protocol.Types` | `protocol/types.ts` | new |
| `Simplex.Messaging.Version` | `version.ts` | new |
| `Simplex.Messaging.Transport` | `transport.ts` | new |
| `Simplex.Messaging.Transport.WebSockets` | `transport/websockets.ts` | new |
| `Simplex.Messaging.Client` | `client.ts` | new |
| `Simplex.Messaging.Crypto.Ratchet` | `crypto/ratchet.ts` | new |
| `Simplex.Messaging.Crypto.ShortLink` | `crypto/shortLink.ts` | new |
| `Simplex.Messaging.Agent.Protocol` | `agent/protocol.ts` | new |
| `Simplex.Messaging.Agent.Client` | `agent/client.ts` | new |
Function names in TypeScript match Haskell names (camelCase preserved). When a Haskell function is `smpClientHandshake`, TypeScript has `smpClientHandshake`. When Haskell has `contactShortLinkKdf`, TypeScript has `contactShortLinkKdf`.
## 3. Relationship to xftp-web
xftp-web (`simplexmq-2/xftp-web/`) is a production TypeScript XFTP client. smp-web reuses its foundations:
**Reused directly (npm dependency)**:
- `protocol/encoding.ts` — Decoder class, Word16/Word32/Int64, ByteString, Large, Bool, Maybe, List encoding
- `crypto/secretbox.ts` — XSalsa20-Poly1305 (cbEncrypt/cbDecrypt, streaming)
- `crypto/padding.ts` — Block padding (2-byte length prefix + `#` fill)
- `crypto/keys.ts` — Ed25519, X25519 key generation, signing, DH, DER encoding
- `crypto/digest.ts` — SHA-256, SHA-512
- `crypto/identity.ts` — X.509 certificate chain parsing, signature verification
**New in smp-web**:
- SMP protocol commands and transmission format
- SMP handshake (different from XFTP handshake)
- WebSocket transport (XFTP uses HTTP/2 fetch)
- SMP client with queue-based correlation
- Agent layer (connections, ratchet, message processing)
- Short link operations (HKDF-SHA512, link data parsing)
**Same build pattern**:
- TypeScript strict, ES2022 modules
- `tsc` → `dist/`
- Haskell tests via `callNode` (same function from XFTPWebTests)
- Each TypeScript function verified byte-for-byte against Haskell
## 4. Server Changes
### Done
- `attachStaticAndWS` — unified HTTP + WebSocket handler via `wai-websockets`
- SNI-based routing: browser (SNI) → Warp → WebSocket upgrade → SMP over WS; native (no SNI) → SMP over TLS
- `acceptWSConnection` — constructs `WS 'TServer` from TLS connection + Warp PendingConnection, preserves peer cert chain
- `AttachHTTP` takes `TLS 'TServer` (not raw Context), enabling proper cert chain forwarding
- Test: `testWebSocketAndTLS` verifies native TLS and WebSocket clients on same port
### Remaining
- CORS headers for cross-origin widget embedding (pattern available in XFTP server)
- Server CLI configuration for enabling/disabling WebSocket support per port
## 5. Build Approach
Bottom-up, function-by-function. Each TypeScript function tested against its Haskell counterpart before building the next.
**Test infrastructure**: `SMPWebTests.hs` reuses `callNode`, `jsOut`, `jsUint8` from `XFTPWebTests.hs` (generalized, not copied).
**Pattern**: for each function:
1. Implement in TypeScript
2. Write Haskell test that calls it via `callNode`
3. Compare output byte-for-byte with Haskell reference
4. Also test cross-language: Haskell encodes → TypeScript decodes, and vice versa
## 6. Implementation Phases
### Phase 1: Protocol Encoding + Handshake
Foundation layer. SMP-specific binary encoding and handshake.
**Functions**:
- SMP transmission format: `[auth ByteString][corrId ByteString][entityId ByteString][command]`
- `encodeTransmission` / `parseTransmission`
- `parseSMPServerHandshake` — versionRange, sessionId, authPubKey (CertChainPubKey)
- `encodeSMPClientHandshake` — version, keyHash, authPubKey, proxyServer, clientService
- Server certificate chain verification (reuse xftp-web identity.ts)
- Version negotiation
**Key encoding details**:
- `authPubKey` uses `encodeAuthEncryptCmds`: Nothing → empty (0 bytes), Just → raw smpEncode (NOT Maybe 0/1 prefix)
- `proxyServer`: Bool 'T'/'F' (v14+)
- `clientService`: Maybe '0'/'1' (v16+)
### Phase 2: SMP Commands
All commands needed for messaging.
**Sender**: SKEY, SEND
**Receiver**: NEW, KEY, SUB, ACK, OFF, DEL
**Link**: LGET
**Common**: PING
**For each command**: encode function + decode function for its response, tested against Haskell.
### Phase 3: Transport
WebSocket connection with SMP block framing.
**Functions**:
- WebSocket connect (`wss://` URL)
- Block send/receive (16384-byte binary frames)
- SMP handshake over WebSocket
- Block encryption: X25519 DH → HKDF-SHA512 → SbChainKeys → per-block XSalsa20-Poly1305
**Block encryption flow**:
1. Client generates ephemeral X25519 keypair, sends public key in handshake
2. Server sends its signed DH key in handshake
3. Both sides compute DH shared secret
4. `sbcInit(sessionId, dhSecret)` → two 32-byte chain keys (HKDF-SHA512)
5. Each block: `sbcHkdf(chainKey)` → ephemeral key + nonce, advance chain
6. Encrypt/decrypt with XSalsa20-Poly1305, blockSize-16 padding target
### Phase 4: Client
Connection management layer.
**Functions**:
- Connection pool: one WebSocket per SMP server
- Command/response correlation via corrId
- Send queue + receive queue (ABQueue pattern from simplexmq-js)
- Automatic reconnection with exponential backoff
- Timeout handling
### Phase 5: Agent — Connection Establishment
Duplex SMP connections with X3DH key agreement.
**Functions**:
- Create receive queue (NEW)
- Join connection via invitation URI
- X3DH key agreement
- Send confirmation (SKEY + SEND)
- Complete handshake (HELLO exchange)
- Connection state machine
### Phase 6: Agent — Double Ratchet
Message encryption/decryption.
**Functions**:
- Signal double ratchet implementation
- Header encryption
- Ratchet state management
- Key derivation (HKDF)
- Message sequence + hash chain verification
### Phase 7: Agent — Message Delivery
Send and receive messages through established connections.
**Functions**:
- Send path: encrypt → encode agent envelope → SEND → handle OK/delivery receipt
- Receive path: SUB → receive MSG → decrypt → verify → ACK
- Delivery receipts
- Message acknowledgment
### Phase 8: Short Links
Entry point for the widget — parse short link, fetch profile.
**Functions**:
- Parse short link URI (contact, group, business address types)
- HKDF key derivation (SHA-512): `contactShortLinkKdf`
- LGET command → LNK response
- Decrypt link data (XSalsa20-Poly1305)
- Parse FixedLinkData, ConnLinkData, UserLinkData
- Extract profile JSON
## 7. Persistence
Agent state (keys, ratchet, connections, messages) must persist across page reloads.
**Open question**: storage backend.
Options:
- **IndexedDB directly** — universal browser support, async API, no additional dependencies. Downside: key-value semantics, no SQL queries, manual indexing.
- **SQLite in browser** — sql.js (WASM-compiled SQLite) or wa-sqlite (with OPFS backend for persistence). Upside: matches Haskell agent's SQLite storage, schema can mirror `Simplex.Messaging.Agent.Store.SQLite`. Downside: additional dependency, WASM bundle size.
- **OPFS + SQLite** — Origin Private File System for durable storage, SQLite for structured access. Best durability, but limited browser support (no Safari private browsing).
**Decision criteria**: how closely we want to mirror the Haskell agent's storage schema, bundle size budget, browser compatibility requirements.
## 8. Testing Strategy
### Unit Tests (per function)
Haskell tests in `SMPWebTests.hs` using `callNode` pattern:
- TypeScript function called via Node.js subprocess
- Output compared byte-for-byte with Haskell reference
- Cross-language tests: encode in one language, decode in the other
### Integration Tests
Against live SMP server (spawned by test setup, same pattern as xftp-web globalSetup.ts):
- WebSocket connect + handshake
- Command round-trips (NEW, KEY, SUB, SEND, ACK)
- Message delivery through server
- Reconnection after disconnect
### Browser Tests
Vitest + Playwright (same as xftp-web):
- Full connection lifecycle in browser environment
- WebSocket transport in real browser
- Persistence round-trips
## 9. Security Model
Same principles as xftp-web:
- **TLS via browser** — browser handles certificate validation for WSS connections
- **SNI routing** — browser connections use SNI, routed to Warp + WebSocket handler
- **Server identity** — verified via certificate chain in SMP handshake (keyHash from short link or known servers)
- **Block encryption** — X25519 DH + SbChainKeys provides forward secrecy per block, on top of TLS
- **End-to-end encryption** — double ratchet between agent peers, server sees only encrypted blobs
- **No server-side secrets** — all keys derived and stored client-side
- **CORS** — required for cross-origin widget embedding, safe because SMP requires auth on every command
- **CSP** — strict content security policy for widget page
**Threat model**: same as xftp-web. Primary risk is page substitution (malicious JS). Mitigated by HTTPS, CSP, SRI, and optionally IPFS hosting with published fingerprints.
@@ -0,0 +1,359 @@
# SMP Agent Web: Spike Plan
Revision 4, 2026-03-20
Parent RFC: [2026-03-20-smp-agent-web.md](../2026-03-20-smp-agent-web.md)
## Revision History
- **Rev 4**: Aligned with RFC. Restructured as bottom-up build plan with per-function Haskell tests. Router WebSocket support done. File structure mirrors Haskell modules.
- **Rev 3**: Fixed multiple encoding errors discovered during audit (see encoding details below).
## Objective
Fetch and display business/contact profile from a SimpleX short link URI, via WebSocket to SMP router. This is the first milestone of the SMP agent web implementation — it proves the protocol encoding, transport, crypto, and data parsing layers work end-to-end.
The spike is not throwaway code. It is the beginning of the `smp-web/` TypeScript library, built bottom-up with each function tested against its Haskell counterpart.
## What This Proves
- WebSocket transport to SMP router works from browser
- SMP protocol encoding is correct (binary format, not ASCII)
- SMP handshake works (version negotiation, server certificate parsing)
- Crypto is compatible (HKDF-SHA512, XSalsa20-Poly1305)
- Short link data parsing matches Haskell (FixedLinkData, ConnLinkData, profile)
## Success Criteria
Haskell test creates a short link, TypeScript fetches and decodes it via WebSocket, profile data matches.
## Protocol Flow
```
1. Parse short link URI
https://simplex.chat/c#<linkKey>?h=hosts&p=port&c=keyHash
→ server, linkKey
2. Derive keys (HKDF-SHA512)
linkKey → (linkId, sbKey)
3. WebSocket connect
wss://server:443 (TLS handled by browser)
4. SMP handshake
← SMPServerHandshake {sessionId, smpVersionRange, authPubKey}
→ SMPClientHandshake {smpVersion, keyHash, authPubKey=Nothing, proxyServer=False, clientService=Nothing}
5. Send LGET
→ [empty auth][corrId][linkId]["LGET"]
6. Receive LNK
← [auth][corrId][linkId]["LNK" space senderId encFixedData encUserData]
7. Decrypt
XSalsa20-Poly1305 with sbKey
→ FixedLinkData, ConnLinkData (with profile JSON)
8. Display profile
```
Note: spike sends `authPubKey=Nothing` so block encryption is not used (blocks are padded only). Block encryption is added in steps 12-13.
## Build Approach
Bottom-up, function-by-function. Each TypeScript function tested against its Haskell counterpart via `callNode` — the same pattern used in xftp-web (see `XFTPWebTests.hs`).
**Project location**: `simplexmq-2/smp-web/`
**Tests**: `simplexmq-2/tests/SMPWebTests.hs` — reuses `callNode`/`jsOut`/`jsUint8` from XFTPWebTests (generalized, not copied)
**xftp-web**: npm dependency via `file:../xftp-web` (encoding, crypto, padding imported directly). Note: libsodium-wrappers-sumo is xftp-web's dependency; tests must init the same sodium instance that xftp-web's secretbox uses. If xftp-web is ever published to npm, libsodium should become a peerDependency.
**File structure**: mirrors Haskell module hierarchy (see RFC section 2)
**Pattern for each function**:
1. Check if xftp-web already implements it (or something close). If so, import and reuse — export from xftp-web if not yet exported. Only write new code when no existing implementation covers the need.
2. Implement in TypeScript, in the file corresponding to its Haskell module
3. Write Haskell test that calls it via `callNode`
4. Compare output byte-for-byte with Haskell reference
5. Cross-language: Haskell encodes → TypeScript decodes, and vice versa
### Parsing Approach
All binary parsing uses xftp-web's `Decoder` class — the same class, not a copy. `Decoder` tracks position over a `Uint8Array`, throws on malformed input, returns subarray views (zero-copy).
SMP command parsing follows the same pattern as xftp-web's `decodeResponse` in `commands.ts`: `readTag` reads bytes until space or end, switch dispatches on the tag string, fields are parsed sequentially with `Decoder` methods (`decodeBytes`, `decodeLarge`, `decodeBool`, etc.).
**Prerequisite xftp-web change**: `readTag` and `readSpace` in xftp-web's `commands.ts` need to be exported so smp-web can import them.
### WebSocket Transport Approach
WebSocket transport follows the simplexmq-js `WSTransport` pattern:
- `WebSocket` connects to `wss://` URL with `binaryType = 'arraybuffer'`
- `onmessage` enqueues received frames into an `ABQueue` (async bounded queue with backpressure)
- `onclose` closes the queue (sentinel-based)
- `readBlock()` dequeues one frame, validates it is exactly 16384 bytes
- `sendBlock(data)` sends one 16384-byte binary frame
The `ABQueue` class from simplexmq-js provides backpressure via semaphores and clean async iteration. It can be included in smp-web or extracted as a shared utility.
The SMP transport layer wraps WebSocket transport:
- Receives raw blocks → unpad → parse transmission
- Encodes transmission → pad → send as block
- After handshake, if block encryption is active: decrypt before unpad, encrypt after pad
## Encoding Reference
Binary encoding rules (from `Simplex.Messaging.Encoding`):
| Type | Format |
|------|--------|
| `Word16` | 2 bytes big-endian |
| `Word32` | 4 bytes big-endian |
| `ByteString` | 1-byte length + bytes (max 255) |
| `Large` | 2-byte length (BE) + bytes (max 65535) |
| `Bool` | 'T' (0x54) or 'F' (0x46) |
| `Maybe a` | '0' (0x30) for Nothing, '1' (0x31) + value for Just |
| `smpEncodeList` | 1-byte count + items |
| `UserLinkData` | ByteString if ≤254 bytes, else 0xFF + Large |
**Critical**: `encodeAuthEncryptCmds Nothing` = empty (0 bytes), NOT 'F' or '0'.
**Transmission format** (binary, NOT ASCII with spaces):
```
[auth ByteString][corrId ByteString][entityId ByteString][command bytes]
```
For v7+ (`implySessId = True`): sessionId is NOT sent on wire, but is prepended to the `authorized` data for signature verification. For unauthenticated commands (LGET), this doesn't apply.
**Block framing**: `pad(transmission, 16384)` = `[2-byte BE length][message][padding with '#' (0x23)]`
## Server Changes — DONE
WebSocket support on the same port as native TLS is implemented and tested.
- `attachStaticAndWS` — unified HTTP + WebSocket handler via `wai-websockets`
- SNI routing: browser (SNI) → Warp → WebSocket upgrade → SMP over WS
- `acceptWSConnection` — constructs `WS 'TServer` from `TLS 'TServer` + PendingConnection
- Test: `testWebSocketAndTLS` in `ServerTests.hs`
**Remaining**: CORS headers for cross-origin widget embedding.
## Implementation Steps
Each step produces working, tested code. Steps 1-11 work without block encryption. Steps 12-13 add it.
### Step 1: Project Setup + xftp-web Changes
**smp-web setup**:
- Create `smp-web/` with `package.json` (xftp-web + `@noble/hashes` as dependencies), `tsconfig.json` (ES2022, strict, same as xftp-web)
- Build: `tsc` → `dist/`
**xftp-web change**:
- Export `readTag` and `readSpace` from `commands.ts` (currently unexported) so smp-web can import them
**Test infrastructure**:
- Create `SMPWebTests.hs`, reusing `callNode`/`jsOut`/`jsUint8` from XFTPWebTests (generalize shared utilities into a common test module, not copy)
- First test: import `decodeBytes` from xftp-web, encode a ByteString, verify output matches Haskell `smpEncode`
### Step 2: SMP Transmission Encode/Decode
**File**: `protocol.ts`
**Haskell reference**: `Simplex.Messaging.Protocol` — `encodeTransmission_`, `transmissionP`
**Implementation**:
- `encodeTransmission(corrId, entityId, command)`: `concatBytes(encodeBytes(emptyAuth), encodeBytes(corrId), encodeBytes(entityId), command)` — unsigned, empty auth byte (0x00)
- `decodeTransmission(data)`: sequential Decoder — `decodeBytes` for auth, corrId, entityId, then `takeAll` for command bytes
- Pad/unpad: reuse xftp-web `blockPad`/`blockUnpad` (same 2-byte length prefix + '#' padding, same 16384 block size)
**Tests**: encode in TypeScript → decode in Haskell (`transmissionP`), encode in Haskell (`encodeTransmission_`) → decode in TypeScript. Byte-for-byte match.
### Step 3: SMP Handshake Parse/Encode
**File**: `transport.ts`
**Haskell reference**: `Simplex.Messaging.Transport` — `SMPServerHandshake`, `SMPClientHandshake`
**Implementation**:
- `parseSMPServerHandshake(d: Decoder)`: `decodeWord16` × 2 for versionRange, `decodeBytes` for sessionId. For authPubKey: if `maxVersion >= 7` and bytes remaining, parse `CertChainPubKey` (reuse xftp-web `identity.ts` for X.509 cert chain parsing and signature extraction). If no bytes remain, authPubKey is absent (encodeAuthEncryptCmds encoded Nothing as empty).
- `encodeSMPClientHandshake(...)`: `concatBytes(encodeWord16(version), encodeBytes(keyHash), authPubKeyBytes, encodeBool(proxyServer), encodeMaybe(encodeService, clientService))`. Where authPubKey: empty bytes for Nothing, `encodeBytes(pubkey)` for Just. proxyServer only for v14+, clientService only for v16+.
**Tests**: Haskell encodes `SMPServerHandshake` → TypeScript parses, all fields match. TypeScript encodes `SMPClientHandshake` → Haskell parses via `smpP`.
### Step 4: LGET Command Encode
**File**: `protocol.ts`
**Haskell reference**: `Simplex.Messaging.Protocol` — `LGET` command encoding
**Implementation**:
- `encodeLGET()`: returns `ascii("LGET")` — 4 bytes, no parameters. The LinkId is carried as entityId in the transmission (step 2), not in the command body.
- Full LGET block: `blockPad(encodeTransmission(corrId, linkId, encodeLGET()), 16384)`
**Tests**: encode full LGET block in TypeScript, Haskell unpad + `transmissionP` + `parseProtocol` decodes as `LGET` with correct corrId and linkId.
### Step 5: LNK Response Parse
**File**: `protocol.ts`
**Haskell reference**: `Simplex.Messaging.Protocol` — `LNK` response encoding (line 1834)
**Implementation**:
- `decodeResponse(d: Decoder)`: `readTag(d)` → switch dispatch (same pattern as xftp-web `decodeResponse`)
- For `"LNK"`: `readSpace(d)`, `decodeBytes(d)` for senderId, `decodeLarge(d)` for encFixedData, `decodeLarge(d)` for encUserData
- Also handle `"ERR"` responses for error reporting
**Tests**: Haskell encodes `LNK senderId (encFixed, encUser)` → TypeScript `decodeResponse` parses. All fields match byte-for-byte.
### Step 6: Short Link URI Parse
**File**: `agent/protocol.ts`
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `ConnShortLink` StrEncoding instance (lines 1599-1612)
**Implementation**:
- `parseShortLink(uri)`: regex to extract scheme (https/simplex), type char (c/g/a), linkKey (base64url, 43 chars → 32 bytes), query params (h=hosts, p=port, c=keyHash)
- `base64UrlDecode(s)`: pad to multiple of 4, replace `-`→`+`, `_`→`/`, decode
- Returns `{scheme, connType, server: {hosts, port, keyHash}, linkKey}`
**Tests**: Haskell `strEncode` a `ConnShortLink` → TypeScript `connShortLinkStrP` parses. All fields match. Test multiple formats: with/without query params, different type chars.
**Done**. Function: `connShortLinkStrP` in `agent/protocol.ts`. Uses `base64urlDecode` from xftp-web `description.ts`.
**Future**:
- Add long link parsing (`ConnectionRequestUri`) and an either-parser that handles both short and long links.
- Add `restoreShortLink`: preset servers are shortened to host-only (`SMPServerOnlyHost` - no port, no keyHash). After parsing, `restoreShortLink` looks up the full server by hostname from a preset servers list. Without this, connections to preset servers will fail. See `Agent/Protocol.hs:1692`.
### Step 7: HKDF Key Derivation
**File**: `crypto/shortLink.ts`
**Haskell reference**: `Simplex.Messaging.Crypto.ShortLink` — `contactShortLinkKdf` (line 48)
**Implementation**:
- `contactShortLinkKdf(linkKey)`: `hkdf(sha512, linkKey, new Uint8Array(0), "SimpleXContactLink", 56)` using `@noble/hashes/hkdf` + `@noble/hashes/sha512`. Split result: first 24 bytes = linkId, remaining 32 bytes = sbKey.
**Note**: Haskell `C.hkdf` uses SHA-512, not SHA3-256.
**Tests**: given known linkKey bytes, TypeScript and Haskell produce identical linkId and sbKey.
### Step 8: Link Data Decrypt
**File**: `crypto/shortLink.ts`
**Haskell reference**: `Simplex.Messaging.Crypto.ShortLink` — `decryptLinkData` (lines 100-120)
**Implementation**:
- `decryptLinkData(sbKey, encFixedData, encUserData)`:
1. For each EncDataBytes: `Decoder` → `decodeBytes(d)` for nonce (24 bytes), `decodeTail(d)` for ciphertext (includes Poly1305 tag)
2. `cbDecrypt(sbKey, nonce, ciphertext)` via xftp-web `secretbox.ts`
3. From decrypted plaintext: `decodeBytes(d)` for signature (1-byte len 0x40 + 64 bytes), `decodeTail(d)` for actual data
4. Return both plaintext data blobs (signature verification skipped for spike)
**Tests**: Haskell `encodeSignLinkData` + `sbEncrypt` with known key/nonce → TypeScript decrypts → plaintext matches.
### Step 9: ConnLinkData Parse
**File**: `agent/protocol.ts`
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `ConnLinkData`, `UserContactData`, `OwnerAuth`, `ConnShortLink`, `ProtocolServer` Encoding instances
**Implementation** (proper decoding, not skipping):
- `decodeConnLinkData(d)`: `anyByte` for connectionMode ('C'=Contact), `decodeWord16` × 2 for agentVRange, then `decodeUserContactData`
- `decodeUserContactData(d)`: `decodeBool` for direct, `smpListP(decodeOwnerAuth, d)` for owners, `smpListP(decodeConnShortLink, d)` for relays, `decodeUserLinkData(d)` for userData
- `decodeOwnerAuth(d)`: `decodeBytes` for outer wrapper, then parse inner: `(ownerId, ownerKey, authOwnerSig)` all as ByteStrings
- `decodeConnShortLink(d)`: `anyByte` for mode, then Contact: `(ctTypeChar, srv, linkKey)` or Invitation: `(srv, linkId, linkKey)`
- `decodeProtocolServer(d)`: `decodeBytes` for scheme+keyHash, `decodeBytes` for host, `decodeBytes` for port — need to verify exact encoding
- `decodeUserLinkData(d)`: first byte 0xFF → `decodeLarge`; otherwise it's the 1-byte length of a ByteString
- `parseProfile(userData)`: check first byte for 'X' (0x58, zstd compressed) — if so, decompress; otherwise `JSON.parse` directly
**Tests**: Haskell encodes `ContactLinkData` with known values → TypeScript decodes → all fields match.
**FixedLinkData**: deferred to step 15. `linkConnReq` (ConnectionRequestUri) is NOT length-prefixed in the tuple encoding — it requires full parsing. FixedLinkData is also needed to validate mutable data signature using rootKey.
### Step 15: FixedLinkData Parse
**File**: `agent/protocol.ts`
**Haskell reference**: `Simplex.Messaging.Agent.Protocol` — `FixedLinkData`, `ConnectionRequestUri`, `ConnReqUriData` Encoding instances
**Implementation**:
- `decodeFixedLinkData(d)`: `decodeWord16` × 2 for agentVRange, `decodeBytes` for rootKey (32 bytes Ed25519), then parse `ConnectionRequestUri` (mode byte + `ConnReqUriData`), optional `decodeBytes` for linkEntityId
- `decodeConnectionRequestUri(d)`: full parsing of `ConnReqUriData` including SMP queue URIs
- Needed for: connecting to the contact, and validating mutable data signature with rootKey
**Tests**: Haskell encodes full `FixedLinkData` → TypeScript decodes → rootKey and linkConnReq fields match.
### Step 10: WebSocket Transport
**File**: `transport/websockets.ts`
**Pattern reference**: simplexmq-js `WSTransport` + `ABQueue`
**Implementation**:
- `ABQueue<T>` class: semaphore-based async bounded queue (from simplexmq-js `queue.ts` — reimplement or include as utility). `enqueue`/`dequeue`/`close`, sentinel-based close, async iterator.
- `connectWS(url)`: `new WebSocket(url)`, `binaryType = 'arraybuffer'`, `onmessage` enqueues `Uint8Array` frames into ABQueue, `onclose` closes queue, `onerror` closes socket. Returns transport handle on `onopen`.
- `readBlock(transport)`: dequeue one frame, verify `byteLength === 16384`, return `Uint8Array`
- `sendBlock(transport, data)`: `ws.send(data)`, verify `data.length === 16384`
- `smpHandshake(transport, keyHash)`: `readBlock` → `blockUnpad` → `parseSMPServerHandshake` → negotiate version → `encodeSMPClientHandshake` → `blockPad` → `sendBlock`. Returns `{sessionId, version}`.
**Integration test**: spawn test SMP server with web credentials (reuse `cfgWebOn` from SMPClient.hs), connect via WebSocket from Node.js, complete handshake, verify sessionId received.
### Step 11: End-to-End Integration
Wire steps 6-10 together: `parseShortLink` → `contactShortLinkKdf` → `connectWS` → `smpHandshake` → encode LGET block → `sendBlock` → `readBlock` → `blockUnpad` → `decodeTransmission` → `decodeResponse` → `decryptLinkData` → `decodeFixedLinkData` + `decodeConnLinkData` → `parseProfile`.
**Test**: Haskell creates a contact address with short link (using agent), TypeScript fetches and decodes it via WebSocket. Profile displayName matches. This is the full spike proof: browser can fetch a SimpleX contact profile via SMP protocol.
### Step 12: Server Certificate Verification
**File**: `transport.ts`
**Approach**: client sends a random challenge in an HTTP header on the WebSocket upgrade request. Server includes the signed challenge in the handshake response. Client verifies the signature using the server's certificate chain.
**Implementation**:
- Generate 32-byte random challenge, send as HTTP header (e.g. `smp-web-challenge`) on WebSocket upgrade
- Parse `CertChainPubKey` from server handshake (already parsed in step 3 as `authPubKey`)
- Verify certificate chain fingerprint matches `keyHash` (reuse xftp-web `caFingerprint`)
- Verify challenge signature (reuse xftp-web `identity.ts` — `extractCertPublicKeyInfo`, signature verification)
- Requires server-side change: detect the challenge header on WebSocket connections, sign `challenge || sessionId` with server key, include proof in handshake
**Tests**: connect to test server, verify challenge-response succeeds. Connect with wrong keyHash, verify rejection.
### Step 13: Block Encryption (DH + SbChainKeys)
**File**: `transport.ts`
**Haskell reference**: `Simplex.Messaging.Crypto` — `sbcInit`, `sbcHkdf`; `Simplex.Messaging.Transport` — `tPutBlock`, `tGetBlock`
**Implementation**:
- `generateX25519KeyPair()`, `dh(peerPub, ownPriv)` — reuse from xftp-web `keys.ts`
- `sbcInit(sessionId, dhSecret)`: `hkdf(sha512, dhSecret, sessionId, "SimpleXSbChainInit", 64)` → split at 32: `(sndChainKey, rcvChainKey)`. Note client swaps send/receive keys vs server (line 858 Transport.hs).
- `sbcHkdf(chainKey)`: `hkdf(sha512, chainKey, "", "SimpleXSbChain", 88)` → split: 32 bytes new chainKey, 32 bytes sbKey, 24 bytes nonce. Returns `{sbKey, nonce, nextChainKey}`.
- `encryptBlock(state, block)`: `sbcHkdf` → `cryptoBox(sbKey, nonce, pad(block, blockSize - 16))` → 16-byte tag + ciphertext
- `decryptBlock(state, block)`: `sbcHkdf` → split tag (first 16 bytes) + ciphertext → `cryptoBoxOpen` → `unpad`
**Tests**: Haskell and TypeScript DH with same keys → identical chain keys. Haskell encrypts block → TypeScript decrypts (and vice versa). Chain key advances identically after each block.
### Step 14: Full Handshake with Auth
**File**: `transport.ts`
**Haskell reference**: `Simplex.Messaging.Transport` — `smpClientHandshake` (lines 792-842)
**Implementation**:
- Update `smpHandshake` to generate ephemeral X25519 keypair and include public key in `encodeSMPClientHandshake` as authPubKey
- Compute DH: `dh(serverDhPub, clientPrivKey)` → shared secret
- `sbcInit(sessionId, dhSecret)` → chain keys (with client-side swap)
- All subsequent `readBlock`/`sendBlock` go through `decryptBlock`/`encryptBlock`
**Tests**: full handshake with real server, block encryption active, exchange encrypted commands. Haskell sends encrypted response → TypeScript decrypts correctly.
## Haskell Code References
### Handshake
- `Simplex.Messaging.Transport` — `smpClientHandshake`, `smpServerHandshake`, `SMPServerHandshake`, `SMPClientHandshake`
- `encodeAuthEncryptCmds` — Nothing → empty, Just → raw smpEncode
### Protocol
- `Simplex.Messaging.Protocol` — `LGET`, `LNK`, `encodeTransmission_`, `transmissionP`
- Block: `pad`/`unPad` in `Simplex.Messaging.Crypto`
### Short Links
- `Simplex.Messaging.Crypto.ShortLink` — `contactShortLinkKdf`, `decryptLinkData`
- `Simplex.Messaging.Agent.Protocol` — `ConnShortLink`, `FixedLinkData`, `ConnLinkData`, `UserLinkData`
### Block Encryption
- `Simplex.Messaging.Crypto` — `sbcInit`, `sbcHkdf`, `sbEncrypt`, `sbDecrypt`, `dh'`
- `Simplex.Messaging.Transport` — `blockEncryption`, `TSbChainKeys`, `tPutBlock`, `tGetBlock`
+1
View File
@@ -515,6 +515,7 @@ test-suite simplexmq-test
XFTPServerTests
WebTests
XFTPWebTests
SMPWebTests
SMPWeb
XFTPWeb
Web.Embedded
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
package-lock.json
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@simplex-chat/smp-web",
"version": "0.1.0",
"description": "SMP protocol client for web/browser environments",
"license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "git+https://github.com/simplex-chat/simplexmq.git",
"directory": "smp-web"
},
"type": "module",
"files": [
"src",
"dist"
],
"scripts": {
"build": "tsc"
},
"dependencies": {
"@noble/hashes": "^1.5.0",
"@simplex-chat/xftp-web": "file:../xftp-web"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"typescript": "^5.4.0",
"ws": "^8.0.0"
}
}
+247
View File
@@ -0,0 +1,247 @@
// Agent protocol types and short link parsing.
// Mirrors: Simplex.Messaging.Agent.Protocol
import {base64urlDecode} from "@simplex-chat/xftp-web/dist/protocol/description.js"
import {
Decoder, decodeBytes, decodeLarge, decodeWord16, decodeBool,
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Short link types (Agent/Protocol.hs:1462-1470)
export type ShortLinkScheme = "simplex" | "https"
export type ContactConnType = "contact" | "channel" | "group" | "relay"
export interface ProtocolServer {
hosts: Uint8Array[] // NonEmpty, each is the strEncoded host bytes
port: Uint8Array
keyHash: Uint8Array
}
export type ConnShortLink =
| {mode: "invitation", scheme: ShortLinkScheme, server: ProtocolServer, linkId: Uint8Array, linkKey: Uint8Array}
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ProtocolServer, linkKey: Uint8Array}
// -- ProtocolServer binary encoding (Protocol.hs:1264-1269)
// smpEncode (host, port, keyHash)
// host: NonEmpty TransportHost = smpEncodeList (1-byte count + each as ByteString)
// port: String = ByteString (1-byte len + bytes)
// keyHash: KeyHash = ByteString (1-byte len + bytes)
export function decodeProtocolServer(d: Decoder): ProtocolServer {
const hostCount = d.anyByte()
if (hostCount === 0) throw new Error("empty server host list")
const hosts: Uint8Array[] = []
for (let i = 0; i < hostCount; i++) hosts.push(decodeBytes(d))
const port = decodeBytes(d)
const keyHash = decodeBytes(d)
return {hosts, port, keyHash}
}
// -- ConnShortLink binary encoding (Agent/Protocol.hs:1631-1649)
// Contact: smpEncode (CMContact, ctTypeChar, srv, linkKey)
// Invitation: smpEncode (CMInvitation, srv, linkId, linkKey)
export interface ConnShortLinkBinary {
mode: "contact" | "invitation"
connType?: ContactConnType
server: ProtocolServer
linkId?: Uint8Array
linkKey: Uint8Array
}
const ctTypeFromByte: Record<number, ContactConnType> = {
0x41: "contact", // 'A'
0x43: "channel", // 'C'
0x47: "group", // 'G'
0x52: "relay", // 'R'
}
export function decodeConnShortLink(d: Decoder): ConnShortLinkBinary {
const mode = d.anyByte()
if (mode === 0x49) {
// Invitation: (srv, linkId, linkKey)
const server = decodeProtocolServer(d)
const linkId = decodeBytes(d)
const linkKey = decodeBytes(d)
return {mode: "invitation", server, linkId, linkKey}
} else if (mode === 0x43) {
// Contact: (ctTypeChar, srv, linkKey)
const ctByte = d.anyByte()
const connType = ctTypeFromByte[ctByte]
if (!connType) throw new Error("unknown contact type: 0x" + ctByte.toString(16))
const server = decodeProtocolServer(d)
const linkKey = decodeBytes(d)
return {mode: "contact", connType, server, linkKey}
}
throw new Error("unknown ConnShortLink mode: 0x" + mode.toString(16))
}
// -- OwnerAuth (Agent/Protocol.hs:1793-1800)
// Outer ByteString wrapping inner: (ownerId, ownerKey, authOwnerSig)
export interface OwnerAuth {
ownerId: Uint8Array
ownerKey: Uint8Array
authOwnerSig: Uint8Array
}
export function decodeOwnerAuth(d: Decoder): OwnerAuth {
const inner = decodeBytes(d)
const id = new Decoder(inner)
const ownerId = decodeBytes(id)
const ownerKey = decodeBytes(id)
const authOwnerSig = decodeBytes(id)
return {ownerId, ownerKey, authOwnerSig}
}
// -- UserLinkData (Agent/Protocol.hs:1891-1894)
// If first byte is 0xFF, read Large; otherwise it's a ByteString (1-byte length)
export function decodeUserLinkData(d: Decoder): Uint8Array {
const firstByte = d.anyByte()
if (firstByte === 0xFF) return decodeLarge(d)
return d.take(firstByte)
}
// -- UserContactData (Agent/Protocol.hs:1881-1889)
export interface UserContactData {
direct: boolean
owners: OwnerAuth[]
relays: ConnShortLinkBinary[]
userData: Uint8Array
}
export function decodeUserContactData(d: Decoder): UserContactData {
const direct = decodeBool(d)
const ownerCount = d.anyByte()
const owners: OwnerAuth[] = []
for (let i = 0; i < ownerCount; i++) owners.push(decodeOwnerAuth(d))
const relayCount = d.anyByte()
const relays: ConnShortLinkBinary[] = []
for (let i = 0; i < relayCount; i++) relays.push(decodeConnShortLink(d))
const userData = decodeUserLinkData(d)
return {direct, owners, relays, userData}
}
// -- ConnLinkData (Agent/Protocol.hs:1838-1855)
// Contact: 'C' + versionRange + UserContactData
export interface ConnLinkDataContact {
mode: "contact"
agentVRange: {min: number; max: number}
userContactData: UserContactData
}
export function decodeConnLinkData(d: Decoder): ConnLinkDataContact {
const modeChar = d.anyByte()
if (modeChar !== 0x43) throw new Error("expected Contact mode 'C' (0x43), got 0x" + modeChar.toString(16))
const min = decodeWord16(d)
const max = decodeWord16(d)
const userContactData = decodeUserContactData(d)
return {mode: "contact", agentVRange: {min, max}, userContactData}
}
// -- FixedLinkData (Agent/Protocol.hs:1830-1836)
// Encoding: smpEncode (agentVRange, rootKey, linkConnReq) <> maybe "" smpEncode linkEntityId
// rootKey is DER-encoded Ed25519 public key (ByteString: 1-byte len + 44 bytes DER)
// linkConnReq is ConnectionRequestUri (variable length, not length-prefixed)
// For now, we parse agentVRange + rootKey and keep the rest as raw bytes.
// Full ConnectionRequestUri parsing is future work.
export interface FixedLinkData {
agentVRange: {min: number; max: number}
rootKey: Uint8Array // DER-encoded Ed25519 public key (44 bytes)
rest: Uint8Array // raw linkConnReq + linkEntityId bytes
}
export function decodeFixedLinkData(d: Decoder): FixedLinkData {
const min = decodeWord16(d)
const max = decodeWord16(d)
const rootKey = decodeBytes(d)
const rest = d.takeAll()
return {agentVRange: {min, max}, rootKey, rest}
}
// -- Profile extraction
export function parseProfile(userData: Uint8Array): unknown {
if (userData.length > 0 && userData[0] === 0x58) {
throw new Error("zstd-compressed profile not yet supported")
}
return JSON.parse(new TextDecoder().decode(userData))
}
// -- Short link URI parsing (below) --
export interface ShortLinkServer {
hosts: string[]
port: string
keyHash: Uint8Array
}
export type ConnShortLinkURI =
| {mode: "invitation", scheme: ShortLinkScheme, server: ShortLinkServer, linkId: Uint8Array, linkKey: Uint8Array}
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ShortLinkServer, linkKey: Uint8Array}
const ctTypeFromChar: Record<string, ContactConnType> = {
a: "contact",
c: "channel",
g: "group",
r: "relay",
}
// Mirrors strP for AConnShortLink (Agent/Protocol.hs:1596-1629)
export function connShortLinkStrP(uri: string): ConnShortLinkURI {
let scheme: ShortLinkScheme
let firstHost: string | null = null
let rest: string
if (uri.startsWith("simplex:")) {
scheme = "simplex"
rest = uri.slice("simplex:".length)
} else if (uri.startsWith("https://")) {
scheme = "https"
const afterScheme = uri.slice("https://".length)
const slashIdx = afterScheme.indexOf("/")
if (slashIdx < 0) throw new Error("bad short link: no path")
firstHost = afterScheme.slice(0, slashIdx)
rest = afterScheme.slice(slashIdx)
} else {
throw new Error("bad short link scheme")
}
if (rest[0] !== "/") throw new Error("bad short link: expected /")
const typeChar = rest[1]
const hashIdx = rest.indexOf("#")
if (hashIdx < 0) throw new Error("bad short link: no #")
const afterHash = rest.slice(hashIdx + 1)
const qIdx = afterHash.indexOf("?")
const fragment = qIdx >= 0 ? afterHash.slice(0, qIdx) : afterHash
const queryStr = qIdx >= 0 ? afterHash.slice(qIdx + 1) : ""
const params = new URLSearchParams(queryStr)
const hParam = params.get("h")
const additionalHosts = hParam ? hParam.split(",") : []
const allHosts = firstHost ? [firstHost, ...additionalHosts] : additionalHosts
if (allHosts.length === 0) throw new Error("short link without server")
const port = params.get("p") ?? ""
const keyHash = params.has("c") ? base64urlDecode(params.get("c")!) : new Uint8Array(0)
const server: ShortLinkServer = {hosts: allHosts, port, keyHash}
if (typeChar === "i") {
const slashIdx = fragment.indexOf("/")
if (slashIdx < 0) throw new Error("invitation link must have linkId/linkKey")
const linkId = base64urlDecode(fragment.slice(0, slashIdx))
const linkKey = base64urlDecode(fragment.slice(slashIdx + 1))
return {mode: "invitation", scheme, server, linkId, linkKey}
} else {
const connType = ctTypeFromChar[typeChar]
if (!connType) throw new Error("unknown contact type: " + typeChar)
const linkKey = base64urlDecode(fragment)
return {mode: "contact", scheme, connType, server, linkKey}
}
}
+50
View File
@@ -0,0 +1,50 @@
// Crypto primitives.
// Mirrors: Simplex.Messaging.Crypto
import {hkdf as nobleHkdf} from "@noble/hashes/hkdf"
import {sha512} from "@noble/hashes/sha512"
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
// C.hkdf (Crypto.hs:1461-1464)
// HKDF-SHA512 extract + expand
export function hkdf(salt: Uint8Array, ikm: Uint8Array, info: string, n: number): Uint8Array {
return nobleHkdf(sha512, ikm, salt, info, n)
}
// -- SbChainKey block encryption (Crypto.hs:1449-1464)
export interface SbKeyNonce {
sbKey: Uint8Array // 32 bytes
nonce: Uint8Array // 24 bytes
}
// sbcInit (Crypto.hs:1452-1455)
// hkdf(sessionId, dhSecret, "SimpleXSbChainInit", 64) -> (sndChainKey, rcvChainKey)
export function sbcInit(sessionId: Uint8Array, dhSecret: Uint8Array): {sndKey: Uint8Array; rcvKey: Uint8Array} {
const derived = hkdf(sessionId, dhSecret, "SimpleXSbChainInit", 64)
return {sndKey: derived.slice(0, 32), rcvKey: derived.slice(32, 64)}
}
// sbcHkdf (Crypto.hs:1459-1464)
// hkdf("", chainKey, "SimpleXSbChain", 88) -> ((sbKey, nonce), nextChainKey)
export function sbcHkdf(chainKey: Uint8Array): {keyNonce: SbKeyNonce; nextChainKey: Uint8Array} {
const out = hkdf(new Uint8Array(0), chainKey, "SimpleXSbChain", 88)
return {
keyNonce: {sbKey: out.slice(32, 64), nonce: out.slice(64, 88)},
nextChainKey: out.slice(0, 32),
}
}
// sbEncrypt (Crypto.hs:1296-1301)
// pad + cryptoBox (tag prepended to ciphertext)
export function sbEncryptBlock(chainKey: Uint8Array, block: Uint8Array, paddedLen: number): {encrypted: Uint8Array; nextChainKey: Uint8Array} {
const {keyNonce: {sbKey, nonce}, nextChainKey} = sbcHkdf(chainKey)
return {encrypted: cbEncrypt(sbKey, nonce, block, paddedLen), nextChainKey}
}
// sbDecrypt (Crypto.hs:1330-1336)
// cryptoBoxOpen + unpad
export function sbDecryptBlock(chainKey: Uint8Array, block: Uint8Array): {decrypted: Uint8Array; nextChainKey: Uint8Array} {
const {keyNonce: {sbKey, nonce}, nextChainKey} = sbcHkdf(chainKey)
return {decrypted: cbDecrypt(sbKey, nonce, block), nextChainKey}
}
+50
View File
@@ -0,0 +1,50 @@
// Short link key derivation and decryption.
// Mirrors: Simplex.Messaging.Crypto.ShortLink
import {hkdf} from "../crypto.js"
import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
import {Decoder, decodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
const emptySalt = new Uint8Array(0)
// contactShortLinkKdf (Crypto/ShortLink.hs:47-50)
// hkdf("", linkKey, "SimpleXContactLink", 56) -> (linkId[24], sbKey[32])
export function contactShortLinkKdf(linkKey: Uint8Array): {linkId: Uint8Array; sbKey: Uint8Array} {
const derived = hkdf(emptySalt, linkKey, "SimpleXContactLink", 56)
return {
linkId: derived.slice(0, 24),
sbKey: derived.slice(24, 56),
}
}
// invShortLinkKdf (Crypto/ShortLink.hs:52-53)
// hkdf("", linkKey, "SimpleXInvLink", 32) -> sbKey[32]
export function invShortLinkKdf(linkKey: Uint8Array): Uint8Array {
return hkdf(emptySalt, linkKey, "SimpleXInvLink", 32)
}
// decryptLinkData (Crypto/ShortLink.hs:100-125)
// Decrypts both EncDataBytes blobs, strips signature prefix, returns raw data.
// Signature verification is skipped for spike.
export function decryptLinkData(
sbKey: Uint8Array,
encFixedData: Uint8Array,
encUserData: Uint8Array
): {fixedData: Uint8Array; userData: Uint8Array} {
return {
fixedData: decryptSigned(sbKey, encFixedData),
userData: decryptSigned(sbKey, encUserData),
}
}
// EncDataBytes format: [nonce 24 bytes][ciphertext with prepended Poly1305 tag]
// After decrypt+unpad: [sig ByteString (1-byte len + 64 bytes)][data]
function decryptSigned(sbKey: Uint8Array, encData: Uint8Array): Uint8Array {
const nonce = encData.subarray(0, 24)
const ct = encData.subarray(24)
const plaintext = cbDecrypt(sbKey, nonce, ct)
// Skip signature: decodeBytes reads 1-byte length + that many bytes
const d = new Decoder(plaintext)
decodeBytes(d) // signature, discarded
return d.takeAll()
}
+12
View File
@@ -0,0 +1,12 @@
// SMP protocol client for web/browser environments.
// Re-exports encoding primitives from xftp-web for convenience.
export {
Decoder,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeWord16, decodeWord16,
encodeBool, decodeBool,
encodeMaybe, decodeMaybe,
encodeList, decodeList,
concatBytes
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
+103
View File
@@ -0,0 +1,103 @@
// SMP protocol commands and transmission format.
// Mirrors: Simplex.Messaging.Protocol
import {
Decoder, concatBytes,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
// -- Transmission encoding (Protocol.hs:2201-2203)
// encodeTransmission_ v (CorrId corrId, queueId, command) =
// smpEncode (corrId, queueId) <> encodeProtocol v command
export function encodeTransmission(corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array): Uint8Array {
return concatBytes(
encodeBytes(new Uint8Array(0)), // empty auth
encodeBytes(corrId),
encodeBytes(entityId),
command
)
}
// Batch encoding (Protocol.hs:2175-2180)
// Each transmission is Large-wrapped, then prefixed with 1-byte count.
export function encodeBatch(...transmissions: Uint8Array[]): Uint8Array {
if (transmissions.length === 0 || transmissions.length > 255) throw new Error("encodeBatch: invalid count")
return concatBytes(new Uint8Array([transmissions.length]), ...transmissions.map(t => encodeLarge(t)))
}
// -- Transmission parsing (Protocol.hs:1629-1642)
// For implySessId = True (v7+): no sessId on wire
export interface RawTransmission {
corrId: Uint8Array
entityId: Uint8Array
command: Uint8Array
}
export function decodeTransmission(d: Decoder): RawTransmission {
const _auth = decodeBytes(d) // authenticator (empty for unsigned)
const corrId = decodeBytes(d)
const entityId = decodeBytes(d)
const command = d.takeAll()
return {corrId, entityId, command}
}
// -- SMP command tags
const SPACE = 0x20
function ascii(s: string): Uint8Array {
const buf = new Uint8Array(s.length)
for (let i = 0; i < s.length; i++) buf[i] = s.charCodeAt(i)
return buf
}
// -- LGET command (Protocol.hs:1709)
// No parameters. EntityId carries LinkId in transmission.
export function encodeLGET(): Uint8Array {
return ascii("LGET")
}
// -- LNK response (Protocol.hs:1834)
// LNK sId d -> e (LNK_, ' ', sId, d)
// where d = (EncFixedDataBytes, EncUserDataBytes), both Large-encoded
export interface LNKResponse {
senderId: Uint8Array
encFixedData: Uint8Array
encUserData: Uint8Array
}
export function decodeLNK(d: Decoder): LNKResponse {
const senderId = decodeBytes(d)
const encFixedData = decodeLarge(d)
const encUserData = decodeLarge(d)
return {senderId, encFixedData, encUserData}
}
// -- Response dispatch (same pattern as xftp-web decodeResponse)
export type SMPResponse =
| {type: "LNK", response: LNKResponse}
| {type: "OK"}
| {type: "ERR", message: string}
export function decodeResponse(d: Decoder): SMPResponse {
const tag = readTag(d)
switch (tag) {
case "LNK": {
readSpace(d)
return {type: "LNK", response: decodeLNK(d)}
}
case "OK": return {type: "OK"}
case "ERR": {
readSpace(d)
return {type: "ERR", message: readTag(d)}
}
default: throw new Error("unknown SMP response: " + tag)
}
}
+77
View File
@@ -0,0 +1,77 @@
// SMP transport: handshake, block framing.
// Mirrors: Simplex.Messaging.Transport
import {
Decoder, concatBytes,
encodeWord16, decodeWord16,
encodeBytes, decodeBytes,
encodeLarge, decodeLarge,
encodeBool,
encodeMaybe,
decodeNonEmpty
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
// -- Version constants (Transport.hs:186-213)
export const SMP_BLOCK_SIZE = 16384
export const currentSMPVersion = 19
export const webClientSMPVersion = 19
// -- SMPServerHandshake (Transport.hs:631-640)
export interface SMPServerHandshake {
smpVersionRange: {min: number; max: number}
sessionId: Uint8Array
authPubKey: SMPAuthPubKey | null
webIdentityProof: Uint8Array | null // raw signature bytes (v19+)
}
export interface SMPAuthPubKey {
certChainDer: Uint8Array[] // DER-encoded certificate chain
signedKeyDer: Uint8Array // DER-encoded SignedExact PubKey
}
export function decodeSMPServerHandshake(d: Decoder): SMPServerHandshake {
const min = decodeWord16(d)
const max = decodeWord16(d)
const sessionId = decodeBytes(d)
// authPubKey: version-gated (v7+)
let authPubKey: SMPAuthPubKey | null = null
if (max >= 7 && d.remaining() > 0) {
const certChainDer = decodeNonEmpty(decodeLarge, d)
const signedKeyDer = decodeLarge(d)
authPubKey = {certChainDer, signedKeyDer}
}
// webIdentityProof: version-gated (v19+)
let webIdentityProof: Uint8Array | null = null
if (max >= webClientSMPVersion && d.remaining() > 0) {
webIdentityProof = decodeBytes(d)
}
return {smpVersionRange: {min, max}, sessionId, authPubKey, webIdentityProof}
}
// -- SMPClientHandshake (Transport.hs:592-604)
export interface SMPClientHandshake {
smpVersion: number
keyHash: Uint8Array
authPubKey: Uint8Array | null // X25519 public key, or null for no block encryption
proxyServer: boolean
clientService: null // not used in web client
}
export function encodeSMPClientHandshake(h: SMPClientHandshake): Uint8Array {
const parts: Uint8Array[] = [
encodeWord16(h.smpVersion),
encodeBytes(h.keyHash),
]
// authPubKey: encodeAuthEncryptCmds — empty for Nothing, encodeBytes for Just (v7+)
if (h.authPubKey !== null) {
parts.push(encodeBytes(h.authPubKey))
}
// proxyServer: Bool (v14+)
parts.push(encodeBool(h.proxyServer))
// clientService: Maybe (v16+) — Nothing = '0' (0x30)
parts.push(encodeMaybe(() => new Uint8Array(0), null))
return concatBytes(...parts)
}
+129
View File
@@ -0,0 +1,129 @@
// WebSocket transport for SMP protocol.
// Mirrors: Simplex.Messaging.Transport.WebSockets (client side)
import WebSocket from "ws"
import {randomBytes} from "crypto"
import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {base64urlEncode} from "@simplex-chat/xftp-web/dist/protocol/description.js"
import {blockPad, blockUnpad} from "@simplex-chat/xftp-web/dist/protocol/transmission.js"
import {verifyIdentityProof} from "@simplex-chat/xftp-web/dist/crypto/identity.js"
import {generateX25519KeyPair, dh, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
import {extractSignedKey} from "@simplex-chat/xftp-web/dist/protocol/handshake.js"
import {decodeSMPServerHandshake, encodeSMPClientHandshake, SMP_BLOCK_SIZE, currentSMPVersion} from "../transport.js"
import {sbcInit, sbEncryptBlock, sbDecryptBlock} from "../crypto.js"
export interface SMPConnection {
ws: WebSocket
sessionId: Uint8Array
smpVersion: number
// Block encryption state (null if no auth)
sndKey: Uint8Array | null
rcvKey: Uint8Array | null
}
export async function connectSMP(url: string, keyHash: Uint8Array, wsOptions?: object): Promise<SMPConnection> {
// Generate challenge and append to URL
const challenge = new Uint8Array(randomBytes(32))
const challengeUrl = url + (url.includes("?") ? "&" : "?") + "challenge=" + base64urlEncode(challenge).replace(/=+$/, "")
const ws = new WebSocket(challengeUrl, wsOptions)
ws.binaryType = "arraybuffer"
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (e) => reject(e)
})
// Receive server handshake (first block)
const serverBlock = await receiveBlock(ws)
const serverHs = decodeSMPServerHandshake(new Decoder(blockUnpad(serverBlock)))
// Negotiate version
const version = Math.min(serverHs.smpVersionRange.max, currentSMPVersion)
if (version < 6) throw new Error("Incompatible server version")
// Verify server identity and extract DH key
let sndKey: Uint8Array | null = null
let rcvKey: Uint8Array | null = null
let clientAuthPubKey: Uint8Array | null = null
if (serverHs.authPubKey) {
// Verify server identity if server supports web challenge (v19+)
if (serverHs.webIdentityProof) {
const ok = verifyIdentityProof({
certChainDer: serverHs.authPubKey.certChainDer,
signedKeyDer: serverHs.authPubKey.signedKeyDer,
sigBytes: serverHs.webIdentityProof,
challenge,
sessionId: serverHs.sessionId,
keyHash,
})
if (!ok) throw new Error("Server identity verification failed")
}
// DH key exchange for block encryption (v11+)
const serverDhKey = extractSignedKey(serverHs.authPubKey.signedKeyDer).dhKey
const clientKp = generateX25519KeyPair()
clientAuthPubKey = encodePubKeyX25519(clientKp.publicKey)
const dhSecret = dh(serverDhKey, clientKp.privateKey)
// Client swaps snd/rcv vs server (Transport.hs:880)
const keys = sbcInit(serverHs.sessionId, dhSecret)
sndKey = keys.rcvKey
rcvKey = keys.sndKey
}
// Send client handshake
const clientHs = encodeSMPClientHandshake({
smpVersion: version,
keyHash,
authPubKey: clientAuthPubKey,
proxyServer: false,
clientService: null
})
sendBlock(ws, blockPad(clientHs, SMP_BLOCK_SIZE))
return {ws, sessionId: serverHs.sessionId, smpVersion: version, sndKey, rcvKey}
}
export function receiveBlock(ws: WebSocket): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
ws.onmessage = (e) => {
const data = e.data
if (data instanceof ArrayBuffer) {
resolve(new Uint8Array(data))
} else if (data instanceof Buffer) {
resolve(new Uint8Array(data))
} else {
reject(new Error("Expected binary frame"))
}
}
ws.onerror = (e) => reject(e)
})
}
export function sendBlock(ws: WebSocket, data: Uint8Array): void {
if (data.length !== SMP_BLOCK_SIZE) throw new Error("Block must be " + SMP_BLOCK_SIZE + " bytes")
ws.send(data)
}
// Encrypted block send: pad to (blockSize - 16), encrypt (adds 16-byte tag)
export function sendEncryptedBlock(conn: SMPConnection, plaintext: Uint8Array): void {
if (!conn.sndKey) throw new Error("no block encryption keys")
const {encrypted, nextChainKey} = sbEncryptBlock(conn.sndKey, plaintext, SMP_BLOCK_SIZE - 16)
conn.sndKey = nextChainKey
ws_send(conn.ws, encrypted)
}
// Encrypted block receive: decrypt (removes 16-byte tag + unpad)
export async function receiveEncryptedBlock(conn: SMPConnection): Promise<Uint8Array> {
if (!conn.rcvKey) throw new Error("no block encryption keys")
const block = await receiveBlock(conn.ws)
const {decrypted, nextChainKey} = sbDecryptBlock(conn.rcvKey, block)
conn.rcvKey = nextChainKey
return decrypted
}
function ws_send(ws: WebSocket, data: Uint8Array): void {
if (data.length !== SMP_BLOCK_SIZE) throw new Error("Encrypted block must be " + SMP_BLOCK_SIZE + " bytes")
ws.send(data)
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+36 -11
View File
@@ -56,6 +56,7 @@ module Simplex.Messaging.Transport
serviceCertsSMPVersion,
newNtfCredsSMPVersion,
clientNoticesSMPVersion,
webClientSMPVersion,
simplexMQVersion,
smpBlockSize,
TransportConfig (..),
@@ -140,7 +141,7 @@ import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, parseRead1, sumTypeJSON)
import Simplex.Messaging.Transport.Buffer
import Simplex.Messaging.Transport.Shared
import Simplex.Messaging.Util (bshow, catchAll, catchAll_, liftEitherWith)
import Simplex.Messaging.Util (bshow, catchAll, catchAll_, liftEitherWith, (<$?>))
import Simplex.Messaging.Version
import Simplex.Messaging.Version.Internal
import System.IO.Error (isEOFError)
@@ -218,6 +219,9 @@ newNtfCredsSMPVersion = VersionSMP 17
clientNoticesSMPVersion :: VersionSMP
clientNoticesSMPVersion = VersionSMP 18
webClientSMPVersion :: VersionSMP
webClientSMPVersion = VersionSMP 19
minClientSMPRelayVersion :: VersionSMP
minClientSMPRelayVersion = VersionSMP 6
@@ -225,13 +229,13 @@ minServerSMPRelayVersion :: VersionSMP
minServerSMPRelayVersion = VersionSMP 6
currentClientSMPRelayVersion :: VersionSMP
currentClientSMPRelayVersion = VersionSMP 18
currentClientSMPRelayVersion = VersionSMP 19
legacyServerSMPRelayVersion :: VersionSMP
legacyServerSMPRelayVersion = VersionSMP 6
currentServerSMPRelayVersion :: VersionSMP
currentServerSMPRelayVersion = VersionSMP 18
currentServerSMPRelayVersion = VersionSMP 19
-- Max SMP protocol version to be used in e2e encrypted
-- connection between client and server, as defined by SMP proxy.
@@ -296,6 +300,10 @@ class Typeable c => Transport (c :: TransportPeer -> Type) where
-- | ALPN value negotiated for the session
getSessionALPN :: c p -> Maybe ALPN
-- | Web client challenge for server identity verification (WebSocket only)
getWebChallenge :: c p -> Maybe ByteString
getWebChallenge _ = Nothing
-- | Close connection
closeConnection :: c p -> IO ()
@@ -537,7 +545,9 @@ data SMPServerHandshake = SMPServerHandshake
sessionId :: SessionId,
-- pub key to agree shared secrets for command authorization and entity ID encryption.
-- todo C.PublicKeyX25519
authPubKey :: Maybe CertChainPubKey
authPubKey :: Maybe CertChainPubKey,
-- | signed web client challenge for server identity verification (v19+)
webIdentityProof :: Maybe C.ASignature
}
-- This is the third handshake message that SMP server sends to services
@@ -629,15 +639,19 @@ ifHasService :: VersionSMP -> a -> a -> a
ifHasService v a b = if v >= serviceCertsSMPVersion then a else b
instance Encoding SMPServerHandshake where
smpEncode SMPServerHandshake {smpVersionRange, sessionId, authPubKey} =
smpEncode (smpVersionRange, sessionId) <> auth
smpEncode SMPServerHandshake {smpVersionRange, sessionId, authPubKey, webIdentityProof} =
smpEncode (smpVersionRange, sessionId) <> auth <> webProof
where
auth = encodeAuthEncryptCmds (maxVersion smpVersionRange) authPubKey
v = maxVersion smpVersionRange
auth = encodeAuthEncryptCmds v authPubKey
webProof = encodeWebIdentityProof v webIdentityProof
smpP = do
(smpVersionRange, sessionId) <- smpP
let v = maxVersion smpVersionRange
-- TODO drop SMP v6: remove special parser and make key non-optional
authPubKey <- authEncryptCmdsP (maxVersion smpVersionRange) smpP
pure SMPServerHandshake {smpVersionRange, sessionId, authPubKey}
authPubKey <- authEncryptCmdsP v smpP
webIdentityProof <- webIdentityProofP v
pure SMPServerHandshake {smpVersionRange, sessionId, authPubKey, webIdentityProof}
-- newtype for CertificateChain and a session key signed with this certificate
data CertChainPubKey = CertChainPubKey
@@ -661,6 +675,16 @@ encodeAuthEncryptCmds v k
authEncryptCmdsP :: VersionSMP -> Parser a -> Parser (Maybe a)
authEncryptCmdsP v p = if v >= authCmdsSMPVersion then optional p else pure Nothing
encodeWebIdentityProof :: VersionSMP -> Maybe C.ASignature -> ByteString
encodeWebIdentityProof v sig
| v >= webClientSMPVersion = maybe "" (smpEncode . C.signatureBytes) sig
| otherwise = ""
webIdentityProofP :: VersionSMP -> Parser (Maybe C.ASignature)
webIdentityProofP v
| v >= webClientSMPVersion = optional $ C.decodeSignature <$?> smpP
| otherwise = pure Nothing
instance Encoding SMPServerHandshakeResponse where
smpEncode = \case
SMPServerHandshakeResponse serviceId -> smpEncode ('R', serviceId)
@@ -758,7 +782,8 @@ smpServerHandshake ::
smpServerHandshake srvCert srvSignKey c (k, pk) kh smpVRange getService = do
let sk = C.signX509 srvSignKey $ C.publicToX509 k
smpVersionRange = maybe legacyServerSMPRelayVRange (const smpVRange) $ getSessionALPN c
sendHandshake th $ SMPServerHandshake {sessionId, smpVersionRange, authPubKey = Just (CertChainPubKey srvCert sk)}
webIdentityProof = C.sign srvSignKey . (<> sessionId) <$> getWebChallenge c
sendHandshake th $ SMPServerHandshake {sessionId, smpVersionRange, authPubKey = Just (CertChainPubKey srvCert sk), webIdentityProof}
SMPClientHandshake {smpVersion = v, keyHash, authPubKey = k', proxyServer, clientService} <- getHandshake th
when (keyHash /= kh) $ throwE $ TEHandshake IDENTITY
case compatibleVRange' smpVersionRange v of
@@ -791,7 +816,7 @@ smpServerHandshake srvCert srvSignKey c (k, pk) kh smpVRange getService = do
-- See https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a
smpClientHandshake :: forall c. Transport c => c 'TClient -> Maybe C.KeyPairX25519 -> C.KeyHash -> VersionRangeSMP -> Bool -> Maybe (ServiceCredentials, C.KeyPairEd25519) -> ExceptT TransportError IO (THandleSMP c 'TClient)
smpClientHandshake c ks_ keyHash@(C.KeyHash kh) vRange proxyServer serviceKeys_ = do
SMPServerHandshake {sessionId = sessId, smpVersionRange, authPubKey} <- getHandshake th
SMPServerHandshake {sessionId = sessId, smpVersionRange, authPubKey, webIdentityProof = _} <- getHandshake th
when (sessionId /= sessId) $ throwE TEBadSession
-- Below logic downgrades version range in case the "client" is SMP proxy server and it is
-- connected to the destination server of the version 11 or older.
+18 -3
View File
@@ -1,5 +1,6 @@
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
@@ -11,6 +12,7 @@ module Simplex.Messaging.Transport.WebSockets (WS (..), acceptWSConnection) wher
import qualified Control.Exception as E
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy as LB
import qualified Data.X509 as X
@@ -41,7 +43,8 @@ data WS (p :: TransportPeer) = WS
wsConnection :: Connection,
wsTransportConfig :: TransportConfig,
wsCertSent :: Bool,
wsPeerCert :: X.CertificateChain
wsPeerCert :: X.CertificateChain,
wsWebChallenge :: Maybe ByteString
}
websocketsOpts :: ConnectionOptions
@@ -65,6 +68,8 @@ instance Transport WS where
{-# INLINE getPeerCertChain #-}
getSessionALPN = wsALPN
{-# INLINE getSessionALPN #-}
getWebChallenge = wsWebChallenge
{-# INLINE getWebChallenge #-}
tlsUnique = tlsUniq
{-# INLINE tlsUnique #-}
closeConnection = S.close . wsStream
@@ -94,7 +99,7 @@ getWS cfg wsCertSent wsPeerCert cxt = withTlsUnique @WS @p cxt connectWS
s <- makeTLSContextStream cxt
wsConnection <- connectPeer s
wsALPN <- T.getNegotiatedProtocol cxt
pure $ WS {tlsUniq, wsALPN, wsStream = s, wsConnection, wsTransportConfig = cfg, wsCertSent, wsPeerCert}
pure $ WS {tlsUniq, wsALPN, wsStream = s, wsConnection, wsTransportConfig = cfg, wsCertSent, wsPeerCert, wsWebChallenge = Nothing}
connectPeer :: Stream -> IO Connection
connectPeer = case sTransportPeer @p of
STServer -> acceptClientRequest
@@ -107,9 +112,19 @@ acceptWSConnection tls pending = withTlsUnique @WS @'TServer cxt $ \wsUniq -> do
wsStream <- makeTLSContextStream cxt
wsConnection <- acceptRequest pending
wsALPN <- T.getNegotiatedProtocol cxt
pure WS {tlsUniq = wsUniq, wsALPN, wsStream, wsConnection, wsTransportConfig = tlsTransportConfig tls, wsCertSent = False, wsPeerCert = tlsPeerCert tls}
let wsWebChallenge = parseChallenge $ requestPath $ pendingRequest pending
pure WS {tlsUniq = wsUniq, wsALPN, wsStream, wsConnection, wsTransportConfig = tlsTransportConfig tls, wsCertSent = False, wsPeerCert = tlsPeerCert tls, wsWebChallenge}
where
cxt = tlsContext tls
-- Parse ?challenge=<base64url> from request path
parseChallenge path = case B.breakSubstring "challenge=" path of
(_, rest)
| B.null rest -> Nothing
| otherwise ->
let val = B.takeWhile (/= '&') $ B.drop 10 rest -- drop "challenge="
in case B64.decodeUnpadded val of
Right ch | B.length ch == 32 -> Just ch
_ -> Nothing
makeTLSContextStream :: T.Context -> IO Stream
makeTLSContextStream cxt =
+3 -1
View File
@@ -33,6 +33,7 @@ import Simplex.Messaging.Server.QueueStore.Postgres.Config (PostgresStoreCfg (..
import Data.X509.Validation (Fingerprint (..))
import Simplex.Messaging.Transport
import Simplex.Messaging.Transport.Client
import Simplex.Messaging.Transport.HTTP2 (httpALPN)
import Simplex.Messaging.Transport.Server (ServerCredentials (..), TransportServerConfig (..), loadFileFingerprint, loadFingerprint, loadServerCredential, mkTransportServerConfig)
import Simplex.Messaging.Transport.WebSockets (WS)
import Simplex.Messaging.Util (ifM)
@@ -293,7 +294,8 @@ cfgWebOn msType port' = updateCfg (cfgMS msType) $ \cfg' ->
{ caCertificateFile = Nothing,
privateKeyFile = "tests/fixtures/web.key",
certificateFile = "tests/fixtures/web.crt"
}
},
transportConfig = mkTransportServerConfig True (Just $ alpnSupportedSMPHandshakes <> httpALPN) True
}
cfgV7 :: AServerConfig
+456
View File
@@ -0,0 +1,456 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TypeApplications #-}
-- | Per-function tests for the smp-web TypeScript SMP client library.
-- Each test calls the Haskell function and the corresponding TypeScript function
-- via node, then asserts byte-identical output.
--
-- Prerequisites: cd smp-web && npm install && npm run build
-- Run: cabal test --test-option=--match="/SMP Web Client/"
module SMPWebTests (smpWebTests) where
import Control.Monad.Except (ExceptT, runExceptT)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.List.NonEmpty (NonEmpty (..))
import System.Directory (doesDirectoryExist)
import Data.Word (Word16)
import qualified Simplex.Messaging.Agent as A
import qualified Simplex.Messaging.Agent.Protocol as AP
import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), UserLinkData (..), UserContactData (..), UserConnLinkData (..))
import Simplex.Messaging.Client (pattern NRMInteractive)
import Simplex.Messaging.Version (mkVersionRange)
import Simplex.Messaging.Version.Internal (Version (..))
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Crypto.ShortLink (contactShortLinkKdf, invShortLinkKdf)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Protocol (EntityId (..), SMPServer, SubscriptionMode (..), pattern SMPServer)
import Simplex.Messaging.Server.Env.STM (AStoreType (..))
import Simplex.Messaging.Server.MsgStore.Types (SMSType (..), SQSType (..))
import Simplex.Messaging.Server.Web (attachStaticAndWS)
import Simplex.Messaging.Transport (TLS, smpBlockSize)
import Simplex.Messaging.Transport.Client (TransportHost (..))
import SMPAgentClient (agentCfg, initAgentServers, testDB)
import SMPClient (cfgWebOn, testKeyHash, testPort, withSmpServerConfig)
import AgentTests.FunctionalAPITests (withAgent)
import Test.Hspec hiding (it)
import Util
import XFTPWebTests (callNode_, jsOut, jsUint8)
smpWebDir :: FilePath
smpWebDir = "smp-web"
callNode :: String -> IO B.ByteString
callNode = callNode_ smpWebDir
impEnc :: String
impEnc = "import { Decoder, decodeLarge } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
impProto_ :: String
impProto_ = "import { encodeTransmission, encodeBatch, decodeTransmission, encodeLGET, decodeLNK, decodeResponse } from './dist/protocol.js';"
impProto :: String
impProto = impEnc <> impProto_
impTransport :: String
impTransport = "import { decodeSMPServerHandshake, encodeSMPClientHandshake } from './dist/transport.js';"
<> "import { Decoder, encodeWord16 } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
impWS :: String
impWS = "import { connectSMP, sendBlock, receiveBlock } from './dist/transport/websockets.js';"
<> "import { blockPad, blockUnpad } from '@simplex-chat/xftp-web/dist/protocol/transmission.js';"
impAgentProto_ :: String
impAgentProto_ = "import { connShortLinkStrP, decodeConnLinkData, decodeFixedLinkData, decodeProtocolServer, decodeConnShortLink, decodeOwnerAuth, decodeUserLinkData, parseProfile } from './dist/agent/protocol.js';"
impAgentProto :: String
impAgentProto = impEnc <> impAgentProto_
impCryptoShortLink :: String
impCryptoShortLink = "import { contactShortLinkKdf, invShortLinkKdf, decryptLinkData } from './dist/crypto/shortLink.js';"
impCrypto :: String
impCrypto = "import { sbcInit, sbcHkdf, sbEncryptBlock, sbDecryptBlock } from './dist/crypto.js';"
-- Init sodium from xftp-web's copy (same instance secretbox.ts uses)
impSodium :: String
impSodium = "import sodium from '@simplex-chat/xftp-web/node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js'; await sodium.ready;"
jsStr :: B.ByteString -> String
jsStr bs = "'" <> BC.unpack bs <> "'"
runRight :: (Show e, HasCallStack) => ExceptT e IO a -> IO a
runRight action = runExceptT action >>= either (error . ("Unexpected error: " <>) . show) pure
smpWebTests :: SpecWith ()
smpWebTests = describe "SMP Web Client" $ do
distExists <- runIO $ doesDirectoryExist (smpWebDir <> "/dist")
if distExists
then smpWebTests_
else
it "skipped (run 'cd smp-web && npm install && npm run build' first)" $
pendingWith "TS project not compiled"
smpWebTests_ :: SpecWith ()
smpWebTests_ = do
describe "protocol" $ do
describe "transmission" $ do
it "encodeTransmission matches Haskell" $ do
let corrId = "1"
entityId = B.pack [1..24]
command = "LGET"
hsEncoded = smpEncode (corrId :: B.ByteString, entityId :: B.ByteString) <> command
tsEncoded <- callNode $ impProto
<> jsOut ("encodeTransmission("
<> jsUint8 corrId <> ","
<> jsUint8 entityId <> ","
<> "new Uint8Array([0x4C,0x47,0x45,0x54])"
<> ")")
tsEncoded `shouldBe` (B.singleton 0 <> hsEncoded)
it "decodeTransmission parses Haskell-encoded" $ do
let corrId = "abc"
entityId = B.pack [10..33]
command = "TEST"
encoded = smpEncode (B.empty :: B.ByteString)
<> smpEncode corrId
<> smpEncode entityId
<> command
tsResult <- callNode $ impProto
<> "const t = decodeTransmission(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array([...t.corrId, ...t.entityId, ...t.command])")
tsResult `shouldBe` (corrId <> entityId <> command)
describe "LGET" $ do
it "encodeLGET produces correct bytes" $ do
tsResult <- callNode $ impProto <> jsOut "encodeLGET()"
tsResult `shouldBe` "LGET"
describe "LNK" $ do
it "decodeLNK parses correctly" $ do
let senderId = B.pack [1..24]
fixedData = B.pack [100..110]
userData = B.pack [200..220]
encoded = smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
tsResult <- callNode $ impProto
<> "const r = decodeLNK(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array([...r.senderId, ...r.encFixedData, ...r.encUserData])")
tsResult `shouldBe` (senderId <> fixedData <> userData)
describe "decodeResponse" $ do
it "decodes LNK response" $ do
let senderId = B.pack [1..24]
fixedData = B.pack [100..110]
userData = B.pack [200..220]
commandBytes = "LNK " <> smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
tsResult <- callNode $ impProto
<> "const r = decodeResponse(new Decoder(" <> jsUint8 commandBytes <> "));"
<> "if (r.type !== 'LNK') throw new Error('expected LNK, got ' + r.type);"
<> jsOut ("new Uint8Array([...r.response.senderId])")
tsResult `shouldBe` senderId
it "decodes OK response" $ do
tsResult <- callNode $ impProto
<> "const r = decodeResponse(new Decoder(new Uint8Array([0x4F, 0x4B])));"
<> jsOut ("new Uint8Array([r.type === 'OK' ? 1 : 0])")
tsResult `shouldBe` B.singleton 1
describe "transport" $ do
describe "SMPServerHandshake" $ do
it "TypeScript parses Haskell-encoded server handshake (no authPubKey)" $ do
-- Manually construct: smpEncode (versionRange, sessionId) <> "" (no authPubKey)
let vRange = (6 :: Word16, 18 :: Word16)
sessId = B.pack [1..32]
encoded = smpEncode vRange <> smpEncode sessId
tsResult <- callNode $ impTransport
<> "const hs = decodeSMPServerHandshake(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array(["
<> "hs.smpVersionRange.min, hs.smpVersionRange.max,"
<> "hs.authPubKey === null ? 1 : 0,"
<> "hs.sessionId.length"
<> "])")
tsResult `shouldBe` B.pack [6, 18, 1, 32]
describe "SMPClientHandshake" $ do
it "TypeScript-encoded client handshake matches Haskell (no authPubKey)" $ do
-- Haskell encoding: smpEncode (v18, keyHash) <> "" (no authPubKey) <> smpEncode False <> smpEncode (Nothing :: Maybe ())
let v = 18 :: Word16
keyHash = B.pack [1..32]
hsEncoded = smpEncode (v, keyHash)
<> "" -- authPubKey Nothing = empty
<> smpEncode False -- proxyServer
<> smpEncode (Nothing :: Maybe B.ByteString) -- clientService
tsEncoded <- callNode $ impTransport
<> jsOut ("encodeSMPClientHandshake({"
<> "smpVersion: 18,"
<> "keyHash: " <> jsUint8 keyHash <> ","
<> "authPubKey: null,"
<> "proxyServer: false,"
<> "clientService: null"
<> "})")
tsEncoded `shouldBe` hsEncoded
describe "crypto/shortLink" $ do
describe "contactShortLinkKdf" $ do
it "TypeScript produces same linkId and sbKey as Haskell" $ do
let linkKey = AP.LinkKey $ B.pack [1..32]
(EntityId hsLinkId, C.SbKey hsKey) = contactShortLinkKdf linkKey
tsResult <- callNode $ impCryptoShortLink
<> "const r = contactShortLinkKdf(" <> jsUint8 (B.pack [1..32]) <> ");"
<> jsOut ("new Uint8Array([...r.linkId, ...r.sbKey])")
tsResult `shouldBe` (hsLinkId <> hsKey)
describe "invShortLinkKdf" $ do
it "TypeScript produces same sbKey as Haskell" $ do
let linkKey = AP.LinkKey $ B.pack [50..81]
C.SbKey hsKey = invShortLinkKdf linkKey
tsResult <- callNode $ impCryptoShortLink
<> jsOut ("invShortLinkKdf(" <> jsUint8 (B.pack [50..81]) <> ")")
tsResult `shouldBe` hsKey
describe "decryptLinkData" $ do
it "TypeScript decrypts Haskell-encrypted data" $ do
let sbKey = C.unsafeSbKey $ B.pack [1..32]
nonce = C.cbNonce $ B.pack [1..24]
-- Simulate encodeSign: smpEncode signature <> plaintext
fakeSig = B.pack [1..64] -- 64-byte "signature"
fixedPlain = "fixed-data-here"
userPlain = "user-data-here"
signedFixed = smpEncode fakeSig <> fixedPlain
signedUser = smpEncode fakeSig <> userPlain
case (,) <$> C.sbEncrypt sbKey nonce signedFixed 2008
<*> C.sbEncrypt sbKey nonce signedUser 13784 of
Left e -> expectationFailure $ "encrypt failed: " <> show e
Right (ctFixed, ctUser) -> do
let encFixed = C.unCbNonce nonce <> ctFixed
encUser = C.unCbNonce nonce <> ctUser
tsResult <- callNode $ impSodium <> impCryptoShortLink
<> "const r = decryptLinkData("
<> jsUint8 (C.unSbKey sbKey) <> ","
<> jsUint8 encFixed <> ","
<> jsUint8 encUser <> ");"
<> jsOut ("new Uint8Array([...r.fixedData, 0, ...r.userData])")
tsResult `shouldBe` (fixedPlain <> B.singleton 0 <> userPlain)
describe "crypto/blockEncryption" $ do
describe "sbcInit + sbcHkdf" $ do
it "TypeScript produces same sbKey/nonce via sbcInit+sbcHkdf as Haskell" $ do
let sessId = B.pack [1..32]
secret = B.pack [50..81]
(sndCk, _rcvCk) = C.sbcInit sessId secret
((C.SbKey sbKey, C.CbNonce nonce), _nextCk) = C.sbcHkdf sndCk
-- TypeScript does sbcInit then sbcHkdf on sndKey, should produce same sbKey/nonce
tsResult <- callNode $ impSodium <> impCrypto
<> "const ck = sbcInit(" <> jsUint8 sessId <> "," <> jsUint8 secret <> ");"
<> "const r = sbcHkdf(ck.sndKey);"
<> jsOut ("new Uint8Array([...r.keyNonce.sbKey, ...r.keyNonce.nonce])")
tsResult `shouldBe` (sbKey <> nonce)
describe "block encrypt/decrypt" $ do
it "Haskell encrypts, TypeScript decrypts" $ do
let sessId = B.pack [1..32]
secret = B.pack [1..32]
(sndCk, _) = C.sbcInit sessId secret
msg = "hello encrypted block"
((sk, nonce), _nextCk) = C.sbcHkdf sndCk
case C.sbEncrypt sk nonce msg (smpBlockSize - 16) of
Left e -> expectationFailure $ "encrypt failed: " <> show e
Right ct -> do
tsResult <- callNode $ impSodium <> impCrypto
<> "const ck = sbcInit(" <> jsUint8 sessId <> "," <> jsUint8 secret <> ");"
<> "const r = sbDecryptBlock(ck.sndKey," <> jsUint8 ct <> ");"
<> jsOut ("r.decrypted")
tsResult `shouldBe` msg
it "TypeScript encrypts, Haskell decrypts" $ do
let sessId = B.pack [10..41]
secret = B.pack [10..41]
(sndCk, _) = C.sbcInit sessId secret
msg = "hello from typescript"
tsResult <- callNode $ impSodium <> impCrypto
<> "const ck = sbcInit(" <> jsUint8 sessId <> "," <> jsUint8 secret <> ");"
<> jsOut ("sbEncryptBlock(ck.sndKey, new TextEncoder().encode('hello from typescript'), " <> show (smpBlockSize - 16) <> ").encrypted")
let ((sk, nonce), _nextCk) = C.sbcHkdf sndCk
case C.sbDecrypt sk nonce tsResult of
Left e -> expectationFailure $ "decrypt failed: " <> show e
Right plain -> plain `shouldBe` msg
describe "agent/protocol" $ do
describe "ProtocolServer binary" $ do
it "decodes Haskell-encoded server" $ do
let srv = SMPServer ("smp.example.com" :| ["smp2.example.com"]) "5223" (C.KeyHash $ B.pack [1..32])
encoded = smpEncode srv
tsResult <- callNode $ impAgentProto
<> "const s = decodeProtocolServer(new Decoder(" <> jsUint8 encoded <> "));"
<> "const enc = new TextEncoder();"
<> jsOut ("new Uint8Array([s.hosts.length, ...s.keyHash, ...enc.encode(new TextDecoder().decode(s.port))])")
tsResult `shouldBe` B.pack ([2] ++ [1..32]) <> "5223"
describe "ConnShortLink binary" $ do
it "decodes Haskell-encoded contact link" $ do
let srv = SMPServer ("relay.example.com" :| []) "" (C.KeyHash $ B.pack [1..32])
linkKey = AP.LinkKey $ B.pack [50..81]
link = AP.CSLContact AP.SLSServer AP.CCTGroup srv linkKey
encoded = smpEncode link
tsResult <- callNode $ impAgentProto
<> "const l = decodeConnShortLink(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array([l.mode === 'contact' ? 1 : 0, l.connType === 'group' ? 1 : 0, ...l.linkKey])")
tsResult `shouldBe` B.pack ([1, 1] ++ [50..81])
describe "ConnLinkData" $ do
it "decodes Haskell-encoded ContactLinkData with profile" $ do
let profileJson = "{\"displayName\":\"alice\",\"fullName\":\"Alice A\"}"
userData = AP.UserLinkData profileJson
ucd = AP.UserContactData {AP.direct = True, AP.owners = [], AP.relays = [], AP.userData = userData}
cld = AP.ContactLinkData (mkVersionRange (Version 1) (Version 3)) ucd :: AP.ConnLinkData 'AP.CMContact
encoded = smpEncode cld
tsResult <- callNode $ impAgentProto
<> "const r = decodeConnLinkData(new Decoder(" <> jsUint8 encoded <> "));"
<> "const p = parseProfile(r.userContactData.userData);"
<> "const enc = new TextEncoder();"
<> jsOut ("new Uint8Array(["
<> "r.agentVRange.min >> 8, r.agentVRange.min & 0xff,"
<> "r.agentVRange.max >> 8, r.agentVRange.max & 0xff,"
<> "r.userContactData.direct ? 1 : 0,"
<> "r.userContactData.owners.length,"
<> "r.userContactData.relays.length,"
<> "...enc.encode(p.displayName)"
<> "])")
tsResult `shouldBe` B.pack [0, 1, 0, 3, 1, 0, 0] <> "alice"
describe "ConnShortLink URI" $ do
it "parses simplex: contact link" $ do
let srv = SMPServer ("smp1.example.com" :| []) "" (C.KeyHash $ B.pack [1..32])
linkKey = AP.LinkKey $ B.pack [100..131]
link = AP.CSLContact AP.SLSSimplex AP.CCTContact srv linkKey
uri = strEncode link
tsResult <- callNode $ impAgentProto
<> "const r = connShortLinkStrP(" <> jsStr uri <> ");"
<> jsOut ("new Uint8Array([...r.linkKey, ...r.server.keyHash])")
tsResult `shouldBe` (B.pack [100..131] <> B.pack [1..32])
it "parses https: contact link with port" $ do
let srv = SMPServer ("smp2.example.com" :| []) "5223" (C.KeyHash $ B.pack [50..81])
linkKey = AP.LinkKey $ B.pack [200..231]
link = AP.CSLContact AP.SLSServer AP.CCTContact srv linkKey
uri = strEncode link
tsResult <- callNode $ impAgentProto
<> "const r = connShortLinkStrP(" <> jsStr uri <> ");"
<> "const enc = new TextEncoder();"
<> jsOut ("new Uint8Array([...r.linkKey, ...r.server.keyHash, ...enc.encode(r.server.port), 0, ...enc.encode(r.server.hosts.join(','))])")
let expected = B.pack [200..231] <> B.pack [50..81] <> "5223" <> B.singleton 0 <> "smp2.example.com"
tsResult `shouldBe` expected
it "parses simplex: contact link with multiple hosts" $ do
let srv = SMPServer ("host1.example.com" :| ["host2.example.com"]) "" (C.KeyHash $ B.pack [1..32])
linkKey = AP.LinkKey $ B.pack [10..41]
link = AP.CSLContact AP.SLSSimplex AP.CCTContact srv linkKey
uri = strEncode link
tsResult <- callNode $ impAgentProto
<> "const r = connShortLinkStrP(" <> jsStr uri <> ");"
<> "const enc = new TextEncoder();"
<> jsOut ("new Uint8Array([...r.linkKey, ...enc.encode(r.server.hosts.join(','))])")
tsResult `shouldBe` (B.pack [10..41] <> "host1.example.com,host2.example.com")
it "parses group link type" $ do
let srv = SMPServer ("smp.example.com" :| []) "" (C.KeyHash $ B.pack [1..32])
linkKey = AP.LinkKey $ B.pack [10..41]
link = AP.CSLContact AP.SLSSimplex AP.CCTGroup srv linkKey
uri = strEncode link
tsResult <- callNode $ impAgentProto
<> "const r = connShortLinkStrP(" <> jsStr uri <> ");"
<> "const enc = new TextEncoder();"
<> jsOut ("enc.encode(r.connType)")
tsResult `shouldBe` "group"
it "round-trips: Haskell encode -> TypeScript parse -> fields match" $ do
let srv = SMPServer ("server1.simplex.im" :| ["server2.simplex.im"]) "443" (C.KeyHash $ B.pack [1..32])
linkKey = AP.LinkKey $ B.pack [200..231]
link = AP.CSLContact AP.SLSServer AP.CCTContact srv linkKey
uri = strEncode link
-- TypeScript returns: mode, scheme, connType, host count, port, linkKey
tsResult <- callNode $ impAgentProto
<> "const r = connShortLinkStrP(" <> jsStr uri <> ");"
<> "const enc = new TextEncoder();"
<> jsOut ("new Uint8Array(["
<> "r.mode === 'contact' ? 1 : 0,"
<> "r.scheme === 'https' ? 1 : 0,"
<> "r.connType === 'contact' ? 1 : 0,"
<> "r.server.hosts.length,"
<> "...r.linkKey"
<> "])")
tsResult `shouldBe` B.pack ([1, 1, 1, 2] ++ [200..231])
describe "WebSocket handshake" $ do
it "TypeScript connects with block encryption, verifies identity, sends encrypted PING" $ do
let msType = ASType SQSMemory SMSJournal
attachStaticAndWS "tests/fixtures" $ \attachHTTP ->
withSmpServerConfig (cfgWebOn msType testPort) (Just attachHTTP) $ \_ -> do
let C.KeyHash kh = testKeyHash
tsResult <- callNode $ impSodium <> impWS <> impProto
<> "import { sendEncryptedBlock, receiveEncryptedBlock } from './dist/transport/websockets.js';"
<> "try {"
<> "const conn = await connectSMP('wss://localhost:" <> testPort <> "', " <> jsUint8 kh <> ", {rejectUnauthorized: false, ALPNProtocols: ['http/1.1']});"
<> "if (!conn.sndKey || !conn.rcvKey) throw new Error('no block encryption keys');"
<> "const ping = encodeBatch(encodeTransmission(new Uint8Array([0x31]), new Uint8Array(0), new Uint8Array([0x50,0x49,0x4E,0x47])));"
<> "sendEncryptedBlock(conn, ping);"
<> "const resp = await receiveEncryptedBlock(conn);"
<> "const d = new Decoder(resp);"
<> "d.anyByte();" -- batch count
<> "const inner = decodeLarge(d);"
<> "const t = decodeTransmission(new Decoder(inner));"
<> jsOut ("t.command")
<> "conn.ws.close(); setTimeout(() => process.exit(0), 100);"
<> "} catch(e) { process.stderr.write('ERROR: ' + e.message + '\\n'); process.exit(1); }"
tsResult `shouldBe` "PONG"
describe "end-to-end" $ do
it "TypeScript fetches short link data via WebSocket" $ do
let msType = ASType SQSMemory SMSJournal
attachStaticAndWS "tests/fixtures" $ \attachHTTP ->
withSmpServerConfig (cfgWebOn msType testPort) (Just attachHTTP) $ \_serverThread ->
withAgent 1 agentCfg initAgentServers testDB $ \a -> do
let testData = "hello from short link"
userData = UserLinkData testData
userCtData = UserContactData {direct = True, owners = [], relays = [], userData = userData}
newLinkData = UserContactLinkData userCtData
(_connId, (CCLink _connReq (Just shortLink), Nothing)) <-
runRight $ A.createConnection a NRMInteractive 1 True True AP.SCMContact (Just newLinkData) Nothing CR.IKPQOn SMSubscribe
let linkUri = strEncode shortLink
tsResult <- callNode $ impSodium <> impWS <> impAgentProto <> impProto_ <> impCryptoShortLink
<> "import { sendEncryptedBlock, receiveEncryptedBlock } from './dist/transport/websockets.js';"
<> "try {"
-- 1. Parse short link URI
<> "const link = connShortLinkStrP(" <> jsStr linkUri <> ");"
-- 2. Derive keys
<> "const {linkId, sbKey} = contactShortLinkKdf(link.linkKey);"
-- 3. Connect via WSS (with block encryption)
<> "const conn = await connectSMP('wss://localhost:" <> testPort <> "', " <> jsUint8 (C.unKeyHash testKeyHash) <> ", {rejectUnauthorized: false, ALPNProtocols: ['http/1.1']});"
-- 4. Send LGET (encrypted)
<> "const lget = encodeBatch(encodeTransmission(new Uint8Array([0x31]), linkId, encodeLGET()));"
<> "sendEncryptedBlock(conn, lget);"
-- 5. Receive LNK response (encrypted)
<> "const resp = await receiveEncryptedBlock(conn);"
<> "const rd = new Decoder(resp);"
<> "rd.anyByte();" -- batch count
<> "const inner = decodeLarge(rd);"
<> "const t = decodeTransmission(new Decoder(inner));"
<> "const r = decodeResponse(new Decoder(t.command));"
<> "if (r.type !== 'LNK') throw new Error('expected LNK, got ' + r.type);"
-- 6. Decrypt link data
<> "const dec = decryptLinkData(sbKey, r.response.encFixedData, r.response.encUserData);"
-- 7. Parse FixedLinkData (rootKey) and ConnLinkData (userData)
<> "const fld = decodeFixedLinkData(new Decoder(dec.fixedData));"
<> "const cld = decodeConnLinkData(new Decoder(dec.userData));"
-- Return rootKey length (44 = valid DER Ed25519) + userData
<> jsOut ("new Uint8Array([fld.rootKey.length, ...cld.userContactData.userData])")
<> "conn.ws.close(); setTimeout(() => process.exit(0), 100);"
<> "} catch(e) { process.stderr.write('ERROR: ' + e.message + '\\n'); process.exit(1); }"
-- First byte: rootKey DER length (44 for Ed25519), rest: userData
B.head tsResult `shouldBe` 44
B.tail tsResult `shouldBe` testData
+2
View File
@@ -39,6 +39,7 @@ import Simplex.FileTransfer.Server.Store (SFSType (..))
import XFTPServerTests (xftpServerTests)
import WebTests (webTests)
import XFTPWebTests (xftpWebTests)
import SMPWebTests (smpWebTests)
#if defined(dbPostgres)
import Fixtures
@@ -175,6 +176,7 @@ main = do
#else
describe "XFTP Web Client" $ xftpWebTests (pure ())
#endif
describe "SMP Web Client" smpWebTests
describe "XRCP" remoteControlTests
describe "Web" webTests
describe "Server CLIs" cliTests
+8 -5
View File
@@ -11,7 +11,7 @@
--
-- Prerequisites: cd xftp-web && npm install && npm run build
-- Run: cabal test --test-option=--match="/XFTP Web Client/"
module XFTPWebTests (xftpWebTests) where
module XFTPWebTests (xftpWebTests, callNode_, jsOut, jsUint8, redirectConsole) where
import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)
import Control.Monad (replicateM, when)
@@ -61,9 +61,9 @@ xftpWebDir = "xftp-web"
redirectConsole :: String
redirectConsole = "console.log = console.warn = (...a) => process.stderr.write(a.map(String).join(' ') + '\\n');"
-- | Run an inline ES module script via node, return stdout as ByteString.
callNode :: String -> IO B.ByteString
callNode script = do
-- | Run an inline ES module script via node in a given directory, return stdout as ByteString.
callNode_ :: FilePath -> String -> IO B.ByteString
callNode_ dir script = do
baseEnv <- getEnvironment
let nodeEnv = ("NODE_TLS_REJECT_UNAUTHORIZED", "0") : baseEnv
(_, Just hout, Just herr, ph) <-
@@ -71,7 +71,7 @@ callNode script = do
(proc "node" ["--input-type=module", "-e", redirectConsole <> script])
{ std_out = CreatePipe,
std_err = CreatePipe,
cwd = Just xftpWebDir,
cwd = Just dir,
env = Just nodeEnv
}
errVar <- newEmptyMVar
@@ -84,6 +84,9 @@ callNode script = do
"node " <> show ec <> "\nstderr: " <> map (toEnum . fromIntegral) (B.unpack err)
pure out
callNode :: String -> IO B.ByteString
callNode = callNode_ xftpWebDir
-- | Format a ByteString as a JS Uint8Array constructor.
jsUint8 :: B.ByteString -> String
jsUint8 bs = "new Uint8Array([" <> intercalate "," (map show (B.unpack bs)) <> "])"
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
dist-web/
package-lock.json
test-results
+2 -2
View File
@@ -81,7 +81,7 @@ export function encodePING(): Uint8Array { return ascii("PING") }
// -- Response decoding
function readTag(d: Decoder): string {
export function readTag(d: Decoder): string {
const start = d.offset()
while (d.remaining() > 0) {
if (d.buf[d.offset()] === 0x20 || d.buf[d.offset()] === 0x0a) break
@@ -92,7 +92,7 @@ function readTag(d: Decoder): string {
return s
}
function readSpace(d: Decoder): void {
export function readSpace(d: Decoder): void {
if (d.anyByte() !== 0x20) throw new Error("expected space")
}