mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-10-12 02:22:45 +00:00
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:
co-authored by
Evgeny @ SimpleX Chat
parent
3536a156d1
commit
3f40febe60
@@ -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`
|
||||
@@ -515,6 +515,7 @@ test-suite simplexmq-test
|
||||
XFTPServerTests
|
||||
WebTests
|
||||
XFTPWebTests
|
||||
SMPWebTests
|
||||
SMPWeb
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
package-lock.json
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)) <> "])"
|
||||
|
||||
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
package-lock.json
|
||||
test-results
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user