mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-30 22:48:26 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f0c2be45 | ||
|
|
8b7fc88cb8 | ||
|
|
5f1216bf07 | ||
|
|
7f95b4540d | ||
|
|
910a922e0d | ||
|
|
ad50ca8644 | ||
|
|
3bc7a8c0a8 | ||
|
|
e56c12ab3c | ||
|
|
34e3d30c78 | ||
|
|
a0af7377ab | ||
|
|
3f40febe60 | ||
|
|
3536a156d1 | ||
|
|
01fe841e3c |
@@ -3,7 +3,7 @@ module Main where
|
||||
import Control.Logger.Simple
|
||||
import Simplex.Messaging.Server.CLI (getEnvPath)
|
||||
import Simplex.Messaging.Server.Main (smpServerCLI_)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticFiles)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticAndWS)
|
||||
import SMPWeb (smpGenerateSite)
|
||||
|
||||
defaultCfgPath :: FilePath
|
||||
@@ -19,4 +19,4 @@ main :: IO ()
|
||||
main = do
|
||||
cfgPath <- getEnvPath "SMP_SERVER_CFG_PATH" defaultCfgPath
|
||||
logPath <- getEnvPath "SMP_SERVER_LOG_PATH" defaultLogPath
|
||||
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticFiles cfgPath logPath
|
||||
withGlobalLogging logCfg $ smpServerCLI_ smpGenerateSite serveStaticFiles attachStaticAndWS cfgPath logPath
|
||||
|
||||
@@ -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`
|
||||
@@ -0,0 +1,355 @@
|
||||
# SMP Client for Browser
|
||||
|
||||
**Parent**: [SMP Agent Web Spike](./2026-03-20-smp-agent-web-spike.md)
|
||||
**Depends on**: Spike 1 (merged) — transport, ratchet, encoding, per-queue E2E
|
||||
|
||||
## Context
|
||||
|
||||
The encoding spike proved all four encryption layers work cross-language. The next implementable and testable piece is the SMP client — the layer that sends commands, correlates responses by CorrId, authenticates with entity keys, and exposes typed async functions.
|
||||
|
||||
Faithful transpilation of `Simplex.Messaging.Client` (Client.hs). Transport is WebSocket (already working), protocol logic is identical to Haskell.
|
||||
|
||||
## Encoding path (per command)
|
||||
|
||||
Traced from `sendSMPMessage` through every function call:
|
||||
|
||||
```
|
||||
1. encodeTransmission_(v, (corrId, entityId, command))
|
||||
→ smpEncode(corrId, entityId) <> encodeProtocol(v, cmd)
|
||||
Already have as encodeTransmission() in protocol.ts — update in place
|
||||
|
||||
2. encodeTransmissionForAuth(thParams, transmission)
|
||||
→ tForAuth = smpEncode(sessionId) <> encodeTransmission_(...)
|
||||
→ tToSend = encodeTransmission_(...) [when implySessId=true, which is always true for v>=7]
|
||||
Note: implySessId means tToSend omits sessionId, but tForAuth includes it (for signing)
|
||||
|
||||
3. authTransmission(thAuth, serviceAuth=false, maybePrivKey, nonce, tForAuth)
|
||||
→ thAuth contains serverPubKey (X25519) from handshake
|
||||
→ maybePrivKey is Nothing for unauthenticated commands (LGET, SEND without key)
|
||||
→ Nothing privKey: no auth, encode empty ByteString
|
||||
→ Just X25519 privKey: TAAuthenticator(cbAuthenticate(serverPubKey, privKey, nonce, tForAuth))
|
||||
→ Just Ed25519 privKey: TASignature(sign(privKey, tForAuth))
|
||||
Note: nonce IS the CorrId (same 24 bytes used for both)
|
||||
Note: serviceAuth is always false for browser client (no service certificates)
|
||||
|
||||
4. tEncodeAuth(serviceAuth=false, maybeAuth)
|
||||
→ Nothing: smpEncode("") [1-byte 0x00]
|
||||
→ Just (TAAuthenticator s, _): smpEncode(s) [1-byte len + 80 bytes]
|
||||
→ Just (TASignature sig, _): smpEncode(signatureBytes sig) [1-byte len + 64 bytes]
|
||||
Note: TAuthorizations = (TransmissionAuth, Maybe serviceSig) — serviceSig always Nothing for us
|
||||
|
||||
5. tEncode(serviceAuth, (auth, tToSend))
|
||||
→ tEncodeAuth(auth) <> tToSend
|
||||
|
||||
6. tEncodeBatch1(serviceAuth, sentRawTransmission)
|
||||
→ lenEncode(1) + smpEncode(Large(tEncode(...)))
|
||||
Single-command batch. Always used when batch=true (v7+).
|
||||
|
||||
7. batchTransmissions_(blockSize, transmissions)
|
||||
→ Pack multiple Large-wrapped transmissions into ≤blockSize blocks
|
||||
→ Count byte prefix, up to 255 per block
|
||||
→ blockSize' = blockSize - 19 (2 pad + 1 count + 16 auth tag)
|
||||
```
|
||||
|
||||
## Parsing path (per received block)
|
||||
|
||||
```
|
||||
1. tParse(thParams, blockBytes)
|
||||
→ batch=true: parse count byte, then N Large-wrapped transmissions
|
||||
→ Each: transmissionP(thParams) parses:
|
||||
- authenticator (ByteString, 1-byte len + data) — ignored by client
|
||||
- rest = authorized bytes
|
||||
- re-parse authorized: corrId (ByteString) + entityId (ByteString) + command (rest)
|
||||
- if implySessId=true: sessionId not in wire format, prepended from thParams for verification
|
||||
→ Returns RawTransmission{authenticator, corrId, entityId, command}
|
||||
|
||||
2. tDecodeClient(thParams, rawTransmission)
|
||||
→ Verify sessId matches (skipped when implySessId=true)
|
||||
→ parseProtocol(v, command) → Either ErrorType BrokerMsg
|
||||
→ Return (corrId, entityId, Right msg | Left err)
|
||||
|
||||
3. clientResp classification (Client.hs:708-712):
|
||||
→ Left err (parse error) → PCEResponseError
|
||||
→ Right msg, protocolError msg = Just err → PCEProtocolError (ERR response)
|
||||
→ Right msg, protocolError msg = Nothing → Right msg (success)
|
||||
|
||||
4. Process: lookup corrId in pendingCommands
|
||||
→ Found: resolve Promise with clientResp
|
||||
→ Not found (empty corrId = server push): deliver to event callback
|
||||
```
|
||||
|
||||
## Functions to implement
|
||||
|
||||
### Crypto (`src/crypto.ts` — extend)
|
||||
|
||||
| Function | Haskell | Implementation |
|
||||
|---|---|---|
|
||||
| `sha512Hash(msg)` | `Crypto.hs:1016` | `sha512(msg)` from `@noble/hashes/sha512` |
|
||||
| `cbAuthenticator(serverPubKey, entityPrivKey, nonce, msg)` | `Crypto.hs:1367` | `cryptoBox(dh(serverPubKey, privKey), nonce, sha512Hash(msg))` → 80 bytes (16 tag + 64 hash) |
|
||||
`cryptoBox` and `dh` already available from xftp-web. `sha512` from `@noble/hashes`.
|
||||
|
||||
Not needed in spike: `cbDecryptNoPad` (only used by `cbVerify` and proxy commands).
|
||||
|
||||
Ed25519 signing: `crypto_sign_detached` from libsodium (already loaded and initialized via xftp-web for secretbox — no second implementation needed).
|
||||
|
||||
Not needed: `cbVerify` (server-side only).
|
||||
|
||||
### Transport update (`src/transport/websockets.ts` — update)
|
||||
|
||||
**Gap: `connectSMP` must return `serverPubKey`** (raw X25519 public key bytes from the handshake). Currently it computes the DH secret and derives block keys, but discards the server's raw public key. The client needs it for `cbAuthenticate` on every command.
|
||||
|
||||
Update `SMPConnection` to include:
|
||||
```typescript
|
||||
interface SMPConnection {
|
||||
ws: WebSocket
|
||||
sessionId: Uint8Array
|
||||
smpVersion: number
|
||||
sndKey: Uint8Array | null
|
||||
rcvKey: Uint8Array | null
|
||||
serverPubKey: Uint8Array | null // raw X25519 public key — needed for command auth
|
||||
}
|
||||
```
|
||||
|
||||
### Protocol encoding (`src/protocol.ts` — update existing)
|
||||
|
||||
Update `encodeTransmission`, `encodeBatch`, `decodeTransmission` in place — these were spike throwaway. Replace with auth-aware versions and update existing tests accordingly.
|
||||
|
||||
| Function | Haskell ref | Notes |
|
||||
|---|---|---|
|
||||
| `encodeTransmission_(v, corrId, entityId, command)` | `Protocol.hs:2194` | Update existing `encodeTransmission`. Also fix `encodeNEW`: QueueReqData should be `Just (QRMessaging Nothing)` not `Nothing`, and rename `sndAuthKey` param to `basicAuth` (it's server auth, not a crypto key) |
|
||||
| `encodeTransmissionForAuth(sessionId, corrId, entityId, command)` | `Protocol.hs:2186` | Returns `{tForAuth, tToSend}`. `implySessId` always true for v>=7 |
|
||||
| `authTransmission(serverPubKey, maybePrivKey, nonce, tForAuth)` | `Client.hs:1372` | `maybePrivKey` is `{type: "x25519"|"ed25519", key} | null`. Null for unauthenticated commands. X25519 → cbAuthenticator. Ed25519 → sign. |
|
||||
| `tEncodeAuth(auth)` | `Protocol.hs:507` | Handles null, authenticator (80 bytes), signature (64 bytes) |
|
||||
| `tEncode(auth, tToSend)` | `Protocol.hs:2171` | `tEncodeAuth(auth) + tToSend` |
|
||||
| `tEncodeBatch1(auth, tToSend)` | `Protocol.hs:2179` | `[count=1] + Large(tEncode(...))` |
|
||||
| `tEncodeForBatch(auth, tToSend)` | `Protocol.hs:2175` | `Large(tEncode(...))` |
|
||||
| `batchTransmissions(blockSize, transmissions)` | `Protocol.hs:2151` | Pack into ≤(blockSize-19)-byte blocks, count prefix |
|
||||
| `transmissionP(sessionId, block)` | `Protocol.hs:1629` | Skip auth bytes (1-byte len + data), parse corrId + entityId + command from rest. `implySessId`=true (sessionId not in wire, no need to verify on client side), `serviceAuth`=false (no serviceSig to skip) |
|
||||
| `tParse(sessionId, block)` | `Protocol.hs:2211` | Parse count, N×Large, each through `transmissionP` |
|
||||
| `tDecodeClient(sessionId, version, rawTransmission)` | `Protocol.hs:2256` | Parse command bytes → typed BrokerMsg |
|
||||
| `encodePING()` | | PING command for keepalive |
|
||||
|
||||
Update `decodeResponse`:
|
||||
- Add `SOK` (subscribe response with optional serviceId, returned by SUB in v19)
|
||||
- Add `INFO` (queue info response, for `getSMPQueueInfo`)
|
||||
- Improve `ERR` parsing: currently reads just the tag string. Need to parse structured `ErrorType` (at minimum AUTH, QUOTA, NO_MSG, INTERNAL) for proper error handling in the client
|
||||
|
||||
### Client (`src/client.ts` — new)
|
||||
|
||||
```typescript
|
||||
interface SMPClient {
|
||||
sessionId: Uint8Array
|
||||
smpVersion: number
|
||||
serverPubKey: Uint8Array // for cbAuthenticate
|
||||
|
||||
// Core: send pre-encoded command, correlate response
|
||||
// Lower-level than Haskell's sendProtocolCommand — takes pre-encoded command bytes
|
||||
// privKey: {type: "x25519", key} | {type: "ed25519", key} | null
|
||||
// Rejects with PCEProtocolError (ERR response), PCEResponseError (parse fail), PCEResponseTimeout
|
||||
sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<BrokerMsg>
|
||||
|
||||
// High-level commands (keys are DER-encoded unless noted)
|
||||
// authKeyPair: {publicKey, privateKey, type: "x25519"} — public goes in NEW encoding, private for auth
|
||||
createQueue(authKeyPair, dhKey, subMode): Promise<QueueIdsKeys>
|
||||
subscribeQueue(privKey, rcvId): Promise<void> // SUB can return MSG (queued message) → pushed to onMessage
|
||||
sendMessage(privKey, sndId, flags, msg): Promise<void> // privKey can be null (before queue secured)
|
||||
ackMessage(privKey, rcvId, msgId): Promise<void> // ACK can return MSG → pushed to onMessage
|
||||
secureQueue(privKey, rcvId, senderKey): Promise<void>
|
||||
secureSndQueue(privKey, sndId): Promise<void>
|
||||
getQueueLink(linkId): Promise<{senderId, linkData}>
|
||||
getQueueInfo(privKey, queueId): Promise<QueueInfo>
|
||||
deleteQueue(privKey, rcvId): Promise<void>
|
||||
suspendQueue(privKey, rcvId): Promise<void>
|
||||
|
||||
close(): void
|
||||
}
|
||||
|
||||
function createSMPClient(
|
||||
url: string,
|
||||
keyHash: Uint8Array,
|
||||
onMessage: (entityId: Uint8Array, msg: BrokerMsg) => void,
|
||||
onDisconnected: () => void,
|
||||
wsOptions?: object,
|
||||
): Promise<SMPClient>
|
||||
```
|
||||
|
||||
Internally:
|
||||
- `connectSMP` for WebSocket + handshake (existing, updated to return serverPubKey)
|
||||
- `Map<string, {resolve, reject}>` for hex(corrId) → Promise correlation
|
||||
- WebSocket `onmessage`: `receiveEncryptedBlock` → `tParse` → for each transmission: `tDecodeClient` → classify via `protocolError` → correlate by corrId or push to `onMessage`
|
||||
- `sendCommand`: generate random 24-byte corrId/nonce → `encodeTransmissionForAuth` → `authTransmission` → `tEncodeBatch1` → `sendEncryptedBlock` → return Promise resolved by correlator
|
||||
- `setInterval` ping: send PING, count timeouts, close after N consecutive
|
||||
- Timeout per command: `setTimeout` on pending Promise, reject with PCEResponseTimeout
|
||||
|
||||
**Message delivery model:**
|
||||
|
||||
All MSGs reach `onMessage` regardless of how they arrive. Three sources:
|
||||
|
||||
1. **Server push** (empty corrId): receive handler calls `onMessage` directly
|
||||
2. **SUB response**: `sendCommand` resolves with MSG → `subscribeQueue` pushes to `onMessage`, returns success to caller
|
||||
3. **ACK response**: same — `ackMessage` pushes to `onMessage`, returns success
|
||||
|
||||
High-level functions never expose MSG to their callers. This mirrors Haskell's `processSUBResponse_` (Client.hs:858-862) and `ackSMPMessage` (Client.hs:1042-1044) which both call `writeSMPMessage` to forward MSGs to msgQ and return OK-equivalent.
|
||||
|
||||
### Client REPL (`smp-web/tests/client-repl.ts` — new, separate from ratchet-repl.ts)
|
||||
|
||||
Separate REPL process holding a WebSocket connection + SMP client state. Same stdin/stdout line protocol approach, different state and commands.
|
||||
|
||||
**Message queue:** The REPL maintains an internal `Message[]` queue. The SMPClient's `onMessage` callback pushes to this queue. MSGs arrive here from three sources: server pushes (no corrId), SUB responses, and ACK responses — all handled identically by the client internals. The `RECV` command dequeues from this queue (or waits with timeout).
|
||||
|
||||
**Concurrency:** Unlike the ratchet REPL (pure, no network), the client REPL receives messages concurrently with stdin. This works because Node's event loop handles WebSocket `onmessage` events between readline callbacks — no explicit threading needed.
|
||||
|
||||
```
|
||||
CONNECT <url> <keyHashHex> [wsOptions]
|
||||
→ Creates SMPClient, returns "ok"
|
||||
|
||||
NEW <rcvAuthKeyHex> <rcvDhKeyHex> [subMode]
|
||||
→ createQueue (defaults: no basic auth, SMSubscribe, QRMessaging, no ntf creds)
|
||||
→ returns "ok: <rcvIdHex> <sndIdHex> <srvDhKeyHex>"
|
||||
|
||||
SUB <rcvIdHex> <rcvPrivKeyHex>
|
||||
→ subscribeQueue, returns "ok"
|
||||
|
||||
SEND <sndIdHex> <sndPrivKeyHex> <bodyHex>
|
||||
→ sendMessage, returns "ok"
|
||||
|
||||
ACK <rcvIdHex> <rcvPrivKeyHex> <msgIdHex>
|
||||
→ ackMessage, returns "ok"
|
||||
|
||||
KEY <rcvIdHex> <rcvPrivKeyHex> <senderKeyHex>
|
||||
→ secureQueue, returns "ok"
|
||||
|
||||
SKEY <sndIdHex> <sndPrivKeyHex>
|
||||
→ secureSndQueue, returns "ok"
|
||||
|
||||
LGET <linkIdHex>
|
||||
→ getQueueLink, returns "ok: <senderIdHex> <linkDataHex>"
|
||||
|
||||
RECV [timeoutMs]
|
||||
→ Dequeue next server-pushed MSG, returns "ok: <entityIdHex> <msgIdHex> <bodyHex>"
|
||||
→ Times out with "error: timeout" if no message arrives
|
||||
```
|
||||
|
||||
### Polymorphic testing
|
||||
|
||||
Same pattern as ratchet tests: `TestPeer` sum type with `TestPeerHS` / `TestPeerJS` dispatch. For SMP client tests:
|
||||
|
||||
```haskell
|
||||
data TestSMPClient
|
||||
= TestClientHS SMPClient
|
||||
| TestClientJS Handle Handle ProcessHandle -- stdin, stdout, process
|
||||
|
||||
-- Dispatch functions
|
||||
tcCreateQueue :: TestSMPClient -> ... -> IO QueueIdsKeys
|
||||
tcSubscribe :: TestSMPClient -> ... -> IO ()
|
||||
tcSendMessage :: TestSMPClient -> ... -> IO ()
|
||||
tcReceiveMessage :: TestSMPClient -> IO (EntityId, MsgId, ByteString)
|
||||
tcSecureQueue :: TestSMPClient -> ... -> IO ()
|
||||
tcAckMessage :: TestSMPClient -> ... -> IO ()
|
||||
```
|
||||
|
||||
Then the same test function runs against HS↔HS, HS↔JS, JS↔HS, JS↔JS peer combinations. The test creates two clients (one receiver, one sender) on the same SMP server, creates a queue, exchanges keys, sends messages — proving protocol compatibility.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit tests (callNode, no server)
|
||||
|
||||
1. `sha512Hash` — same input → same output as Haskell
|
||||
2. `cbAuthenticator` — same serverPubKey + entityPrivKey + nonce + message → same 80 bytes as Haskell
|
||||
3. `encodeTransmissionForAuth` — same sessionId + corrId + entityId + command (encoded at v19) → same `{tForAuth, tToSend}` as Haskell
|
||||
4. `authTransmission` with X25519 key — same keys + nonce + tForAuth → same authenticated bytes as Haskell
|
||||
5. `authTransmission` with Ed25519 key — same key + tForAuth → same signature bytes as Haskell
|
||||
6. `authTransmission` with no key (Nothing) — produces empty auth, matches Haskell
|
||||
7. `tEncodeBatch1` — same auth + transmission → same block bytes as Haskell
|
||||
8. `tParse` + `tDecodeClient` — TS parses Haskell-encoded response block, extracts corrId + entityId + typed response
|
||||
9. `batchTransmissions` — given N transmissions, produces same batch boundaries and block bytes as Haskell
|
||||
|
||||
### Integration tests (with SMP server, using REPL)
|
||||
|
||||
10. JS client connects, sends PING, receives PONG
|
||||
11. JS client creates queue (NEW → IDS)
|
||||
12. JS receiver creates queue + subscribes, JS sender sends message, receiver gets MSG
|
||||
13. Full handshake: create queue → secure (KEY) → subscribe → send → receive MSG → ack
|
||||
|
||||
### Polymorphic integration tests
|
||||
|
||||
14. Same test function, peer combinations:
|
||||
- HS sender, JS receiver
|
||||
- JS sender, HS receiver
|
||||
- JS sender, JS receiver
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Transport update — `connectSMP` returns `serverPubKey`
|
||||
2. Crypto additions — `sha512Hash`, `cbAuthenticator`, Ed25519 `sign`
|
||||
3. Protocol encoding updates — `encodeTransmissionForAuth`, `authTransmission`, `tEncode`, `tEncodeBatch1`, `batchTransmissions`, `encodePING`
|
||||
4. Protocol parsing updates — `transmissionP`, `tParse`, `tDecodeClient`, update `decodeResponse` (add `SOK`, `INFO`, structured `ERR`)
|
||||
5. Unit tests for steps 1-4
|
||||
6. Client core — `createSMPClient`, `sendCommand`, corrId correlation, receive dispatch, ping
|
||||
7. High-level command functions
|
||||
8. Client REPL
|
||||
9. Integration tests with server
|
||||
10. Polymorphic test wiring
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `smp-web/src/transport/websockets.ts` | Update `connectSMP` to return `serverPubKey` |
|
||||
| `smp-web/src/crypto.ts` | Add `sha512Hash`, `cbAuthenticator`, Ed25519 `sign` |
|
||||
| `smp-web/src/protocol.ts` | Update transmission encoding/parsing, add auth, batching |
|
||||
| `smp-web/src/client.ts` | New — SMP client |
|
||||
| `smp-web/tests/client-repl.ts` | New — SMP client REPL for integration tests |
|
||||
| `tests/SMPWebTests.hs` | Unit + integration tests |
|
||||
|
||||
## Scope
|
||||
|
||||
### Client spike (this plan)
|
||||
|
||||
Core client: connect, auth, send/receive, correlate, ping. High-level commands: NEW, SUB, KEY, SKEY, SEND, ACK, OFF, DEL, LGET, GET, QUE (getSMPQueueInfo). Single-command path. Tests against real server.
|
||||
|
||||
### Client MVP (next, after spike)
|
||||
|
||||
- Proxy commands (PRXY, PFWD, PRES) — essential for privacy, users must not connect directly to untrusted servers
|
||||
- Batch subscribe (subscribeSMPQueues) — needed for groups
|
||||
- `reverseNonce` — needed for proxy
|
||||
- Batch delete (deleteSMPQueues)
|
||||
|
||||
### Post-MVP
|
||||
|
||||
| What | Why |
|
||||
|---|---|
|
||||
| Notification commands (NKEY, NDEL, NSUB) | Value only with webpush support |
|
||||
| Service certificates (serviceAuth, serviceSig) | Browser doesn't use |
|
||||
| Stream commands (streamSubscribeSMPQueues) | Not used in Haskell client either |
|
||||
| NetworkConfig, SOCKS, host mode, transport selection | Browser connects via WebSocket directly |
|
||||
| Queue link management (LSET, LDEL, LKEY) | Only needed to create links, not join them |
|
||||
| `cbVerify` | Server-side only |
|
||||
|
||||
## Haskell references
|
||||
|
||||
- `Client.hs:179-200` — ProtocolClient, PClient types
|
||||
- `Client.hs:248` — `type SMPClient = ProtocolClient SMPVersion ErrorType BrokerMsg`
|
||||
- `Client.hs:506-512` — Request type
|
||||
- `Client.hs:628-642` — client connection, handshake, raceAny_ [send, process, receive, monitor]
|
||||
- `Client.hs:644-658` — send loop, receive loop
|
||||
- `Client.hs:660-678` — monitor/ping loop
|
||||
- `Client.hs:680-719` — process loop, processMsg (corrId correlation, clientResp classification)
|
||||
- `Client.hs:810-828` — createSMPQueue
|
||||
- `Client.hs:833-836` — subscribeSMPQueue
|
||||
- `Client.hs:938-939` — secureSMPQueue
|
||||
- `Client.hs:1027-1031` — sendSMPMessage
|
||||
- `Client.hs:1040-1045` — ackSMPMessage (note: ACK can return MSG)
|
||||
- `Client.hs:1239-1243` — okSMPCommand pattern
|
||||
- `Client.hs:1300-1326` — sendProtocolCommand_, sendRecv, size check, tEncodeBatch1
|
||||
- `Client.hs:1333-1344` — getResponse, timeout handling
|
||||
- `Client.hs:1349-1370` — mkTransmission_, CorrId=nonce, encodeTransmissionForAuth, authTransmission
|
||||
- `Client.hs:1372-1391` — authTransmission, authenticate (X25519 vs Ed25519), service sig
|
||||
- `Protocol.hs:488-525` — RawTransmission, TransmissionAuth, TAuthorizations, tEncodeAuth
|
||||
- `Protocol.hs:1629-1643` — transmissionP
|
||||
- `Protocol.hs:2129-2198` — batching, tEncode, tEncodeBatch1, batchTransmissions_
|
||||
- `Protocol.hs:2207-2267` — tGetClient, tParse, tDecodeClient
|
||||
- `Crypto.hs:1016` — sha512Hash
|
||||
- `Crypto.hs:1296-1298` — cbEncryptNoPad (= cryptoBox without padding)
|
||||
- `Crypto.hs:1330-1331` — cbDecryptNoPad
|
||||
- `Crypto.hs:1366-1371` — cbAuthenticate, cbVerify
|
||||
@@ -0,0 +1,238 @@
|
||||
# SMP Client MVP: Proxy + Batching — Transpilation Plan
|
||||
|
||||
**Parent**: [SMP Client Spike](./2026-05-17-smp-client.md)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function at specific lines. Same name, same steps, same call chain. No inferences, no approximations. Each entry below gives the exact source to transpile from.
|
||||
|
||||
## Crypto functions
|
||||
|
||||
### `reverseNonce` → transpile `Crypto.hs:1409-1410`
|
||||
```haskell
|
||||
reverseNonce (CryptoBoxNonce s) = CryptoBoxNonce (B.reverse s)
|
||||
```
|
||||
TS: `function reverseNonce(nonce: Uint8Array): Uint8Array` — reverse the 24 bytes.
|
||||
|
||||
### `cbDecryptNoPad` → transpile `Crypto.hs:1330-1331`
|
||||
```haskell
|
||||
cbDecryptNoPad (DhSecretX25519 secret) = sbDecryptNoPad_ secret
|
||||
```
|
||||
Which is `sbDecryptNoPad_` from secretbox. xftp-web's `cbDecrypt` does decrypt+unpad. Need decrypt without unpad — extract tag(16) + cipher, decrypt, verify tag, return raw (no unpad). Use xftp-web's `sbInit`/`sbDecryptChunk`/`sbAuth` directly.
|
||||
|
||||
## Protocol encoding functions
|
||||
|
||||
### `encodeProtocolServer` → transpile `Protocol.hs:1264-1266`
|
||||
```haskell
|
||||
smpEncode ProtocolServer {host, port, keyHash} = smpEncode (host, port, keyHash)
|
||||
```
|
||||
Where:
|
||||
- `host :: NonEmpty TransportHost` → `smpEncodeList` (1-byte count + items)
|
||||
- Each `TransportHost` → `smpEncode (strEncode host)` → `encodeBytes(ascii(hostname))` (`Transport/Client.hs:77-78`)
|
||||
- `port :: ServiceName` = ByteString → `encodeBytes(port)`
|
||||
- `keyHash :: KeyHash` = ByteString → `encodeBytes(keyHash)`
|
||||
|
||||
File: `src/protocol.ts`
|
||||
|
||||
### `encodePRXY` → transpile `Protocol.hs:1710`
|
||||
```haskell
|
||||
PRXY host auth_ -> e (PRXY_, ' ', host, auth_)
|
||||
```
|
||||
= `"PRXY " + smpEncode(server) + smpEncode(Maybe BasicAuth)`
|
||||
|
||||
Where `Maybe BasicAuth` = `encodeMaybe(encodeBytes, auth)`.
|
||||
|
||||
### `encodePFWD` → transpile `Protocol.hs:1711`
|
||||
```haskell
|
||||
PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s)
|
||||
```
|
||||
= `"PFWD " + encodeWord16(version) + encodeBytes(pubKeyDer) + encTransmission` (Tail = no length prefix)
|
||||
|
||||
### `decodePKEY` → transpile `Protocol.hs:1894`
|
||||
```haskell
|
||||
PKEY_ -> PKEY <$> _smpP <*> smpP <*> smpP
|
||||
```
|
||||
= space + `decodeBytes(d)` (sessionId) + `decodeVersionRange(d)` + `decodeCertChainPubKey(d)`
|
||||
|
||||
`VersionRange` encoding (`Version.hs`): `smpEncode (minVersion, maxVersion)` = two Word16.
|
||||
|
||||
`CertChainPubKey` encoding (`Transport.hs:663-667`): `smpEncode (encodeCertChain chain, SignedObject signedPubKey)` — `encodeCertChain` is `Large`-encoded DER bytes, `SignedObject` is `Large`-encoded DER bytes.
|
||||
|
||||
### `decodePRES` → transpile `Protocol.hs:1896`
|
||||
```haskell
|
||||
PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP)
|
||||
```
|
||||
= space + rest of bytes (Tail) → `EncResponse`
|
||||
|
||||
### Add to `decodeResponse`: `PKEY` and `PRES` cases.
|
||||
|
||||
## Client functions
|
||||
|
||||
### `sendProtocolCommands` → transpile `Client.hs:1262-1278`
|
||||
|
||||
Call chain:
|
||||
1. `mapM (mkTransmission c) cs` — for each command: generate corrId, encode, auth, register pending request
|
||||
2. `batchTransmissions' thParams` — pack into blocks
|
||||
3. `mapM (sendBatch c nm) bs` — send each block, collect responses
|
||||
4. `validate` — verify response count matches command count
|
||||
|
||||
In TS: `mkTransmission` = the existing `sendCommand` logic (corrId generation, `encodeTransmissionForAuth`, `authTransmission`) but separated into encode+register vs send+await. Need to refactor `sendCommand` to split these.
|
||||
|
||||
### `batchTransmissions'` → transpile `Protocol.hs:2135-2148`
|
||||
|
||||
Already have `batchTransmissions` in protocol.ts that does `batchTransmissions_`. Need `batchTransmissions'` which wraps with `tEncodeForBatch` before batching. Currently the TS `batchTransmissions` takes pre-encoded Large-wrapped bytes. Need to match the Haskell call chain exactly:
|
||||
|
||||
```haskell
|
||||
batchTransmissions' params ts
|
||||
| batch = batchTransmissions_ bSize $ L.map (first $ fmap $ tEncodeForBatch serviceAuth) ts
|
||||
```
|
||||
|
||||
### `sendBatch` → transpile `Client.hs:1285-1298`
|
||||
|
||||
Three cases:
|
||||
- `TBError`: return error response
|
||||
- `TBTransmissions s n rs`: send block `s`, await all `n` responses concurrently
|
||||
- `TBTransmission s r`: send block `s`, await one response
|
||||
|
||||
In browser: "concurrently" = all promises pending simultaneously, resolved by `onBlock` handler as responses arrive.
|
||||
|
||||
### `subscribeSMPQueues` → transpile `Client.hs:840-845`
|
||||
```haskell
|
||||
subscribeSMPQueues c qs = do
|
||||
liftIO $ enablePings c
|
||||
sendProtocolCommands c NRMBackground cs >>= mapM (processSUBResponse c)
|
||||
where
|
||||
cs = L.map (\(rId, rpKey) -> (rId, Just rpKey, Cmd SRecipient SUB)) qs
|
||||
```
|
||||
|
||||
### `processSUBResponse` → transpile `Client.hs:854-862`
|
||||
```haskell
|
||||
processSUBResponse c (Response rId r) = pure r $>>= processSUBResponse_ c rId
|
||||
processSUBResponse_ c rId = \case
|
||||
OK -> pure $ Right Nothing
|
||||
SOK serviceId_ -> pure $ Right serviceId_
|
||||
cmd@MSG {} -> writeSMPMessage c rId cmd $> Right Nothing
|
||||
r' -> pure . Left $ unexpectedResponse r'
|
||||
```
|
||||
MSG → push to `onMessage`, return success. Same pattern as single subscribe.
|
||||
|
||||
### `deleteSMPQueues` → transpile `Client.hs:1062-1065`
|
||||
```haskell
|
||||
deleteSMPQueues = okSMPCommands DEL
|
||||
```
|
||||
Uses `okSMPCommands` (`Client.hs:1245-1253`) which calls `sendProtocolCommands` and checks each response is OK.
|
||||
|
||||
### `connectSMPProxiedRelay` → transpile `Client.hs:1069-1093`
|
||||
|
||||
Call chain:
|
||||
1. Send `PRXY relayServ proxyAuth` to proxy (via `sendProtocolCommand_`, entityId = NoEntity)
|
||||
2. Receive `PKEY sessionId versionRange certChainPubKey`
|
||||
3. Check version compatibility
|
||||
4. `validateRelay chain key` — validate cert chain against relay's keyHash, extract X25519 key
|
||||
5. Return `ProxiedRelay {sessionId, version, auth, relayKey}`
|
||||
|
||||
`validateRelay` (`Client.hs:1085-1093`):
|
||||
1. `chainIdCaCerts chain` → extract leaf, id, ca certs
|
||||
2. Check `Fingerprint kh == getFingerprint idCert SHA256`
|
||||
3. `x509validate caCert (hostName, port) chain`
|
||||
4. Extract server key from leaf cert
|
||||
5. Verify signed key against server key
|
||||
|
||||
In browser: we already have `verifyIdentityProof` and `extractSignedKey` from xftp-web. Need to adapt for relay validation where we receive the cert chain in the PKEY response (DER-encoded, not from TLS handshake).
|
||||
|
||||
### `proxySMPCommand` → transpile `Client.hs:1157-1206`
|
||||
|
||||
Call chain:
|
||||
1. Construct `serverThParams` = `smpTHParamsSetVersion v proxyThParams {sessionId, thAuth = serverThAuth}`
|
||||
- `serverThAuth = thAuth proxyThParams with peerServerPubKey = relayKey`
|
||||
2. Generate ephemeral X25519 keypair: `(cmdPubKey, cmdPrivKey)`
|
||||
3. `cmdSecret = dh(relayKey, cmdPrivKey)`
|
||||
4. Generate random nonce (also used as corrId)
|
||||
5. `encodeTransmissionForAuth serverThParams (CorrId corrId, sId, Cmd sParty command)` — encode as if sending to relay
|
||||
6. `authTransmission serverThAuth False spKey nonce tForAuth` — authenticate with entity key against relay
|
||||
7. `batchTransmissions serverThParams [Right (auth, tToSend)]` — batch into single block
|
||||
8. `cbEncrypt cmdSecret nonce batchBlock paddedProxiedTLength` → `EncTransmission`
|
||||
9. Send `PFWD version cmdPubKey encTransmission` to proxy (entityId = sessionId)
|
||||
10. Receive `PRES (EncResponse encResponse)`
|
||||
11. `cbDecrypt cmdSecret (reverseNonce nonce) encResponse` — decrypt relay's response
|
||||
12. `tParse serverThParams decrypted` — parse as relay's response
|
||||
13. `tDecodeClient serverThParams parsed` — decode command
|
||||
14. Classify: `Right (ERR e)` → throw PCEProtocolError, `Right r` → return Right r, `Left e` → throw PCEResponseError
|
||||
|
||||
Error wrapping (`Client.hs:1200-1206`): proxy-level errors (from PFWD response itself) → `ProxyClientError` returned as `Left`. Relay-level errors (inside PRES) → `PCEProtocolError` thrown.
|
||||
|
||||
### `paddedProxiedTLength` → `Protocol.hs:306-307` = 16226
|
||||
|
||||
## Constants
|
||||
|
||||
```
|
||||
paddedProxiedTLength = 16226 -- Protocol.hs:306
|
||||
serviceCertsSMPVersion = 16 -- Transport.hs:213
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (callNode, no server)
|
||||
Each encoding function tested byte-for-byte against Haskell:
|
||||
1. `reverseNonce` — reverse known bytes, compare
|
||||
2. `encodeProtocolServer` — encode known server, compare with `smpEncode @SMPServer`
|
||||
3. `encodePRXY` — encode PRXY command, compare with `encodeProtocol v (Cmd SProxiedClient (PRXY srv auth))`
|
||||
4. `encodePFWD` — encode PFWD command, compare with `encodeProtocol v (Cmd SProxiedClient (PFWD v pk et))`
|
||||
5. `batchTransmissions` with multiple commands — same batch boundaries as `batchTransmissions_` in Haskell
|
||||
|
||||
### Integration tests (with two SMP servers, from SMPProxyTests.hs pattern)
|
||||
6. `connectProxiedRelay` — JS connects to proxy, sends PRXY for relay, gets PKEY, validates cert, extracts key
|
||||
7. `proxySMPMessage` — JS sends SEND via proxy to relay, HS receiver gets MSG
|
||||
8. Full proxy roundtrip — JS creates queue on relay via proxy, sends message via proxy, HS receives
|
||||
|
||||
### Batch tests
|
||||
9. `subscribeSMPQueues` — JS batch-subscribes to N queues, verifies all subscribed
|
||||
10. `deleteSMPQueues` — JS batch-deletes N queues
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `reverseNonce`, `cbDecryptNoPad`
|
||||
2. `encodeProtocolServer`, `encodePRXY`, `encodePFWD`
|
||||
3. `decodePKEY`, `decodePRES`, update `decodeResponse`
|
||||
4. Unit tests for steps 1-3
|
||||
5. Refactor `sendCommand` → split into `mkTransmission` (encode+register) and send
|
||||
6. `sendProtocolCommands`, `sendBatch`
|
||||
7. `subscribeSMPQueues`, `deleteSMPQueues`
|
||||
8. Batch integration tests
|
||||
9. `connectSMPProxiedRelay` (cert validation, PRXY/PKEY)
|
||||
10. `proxySMPCommand`, `proxySMPMessage`
|
||||
11. Proxy integration tests
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `smp-web/src/crypto.ts` | `reverseNonce`, `cbDecryptNoPad` |
|
||||
| `smp-web/src/protocol.ts` | `encodeProtocolServer`, `encodePRXY`, `encodePFWD`, `decodePKEY`, `decodePRES` |
|
||||
| `smp-web/src/client.ts` | `sendProtocolCommands`, `sendBatch`, `subscribeSMPQueues`, `deleteSMPQueues`, `connectSMPProxiedRelay`, `proxySMPCommand` |
|
||||
| `smp-web/tests/client-repl.ts` | Proxy + batch REPL commands |
|
||||
| `tests/SMPWebTests.hs` | Tests |
|
||||
|
||||
## Haskell source — exact lines to transpile
|
||||
|
||||
| TS function | Haskell function | File:lines |
|
||||
|---|---|---|
|
||||
| `reverseNonce` | `reverseNonce` | `Crypto.hs:1409-1410` |
|
||||
| `cbDecryptNoPad` | `cbDecryptNoPad` / `sbDecryptNoPad_` | `Crypto.hs:1330-1331` |
|
||||
| `encodeProtocolServer` | `instance Encoding (ProtocolServer p)` | `Protocol.hs:1264-1266` |
|
||||
| `encodePRXY` | `encodeProtocol v (PRXY ...)` | `Protocol.hs:1710` |
|
||||
| `encodePFWD` | `encodeProtocol v (PFWD ...)` | `Protocol.hs:1711` |
|
||||
| `decodePKEY` | `protocolP v PKEY_` | `Protocol.hs:1894` |
|
||||
| `decodePRES` | `protocolP v PRES_` | `Protocol.hs:1896` |
|
||||
| `sendProtocolCommands` | `sendProtocolCommands` | `Client.hs:1262-1278` |
|
||||
| `sendBatch` | `sendBatch` | `Client.hs:1285-1298` |
|
||||
| `subscribeSMPQueues` | `subscribeSMPQueues` | `Client.hs:840-845` |
|
||||
| `processSUBResponse` | `processSUBResponse` + `processSUBResponse_` | `Client.hs:854-862` |
|
||||
| `deleteSMPQueues` | `deleteSMPQueues` via `okSMPCommands` | `Client.hs:1062-1065, 1245-1253` |
|
||||
| `connectSMPProxiedRelay` | `connectSMPProxiedRelay` | `Client.hs:1069-1093` |
|
||||
| `validateRelay` | `validateRelay` (inside `connectSMPProxiedRelay`) | `Client.hs:1085-1093` |
|
||||
| `proxySMPCommand` | `proxySMPCommand` | `Client.hs:1157-1206` |
|
||||
| `proxyOKSMPCommand` | `proxyOKSMPCommand` | `Client.hs:1150-1155` |
|
||||
| `smpTHParamsSetVersion` | `smpTHParamsSetVersion` | `Transport.hs:921-926` |
|
||||
| `batchTransmissions'` | `batchTransmissions'` | `Protocol.hs:2135-2148` |
|
||||
| `batchTransmissions_` | `batchTransmissions_` | `Protocol.hs:2150-2169` |
|
||||
@@ -0,0 +1,195 @@
|
||||
# Agent for Browser: Transpilation Breakdown
|
||||
|
||||
**Parent**: [SMP Client MVP](./2026-05-20-client-mvp.md)
|
||||
**Depends on**: SMP Client (complete, 96 tests), encoding/encryption spike (complete)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function. Same name, same steps, same call chain. No inferences.
|
||||
|
||||
## Scope
|
||||
|
||||
The web widget JOINS connections (never creates addresses). It sends and receives messages. It handles the connection handshake. It does NOT create invitations, manage notifications, transfer files, or do remote control.
|
||||
|
||||
## Architecture difference from Haskell
|
||||
|
||||
Haskell agent uses SQLite + multiple background threads (subscriber, delivery workers, cleanup manager, NTF supervisor). Browser agent uses IndexedDB + event-driven architecture (WebSocket onmessage, Promises, no threads).
|
||||
|
||||
The protocol logic is identical. The concurrency model differs. The store interface differs. The transpilation focuses on the protocol logic.
|
||||
|
||||
## Breakdown into testable pieces
|
||||
|
||||
### Piece 1: Agent protocol types (Agent/Protocol.hs)
|
||||
|
||||
Already partially done (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage). What's missing for the handshake:
|
||||
|
||||
| Type | Haskell location | What's needed |
|
||||
|------|-----------------|---------------|
|
||||
| `SMPQueueInfo` | `Agent/Protocol.hs:1310-1327` | Binary encode/decode — version-dependent, complex |
|
||||
| `SMPQueueUri` | `Agent/Protocol.hs:1344-1431` | Binary encode/decode + string encode/decode |
|
||||
| `SMPQueueAddress` | `Agent/Protocol.hs:1350-1356` | `{smpServer, senderId, dhPublicKey, queueMode}` |
|
||||
| `ConnectionRequestUri` | `Agent/Protocol.hs:1436-1441` | Binary encode/decode: `CRInvitationUri` + `CRContactUri` |
|
||||
| `ConnReqUriData` | `Agent/Protocol.hs:1728-1734` | Binary encode/decode: `{crAgentVRange, crSmpQueues, crClientData}` |
|
||||
| `SMPConfirmation` | `Agent/Protocol.hs:798-810` | Not wire-encoded — internal data structure for confirmation handling |
|
||||
| `E2ERatchetParams` | `Crypto/Ratchet.hs:223-241` | Already have encode/decode in ratchet.ts — need to verify completeness |
|
||||
|
||||
**Test**: each encode/decode function tested byte-for-byte against Haskell via callNode.
|
||||
|
||||
### Piece 2: Connection handshake — joinConnection (Agent.hs)
|
||||
|
||||
The join flow, transpiled step by step:
|
||||
|
||||
```
|
||||
joinConnection (Agent.hs:~1200-1300)
|
||||
1. Parse ConnectionRequestUri (already have URI parsing)
|
||||
2. Create RcvQueue on a selected server (newRcvQueue — Agent/Client.hs:1373)
|
||||
- Generate X25519 DH keypair for queue
|
||||
- Generate X25519/Ed25519 auth keypair
|
||||
- Call createSMPQueue on SMP client
|
||||
- Get back rcvId, sndId, srvDhKey
|
||||
3. Store connection + queue in database
|
||||
4. Generate X448 E2E ratchet params (generateRcvE2EParams — already have)
|
||||
5. Build ConnInfo (profile data)
|
||||
6. Encrypt ConnInfo with ratchet → encConnInfo
|
||||
7. Build AgentConfirmation envelope
|
||||
8. Wrap in ClientMessage + per-queue E2E encrypt → ClientMsgEnvelope
|
||||
9. Send via SMP SEND to the contact address queue
|
||||
10. Subscribe to own receive queue (SUB)
|
||||
11. Return connection ID
|
||||
```
|
||||
|
||||
Each step is independently testable. The full flow is an integration test.
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `joinConnection` | `Agent.hs:~1200` | Top-level join |
|
||||
| `joinConn` | `Agent.hs:~1230` | Internal join logic |
|
||||
| `newRcvQueue` | `Agent/Client.hs:1373-1420` | Create queue on server |
|
||||
| `sendConfirmation` | `Agent/Client.hs:1788-1794` | Encrypt+send confirmation |
|
||||
| `sendInvitation` | `Agent/Client.hs:1796-1806` | Encrypt+send invitation |
|
||||
| `mkAgentConfirmation` | `Agent.hs:~3700` | Build confirmation envelope |
|
||||
| `agentCbEncrypt` | `Agent/Client.hs:2074-2082` | Per-queue E2E encrypt (already have) |
|
||||
|
||||
### Piece 3: Message processing — subscriber (Agent.hs)
|
||||
|
||||
Incoming message handling:
|
||||
|
||||
```
|
||||
subscriber (Agent.hs:2912-2919)
|
||||
→ reads from msgQ (populated by SMP client's onMessage callback)
|
||||
→ processSMPTransmissions (Agent.hs:2997-3297)
|
||||
→ for each transmission:
|
||||
→ STEvent (server push MSG):
|
||||
→ decryptClientMessage (per-queue E2E decrypt)
|
||||
→ parse AgentMsgEnvelope
|
||||
→ for AgentMsgEnvelope 'M':
|
||||
→ agentRatchetDecrypt (double ratchet decrypt)
|
||||
→ parse AgentMessage
|
||||
→ dispatch on AMessage type:
|
||||
→ HELLO: complete handshake
|
||||
→ A_MSG body: deliver to user
|
||||
→ A_RCVD: delivery receipt
|
||||
→ QADD/QKEY/QUSE/QTEST: queue switching (skip for MVP)
|
||||
→ EREADY: ratchet sync (skip for MVP)
|
||||
```
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `processSMPTransmissions` | `Agent.hs:2997-3297` | Top-level message dispatcher |
|
||||
| `decryptClientMessage` | `Agent.hs:3282-3293` | Per-queue E2E decrypt + parse envelope |
|
||||
| `agentRatchetDecrypt` | `Agent.hs:3757-3767` | Ratchet decrypt + store update |
|
||||
| `helloMsg` | `Agent.hs:~3400` | Process HELLO (complete handshake) |
|
||||
| `smpConfirmation` | `Agent.hs:~3500` | Process received confirmation |
|
||||
| `smpInvitation` | `Agent.hs:~3600` | Process received invitation |
|
||||
|
||||
### Piece 4: Message sending — sendMessage (Agent.hs)
|
||||
|
||||
```
|
||||
sendMessage (Agent.hs:~1500)
|
||||
→ enqueueMessageB
|
||||
→ agentRatchetEncryptHeader (get ratchet encrypt key)
|
||||
→ rcEncryptMsg (encrypt message body)
|
||||
→ store encrypted message in DB
|
||||
→ createSndMsgDelivery (link to queue)
|
||||
→ delivery worker sends via SMP SEND
|
||||
```
|
||||
|
||||
**Key Haskell functions to transpile:**
|
||||
|
||||
| Function | File:lines | What it does |
|
||||
|----------|-----------|--------------|
|
||||
| `sendMessage` | `Agent.hs:~1500` | Top-level send |
|
||||
| `enqueueMessageB` | `Agent.hs:~2020-2060` | Encode + encrypt + store |
|
||||
| `agentRatchetEncrypt` | `Agent.hs:3742-3746` | Ratchet encrypt |
|
||||
| `agentRatchetEncryptHeader` | `Agent.hs:3748-3754` | Ratchet encrypt header |
|
||||
| `encodeAgentMsgStr` | `Agent.hs:2050-2054` | Encode AgentMessage to bytes |
|
||||
| `runSmpQueueMsgDelivery` | `Agent.hs:2092-2220` | Delivery worker — read from DB, send via SMP |
|
||||
|
||||
### Piece 5: Message acknowledgment (Agent.hs)
|
||||
|
||||
```
|
||||
ackMessage (Agent.hs:~1550)
|
||||
→ withStore: mark message as acknowledged
|
||||
→ send ACK to SMP server
|
||||
→ optionally send delivery receipt (A_RCVD)
|
||||
```
|
||||
|
||||
### Piece 6: Store interface (IndexedDB)
|
||||
|
||||
The agent uses ~50 distinct store operations. For the web widget MVP, we need:
|
||||
|
||||
| Store operation | What it does | Used by |
|
||||
|----------------|--------------|---------|
|
||||
| `createConnection` | Create connection record | joinConnection |
|
||||
| `getConn` | Get connection by ID | all operations |
|
||||
| `updateRcvIds` | Increment receive IDs | message processing |
|
||||
| `createRcvMsg` | Store received message | message processing |
|
||||
| `getRatchetForUpdate` | Get ratchet state for modify | encrypt/decrypt |
|
||||
| `updateRatchet` | Store updated ratchet | encrypt/decrypt |
|
||||
| `getSkippedMsgKeys` | Get skipped message keys | ratchet decrypt |
|
||||
| `createSndMsg` | Store sent message | sendMessage |
|
||||
| `createSndMsgDelivery` | Link message to queue | sendMessage |
|
||||
| `getPendingQueueMsg` | Get next message to send | delivery worker |
|
||||
| `updateSndMsgStatus` | Update delivery status | delivery worker |
|
||||
| `deleteMsg` | Delete after ACK | ackMessage |
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Piece 1**: Agent protocol types — `SMPQueueInfo`, `SMPQueueUri`, `ConnectionRequestUri`, `ConnReqUriData` encode/decode. Test each against Haskell.
|
||||
2. **Piece 6**: Store interface — define TypeScript interface matching the needed operations. Implement with in-memory Map first (for testing), IndexedDB later.
|
||||
3. **Piece 4**: Message sending — `sendMessage` + `agentRatchetEncrypt` + delivery. Test: encrypt in TS, decrypt in HS.
|
||||
4. **Piece 3**: Message processing — `processSMPTransmissions` + `agentRatchetDecrypt`. Test: encrypt in HS, decrypt in TS.
|
||||
5. **Piece 2**: Connection handshake — `joinConnection`. Test: TS joins, HS accepts, messages flow.
|
||||
6. **Piece 5**: Message acknowledgment — `ackMessage`. Test: full roundtrip with ack.
|
||||
|
||||
Each piece is independently testable. Piece 1 uses callNode (no server). Pieces 3-5 use the SMP client REPL + real server. Piece 2 is the integration test that ties everything together.
|
||||
|
||||
## What to skip
|
||||
|
||||
| Feature | Why skip | Haskell functions |
|
||||
|---------|----------|-------------------|
|
||||
| Queue switching | Additive, not needed for MVP | `switchConnection`, QADD/QKEY/QUSE/QTEST handling |
|
||||
| Ratchet sync | MVP shows error, suggests reconnecting | `synchronizeRatchet`, EREADY handling |
|
||||
| Notifications | No webpush yet | All NTF functions |
|
||||
| File transfer | Separate protocol | All XFTP functions |
|
||||
| Remote control | Not in scope | All RC functions |
|
||||
| Client notices | Server admin feature | `processClientNotices` |
|
||||
| Cleanup manager | Can do manual cleanup | `cleanupManager` |
|
||||
| Server management | Configured at init | `setProtocolServers`, `testProtocolServer` |
|
||||
| Connection creation | Widget only joins | `createConnection`, short links |
|
||||
| Delivery receipts | Can add later | A_RCVD handling, receipt sending |
|
||||
| Multiple receive queues | Single queue per connection for MVP | Queue replacement logic |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `smp-web/src/agent/protocol.ts` | Extend with SMPQueueInfo, ConnectionRequestUri, etc. |
|
||||
| `smp-web/src/agent/store.ts` | New — store interface + in-memory implementation |
|
||||
| `smp-web/src/agent/agent.ts` | New — agent logic: join, send, receive, ack |
|
||||
| `smp-web/tests/agent-repl.ts` | New — agent REPL for testing (or extend client-repl) |
|
||||
| `tests/SMPWebTests.hs` | Agent tests |
|
||||
@@ -0,0 +1,260 @@
|
||||
# Agent API Inventory for Web Widget
|
||||
|
||||
Every exported function from `Agent.hs`, classified as MVP / post-MVP / skip, with reasoning.
|
||||
|
||||
## Context
|
||||
|
||||
The web widget:
|
||||
- Joins existing connections via addresses hardcoded in the widget or sent as simplex links
|
||||
- Sends and receives messages
|
||||
- Does NOT create addresses, invitation links, or short links
|
||||
- Does NOT transfer files (post-MVP)
|
||||
- Does NOT manage notifications (post-MVP)
|
||||
- Does NOT rotate queues
|
||||
- Must accept ratchet re-sync initiated by the other side (but does not initiate)
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `getSMPAgentClient` | YES | Initialize agent — required |
|
||||
| `getSMPAgentClient_` | NO | Variant with extra params, not needed |
|
||||
| `disconnectAgentClient` | YES | Clean shutdown — required |
|
||||
| `disposeAgentClient` | NO | Hard shutdown — disconnectAgentClient is sufficient |
|
||||
| `resumeAgentClient` | NO | Widget doesn't suspend/resume — runs while page is open |
|
||||
| `foregroundAgent` | NO | Mobile-only concept |
|
||||
| `suspendAgent` | NO | Mobile-only concept |
|
||||
|
||||
## User management
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `createUser` | YES | Widget needs at least one user to own connections |
|
||||
| `deleteUser` | NO | Widget doesn't delete users — page reload is cleanup |
|
||||
|
||||
## Connection creation (widget never creates)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `createConnection` | NO | Widget joins, never creates |
|
||||
| `createConnectionAsync` | NO | Same |
|
||||
| `prepareConnectionLink` | NO | For creating links |
|
||||
| `createConnectionForLink` | NO | For creating links |
|
||||
| `setConnShortLink` | NO | For creating short links |
|
||||
| `setConnShortLinkAsync` | NO | Same |
|
||||
| `deleteConnShortLink` | NO | For managing short links |
|
||||
| `getConnShortLink` | NO | For reading short links — widget uses hardcoded address |
|
||||
| `getConnShortLinkAsync` | NO | Same |
|
||||
| `getConnLinkPrivKey` | NO | For link management |
|
||||
| `deleteLocalInvShortLink` | NO | For link cleanup |
|
||||
| `changeConnectionUser` | NO | Widget has one user |
|
||||
|
||||
## Joining connections
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `prepareConnectionToJoin` | YES | Create connection record before join — prevents race with incoming confirmation |
|
||||
| `joinConnection` | YES | Core function — join via address URI |
|
||||
| `joinConnectionAsync` | NO | Async variant — widget can use sync joinConnection with await |
|
||||
| `connRequestPQSupport` | YES | Determine PQ support from connection request — needed for join |
|
||||
|
||||
## Handshake (accepting incoming)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `allowConnection` | YES | Allow connection after receiving CONF — part of handshake |
|
||||
| `allowConnectionAsync` | NO | Async variant |
|
||||
| `acceptContact` | YES | Accept contact request (for group join flow) |
|
||||
| `acceptContactAsync` | NO | Async variant |
|
||||
| `prepareConnectionToAccept` | YES | Prepare for accept — same race prevention as prepareConnectionToJoin |
|
||||
| `rejectContact` | NO | Widget doesn't reject — it always accepts |
|
||||
|
||||
## Subscription
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `subscribeConnection` | YES | Subscribe to receive messages on one connection |
|
||||
| `subscribeConnections` | YES | Batch subscribe — needed after page reload |
|
||||
| `subscribeAllConnections` | YES | Subscribe everything for a user — simplest for widget |
|
||||
| `resubscribeConnection` | YES | Resubscribe after network recovery |
|
||||
| `resubscribeConnections` | YES | Batch resubscribe |
|
||||
| `getConnectionMessages` | NO | Fetch stored messages — widget processes messages as they arrive |
|
||||
| `getNotificationConns` | NO | Push notification management |
|
||||
| `subscribeClientService` | NO | Service certificate management |
|
||||
|
||||
## Messaging
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `sendMessage` | YES | Send a message — core function |
|
||||
| `sendMessages` | NO | Batch send — sendMessage is sufficient for MVP |
|
||||
| `sendMessagesB` | NO | Batch send with error handling — optimization |
|
||||
| `ackMessage` | YES | Acknowledge received message — required for protocol correctness |
|
||||
| `ackMessageAsync` | NO | Async variant |
|
||||
|
||||
## Queue management
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `switchConnection` | NO | Queue rotation — not needed |
|
||||
| `switchConnectionAsync` | NO | Same |
|
||||
| `abortConnectionSwitch` | NO | Cancel rotation |
|
||||
| `getConnectionQueueInfo` | NO | Debug info |
|
||||
| `suspendConnection` | NO | Widget deletes or ignores, doesn't suspend |
|
||||
|
||||
## Ratchet
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `synchronizeRatchet` | NO | Widget doesn't initiate ratchet sync. But MUST handle incoming EREADY — that's in processSMPTransmissions, not a separate API call. |
|
||||
| `getConnectionRatchetAdHash` | NO | Verification UI not in widget |
|
||||
|
||||
## Connection cleanup
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `deleteConnection` | YES | User may want to delete a conversation |
|
||||
| `deleteConnectionAsync` | NO | Async variant |
|
||||
| `deleteConnections` | NO | Batch delete — single delete sufficient |
|
||||
| `deleteConnectionsAsync` | NO | Same |
|
||||
| `getConnectionServers` | NO | Info only |
|
||||
| `compareConnections` | NO | Database sync tool |
|
||||
| `syncConnections` | NO | Database sync tool |
|
||||
|
||||
## Server configuration
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `setProtocolServers` | NO | Widget initialized with servers, doesn't change them |
|
||||
| `checkUserServers` | NO | Admin function |
|
||||
| `testProtocolServer` | NO | Admin function |
|
||||
| `setNtfServers` | NO | No notifications in MVP |
|
||||
| `setNetworkConfig` | NO | Widget uses default network config |
|
||||
| `setUserNetworkInfo` | NO | Widget doesn't track network state changes |
|
||||
| `reconnectAllServers` | NO | Widget handles reconnection via SMP client |
|
||||
| `reconnectSMPServer` | NO | Same |
|
||||
|
||||
## Notifications (all post-MVP)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `registerNtfToken` | NO | No webpush yet |
|
||||
| `verifyNtfToken` | NO | Same |
|
||||
| `checkNtfToken` | NO | Same |
|
||||
| `deleteNtfToken` | NO | Same |
|
||||
| `getNtfToken` | NO | Same |
|
||||
| `getNtfTokenData` | NO | Same |
|
||||
| `toggleConnectionNtfs` | NO | Same |
|
||||
|
||||
## File transfer (all post-MVP)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `xftpStartWorkers` | NO | Post-MVP |
|
||||
| `xftpStartSndWorkers` | NO | Same |
|
||||
| `xftpReceiveFile` | NO | Same |
|
||||
| `xftpDeleteRcvFile` | NO | Same |
|
||||
| `xftpDeleteRcvFiles` | NO | Same |
|
||||
| `xftpSendFile` | NO | Same |
|
||||
| `xftpSendDescription` | NO | Same |
|
||||
| `xftpDeleteSndFileInternal` | NO | Same |
|
||||
| `xftpDeleteSndFilesInternal` | NO | Same |
|
||||
| `xftpDeleteSndFileRemote` | NO | Same |
|
||||
| `xftpDeleteSndFilesRemote` | NO | Same |
|
||||
|
||||
## Remote control (all skip)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `rcNewHostPairing` | NO | Not in scope |
|
||||
| `rcConnectHost` | NO | Same |
|
||||
| `rcConnectCtrl` | NO | Same |
|
||||
| `rcDiscoverCtrl` | NO | Same |
|
||||
|
||||
## Debug/stats (all skip)
|
||||
|
||||
| Function | MVP? | Reason |
|
||||
|----------|------|--------|
|
||||
| `getAgentSubsTotal` | NO | Debug |
|
||||
| `getAgentServersSummary` | NO | Debug |
|
||||
| `resetAgentServersStats` | NO | Debug |
|
||||
| `execAgentStoreSQL` | NO | Debug |
|
||||
| `getAgentMigrations` | NO | Debug |
|
||||
| `debugAgentLocks` | NO | Debug |
|
||||
| `getAgentSubscriptions` | NO | Debug |
|
||||
| `logConnection` | NO | Debug |
|
||||
| `withAgentEnv` | NO | Test utility |
|
||||
|
||||
## Summary
|
||||
|
||||
### Corrections after reviewing simplex-chat-2 Subscriber.hs + Commands.hs
|
||||
|
||||
The widget handles business chats (groups). Group flows trigger agent calls the widget doesn't initiate but must support:
|
||||
|
||||
- Member introductions create connections asynchronously → `createConnectionAsync`
|
||||
- Members join via introductions → `joinConnectionAsync`, `prepareConnectionToJoin`
|
||||
- Members leave/deleted → `deleteConnectionAsync`, `deleteConnectionsAsync`
|
||||
- Group messages go to all members → `sendMessages`
|
||||
- All message acks use async variant → `ackMessageAsync`
|
||||
- Accepting group invitations → `allowConnectionAsync`
|
||||
- `acceptContact` — used in contact request acceptance flow (APIAcceptContactRequest in Commands.hs)
|
||||
- `toggleConnectionNtfs` — used when CON received for group member (Subscriber.hs:850)
|
||||
|
||||
### MVP functions (27 of 90):
|
||||
|
||||
**Lifecycle:** `getSMPAgentClient`, `disconnectAgentClient`
|
||||
|
||||
**User:** `createUser`
|
||||
|
||||
**Join:** `prepareConnectionToJoin`, `joinConnection`, `joinConnectionAsync`, `connRequestPQSupport`
|
||||
|
||||
**Handshake:** `allowConnection`, `allowConnectionAsync`, `acceptContact`, `acceptContactAsync`, `prepareConnectionToAccept`
|
||||
|
||||
**Connection creation (for group member flows):** `createConnectionAsync`, `deleteConnectionAsync`, `deleteConnectionsAsync`
|
||||
|
||||
**Subscribe:** `subscribeConnection`, `subscribeConnections`, `subscribeAllConnections`, `resubscribeConnection`, `resubscribeConnections`
|
||||
|
||||
**Message:** `sendMessage`, `sendMessages`, `ackMessage`, `ackMessageAsync`
|
||||
|
||||
**Cleanup:** `deleteConnection`
|
||||
|
||||
**Notification toggle:** `toggleConnectionNtfs`
|
||||
|
||||
**Internal (not exported but required):** `subscriber`/`processSMPTransmissions` (message processing), `runSmpQueueMsgDelivery` (delivery worker), all encryption/decryption functions, store operations for the above.
|
||||
|
||||
### Message types to handle in processSMPTransmissions:
|
||||
|
||||
| AMessage | Handle? | Reason |
|
||||
|----------|---------|--------|
|
||||
| `HELLO` | YES | Complete handshake |
|
||||
| `A_MSG body` | YES | Deliver message to user |
|
||||
| `A_RCVD receipts` | YES | Process delivery receipts (show checkmarks) |
|
||||
| `A_QCONT addr` | NO | Queue continuation after quota — skip |
|
||||
| `QADD qs` | NO | Queue rotation — skip |
|
||||
| `QKEY qs` | NO | Queue rotation — skip |
|
||||
| `QUSE qs` | NO | Queue rotation — skip |
|
||||
| `QTEST qs` | NO | Queue rotation — skip |
|
||||
| `EREADY msgId` | ACCEPT | Must handle incoming (don't initiate) — reset ratchet sync state |
|
||||
|
||||
### AgentMsgEnvelope types to handle:
|
||||
|
||||
| Variant | Handle? | Reason |
|
||||
|---------|---------|--------|
|
||||
| `AgentConfirmation` | YES | Handshake — received when peer confirms |
|
||||
| `AgentMsgEnvelope` | YES | Normal encrypted messages |
|
||||
| `AgentInvitation` | YES | Received when joining contact address |
|
||||
| `AgentRatchetKey` | ACCEPT | Must handle incoming ratchet key — don't initiate |
|
||||
|
||||
### Store operations needed:
|
||||
|
||||
Based on the 18 MVP functions, the store needs (rough count):
|
||||
|
||||
- Connection CRUD: ~8 operations
|
||||
- Queue CRUD: ~6 operations
|
||||
- Ratchet state: ~4 operations (get, update, skipped keys)
|
||||
- Message storage: ~6 operations (create rcv/snd msg, update status, delete)
|
||||
- Message delivery: ~4 operations (create delivery, get pending, update status)
|
||||
- User: ~2 operations (create, get)
|
||||
- Confirmation: ~3 operations (create, get, delete)
|
||||
|
||||
**Estimated: ~33 store operations.** This is what determines whether SQLite WASM or IndexedDB direct is more practical.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Agent Store: IndexedDB Design
|
||||
|
||||
**Parent**: [Agent Plan](./2026-05-22-agent.md)
|
||||
|
||||
## Schema
|
||||
|
||||
IndexedDB object stores, mapped from SQLite tables. Each store mirrors the Haskell schema from `agent_schema.sql`.
|
||||
|
||||
### Object stores needed (16)
|
||||
|
||||
```
|
||||
users
|
||||
key: userId (autoincrement)
|
||||
fields: deleted
|
||||
|
||||
connections
|
||||
key: connId (Uint8Array)
|
||||
fields: connMode, lastInternalMsgId, lastInternalRcvMsgId, lastInternalSndMsgId,
|
||||
lastExternalSndMsgId, lastRcvMsgHash, lastSndMsgHash, smpAgentVersion,
|
||||
duplexHandshake, enableNtfs, deleted, userId, ratchetSyncState, pqSupport
|
||||
|
||||
rcv_queues
|
||||
key: [host, port, rcvId] (compound)
|
||||
index: [connId], [host, port, sndId]
|
||||
fields: connId, rcvPrivateKey, rcvDhSecret, e2ePrivKey, e2eDhSecret, sndId, sndKey,
|
||||
status, smpClientVersion, rcvQueueId, rcvPrimary, replaceRcvQueueId, queueMode,
|
||||
serverKeyHash, lastBrokerTs
|
||||
|
||||
snd_queues
|
||||
key: [host, port, sndId] (compound)
|
||||
index: [connId]
|
||||
fields: connId, sndPrivateKey, e2eDhSecret, status, smpClientVersion,
|
||||
sndPublicKey, e2ePubKey, sndQueueId, sndPrimary, queueMode, serverKeyHash
|
||||
|
||||
messages
|
||||
key: [connId, internalId] (compound)
|
||||
index: [connId]
|
||||
fields: internalTs, internalRcvId, internalSndId, msgType, msgBody, msgFlags, pqEncryption
|
||||
|
||||
rcv_messages
|
||||
key: [connId, internalRcvId] (compound)
|
||||
index: [connId, internalId]
|
||||
fields: internalId, externalSndId, brokerId, brokerTs, internalHash,
|
||||
externalPrevSndHash, integrity, userAck, rcvQueueId, receiveAttempts
|
||||
|
||||
snd_messages
|
||||
key: [connId, internalSndId] (compound)
|
||||
index: [connId, internalId]
|
||||
fields: internalId, internalHash, previousMsgHash, retryIntSlow, retryIntFast,
|
||||
rcptInternalId, rcptStatus, msgEncryptKey, paddedMsgLen, sndMessageBodyId
|
||||
|
||||
snd_message_deliveries
|
||||
key: sndMessageDeliveryId (autoincrement)
|
||||
index: [connId, sndQueueId]
|
||||
fields: connId, sndQueueId, internalId, failed
|
||||
|
||||
snd_message_bodies
|
||||
key: sndMessageBodyId (autoincrement)
|
||||
fields: agentMsg
|
||||
|
||||
conn_confirmations
|
||||
key: confirmationId (Uint8Array)
|
||||
index: [connId]
|
||||
fields: connId, e2eSndPubKey, senderKey, ratchetState, senderConnInfo,
|
||||
accepted, ownConnInfo, smpReplyQueues, smpClientVersion
|
||||
|
||||
conn_invitations
|
||||
key: invitationId (Uint8Array)
|
||||
index: [contactConnId]
|
||||
fields: contactConnId, crInvitation, recipientConnInfo, accepted, ownConnInfo
|
||||
|
||||
ratchets
|
||||
key: connId (Uint8Array)
|
||||
fields: x3dhPrivKey1, x3dhPrivKey2, ratchetState, e2eVersion,
|
||||
x3dhPubKey1, x3dhPubKey2, pqPrivKem, pqPubKem
|
||||
|
||||
skipped_messages
|
||||
key: skippedMessageId (autoincrement)
|
||||
index: [connId]
|
||||
fields: connId, headerKey, msgN, msgKey
|
||||
|
||||
servers
|
||||
key: [host, port] (compound)
|
||||
fields: keyHash
|
||||
|
||||
commands
|
||||
key: commandId (autoincrement)
|
||||
index: [connId], [host, port]
|
||||
fields: connId, host, port, corrId, commandTag, command, agentVersion, serverKeyHash, failed
|
||||
|
||||
encrypted_rcv_message_hashes
|
||||
key: id (autoincrement)
|
||||
index: [connId, hash]
|
||||
fields: connId, hash, createdAt
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
TypeScript interface matching the ~60 store operations. Each method maps to a specific Haskell function in `AgentStore.hs`.
|
||||
|
||||
The interface will be defined in `src/agent/store.ts`. Implementation in `src/agent/store-idb.ts` (IndexedDB).
|
||||
|
||||
## Implementation approach
|
||||
|
||||
1. Define the TypeScript interface first — every method name matches the Haskell function name
|
||||
2. Implement with IndexedDB transactions
|
||||
3. Test each operation in isolation before wiring to agent
|
||||
|
||||
IndexedDB transactions map to SQLite transactions — both are ACID within a single store/table. Cross-store atomicity in IndexedDB requires putting multiple stores in one transaction, which is supported.
|
||||
|
||||
## Key differences from SQLite
|
||||
|
||||
1. **No SQL joins** — denormalize where needed, or do application-level joins
|
||||
2. **No AUTO INCREMENT guaranteed ordering** — use explicit counters
|
||||
3. **Blob keys** — IndexedDB supports ArrayBuffer keys natively
|
||||
4. **Compound keys** — IndexedDB supports array keys: `[host, port, rcvId]`
|
||||
5. **Indexes** — must be declared upfront in `onupgradeneeded`
|
||||
|
||||
## Testing
|
||||
|
||||
Each store operation tested by: write data, read it back, verify it matches. No server needed — pure store tests using `fake-indexeddb` in Node.js.
|
||||
@@ -0,0 +1,666 @@
|
||||
# Agent Client Middle Layer: Transpilation Plan
|
||||
|
||||
**Parent**: [Agent Plan](./2026-05-22-agent.md)
|
||||
**Depends on**: Store (complete, 98 tests), SMP Client (complete, 99 tests), Ratchet (complete), Agent Protocol Types (complete)
|
||||
|
||||
## Rule
|
||||
|
||||
Every TypeScript function is a faithful transpilation of a specific Haskell function. Same name, same steps, same call chain. No inferences, no simplifications, no "browser-friendly" shortcuts. The concurrency primitives differ (Promises vs STM, event callbacks vs TBQueue), but the logic, state transitions, and decision paths must be identical.
|
||||
|
||||
## Architecture mapping
|
||||
|
||||
| Haskell | TypeScript | Notes |
|
||||
|---------|-----------|-------|
|
||||
| `TVar a` | mutable variable (object property) | Single-threaded, no atomicity needed |
|
||||
| `TMap k v` | `Map<K, V>` | No STM, direct mutation |
|
||||
| `TBQueue a` | `ABQueue<T>` | `subQ` for user events, `msgQ` for server messages |
|
||||
| `TMVar a` | `Promise` + resolver, or flag | For worker doWork signaling |
|
||||
| `STM` transaction | synchronous code block | Single-threaded JS, no races |
|
||||
| `forkIO` / `async` | `setTimeout(0)` / microtask | Event loop scheduling |
|
||||
| `Worker` thread | delivery loop function | Triggered by `submitPendingMsg`, runs via microtask |
|
||||
| `ReaderT Env IO` (AM') | closure over agent state | Config + store + DRG captured in closure |
|
||||
| `ExceptT AgentErrorType` (AM) | thrown errors / Result type | TBD: throw vs return Either |
|
||||
|
||||
## Files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `smp-web/src/agent/queue.ts` | Sem, ABQueue (copied from simplex-chat) |
|
||||
| `smp-web/src/agent/tmvar.ts` | TMVar — single-cell blocking variable |
|
||||
| `smp-web/src/agent/session.ts` | SessionVar, getSessVar (Promise-based) |
|
||||
| `smp-web/src/agent/retry.ts` | RetryInterval, RetryInterval2, withRetryLock2 |
|
||||
| `smp-web/src/agent/subscriptions.ts` | TSessionSubs transpilation |
|
||||
| `smp-web/src/agent/client.ts` | AgentClient state, session management, worker infrastructure, queue operations |
|
||||
| `smp-web/src/agent/agent.ts` | Top-level agent API (joinConnection, sendMessage, etc.) |
|
||||
| `smp-web/tests/agent-repl.ts` | Agent-level REPL for cross-language testing |
|
||||
|
||||
## Implementation order
|
||||
|
||||
Each step produces a testable artifact.
|
||||
|
||||
### Step 1: Pure infrastructure (TS-only tests)
|
||||
- `queue.ts` — Sem, ABQueue (copied verbatim from simplex-chat)
|
||||
- `tmvar.ts` — TMVar (new)
|
||||
- `session.ts` — SessionVar, getSessVar, removeSessVar, tryReadSessVar
|
||||
- `retry.ts` — RetryInterval types + nextRetryDelay + withRetryInterval + withRetryLock2
|
||||
- `subscriptions.ts` — TSessionSubs (all 22 functions)
|
||||
- **Test**: TS unit tests for each module — no server needed
|
||||
|
||||
### Step 2: AgentClient state + worker infrastructure (TS-only tests)
|
||||
- AgentClient record, newAgentClient
|
||||
- Worker, newWorker, getAgentWorker, runWorkerAsync, waitForWork, hasWorkToDo, withWork
|
||||
- AgentOpState, operation bracket, suspend/resume
|
||||
- Locking (withConnLock, withInvLock)
|
||||
- Server selection (userServers, pickServer, getNextServer, withNextSrv)
|
||||
- Store wrappers (withStore, withStore', storeError)
|
||||
- **Test**: TS tests — create agent client, test worker lifecycle, test server selection
|
||||
|
||||
### Step 3a: SMP session management + direct queue operations
|
||||
- getSMPServerClient, smpConnectClient, waitForSMPClient
|
||||
- agentCbEncrypt, agentCbEncryptOnce, agentCbDecrypt (using existing ClientMsgEnvelope from protocol.ts)
|
||||
- sendAgentMessage, sendConfirmation, sendInvitation
|
||||
- secureQueue, secureSndQueue, sendAck
|
||||
- decryptSMPMessage
|
||||
- newRcvQueue
|
||||
- subscribeQueues, subscribeServerQueues
|
||||
- addNewQueueSubscription
|
||||
- **Test**: TS-only round-trip test for agentCbEncrypt (encode → decode → decrypt), then agent-repl cross-language tests (TS creates queue → Haskell sends → TS receives, TS sends → Haskell receives)
|
||||
|
||||
### Step 3b: Disconnect handling + resubscription
|
||||
- smpClientDisconnected (full: remove proxied relays, notify DOWN, trigger resubscribe)
|
||||
- resubscribeSMPSession, resubscribeSessQueues
|
||||
- processSubResults (partition into failed/subscribed)
|
||||
- subscribeSessQueues_ (batch SUB + process results)
|
||||
- **Test**: agent-repl: establish connection → kill WebSocket → verify subs move to pending → reconnect → verify resubscribed
|
||||
|
||||
### Step 3c: Proxy operations
|
||||
- getSMPProxyClient (get/create proxied relay session)
|
||||
- withProxySession (bracket for proxied operations)
|
||||
- sendOrProxySMPMessage (decide direct vs proxy, delegate)
|
||||
- sendOrProxySMPCommand (decide direct vs proxy for SKEY etc)
|
||||
- ipAddressProtected, shouldUseProxy logic
|
||||
- withClient_, withClient, withSMPClient, withLogClient_
|
||||
- **Test**: agent-repl: TS sends via proxy → Haskell receives
|
||||
|
||||
### Step 4: Agent message flow (cross-language end-to-end)
|
||||
- agentRatchetEncrypt, agentRatchetEncryptHeader, agentRatchetDecrypt
|
||||
- encodeAgentMsgStr
|
||||
- enqueueMessageB, storeConfirmation, enqueueConfirmation
|
||||
- submitPendingMsg, getDeliveryWorker, runSmpQueueMsgDelivery
|
||||
- enqueueCommand, runCommandProcessing
|
||||
- **Test via agent-repl**: TS encrypts agent message → Haskell decrypts, Haskell encrypts → TS decrypts
|
||||
|
||||
### Step 5: Connection handshake + full agent API (cross-language end-to-end)
|
||||
- newConnToJoin, joinConn, joinConnSrv, startJoinInvitation
|
||||
- compatibleInvitationUri, compatibleContactUri
|
||||
- secureConfirmQueue(Async), agentSecureSndQueue
|
||||
- mkAgentConfirmation, createReplyQueue, newRcvConnSrv, createRcvQueue
|
||||
- newSndQueue, connectReplyQueues
|
||||
- allowConnection'
|
||||
- processSMPTransmissions, subscriber
|
||||
- decryptClientMessage, agentClientMsg
|
||||
- smpConfirmation, helloMsg, smpInvitation
|
||||
- sendMessage', sendMessagesB_
|
||||
- ackMessage', ackQueueMessage
|
||||
- subscribeConnection(s)
|
||||
- **Test**: TS joins invitation URI created by Haskell agent → handshake completes → messages flow both ways → ack
|
||||
|
||||
---
|
||||
|
||||
## Piece -1: Concurrency primitives
|
||||
|
||||
### `Sem` and `ABQueue` — copy from simplex-chat
|
||||
|
||||
Copy verbatim from `/code/simplex-chat/packages/simplex-chat-client/typescript/src/queue.ts` into `smp-web/src/agent/queue.ts`.
|
||||
|
||||
`Sem` — counting semaphore. `wait()` blocks if permits=0. `signal()` increments and wakes a waiter.
|
||||
`ABQueue` — async bounded queue. Two semaphores (enq for items, deq for slots). Backpressure on full. Close via sentinel. Implements AsyncIterator.
|
||||
|
||||
Used for:
|
||||
- `subQ` — agent events to user. Agent writes, user reads via `dequeue()` loop or async iterator.
|
||||
- `msgQ` — WebSocket onmessage enqueues, subscriber loop dequeues and calls `processSMPTransmissions`.
|
||||
- Queues prevent deadlock: without them, processing a received message that triggers a send, which triggers another event, could cause unbounded reentrancy in single-threaded JS.
|
||||
|
||||
### `TMVar<T>` — new, in `smp-web/src/agent/tmvar.ts`
|
||||
|
||||
Single-cell mutable variable, empty or full. Blocking take/put/read.
|
||||
|
||||
```typescript
|
||||
class TMVar<T> {
|
||||
private val: T | undefined
|
||||
private full: boolean
|
||||
private takeQ: Array<(v: T) => void> = [] // waiters for value to appear
|
||||
private putQ: Array<(v: T) => void> = [] // waiters for cell to empty
|
||||
|
||||
static empty<T>(): TMVar<T> // create empty
|
||||
static new<T>(v: T): TMVar<T> // create full
|
||||
|
||||
take(): Promise<T> // block until full, take value, leave empty
|
||||
put(v: T): Promise<void> // block until empty, put value
|
||||
read(): Promise<T> // block until full, return value without taking
|
||||
tryTake(): T | undefined // non-blocking take
|
||||
tryPut(v: T): boolean // non-blocking put, returns false if full
|
||||
tryRead(): T | undefined // non-blocking read
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
Used for:
|
||||
- `doWork :: TMVar ()` — worker signal. `waitForWork` = `read()`. `noWorkToDo` = `tryTake()`. `hasWorkToDo` = `tryPut(undefined)`.
|
||||
- `action :: TMVar (Maybe ThreadId)` — worker running state. `runWorkerAsync` takes, checks, starts async loop.
|
||||
- Retry lock in `withRetryLock2`.
|
||||
|
||||
The doWork race condition: worker clears FIRST (`tryTake`), THEN checks store. If work found, re-sets (`tryPut`). Any signal arriving during the store check (via `await` yielding to onmessage → `hasWorkToDo`) stays set because it happened after the clear. If we did read-then-clear-if-empty, the clear could swallow a signal set between the store check and the clear.
|
||||
|
||||
### Locks — `Sem(1)` or Promise chain
|
||||
|
||||
For `withConnLock`, `withInvLock`: `Map<string, Lock>`. Each Lock is either:
|
||||
- `Sem(1)` — acquire = `wait()`, release = `signal()`, wrap in try/finally
|
||||
- Or Promise chain (each `withLock` appends to previous promise)
|
||||
|
||||
`Sem(1)` is simpler and correct. Wrap in helper:
|
||||
|
||||
```typescript
|
||||
async function withLock(locks: Map<string, Sem>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let sem = locks.get(key)
|
||||
if (!sem) { sem = new Sem(1); locks.set(key, sem) }
|
||||
await sem.wait()
|
||||
try { return await fn() } finally { sem.signal() }
|
||||
}
|
||||
```
|
||||
|
||||
### SessionVar — Promise with exposed resolver
|
||||
|
||||
`SessionVar` tracks pending protocol client connections. First caller creates a Promise, connects, resolves it. Subsequent callers await the same Promise.
|
||||
|
||||
```typescript
|
||||
interface SessionVar<T> {
|
||||
id: number
|
||||
ts: number
|
||||
promise: Promise<T>
|
||||
resolve: (v: T) => void
|
||||
reject: (e: Error) => void
|
||||
value: T | undefined // set after resolve, for tryRead
|
||||
}
|
||||
```
|
||||
|
||||
`getSessVar`: if key exists in Map, return Right (existing). Else create new with unresolved Promise, insert, return Left (new).
|
||||
`removeSessVar`: delete if ID matches.
|
||||
`tryReadSessVar`: return `value` if set.
|
||||
|
||||
No TMVar needed — Promise coalesces reads naturally.
|
||||
|
||||
---
|
||||
|
||||
## Piece 0: SessionVar (`session.ts`)
|
||||
|
||||
Transpile from `Simplex/Messaging/Session.hs` (43 lines).
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `SessionVar` type | 18-22 | `{sessionVar: TMVar a, sessionVarId: number, sessionVarTs: Date}` |
|
||||
| `getSessVar` | 24-33 | Get existing or create new empty session var for key |
|
||||
| `removeSessVar` | 35-39 | Remove if ID matches (guards against removing replaced session) |
|
||||
| `tryReadSessVar` | 41-42 | Non-blocking read of session var value |
|
||||
|
||||
Browser: `TMVar a` → `{value: T | undefined, resolve: (() => void) | null}`. `getSessVar` returns Left (new, empty) or Right (existing).
|
||||
|
||||
---
|
||||
|
||||
## Piece 1: RetryInterval (`retry.ts`)
|
||||
|
||||
Transpile from `Agent/RetryInterval.hs` (119 lines).
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `RetryInterval` type | 27-31 | `{initialInterval, increaseAfter, maxInterval}` (microseconds) |
|
||||
| `RetryInterval2` type | 33-36 | `{riSlow, riFast}` |
|
||||
| `RI2State` type | 38-41 | `{slowInterval, fastInterval}` |
|
||||
| `RetryIntervalMode` type | 51 | `RISlow \| RIFast` |
|
||||
| `nextRetryDelay` | 114-118 | Pure: if elapsed < increaseAfter, keep delay; else min(delay*3/2, max) |
|
||||
| `updateRetryInterval2` | 44-49 | Update RI2 from saved state |
|
||||
| `withRetryInterval` | 54-55 | Wrapper around withRetryIntervalCount |
|
||||
| `withRetryIntervalCount` | 57-66 | Loop: action(n, delay, loop); loop sleeps then recurses with updated delay |
|
||||
| `withRetryLock2` | 90-112 | Two-mode retry with lock: action gets RI2State + loop function that takes mode |
|
||||
|
||||
Browser adaptation: `threadDelay'` → `setTimeout` wrapped in Promise. `TMVar` lock → Promise-based signal. Logic identical.
|
||||
|
||||
---
|
||||
|
||||
## Piece 2: TSessionSubs (`subscriptions.ts`)
|
||||
|
||||
Transpile from `Agent/TSessionSubs.hs` (202 lines). Every function, every branch.
|
||||
|
||||
Transport session key: `(UserId, SMPServer)` — serialized to string for Map key. One session per server, no per-entity multiplexing.
|
||||
|
||||
| Function | Haskell lines | Purpose |
|
||||
|----------|--------------|---------|
|
||||
| `TSessionSubs` type | 49-51 | `Map<string, SessSubs>` (string = serialized transport session) |
|
||||
| `SessSubs` type | 53-57 | `{sessId: SessionId \| null, activeSubs: Map<string, RcvQueueSub>, pendingSubs: Map<string, RcvQueueSub>}` |
|
||||
| `emptyIO` | 59-61 | Create empty TSessionSubs |
|
||||
| `clear` | 63-65 | Clear all |
|
||||
| `getSessSubs` | 71-77 | Get or create SessSubs for a transport session |
|
||||
| `hasActiveSub` | 79-81 | Check if rcvId has active subscription |
|
||||
| `hasPendingSub` | 83-85 | Check if rcvId has pending subscription |
|
||||
| `addPendingSub` | 91-92 | Add to pendingSubs |
|
||||
| `setSessionId` | 94-99 | Set session ID; if changed, move active→pending |
|
||||
| `addActiveSub` | 101-110 | If sessId matches, add to active + remove from pending; else add to pending |
|
||||
| `batchAddActiveSubs` | 112-121 | Batch version of addActiveSub |
|
||||
| `batchAddPendingSubs` | 123-126 | Batch add to pending |
|
||||
| `deletePendingSub` | 128-129 | Delete from pending |
|
||||
| `batchDeletePendingSubs` | 131-134 | Batch delete from pending |
|
||||
| `deleteSub` | 136-137 | Delete from both active and pending |
|
||||
| `batchDeleteSubs` | 139-143 | Batch delete from both |
|
||||
| `hasPendingSubs` | 145-146 | Check if any pending exist for session |
|
||||
| `getPendingSubs` | 148-150 | Get all pending for session |
|
||||
| `getActiveSubs` | 152-154 | Get all active for session |
|
||||
| `setSubsPending` | 159-177 | Move active→pending on disconnect; handles session mode transitions |
|
||||
| `setSubsPending_` | 179-187 | Internal: write new sessId, move active→pending |
|
||||
| `updateClientNotices` | 189-192 | Update clientNoticeId on pending subs |
|
||||
| `foldSessionSubs` | 194-195 | Fold over all sessions |
|
||||
| `mapSubs` | 197-201 | Map over active and pending |
|
||||
|
||||
Critical: `setSubsPending` has mode-dependent logic (TSMEntity vs TSMUser/TSMServer). Must be transpiled exactly.
|
||||
|
||||
---
|
||||
|
||||
## Piece 3: AgentClient state + worker infrastructure (`client.ts`)
|
||||
|
||||
### AgentClient state
|
||||
|
||||
Transpile from `AgentClient` record (Client.hs:328-378) and `newAgentClient` (Client.hs:498-584).
|
||||
|
||||
| Field | Haskell type | TS type | Purpose |
|
||||
|-------|-------------|---------|---------|
|
||||
| `active` | `TVar Bool` | `boolean` | Is agent active |
|
||||
| `subQ` | `TBQueue ATransmission` | `ABQueue` | Events to user |
|
||||
| `msgQ` | `TBQueue (ServerTransmissionBatch ...)` | `ABQueue` | WebSocket onmessage enqueues, subscriber dequeues |
|
||||
| `smpServers` | `TMap UserId (UserServers 'PSMP)` | `Map<UserId, UserServers>` | Server configs per user |
|
||||
| `smpClients` | `TMap SMPTransportSession SMPClientVar` | `Map<string, SMPClient \| Promise<SMPClient>>` | Active SMP connections |
|
||||
| `smpProxiedRelays` | `TMap SMPTransportSession SMPServerWithAuth` | `Map<string, SMPServerWithAuth>` | Proxy routing |
|
||||
| `useNetworkConfig` | `TVar (NetworkConfig, NetworkConfig)` | `{slow: NetworkConfig, fast: NetworkConfig}` | Network config |
|
||||
| `userNetworkInfo` | `TVar UserNetworkInfo` | `UserNetworkInfo` | Online/offline state |
|
||||
| `subscrConns` | `TVar (Set ConnId)` | `Set<string>` | Connections being subscribed |
|
||||
| `currentSubs` | `TSessionSubs` | `TSessionSubs` | Active/pending subscriptions |
|
||||
| `removedSubs` | `TMap ...` | `Map<string, Map<string, SMPClientError>>` | Failed subscriptions |
|
||||
| `workerSeq` | `TVar Int` | `number` | Worker ID sequence |
|
||||
| `smpDeliveryWorkers` | `TMap SndQAddr (Worker, TMVar ())` | `Map<string, DeliveryWorker>` | Per-queue delivery workers |
|
||||
| `asyncCmdWorkers` | `TMap (ConnId, Maybe SMPServer) Worker` | `Map<string, Worker>` | Async command workers |
|
||||
| `rcvNetworkOp` | `TVar AgentOpState` | `AgentOpState` | Receive operation state |
|
||||
| `msgDeliveryOp` | `TVar AgentOpState` | `AgentOpState` | Delivery operation state |
|
||||
| `sndNetworkOp` | `TVar AgentOpState` | `AgentOpState` | Send operation state |
|
||||
| `agentState` | `TVar AgentState` | `AgentState` | Foreground/suspended/suspending |
|
||||
| `connLocks` | `TMap ConnId Lock` | `Map<string, Promise<void>>` | Connection locks |
|
||||
| `invLocks` | `TMap ByteString Lock` | `Map<string, Promise<void>>` | Invitation locks |
|
||||
| `agentEnv` | `Env` | closure | Config + store + RNG |
|
||||
|
||||
Fields NOT needed for MVP: `ntfServers`, `ntfClients`, `xftpServers`, `xftpClients`, `smpSubWorkers`, `clientNotices`, `clientNoticesLock`, `getMsgLocks`, `deleteLock`, `proxySessTs`, `*Stats`, `srvStatsStartedAt`, `acThread`, `presetDomains`, `presetServers`.
|
||||
|
||||
### Worker infrastructure
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `Worker` type | Env/SQLite.hs:317-322 | `{workerId, doWork: TMVar (), action: TMVar (Maybe ThreadId), restarts}` |
|
||||
| `RestartCount` type | Env/SQLite.hs:324-327 | `{restartMinute, restartCount}` |
|
||||
| `updateRestartCount` | Env/SQLite.hs:329-332 | Reset count if minute changed, else increment |
|
||||
| `newWorker` | Client.hs:439-445 | Create worker with doWork TMVar |
|
||||
| `getAgentWorker` | Client.hs:387-389 | Get-or-create worker for key |
|
||||
| `getAgentWorker'` | Client.hs:391-437 | Full version with restart logic |
|
||||
| `runWorkerAsync` | Client.hs:447-454 | Start worker if not running |
|
||||
| `waitForWork` | Client.hs:2118-2119 | Block until doWork has value |
|
||||
| `hasWorkToDo` / `hasWorkToDo'` | Client.hs:2171-2176 | Signal work available (tryPutTMVar) |
|
||||
| `withWork` / `withWork_` | Client.hs:2122-2140 | Wait for work, get item from store, run action |
|
||||
|
||||
Browser adaptation: `TMVar ()` → boolean flag + resolver. `forkIO` → `setTimeout(0)`. Worker restart logic must be preserved exactly.
|
||||
|
||||
### Operation state management
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `AgentOpState` type | Client.hs:470 | `{opSuspended, opsInProgress}` |
|
||||
| `AgentState` type | Client.hs:472-473 | `ASForeground \| ASSuspending \| ASSuspended` |
|
||||
| `agentOperationBracket` | Client.hs:2232-2245 | Begin/end operation with suspend check |
|
||||
| `beginAgentOperation` | Client.hs:2223-2230 | Increment opsInProgress |
|
||||
| `endAgentOperation` | Client.hs:2179-2197 | Decrement opsInProgress, cascade suspend |
|
||||
| `waitUntilActive` | Client.hs:956-957 | Block until agent is active |
|
||||
| `throwWhenInactive` | Client.hs:959-962 | Throw if not active |
|
||||
| `waitWhileSuspended` | Client.hs:2248-2253 | Block while suspended |
|
||||
| `waitForUserNetwork` | Client.hs:924-928 | Block until network online |
|
||||
| `noWorkToDo` | Client.hs:2167-2168 | Clear work flag (tryTakeTMVar) |
|
||||
|
||||
### Store wrappers
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `withStore` | Client.hs:2259-2270 | Run store action, convert StoreError to AgentErrorType |
|
||||
| `withStore'` | Client.hs:2255-2257 | Simplified withStore (always Right) |
|
||||
| `storeError` | Client.hs (exported) | StoreError → AgentErrorType |
|
||||
|
||||
### Server selection
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `userServers` | Client.hs:2312-2318 | Get user's server map |
|
||||
| `pickServer` | Client.hs:2318-2325 | Pick server from NonEmpty list |
|
||||
| `getNextServer` | Client.hs:2325-2350 | Get next server avoiding used hosts |
|
||||
| `withNextSrv` | Client.hs:2375-2407 | Retry with next server on failure |
|
||||
|
||||
### Locking
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `withConnLock` | Client.hs:1003-1006 | Per-connection mutex |
|
||||
| `withConnLocks` | Client.hs:1020-1022 | Multiple connection mutex |
|
||||
| `withInvLock` | Client.hs:1012-1015 | Per-invitation mutex |
|
||||
|
||||
Browser: locks via Promise chains. Single-threaded JS means no actual contention, but the ordering semantics must be preserved for async operations.
|
||||
|
||||
---
|
||||
|
||||
## Piece 4: SMP session management
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `getSMPServerClient` | Client.hs:642-651 | Get or create SMP WebSocket client for transport session |
|
||||
| `getSMPProxyClient` | Client.hs:653-702 | Get or create proxied relay session |
|
||||
| `smpConnectClient` | Client.hs:704-718 | Actually connect SMP client via WebSocket |
|
||||
| `smpClientDisconnected` | Client.hs:720-754 | Handle disconnect: move subs to pending, notify, resubscribe |
|
||||
| `resubscribeSMPSession` | Client.hs:756-790 | Create resubscription worker |
|
||||
| `mkTransportSession` | Client.hs:1345-1348 | Build transport session key |
|
||||
| `mkSMPTransportSession` | Client.hs:1357-1360 | Build SMP transport session from queue |
|
||||
| `getSessionMode` | Client.hs:1369-1370 | Get current session mode |
|
||||
| `withClient_` | Client.hs:1037-1045 | Bracket: get client → run action → handle errors |
|
||||
| `withClient` | Client.hs:1071-1073 | withClient_ + liftClient |
|
||||
| `withSMPClient` | Client.hs:1079-1082 | withClient for SMP queues |
|
||||
| `withLogClient_` | Client.hs:1064-1069 | withClient_ with logging |
|
||||
| `withProxySession` | Client.hs:1047-1062 | Bracket for proxied operations |
|
||||
| `sendOrProxySMPMessage` | Client.hs:1084-1094 | Decide direct vs proxy for SEND |
|
||||
| `sendOrProxySMPCommand` | Client.hs:1096-1180 | Decide direct vs proxy for commands (SKEY etc) |
|
||||
| `ipAddressProtected` | Client.hs:1181-1185 | Check if server is in protected domains |
|
||||
| `liftClient` | Client.hs:1201-1203 | Convert protocol client error |
|
||||
| `protocolClientError` | Client.hs:1205-1235 | Error conversion |
|
||||
| `waitForProtocolClient` | Client.hs:847-868 | Wait for pending client connection |
|
||||
| `newProtocolClient` | Client.hs:870-896 | Create protocol client with error handling |
|
||||
| `activeClientSession` | Client.hs:1663-1666 | Check if client session is current (compares sessionId) |
|
||||
| `removeSubscription` | Client.hs:1752-1755 | Remove single subscription from currentSubs + subscrConns |
|
||||
| `removeSubscriptions` | Client.hs:1757-1763 | Remove multiple subscriptions |
|
||||
| `hasActiveSubscription` | Client.hs:1736-1740 | Check if queue has active sub |
|
||||
| `hasPendingSubscription` | Client.hs:1742-1747 | Check if queue has pending sub |
|
||||
| `getClientConfig` | Client.hs:904-908 | Get protocol client config (slow/fast network) |
|
||||
| `getNetworkConfig` | Client.hs:910-918 | Get current network config |
|
||||
| `getFastNetworkConfig` | Client.hs:920-922 | Get fast network config |
|
||||
| `slowNetworkConfig` | Client.hs:586-592 | Derive slow config from fast |
|
||||
| `batchQueues` | Client.hs:1679-1684 | Group queues by transport session |
|
||||
| `sendTSessionBatches` | Client.hs:1674-1678 | Send batched operations per session (mapConcurrently) |
|
||||
| `sendClientBatch` | Client.hs:1686-1688 | Send batch to single client session (wrapper) |
|
||||
| `sendClientBatch_` | Client.hs:1690-1722 | Send batch to single client: get client, run action, handle errors |
|
||||
| `checkQueues` | Client.hs:1590-1595 | Filter out prohibited queues (GET lock check) |
|
||||
| `subscribeSessQueues_` | Client.hs:1611-1651 | Send SUB batch via sendClientBatch_ + process results |
|
||||
|
||||
---
|
||||
|
||||
## Piece 5: Queue operations (use session management)
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `newRcvQueue` | Client.hs:1373-1377 | Generate keys, create queue on SMP server |
|
||||
| `newRcvQueue_` | Client.hs:1394-1474 | Full queue creation: auth keys, DH, createSMPQueue, build RcvQueue record |
|
||||
| `subscribeQueues` | Client.hs:1543-1556 | Batch subscribe rcv queues grouped by transport session |
|
||||
| `subscribeQueues_` | Client.hs:1556-1720 | Subscribe batch for one session |
|
||||
| `processSubResults` | Client.hs:1476-1510 | Process subscribe results: partition into failed/subscribed/notices |
|
||||
| `addNewQueueSubscription` | Client.hs:1724-1728 | Add queue to active subs after creation |
|
||||
| `sendConfirmation` | Client.hs:1788-1794 | Per-queue E2E encrypt confirmation → SEND via sendOrProxySMPMessage |
|
||||
| `sendInvitation` | Client.hs:1796-1806 | Per-queue E2E encrypt invitation → SEND via sendOrProxySMPMessage |
|
||||
| `sendAgentMessage` | Client.hs:1948-1952 | Per-queue E2E encrypt message → SEND via sendOrProxySMPMessage |
|
||||
| `agentCbEncrypt` | Client.hs:2074-2082 | Per-queue E2E encrypt with stored DH secret |
|
||||
| `agentCbEncryptOnce` | Client.hs:2085-2095 | Per-queue E2E encrypt with ephemeral DH (for invitations) |
|
||||
| `agentCbDecrypt` | Client.hs:2099-2102 | Per-queue E2E decrypt |
|
||||
| `secureQueue` | Client.hs:1830-1833 | Send KEY command |
|
||||
| `secureSndQueue` | Client.hs:1835-1841 | Send SKEY command via sendOrProxySMPCommand |
|
||||
| `sendAck` | Client.hs:1904-1907 | Send ACK command |
|
||||
| `decryptSMPMessage` | Client.hs:1824-1828 | Decrypt received SMP message body |
|
||||
| `suspendQueue` | Client.hs:1926-1929 | Send OFF command |
|
||||
| `deleteQueue` | Client.hs:1931-1934 | Send DEL command |
|
||||
| `deleteQueues` | Client.hs:1936-1945 | Batch DEL |
|
||||
| `getQueueMessage` | Client.hs:1808-1822 | Send GET + decrypt (for polling, if needed) |
|
||||
| `notifySub` / `notifySub'` | Client.hs:791-797 | Write event to subQ |
|
||||
| `cryptoError` | Client.hs:2104-2115 | CryptoError → AgentErrorType |
|
||||
|
||||
---
|
||||
|
||||
## Piece 6: Agent API (top level)
|
||||
|
||||
| Function | Haskell location | Purpose |
|
||||
|----------|-----------------|---------|
|
||||
| `joinConn` | Agent.hs:1260-1263 | Top-level join: pick server, delegate |
|
||||
| `joinConnSrv` (invitation) | Agent.hs:1342-1357 | Lock, startJoinInvitation, secureConfirmQueue |
|
||||
| `joinConnSrv` (contact) | Agent.hs:1358-1388 | Lock, create rcv queue, sendInvitation |
|
||||
| `startJoinInvitation` | Agent.hs:1270-1310 | Version check, ratchet params, snd queue, createRatchet_ |
|
||||
| `compatibleInvitationUri` | Agent.hs:1321-1328 | Version range compatibility |
|
||||
| `compatibleContactUri` | Agent.hs:1330-1336 | Version range compatibility |
|
||||
| `secureConfirmQueue` | Agent.hs:3653-3671 | Secure + send confirmation synchronously |
|
||||
| `secureConfirmQueueAsync` | Agent.hs:3645-3651 | Secure + store confirmation for async delivery |
|
||||
| `agentSecureSndQueue` | Agent.hs:3673-3684 | SKEY decision logic |
|
||||
| `mkAgentConfirmation` | Agent.hs:3686-3691 | Build AgentConnInfoReply with reply queue |
|
||||
| `storeConfirmation` | Agent.hs:3698-3712 | Ratchet encrypt + store as SndMsg |
|
||||
| `enqueueConfirmation` | Agent.hs:3693-3696 | Store + submit for delivery |
|
||||
| `createReplyQueue` | Agent.hs:1415-1424 | Create rcv queue for reply |
|
||||
| `newRcvConnSrv` | Agent.hs:1155-1220 | Full create-connection-with-rcv-queue |
|
||||
| `createRcvQueue` | Agent.hs:972-983 | Wrapper: newRcvQueue_ + updateNewConnRcv + addNewQueueSubscription |
|
||||
| `newConnToJoin` | Agent.hs:1237-1253 | Create ConnData for join, store connection |
|
||||
| `newSndQueue` | Agent.hs:3769-3802 | Build SndQueue from SMPQueueInfo |
|
||||
| `getNextSMPServer` | Agent.hs (via Client.hs) | Pick server for new queue, avoiding contact's server |
|
||||
| `connReqQueue` | Agent.hs:1265-1268 | Extract first queue from ConnectionRequestUri |
|
||||
| `versionPQSupport_` | Agent.hs:1338-1340 | PQ support based on agent+e2e versions |
|
||||
| `sendMessage'` | Agent.hs:1705-1706 | Top-level send |
|
||||
| `sendMessagesB_` | Agent.hs:1725-1760 | Get conn, prepare, delegate |
|
||||
| `enqueueMessageB` | Agent.hs:1989-2048 | Core: updateSndIds, encode, ratchetEncryptHeader, createSndMsg, createSndMsgDelivery |
|
||||
| `encodeAgentMsgStr` | Agent.hs:2050-2054 | Encode AgentMessage to bytes |
|
||||
| `agentRatchetEncrypt` | Agent.hs:3742-3746 | Ratchet encrypt message body |
|
||||
| `agentRatchetEncryptHeader` | Agent.hs:3748-3754 | Get encrypt key from ratchet |
|
||||
| `agentRatchetDecrypt` | Agent.hs:3757-3767 | Ratchet decrypt with skipped keys |
|
||||
| `submitPendingMsg` | Agent.hs:2087-2090 | Signal delivery worker |
|
||||
| `getDeliveryWorker` | Agent.hs:2079-2085 | Get-or-create delivery worker for queue |
|
||||
| `runSmpQueueMsgDelivery` | Agent.hs:2092-2270 | Delivery loop: getPendingQueueMsg, dispatch on msgType, send, handle errors |
|
||||
| `ackMessage'` | Agent.hs:2285-2323 | ACK + delete + optional receipt |
|
||||
| `ackQueueMessage` | Agent.hs:2410-2430 | Send ACK to server, handle response |
|
||||
| `subscriber` | Agent.hs:2912-2919 | Read from msgQ, dispatch |
|
||||
| `processSMPTransmissions` | Agent.hs:2997-3297 | Incoming message dispatcher |
|
||||
| `decryptClientMessage` | Agent.hs:3282-3296 | Per-queue E2E decrypt + parse envelope |
|
||||
| `agentClientMsg` | Agent.hs:3207-3225 | Ratchet decrypt, parse, store RcvMsg |
|
||||
| `smpConfirmation` | Agent.hs:3298-3370 | Process received confirmation |
|
||||
| `helloMsg` | Agent.hs:3372-3393 | Process HELLO |
|
||||
| `smpInvitation` | Agent.hs:3515-3570 | Process received invitation |
|
||||
| `allowConnection'` | Agent.hs:1427-1434 | Accept confirmation, enqueue ICAllowSecure |
|
||||
| `connectReplyQueues` | Agent.hs:3630-3643 | Process reply queues from confirmation |
|
||||
| `enqueueCommand` | Agent.hs:1764-1767 | Store command + start worker |
|
||||
| `runCommandProcessing` | Agent.hs:1789-1902 | Async command worker loop |
|
||||
| `enqueueMessage` / `enqueueMessages` | Agent.hs:~2370+ | Convenience wrappers |
|
||||
| `resumeMsgDelivery` | Agent.hs:2072-2076 | Resume delivery worker for a snd queue |
|
||||
| `resumeConnCmds` | Agent.hs:1773-1776 | Resume async command workers for connections |
|
||||
| `resumeAllCommands` | Agent.hs:1778-1781 | Resume all pending async commands on startup |
|
||||
| `enqueueSavedMessage` | Agent.hs:2056-2057 | Create delivery for additional snd queues |
|
||||
| `checkMsgIntegrity` | Agent.hs:3603-3610 | Verify message sequence integrity (local fn in processSMPTransmissions) |
|
||||
| `subscribeConnection'` | Agent.hs:1472-1474 | Subscribe single connection (delegates to subscribeConnections') |
|
||||
| `subscribeConnections'` | Agent.hs:1488-1490 | Get conn subs from store, delegate to subscribeConnections_ |
|
||||
| `subscribeConnections_` | Agent.hs:1492-1527 | Core: partition conns, resume delivery, subscribe rcv queues |
|
||||
|
||||
---
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
1. **Store field naming**: No mapping. IDB returns snake_case (`row.conn_id`), agent code uses it directly.
|
||||
2. **Error handling**: Throw custom `AgentError` exception with typed error data matching Haskell `AgentErrorType`. Catches process errors by type.
|
||||
3. **subQ**: Keep ABQueue. Agent writes events, user reads. Queues prevent deadlock — without them, a callback within message processing that triggers a send could deadlock single-threaded JS.
|
||||
4. **msgQ**: Keep ABQueue. WebSocket onmessage enqueues, subscriber loop dequeues. Prevents reentrancy.
|
||||
5. **Structured commands in IDB**: Store as JS objects. New fields optional.
|
||||
6. **Transport session key**: `(userId, server)` tuple. One WebSocket per server. No TSMEntity. All TSMEntity-specific branches dropped.
|
||||
7. **Join flows**: Both invitation and contact needed. Contact for widget's primary flow (joining address). Invitation for internal group member connections.
|
||||
8. **Async join**: `joinConnSrvAsync` / `secureConfirmQueueAsync` as primary path.
|
||||
9. **Version ranges**: Match Haskell defaults.
|
||||
10. **Config defaults**: Match Haskell defaults.
|
||||
11. **`ep/conc-msgs` branch**: Ignore. Use queues.
|
||||
12. **Connection type dispatch**: Compute from `conn_mode` field + queue presence. `conn_mode = "INV"` with rcv+snd queues = duplex, with only rcv = rcv, etc.
|
||||
13. **`withAgentEnv`**: No-op in TS — env in closure.
|
||||
14. **`getConnSubs` for subscribe**: Use `getConn` (returns conn + queues) in subscribe flow.
|
||||
15. **Client notices**: Skip in `subscribeSessQueues_`.
|
||||
|
||||
---
|
||||
|
||||
## Store methods to add
|
||||
|
||||
These store methods are not yet in `store.ts` / `store-idb.ts` but are needed by the agent layer:
|
||||
|
||||
| Method | AgentStore.hs lines | Used by |
|
||||
|--------|-------------------|---------|
|
||||
| `createSndRatchet` | 1271-1287 | `startJoinInvitation` — stores ratchet + e2e pub keys for sending side |
|
||||
| `getSndRatchet` | 1289-1300 | `startJoinInvitation` — retry path, get previously created snd ratchet |
|
||||
| `updateNewConnSnd` | 424-431 | `startJoinInvitation` — add snd queue to new connection |
|
||||
| `createSndConn` | 433-440 | May be needed for contact join flow |
|
||||
| `setRcvQueueStatus` | already exists | — |
|
||||
| `setSndQueueStatus` | already exists | — |
|
||||
| `setRcvSwitchStatus` | skip (queue switching) | — |
|
||||
| `setSndSwitchStatus` | skip (queue switching) | — |
|
||||
|
||||
---
|
||||
|
||||
## What to skip for MVP
|
||||
|
||||
| Feature | Functions to skip |
|
||||
|---------|------------------|
|
||||
| Queue switching | `switchConnection`, QADD/QKEY/QUSE/QTEST handlers, `switchDuplexConnection` |
|
||||
| Ratchet sync | `synchronizeRatchet`, EREADY handler, `newRatchetKey` |
|
||||
| Notifications | All NTF functions, `newQueueNtfSubscription` |
|
||||
| File transfer | All XFTP functions |
|
||||
| Remote control | All RC functions |
|
||||
| Connection creation | `createConnection`, `newConn`, short links creation |
|
||||
| Delivery receipts sending | `sendRcpt` in ackMessage (receiving A_RCVD is kept) |
|
||||
| Multiple rcv queues | Queue replacement logic in processSMPTransmissions |
|
||||
| Cleanup manager | `cleanupManager`, `deleteRcvMsgHashesExpired`, etc. |
|
||||
| Server management | `setProtocolServers`, `testProtocolServer` |
|
||||
| Client notices | `processClientNotices`, `subscribeClientService` |
|
||||
| Statistics | All `inc*ServerStat` calls, `getAgentServersSummary` |
|
||||
| Connection comparison | `compareConnections`, `syncConnections` |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Principle: every step produces testable output
|
||||
|
||||
Cross-language tests from Haskell give the highest confidence because they verify wire compatibility. Pure TS tests verify internal logic. The goal is to have Haskell tests at every step where the TS code touches the network.
|
||||
|
||||
### Step 1 tests: pure TS (no server)
|
||||
|
||||
File: `tests/infra-test.ts` (run with `node`, like store-test.ts)
|
||||
|
||||
- **RetryInterval**: `nextRetryDelay` returns correct values for various elapsed/delay combinations. `withRetryIntervalCount` calls action with increasing delays.
|
||||
- **TSessionSubs**: Full lifecycle: add pending → set session ID → add active (moves from pending) → disconnect (moves back to pending) → reconnect. Test `setSubsPending` mode logic (entity vs user session). Test batch operations.
|
||||
- **SessionVar**: `getSessVar` returns Left for new, Right for existing. `removeSessVar` only removes matching ID.
|
||||
|
||||
### Step 2 tests: TS with store (no server)
|
||||
|
||||
File: `tests/worker-test.ts`
|
||||
|
||||
- Create AgentClient with real IndexedDB store (fake-indexeddb).
|
||||
- Test worker lifecycle: create worker → signal work → worker runs → no work → worker waits → signal again.
|
||||
- Test server selection: configure servers → `getNextServer` rotates avoiding used hosts.
|
||||
- Test locking: `withConnLock` serializes async operations on same connId.
|
||||
|
||||
### Step 3 tests: agent-repl + Haskell (real SMP server)
|
||||
|
||||
File: `tests/agent-repl.ts` — new REPL with higher-level commands.
|
||||
|
||||
The agent-repl exposes mid-level operations that Haskell can drive:
|
||||
|
||||
```
|
||||
AGENT_INIT <serverUrl> <userId>
|
||||
→ creates AgentClient, connects to server
|
||||
|
||||
CREATE_RCV_QUEUE <connIdHex>
|
||||
→ newRcvQueue on server, returns rcvId, sndId, sndQueueUri
|
||||
→ Haskell can then SEND to this queue
|
||||
|
||||
SUBSCRIBE <connIdHex>
|
||||
→ subscribeQueues for connection's rcv queue
|
||||
|
||||
SEND_AGENT_MSG <sndQueueHex> <msgBodyHex>
|
||||
→ agentCbEncrypt + sendAgentMessage
|
||||
|
||||
RECV
|
||||
→ wait for MSG from WebSocket, decryptSMPMessage, return parsed body
|
||||
|
||||
SECURE_SND <sndQueueHex>
|
||||
→ secureSndQueue (SKEY)
|
||||
|
||||
SEND_CONFIRMATION <sndQueueHex> <confirmationHex>
|
||||
→ sendConfirmation
|
||||
|
||||
ACK <rcvIdHex> <msgIdHex>
|
||||
→ sendAck
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Queue creation**: TS creates rcv queue → Haskell verifies by sending to it → TS receives
|
||||
2. **Subscribe + receive**: TS subscribes → Haskell sends MSG → TS decrypts and returns
|
||||
3. **Send**: TS sends agent message → Haskell receives and decrypts
|
||||
4. **SKEY**: TS sends SKEY → Haskell verifies queue secured
|
||||
5. **Proxy send**: TS sends via proxy → Haskell receives
|
||||
|
||||
These tests verify the entire session management + queue operations layer without needing the full agent handshake.
|
||||
|
||||
### Step 4 tests: agent-repl + Haskell (ratchet operations)
|
||||
|
||||
Extend agent-repl:
|
||||
|
||||
```
|
||||
RATCHET_ENCRYPT <connIdHex> <plaintextHex>
|
||||
→ agentRatchetEncrypt, return encrypted agent envelope
|
||||
|
||||
RATCHET_DECRYPT <connIdHex> <encryptedHex>
|
||||
→ agentRatchetDecrypt, return plaintext
|
||||
|
||||
ENQUEUE_MSG <connIdHex> <msgBodyHex>
|
||||
→ enqueueMessageB (encrypt + store + create delivery)
|
||||
|
||||
DELIVER
|
||||
→ runSmpQueueMsgDelivery one iteration (getPendingQueueMsg + send)
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Ratchet encrypt cross-language**: Initialize ratchet in both → TS encrypts → Haskell decrypts (and vice versa). Already have ratchet cross-language tests, extend to agent envelope level.
|
||||
2. **Enqueue + deliver**: TS enqueues message → delivery worker sends → Haskell receives and decrypts entire agent message envelope.
|
||||
3. **Receive + store**: Haskell sends agent message → TS receives, decrypts, stores RcvMsg → verify stored correctly.
|
||||
|
||||
### Step 5 tests: full handshake (end-to-end)
|
||||
|
||||
Extend agent-repl or create dedicated test:
|
||||
|
||||
```
|
||||
JOIN <connectionRequestUri> <connInfo>
|
||||
→ full joinConnection flow
|
||||
|
||||
ALLOW <confId> <connInfo>
|
||||
→ allowConnection
|
||||
|
||||
SEND <connIdHex> <msgBody>
|
||||
→ sendMessage
|
||||
|
||||
ACK_MSG <connIdHex> <msgId>
|
||||
→ ackMessage
|
||||
```
|
||||
|
||||
Haskell test scenarios:
|
||||
1. **Join invitation**: Haskell creates invitation → TS joins → handshake completes (CONF, HELLO exchange) → messages flow both ways.
|
||||
2. **Join contact**: Haskell creates contact address → TS joins → Haskell accepts → messages flow.
|
||||
3. **Multiple connections**: TS joins two different connections on different servers simultaneously.
|
||||
4. **Reconnect**: Connection established → WebSocket drops → resubscribe → messages resume.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
All cross-language tests go in `tests/SMPWebTests.hs`, extending the existing 99 tests. Each agent-repl command is a single stdin/stdout exchange (like client-repl). Haskell `callNode` drives the TS process.
|
||||
|
||||
Estimated test count per step:
|
||||
- Step 1: ~15 TS tests (retry: 5, subs: 8, session: 2)
|
||||
- Step 2: ~8 TS tests (worker: 4, server selection: 2, locking: 2)
|
||||
- Step 3: ~8 Haskell tests (queue create, subscribe, send, receive, SKEY, proxy)
|
||||
- Step 4: ~6 Haskell tests (ratchet encrypt/decrypt, enqueue+deliver, receive+store)
|
||||
- Step 5: ~6 Haskell tests (join invitation, join contact, send/receive/ack, reconnect)
|
||||
@@ -359,6 +359,7 @@ library
|
||||
, temporary ==1.3.*
|
||||
, wai >=3.2 && <3.3
|
||||
, wai-app-static >=3.1 && <3.2
|
||||
, wai-websockets >=3.0.1 && <3.1
|
||||
, warp ==3.3.30
|
||||
, warp-tls ==3.4.7
|
||||
, websockets ==0.12.*
|
||||
@@ -514,6 +515,7 @@ test-suite simplexmq-test
|
||||
XFTPServerTests
|
||||
WebTests
|
||||
XFTPWebTests
|
||||
SMPWebTests
|
||||
SMPWeb
|
||||
XFTPWeb
|
||||
Web.Embedded
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,14 @@
|
||||
addToLibrary({
|
||||
js_random_bytes: function(buf, len) {
|
||||
var bytes = new Uint8Array(len);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
// Node.js fallback
|
||||
var nodeCrypto = require('crypto');
|
||||
var nodeBytes = nodeCrypto.randomBytes(len);
|
||||
bytes.set(nodeBytes);
|
||||
}
|
||||
HEAPU8.set(bytes, buf);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
20080913
|
||||
D. J. Bernstein
|
||||
Public domain.
|
||||
|
||||
SHA-512 implementation from SUPERCOP/NaCl.
|
||||
Source: https://bench.cr.yp.to/supercop.html
|
||||
crypto_hashblocks/sha512/ref/blocks.c
|
||||
crypto_hash/sha512/ref/hash.c
|
||||
|
||||
Combined into a single file for WASM compilation alongside sntrup761.
|
||||
*/
|
||||
|
||||
#include "sha512.h"
|
||||
|
||||
typedef unsigned long long uint64;
|
||||
|
||||
/* -- crypto_hashblocks_sha512 (blocks.c) -- */
|
||||
|
||||
static uint64 load_bigendian(const unsigned char *x)
|
||||
{
|
||||
return
|
||||
(uint64) (x[7]) \
|
||||
| (((uint64) (x[6])) << 8) \
|
||||
| (((uint64) (x[5])) << 16) \
|
||||
| (((uint64) (x[4])) << 24) \
|
||||
| (((uint64) (x[3])) << 32) \
|
||||
| (((uint64) (x[2])) << 40) \
|
||||
| (((uint64) (x[1])) << 48) \
|
||||
| (((uint64) (x[0])) << 56)
|
||||
;
|
||||
}
|
||||
|
||||
static void store_bigendian(unsigned char *x,uint64 u)
|
||||
{
|
||||
x[7] = u; u >>= 8;
|
||||
x[6] = u; u >>= 8;
|
||||
x[5] = u; u >>= 8;
|
||||
x[4] = u; u >>= 8;
|
||||
x[3] = u; u >>= 8;
|
||||
x[2] = u; u >>= 8;
|
||||
x[1] = u; u >>= 8;
|
||||
x[0] = u;
|
||||
}
|
||||
|
||||
#define SHR(x,c) ((x) >> (c))
|
||||
#define ROTR(x,c) (((x) >> (c)) | ((x) << (64 - (c))))
|
||||
|
||||
#define Ch(x,y,z) ((x & y) ^ (~x & z))
|
||||
#define Maj(x,y,z) ((x & y) ^ (x & z) ^ (y & z))
|
||||
#define Sigma0(x) (ROTR(x,28) ^ ROTR(x,34) ^ ROTR(x,39))
|
||||
#define Sigma1(x) (ROTR(x,14) ^ ROTR(x,18) ^ ROTR(x,41))
|
||||
#define sigma0(x) (ROTR(x, 1) ^ ROTR(x, 8) ^ SHR(x,7))
|
||||
#define sigma1(x) (ROTR(x,19) ^ ROTR(x,61) ^ SHR(x,6))
|
||||
|
||||
#define M(w0,w14,w9,w1) w0 = sigma1(w14) + w9 + sigma0(w1) + w0;
|
||||
|
||||
#define EXPAND \
|
||||
M(w0 ,w14,w9 ,w1 ) \
|
||||
M(w1 ,w15,w10,w2 ) \
|
||||
M(w2 ,w0 ,w11,w3 ) \
|
||||
M(w3 ,w1 ,w12,w4 ) \
|
||||
M(w4 ,w2 ,w13,w5 ) \
|
||||
M(w5 ,w3 ,w14,w6 ) \
|
||||
M(w6 ,w4 ,w15,w7 ) \
|
||||
M(w7 ,w5 ,w0 ,w8 ) \
|
||||
M(w8 ,w6 ,w1 ,w9 ) \
|
||||
M(w9 ,w7 ,w2 ,w10) \
|
||||
M(w10,w8 ,w3 ,w11) \
|
||||
M(w11,w9 ,w4 ,w12) \
|
||||
M(w12,w10,w5 ,w13) \
|
||||
M(w13,w11,w6 ,w14) \
|
||||
M(w14,w12,w7 ,w15) \
|
||||
M(w15,w13,w8 ,w0 )
|
||||
|
||||
#define F(w,k) \
|
||||
T1 = h + Sigma1(e) + Ch(e,f,g) + k + w; \
|
||||
T2 = Sigma0(a) + Maj(a,b,c); \
|
||||
h = g; \
|
||||
g = f; \
|
||||
f = e; \
|
||||
e = d + T1; \
|
||||
d = c; \
|
||||
c = b; \
|
||||
b = a; \
|
||||
a = T1 + T2;
|
||||
|
||||
static int crypto_hashblocks_sha512(unsigned char *statebytes,const unsigned char *in,unsigned long long inlen)
|
||||
{
|
||||
uint64 state[8];
|
||||
uint64 a;
|
||||
uint64 b;
|
||||
uint64 c;
|
||||
uint64 d;
|
||||
uint64 e;
|
||||
uint64 f;
|
||||
uint64 g;
|
||||
uint64 h;
|
||||
uint64 T1;
|
||||
uint64 T2;
|
||||
|
||||
a = load_bigendian(statebytes + 0); state[0] = a;
|
||||
b = load_bigendian(statebytes + 8); state[1] = b;
|
||||
c = load_bigendian(statebytes + 16); state[2] = c;
|
||||
d = load_bigendian(statebytes + 24); state[3] = d;
|
||||
e = load_bigendian(statebytes + 32); state[4] = e;
|
||||
f = load_bigendian(statebytes + 40); state[5] = f;
|
||||
g = load_bigendian(statebytes + 48); state[6] = g;
|
||||
h = load_bigendian(statebytes + 56); state[7] = h;
|
||||
|
||||
while (inlen >= 128) {
|
||||
uint64 w0 = load_bigendian(in + 0);
|
||||
uint64 w1 = load_bigendian(in + 8);
|
||||
uint64 w2 = load_bigendian(in + 16);
|
||||
uint64 w3 = load_bigendian(in + 24);
|
||||
uint64 w4 = load_bigendian(in + 32);
|
||||
uint64 w5 = load_bigendian(in + 40);
|
||||
uint64 w6 = load_bigendian(in + 48);
|
||||
uint64 w7 = load_bigendian(in + 56);
|
||||
uint64 w8 = load_bigendian(in + 64);
|
||||
uint64 w9 = load_bigendian(in + 72);
|
||||
uint64 w10 = load_bigendian(in + 80);
|
||||
uint64 w11 = load_bigendian(in + 88);
|
||||
uint64 w12 = load_bigendian(in + 96);
|
||||
uint64 w13 = load_bigendian(in + 104);
|
||||
uint64 w14 = load_bigendian(in + 112);
|
||||
uint64 w15 = load_bigendian(in + 120);
|
||||
|
||||
F(w0 ,0x428a2f98d728ae22ULL)
|
||||
F(w1 ,0x7137449123ef65cdULL)
|
||||
F(w2 ,0xb5c0fbcfec4d3b2fULL)
|
||||
F(w3 ,0xe9b5dba58189dbbcULL)
|
||||
F(w4 ,0x3956c25bf348b538ULL)
|
||||
F(w5 ,0x59f111f1b605d019ULL)
|
||||
F(w6 ,0x923f82a4af194f9bULL)
|
||||
F(w7 ,0xab1c5ed5da6d8118ULL)
|
||||
F(w8 ,0xd807aa98a3030242ULL)
|
||||
F(w9 ,0x12835b0145706fbeULL)
|
||||
F(w10,0x243185be4ee4b28cULL)
|
||||
F(w11,0x550c7dc3d5ffb4e2ULL)
|
||||
F(w12,0x72be5d74f27b896fULL)
|
||||
F(w13,0x80deb1fe3b1696b1ULL)
|
||||
F(w14,0x9bdc06a725c71235ULL)
|
||||
F(w15,0xc19bf174cf692694ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xe49b69c19ef14ad2ULL)
|
||||
F(w1 ,0xefbe4786384f25e3ULL)
|
||||
F(w2 ,0x0fc19dc68b8cd5b5ULL)
|
||||
F(w3 ,0x240ca1cc77ac9c65ULL)
|
||||
F(w4 ,0x2de92c6f592b0275ULL)
|
||||
F(w5 ,0x4a7484aa6ea6e483ULL)
|
||||
F(w6 ,0x5cb0a9dcbd41fbd4ULL)
|
||||
F(w7 ,0x76f988da831153b5ULL)
|
||||
F(w8 ,0x983e5152ee66dfabULL)
|
||||
F(w9 ,0xa831c66d2db43210ULL)
|
||||
F(w10,0xb00327c898fb213fULL)
|
||||
F(w11,0xbf597fc7beef0ee4ULL)
|
||||
F(w12,0xc6e00bf33da88fc2ULL)
|
||||
F(w13,0xd5a79147930aa725ULL)
|
||||
F(w14,0x06ca6351e003826fULL)
|
||||
F(w15,0x142929670a0e6e70ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x27b70a8546d22ffcULL)
|
||||
F(w1 ,0x2e1b21385c26c926ULL)
|
||||
F(w2 ,0x4d2c6dfc5ac42aedULL)
|
||||
F(w3 ,0x53380d139d95b3dfULL)
|
||||
F(w4 ,0x650a73548baf63deULL)
|
||||
F(w5 ,0x766a0abb3c77b2a8ULL)
|
||||
F(w6 ,0x81c2c92e47edaee6ULL)
|
||||
F(w7 ,0x92722c851482353bULL)
|
||||
F(w8 ,0xa2bfe8a14cf10364ULL)
|
||||
F(w9 ,0xa81a664bbc423001ULL)
|
||||
F(w10,0xc24b8b70d0f89791ULL)
|
||||
F(w11,0xc76c51a30654be30ULL)
|
||||
F(w12,0xd192e819d6ef5218ULL)
|
||||
F(w13,0xd69906245565a910ULL)
|
||||
F(w14,0xf40e35855771202aULL)
|
||||
F(w15,0x106aa07032bbd1b8ULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0x19a4c116b8d2d0c8ULL)
|
||||
F(w1 ,0x1e376c085141ab53ULL)
|
||||
F(w2 ,0x2748774cdf8eeb99ULL)
|
||||
F(w3 ,0x34b0bcb5e19b48a8ULL)
|
||||
F(w4 ,0x391c0cb3c5c95a63ULL)
|
||||
F(w5 ,0x4ed8aa4ae3418acbULL)
|
||||
F(w6 ,0x5b9cca4f7763e373ULL)
|
||||
F(w7 ,0x682e6ff3d6b2b8a3ULL)
|
||||
F(w8 ,0x748f82ee5defb2fcULL)
|
||||
F(w9 ,0x78a5636f43172f60ULL)
|
||||
F(w10,0x84c87814a1f0ab72ULL)
|
||||
F(w11,0x8cc702081a6439ecULL)
|
||||
F(w12,0x90befffa23631e28ULL)
|
||||
F(w13,0xa4506cebde82bde9ULL)
|
||||
F(w14,0xbef9a3f7b2c67915ULL)
|
||||
F(w15,0xc67178f2e372532bULL)
|
||||
|
||||
EXPAND
|
||||
|
||||
F(w0 ,0xca273eceea26619cULL)
|
||||
F(w1 ,0xd186b8c721c0c207ULL)
|
||||
F(w2 ,0xeada7dd6cde0eb1eULL)
|
||||
F(w3 ,0xf57d4f7fee6ed178ULL)
|
||||
F(w4 ,0x06f067aa72176fbaULL)
|
||||
F(w5 ,0x0a637dc5a2c898a6ULL)
|
||||
F(w6 ,0x113f9804bef90daeULL)
|
||||
F(w7 ,0x1b710b35131c471bULL)
|
||||
F(w8 ,0x28db77f523047d84ULL)
|
||||
F(w9 ,0x32caab7b40c72493ULL)
|
||||
F(w10,0x3c9ebe0a15c9bebcULL)
|
||||
F(w11,0x431d67c49c100d4cULL)
|
||||
F(w12,0x4cc5d4becb3e42b6ULL)
|
||||
F(w13,0x597f299cfc657e2aULL)
|
||||
F(w14,0x5fcb6fab3ad6faecULL)
|
||||
F(w15,0x6c44198c4a475817ULL)
|
||||
|
||||
a += state[0];
|
||||
b += state[1];
|
||||
c += state[2];
|
||||
d += state[3];
|
||||
e += state[4];
|
||||
f += state[5];
|
||||
g += state[6];
|
||||
h += state[7];
|
||||
|
||||
state[0] = a;
|
||||
state[1] = b;
|
||||
state[2] = c;
|
||||
state[3] = d;
|
||||
state[4] = e;
|
||||
state[5] = f;
|
||||
state[6] = g;
|
||||
state[7] = h;
|
||||
|
||||
in += 128;
|
||||
inlen -= 128;
|
||||
}
|
||||
|
||||
store_bigendian(statebytes + 0,state[0]);
|
||||
store_bigendian(statebytes + 8,state[1]);
|
||||
store_bigendian(statebytes + 16,state[2]);
|
||||
store_bigendian(statebytes + 24,state[3]);
|
||||
store_bigendian(statebytes + 32,state[4]);
|
||||
store_bigendian(statebytes + 40,state[5]);
|
||||
store_bigendian(statebytes + 48,state[6]);
|
||||
store_bigendian(statebytes + 56,state[7]);
|
||||
|
||||
return inlen;
|
||||
}
|
||||
|
||||
/* -- crypto_hash_sha512 (hash.c) -- */
|
||||
|
||||
static const unsigned char iv[64] = {
|
||||
0x6a,0x09,0xe6,0x67,0xf3,0xbc,0xc9,0x08,
|
||||
0xbb,0x67,0xae,0x85,0x84,0xca,0xa7,0x3b,
|
||||
0x3c,0x6e,0xf3,0x72,0xfe,0x94,0xf8,0x2b,
|
||||
0xa5,0x4f,0xf5,0x3a,0x5f,0x1d,0x36,0xf1,
|
||||
0x51,0x0e,0x52,0x7f,0xad,0xe6,0x82,0xd1,
|
||||
0x9b,0x05,0x68,0x8c,0x2b,0x3e,0x6c,0x1f,
|
||||
0x1f,0x83,0xd9,0xab,0xfb,0x41,0xbd,0x6b,
|
||||
0x5b,0xe0,0xcd,0x19,0x13,0x7e,0x21,0x79
|
||||
};
|
||||
|
||||
void crypto_hash_sha512(unsigned char *out,
|
||||
const unsigned char *in,
|
||||
unsigned long long inlen)
|
||||
{
|
||||
unsigned char h[64];
|
||||
unsigned char padded[256];
|
||||
int i;
|
||||
unsigned long long bytes = inlen;
|
||||
|
||||
for (i = 0;i < 64;++i) h[i] = iv[i];
|
||||
|
||||
crypto_hashblocks_sha512(h,in,inlen);
|
||||
in += inlen;
|
||||
inlen &= 127;
|
||||
in -= inlen;
|
||||
|
||||
for (i = 0;i < (int)inlen;++i) padded[i] = in[i];
|
||||
padded[inlen] = 0x80;
|
||||
|
||||
if (inlen < 112) {
|
||||
for (i = inlen + 1;i < 119;++i) padded[i] = 0;
|
||||
padded[119] = bytes >> 61;
|
||||
padded[120] = bytes >> 53;
|
||||
padded[121] = bytes >> 45;
|
||||
padded[122] = bytes >> 37;
|
||||
padded[123] = bytes >> 29;
|
||||
padded[124] = bytes >> 21;
|
||||
padded[125] = bytes >> 13;
|
||||
padded[126] = bytes >> 5;
|
||||
padded[127] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,128);
|
||||
} else {
|
||||
for (i = inlen + 1;i < 247;++i) padded[i] = 0;
|
||||
padded[247] = bytes >> 61;
|
||||
padded[248] = bytes >> 53;
|
||||
padded[249] = bytes >> 45;
|
||||
padded[250] = bytes >> 37;
|
||||
padded[251] = bytes >> 29;
|
||||
padded[252] = bytes >> 21;
|
||||
padded[253] = bytes >> 13;
|
||||
padded[254] = bytes >> 5;
|
||||
padded[255] = bytes << 3;
|
||||
crypto_hashblocks_sha512(h,padded,256);
|
||||
}
|
||||
|
||||
for (i = 0;i < 64;++i) out[i] = h[i];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface Sntrup761Module {
|
||||
_sntrup761_wasm_keypair(pk: number, sk: number): void
|
||||
_sntrup761_wasm_enc(c: number, k: number, pk: number): void
|
||||
_sntrup761_wasm_dec(k: number, c: number, sk: number): void
|
||||
_malloc(size: number): number
|
||||
_free(ptr: number): void
|
||||
HEAPU8: Uint8Array
|
||||
}
|
||||
|
||||
declare function createSntrup761(): Promise<Sntrup761Module>
|
||||
export default createSntrup761
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* WASM wrapper for sntrup761.
|
||||
* Provides JS-callable functions with RNG from JS imports.
|
||||
*
|
||||
* Build: emcc sntrup761_wasm.c sntrup761.c sha512.c -O2 -o sntrup761.js \
|
||||
* -s EXPORTED_FUNCTIONS='["_sntrup761_wasm_keypair","_sntrup761_wasm_enc","_sntrup761_wasm_dec","_malloc","_free"]' \
|
||||
* -s EXPORTED_RUNTIME_METHODS='["ccall","cwrap"]'
|
||||
*/
|
||||
|
||||
#include "sntrup761.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Import RNG from JS environment */
|
||||
extern void js_random_bytes(unsigned char *buf, int len);
|
||||
|
||||
/* RNG callback adapter for sntrup761 */
|
||||
static void wasm_random(void *ctx, size_t length, uint8_t *dst) {
|
||||
(void)ctx;
|
||||
js_random_bytes(dst, (int)length);
|
||||
}
|
||||
|
||||
/* JS-callable wrappers */
|
||||
|
||||
void sntrup761_wasm_keypair(unsigned char *pk, unsigned char *sk) {
|
||||
sntrup761_keypair(pk, sk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_enc(unsigned char *c, unsigned char *k, const unsigned char *pk) {
|
||||
sntrup761_enc(c, k, pk, NULL, wasm_random);
|
||||
}
|
||||
|
||||
void sntrup761_wasm_dec(unsigned char *k, const unsigned char *c, const unsigned char *sk) {
|
||||
sntrup761_dec(k, c, sk);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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:wasm": "mkdir -p dist/wasm && npx emcc cbits/sntrup761_wasm.c ../cbits/sntrup761.c cbits/sha512.c -I../cbits -O2 -o dist/wasm/sntrup761.mjs -s EXPORTED_FUNCTIONS='[\"_sntrup761_wasm_keypair\",\"_sntrup761_wasm_enc\",\"_sntrup761_wasm_dec\",\"_malloc\",\"_free\"]' -s EXPORTED_RUNTIME_METHODS='[\"ccall\",\"cwrap\",\"HEAPU8\"]' -s MODULARIZE=1 -s EXPORT_NAME='createSntrup761' -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT='web,node' --js-library cbits/js_random.js && cp cbits/sntrup761.d.mts dist/wasm/",
|
||||
"build:ts": "tsc",
|
||||
"build:test": "tsc -p tsconfig.test.json",
|
||||
"build": "npm run build:wasm && npm run build:ts && npm run build:test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@simplex-chat/xftp-web": "file:../xftp-web",
|
||||
"emsdk": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"typescript": "^5.4.0",
|
||||
"ws": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
// AgentClient — agent state, worker infrastructure, locking, server selection.
|
||||
// Transpilation of Agent/Client.hs (AgentClient record, workers, operation state, locks, server selection).
|
||||
// Session management and queue operations will be added in subsequent steps.
|
||||
|
||||
import {ABQueue} from "./queue.js"
|
||||
import {TMVar} from "./tmvar.js"
|
||||
import {Sem} from "./queue.js"
|
||||
import {TSessionSubs} from "./subscriptions.js"
|
||||
import type {AgentStore} from "./store.js"
|
||||
import type {RetryInterval2} from "./retry.js"
|
||||
|
||||
// -- Error types (Client.hs:2296-2310 storeError, Client.hs:2104-2115 cryptoError)
|
||||
|
||||
export class AgentError extends Error {
|
||||
constructor(public readonly type: AgentErrorType) {
|
||||
super(agentErrorToString(type))
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentErrorType =
|
||||
| {tag: "AGENT", err: AgentErr}
|
||||
| {tag: "BROKER", addr: string, err: BrokerErr}
|
||||
| {tag: "SMP", addr: string, err: string}
|
||||
| {tag: "PROXY", proxyServer: string, relayServer: string, proxyErr: string}
|
||||
| {tag: "CONN", err: ConnErr, context: string}
|
||||
| {tag: "CMD", err: CmdErr, context: string}
|
||||
| {tag: "INTERNAL", msg: string}
|
||||
| {tag: "CRITICAL", important: boolean, msg: string}
|
||||
| {tag: "INACTIVE"}
|
||||
| {tag: "NO_USER"}
|
||||
|
||||
export type AgentErr =
|
||||
| "A_VERSION" | "A_ENCRYPTION" | "A_DUPLICATE" | "A_PROHIBITED" | "A_MESSAGE"
|
||||
| {tag: "A_QUEUE", msg: string}
|
||||
| {tag: "A_CRYPTO", err: string}
|
||||
|
||||
export type BrokerErr = "TIMEOUT" | "NETWORK" | "HOST" | "TRANSPORT" | {tag: "RESPONSE", err: string} | {tag: "UNEXPECTED", msg: string}
|
||||
|
||||
export type ConnErr = "NOT_FOUND" | "DUPLICATE" | "SIMPLEX" | "NOT_ACCEPTED" | "NOT_AVAILABLE"
|
||||
|
||||
export type CmdErr = "PROHIBITED" | "SYNTAX" | "NO_CONN" | {tag: "LARGE", msg: string}
|
||||
|
||||
function agentErrorToString(e: AgentErrorType): string {
|
||||
switch (e.tag) {
|
||||
case "INTERNAL": return `INTERNAL: ${e.msg}`
|
||||
case "CRITICAL": return `CRITICAL: ${e.msg}`
|
||||
case "AGENT": return `AGENT ${typeof e.err === "string" ? e.err : e.err.tag}`
|
||||
case "BROKER": return `BROKER ${e.addr} ${typeof e.err === "string" ? e.err : e.err.tag}`
|
||||
case "SMP": return `SMP ${e.addr} ${e.err}`
|
||||
case "CONN": return `CONN ${e.err} ${e.context}`
|
||||
case "CMD": return `CMD ${typeof e.err === "string" ? e.err : e.err.tag} ${e.context}`
|
||||
default: return e.tag
|
||||
}
|
||||
}
|
||||
|
||||
// -- Agent config (Env/SQLite.hs:136-180, 182-205)
|
||||
|
||||
export interface AgentConfig {
|
||||
tbqSize: number
|
||||
connIdBytes: number
|
||||
smpAgentVRange: [number, number] // [min, max]
|
||||
smpClientVRange: [number, number]
|
||||
e2eEncryptVRange: [number, number]
|
||||
messageRetryInterval: RetryInterval2
|
||||
messageTimeout: number // ms
|
||||
helloTimeout: number // ms
|
||||
quotaExceededTimeout: number // ms
|
||||
maxWorkerRestartsPerMin: number
|
||||
}
|
||||
|
||||
export const defaultAgentConfig: AgentConfig = {
|
||||
tbqSize: 128,
|
||||
connIdBytes: 12,
|
||||
// supportedSMPAgentVRange = [minSupportedSMPAgentVersion=2, currentSMPAgentVersion=7] (Agent/Protocol.hs:315-322)
|
||||
smpAgentVRange: [2, 7],
|
||||
// supportedSMPClientVRange = [initialSMPClientVersion=1, currentSMPClientVersion=4] (Protocol.hs:282-297)
|
||||
// NOTE: this is VersionSMPC (SMP client protocol), NOT the SMP transport version (≤18)
|
||||
smpClientVRange: [1, 4],
|
||||
// supportedE2EEncryptVRange = [kdfX3DHE2EEncryptVersion=2, currentE2EEncryptVersion=3] (Ratchet.hs:146-155)
|
||||
e2eEncryptVRange: [2, 3],
|
||||
messageRetryInterval: {
|
||||
riFast: {initialInterval: 2_000_000, increaseAfter: 10_000_000, maxInterval: 120_000_000},
|
||||
riSlow: {initialInterval: 300_000_000, increaseAfter: 60_000_000, maxInterval: 6 * 3600_000_000},
|
||||
},
|
||||
messageTimeout: 2 * 86400_000,
|
||||
helloTimeout: 2 * 86400_000,
|
||||
quotaExceededTimeout: 7 * 86400_000,
|
||||
maxWorkerRestartsPerMin: 5,
|
||||
}
|
||||
|
||||
// -- Server types
|
||||
|
||||
export interface SMPServerWithAuth {
|
||||
server: string // serialized server address
|
||||
auth: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface UserServers {
|
||||
storageSrvs: Array<[number | null, SMPServerWithAuth]> // [(Maybe OperatorId, ProtoServerWithAuth)]
|
||||
proxySrvs: Array<[number | null, SMPServerWithAuth]>
|
||||
knownHosts: Set<string>
|
||||
}
|
||||
|
||||
// -- Worker (Env/SQLite.hs:317-332)
|
||||
|
||||
export interface Worker {
|
||||
workerId: number
|
||||
doWork: TMVar<void>
|
||||
action: TMVar<number | null> // null = not running, number = "running" placeholder (no threadId in JS)
|
||||
restarts: {restartMinute: number, restartCount: number}
|
||||
}
|
||||
|
||||
// updateRestartCount (Env/SQLite.hs:329-332)
|
||||
function updateRestartCount(now: number, rc: {restartMinute: number, restartCount: number}): {restartMinute: number, restartCount: number} {
|
||||
const min = Math.floor(now / 60000)
|
||||
return {restartMinute: min, restartCount: min === rc.restartMinute ? rc.restartCount + 1 : 1}
|
||||
}
|
||||
|
||||
// -- AgentOperation (Client.hs:456-470)
|
||||
|
||||
export type AgentOperation = "AORcvNetwork" | "AOMsgDelivery" | "AOSndNetwork" | "AODatabase"
|
||||
|
||||
export interface AgentOpState {
|
||||
opSuspended: boolean
|
||||
opsInProgress: number
|
||||
}
|
||||
|
||||
export type AgentState = "ASForeground" | "ASSuspending" | "ASSuspended"
|
||||
|
||||
// -- ATransmission event type
|
||||
|
||||
export type ATransmission = [string, Uint8Array, any] // (corrId, connId, event)
|
||||
|
||||
// -- AgentClient (Client.hs:328-378)
|
||||
|
||||
export interface AgentClient {
|
||||
active: boolean
|
||||
subQ: ABQueue<ATransmission>
|
||||
msgQ: ABQueue<any> // ServerMsg from SMP clients, processed by subscriber loop
|
||||
config: AgentConfig
|
||||
store: AgentStore
|
||||
smpServers: Map<number, UserServers> // userId → servers
|
||||
smpClients: Map<string, any> // tSessKey → SMPClient or pending
|
||||
smpProxiedRelays: Map<string, SMPServerWithAuth>
|
||||
userNetworkInfo: {networkType: string, online: boolean}
|
||||
subscrConns: Set<string> // hex connIds being subscribed
|
||||
currentSubs: TSessionSubs
|
||||
// Monotonic counter shared by newWorker (workerId) and getSessVar (sessionVarId).
|
||||
// Mutable ref so getSessVar (session.ts) can increment the same counter — Haskell uses one TVar.
|
||||
workerSeq: {val: number}
|
||||
smpDeliveryWorkers: Map<string, {worker: Worker, retryLock: TMVar<void>}>
|
||||
asyncCmdWorkers: Map<string, Worker>
|
||||
rcvNetworkOp: AgentOpState
|
||||
msgDeliveryOp: AgentOpState
|
||||
sndNetworkOp: AgentOpState
|
||||
databaseOp: AgentOpState
|
||||
agentState: AgentState
|
||||
connLocks: Map<string, Sem>
|
||||
invLocks: Map<string, Sem>
|
||||
randomServer: {gen: () => number} // random index generator
|
||||
}
|
||||
|
||||
// newAgentClient (Client.hs:498-584)
|
||||
export function newAgentClient(config: AgentConfig, store: AgentStore, smpServers: Map<number, UserServers>): AgentClient {
|
||||
return {
|
||||
active: true,
|
||||
subQ: new ABQueue<ATransmission>(config.tbqSize),
|
||||
msgQ: new ABQueue<any>(config.tbqSize),
|
||||
config,
|
||||
store,
|
||||
smpServers,
|
||||
smpClients: new Map(),
|
||||
smpProxiedRelays: new Map(),
|
||||
userNetworkInfo: {networkType: "UNOther", online: true},
|
||||
subscrConns: new Set(),
|
||||
currentSubs: new TSessionSubs(),
|
||||
workerSeq: {val: 0},
|
||||
smpDeliveryWorkers: new Map(),
|
||||
asyncCmdWorkers: new Map(),
|
||||
rcvNetworkOp: {opSuspended: false, opsInProgress: 0},
|
||||
msgDeliveryOp: {opSuspended: false, opsInProgress: 0},
|
||||
sndNetworkOp: {opSuspended: false, opsInProgress: 0},
|
||||
databaseOp: {opSuspended: false, opsInProgress: 0},
|
||||
agentState: "ASForeground",
|
||||
connLocks: new Map(),
|
||||
invLocks: new Map(),
|
||||
randomServer: {gen: () => Math.random()},
|
||||
}
|
||||
}
|
||||
|
||||
// -- Worker functions (Client.hs:439-454, 2118-2176)
|
||||
|
||||
// newWorker (Client.hs:439-445)
|
||||
export function newWorker(c: AgentClient): Worker {
|
||||
const workerId = c.workerSeq.val++
|
||||
return {
|
||||
workerId,
|
||||
doWork: TMVar.new<void>(undefined), // starts with "has work"
|
||||
action: TMVar.new<number | null>(null), // not running
|
||||
restarts: {restartMinute: 0, restartCount: 0},
|
||||
}
|
||||
}
|
||||
|
||||
// waitForWork (Client.hs:2118-2119)
|
||||
export function waitForWork(doWork: TMVar<void>): Promise<void> {
|
||||
return doWork.read().then(() => {})
|
||||
}
|
||||
|
||||
// noWorkToDo (Client.hs:2167-2168)
|
||||
export function noWorkToDo(doWork: TMVar<void>): void {
|
||||
doWork.tryTake()
|
||||
}
|
||||
|
||||
// hasWorkToDo (Client.hs:2171-2172)
|
||||
export function hasWorkToDo(w: Worker): void {
|
||||
hasWorkToDo_(w.doWork)
|
||||
}
|
||||
|
||||
// hasWorkToDo' (Client.hs:2175-2176)
|
||||
export function hasWorkToDo_(doWork: TMVar<void>): void {
|
||||
doWork.tryPut(undefined)
|
||||
}
|
||||
|
||||
// runWorkerAsync (Client.hs:447-454)
|
||||
// Ensures work runs at most once concurrently. If already running, no-op.
|
||||
// In Haskell this uses bracket + forkIO. In JS, fire-and-forget Promise.
|
||||
//
|
||||
// bracket (takeTMVar action) (tryPutTMVar action) (\a -> when (isNothing a) start)
|
||||
// start = putTMVar action . Just =<< mkWeakThreadId =<< forkIO work
|
||||
export async function runWorkerAsync(w: Worker, work: () => Promise<void>): Promise<void> {
|
||||
const a = await w.action.take()
|
||||
if (a !== null) {
|
||||
// Already running — put back and return
|
||||
w.action.tryPut(a)
|
||||
return
|
||||
}
|
||||
// Mark as running, start work in background
|
||||
await w.action.put(1)
|
||||
// forkIO — fire and forget. Work function contains its own restart loop (runWork).
|
||||
// When work eventually stops (max restarts or worker removed), reset action to null.
|
||||
work().catch(() => {}).finally(() => {
|
||||
w.action.tryTake()
|
||||
w.action.tryPut(null)
|
||||
})
|
||||
}
|
||||
|
||||
// getAgentWorker (Client.hs:387-437)
|
||||
// Get or create a worker for the given key. If hasWork=true, signal the worker.
|
||||
// Starts the worker async loop if not already running.
|
||||
// The work function should use `forever` internally — this function handles crash restart.
|
||||
export async function getAgentWorker(
|
||||
name: string,
|
||||
hasWork_: boolean,
|
||||
c: AgentClient,
|
||||
key: string,
|
||||
workers: Map<string, Worker>,
|
||||
work: (w: Worker) => Promise<void>,
|
||||
): Promise<Worker> {
|
||||
// getWorker >>= maybe createWorker whenExists
|
||||
let w = workers.get(key)
|
||||
if (w) {
|
||||
if (hasWork_) hasWorkToDo(w)
|
||||
} else {
|
||||
w = newWorker(c)
|
||||
workers.set(key, w)
|
||||
}
|
||||
const worker = w
|
||||
// runWorker w = runWorkerAsync (toW w) runWork
|
||||
await runWorkerAsync(worker, () => runWork(name, c, key, workers, worker, work))
|
||||
return worker
|
||||
}
|
||||
|
||||
// runWork (Client.hs:405-413) — runs work, on error checks whether to restart
|
||||
async function runWork(
|
||||
name: string,
|
||||
c: AgentClient,
|
||||
key: string,
|
||||
workers: Map<string, Worker>,
|
||||
worker: Worker,
|
||||
work: (w: Worker) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// tryAllErrors' (work w) >>= restartOrDelete
|
||||
let error: unknown = undefined
|
||||
try {
|
||||
await work(worker)
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
// restartOrDelete (Client.hs:407-413)
|
||||
const now = Date.now()
|
||||
// getWorker >>= maybe (pure False) (shouldRestart ...)
|
||||
const currentWorker = workers.get(key)
|
||||
if (!currentWorker) return // worker was removed from map, don't restart
|
||||
if (currentWorker.workerId !== worker.workerId) return // replaced by new worker
|
||||
// shouldRestart (Client.hs:414-437)
|
||||
const rc = updateRestartCount(now, worker.restarts)
|
||||
const isActive = c.active
|
||||
const errStr = error !== undefined ? `, error: ${error}` : ", no error"
|
||||
const msg = `Worker ${name} for ${key} terminated ${rc.restartCount} times${errStr}`
|
||||
if (isActive && rc.restartCount < c.config.maxWorkerRestartsPerMin) {
|
||||
// checkRestarts: restart
|
||||
worker.restarts = rc
|
||||
hasWorkToDo_(worker.doWork)
|
||||
// Haskell: `void $ tryPutTMVar action Nothing` — a no-op here because `action` is
|
||||
// full (=1) for the whole restart chain (recursion stays inside the fired work()).
|
||||
// We must NOT empty it: doing so would let a concurrent getAgentWorker start a
|
||||
// second worker. tryPut on a full TMVar is a no-op, matching Haskell exactly.
|
||||
worker.action.tryPut(null)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "INTERNAL", msg}}])
|
||||
// when restart runWork — restart the worker
|
||||
await runWork(name, c, key, workers, worker, work)
|
||||
} else {
|
||||
// checkRestarts: delete
|
||||
workers.delete(key)
|
||||
if (isActive) {
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "CRITICAL", important: true, msg}}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withWork_ (Client.hs:2126-2140)
|
||||
// Clear work signal, get work from store, if found re-signal and run action.
|
||||
export async function withWork<T>(
|
||||
c: AgentClient,
|
||||
doWork: TMVar<void>,
|
||||
getWork: () => Promise<T | null>,
|
||||
action: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
noWorkToDo(doWork)
|
||||
let item: T | null
|
||||
try {
|
||||
item = await getWork()
|
||||
} catch (e) {
|
||||
hasWorkToDo_(doWork)
|
||||
const msg = `withWork error: ${e}`
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "INTERNAL", msg}}])
|
||||
return
|
||||
}
|
||||
if (item !== null) {
|
||||
hasWorkToDo_(doWork)
|
||||
await action(item)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Operation state (Client.hs:2179-2253)
|
||||
|
||||
function agentOpState(c: AgentClient, op: AgentOperation): AgentOpState {
|
||||
switch (op) {
|
||||
case "AORcvNetwork": return c.rcvNetworkOp
|
||||
case "AOMsgDelivery": return c.msgDeliveryOp
|
||||
case "AOSndNetwork": return c.sndNetworkOp
|
||||
case "AODatabase": return c.databaseOp
|
||||
}
|
||||
}
|
||||
|
||||
// beginAgentOperation (Client.hs:2223-2230)
|
||||
// DEVIATION: Haskell blocks (STM `retry`) while opSuspended, resuming when the agent
|
||||
// returns to foreground. Single-threaded JS can't synchronously block; the widget never
|
||||
// suspends (no suspendAgent), so opSuspended stays false and this path is unreachable.
|
||||
// We throw rather than silently proceed, to surface any unexpected suspend during dev.
|
||||
export function beginAgentOperation(c: AgentClient, op: AgentOperation): void {
|
||||
const s = agentOpState(c, op)
|
||||
if (s.opSuspended) throw new AgentError({tag: "INACTIVE"})
|
||||
s.opsInProgress++
|
||||
}
|
||||
|
||||
// endAgentOperation (Client.hs:2179-2197)
|
||||
export function endAgentOperation(c: AgentClient, op: AgentOperation): void {
|
||||
const s = agentOpState(c, op)
|
||||
s.opsInProgress = Math.max(0, s.opsInProgress - 1)
|
||||
if (s.opSuspended && s.opsInProgress === 0 && c.agentState === "ASSuspending") {
|
||||
cascadeSuspend(c, op)
|
||||
}
|
||||
}
|
||||
|
||||
function cascadeSuspend(c: AgentClient, op: AgentOperation): void {
|
||||
switch (op) {
|
||||
case "AORcvNetwork":
|
||||
suspendOp(c, "AOMsgDelivery", () => suspendSendingAndDatabase(c))
|
||||
break
|
||||
case "AOMsgDelivery":
|
||||
suspendSendingAndDatabase(c)
|
||||
break
|
||||
case "AOSndNetwork":
|
||||
suspendOp(c, "AODatabase", () => notifySuspended(c))
|
||||
break
|
||||
case "AODatabase":
|
||||
notifySuspended(c)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function suspendSendingAndDatabase(c: AgentClient): void {
|
||||
suspendOp(c, "AOSndNetwork", () => suspendOp(c, "AODatabase", () => notifySuspended(c)))
|
||||
}
|
||||
|
||||
function suspendOp(c: AgentClient, op: AgentOperation, endedAction: () => void): void {
|
||||
const s = agentOpState(c, op)
|
||||
s.opSuspended = true
|
||||
if (s.opsInProgress === 0 && c.agentState === "ASSuspending") endedAction()
|
||||
}
|
||||
|
||||
function notifySuspended(c: AgentClient): void {
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "SUSPENDED"}])
|
||||
c.agentState = "ASSuspended"
|
||||
}
|
||||
|
||||
// throwWhenInactive (Client.hs:959-962)
|
||||
export function throwWhenInactive(c: AgentClient): void {
|
||||
if (!c.active) throw new AgentError({tag: "INACTIVE"})
|
||||
}
|
||||
|
||||
// waitForUserNetwork (Client.hs:924-928)
|
||||
// In browser: if offline, we just throw. No blocking wait.
|
||||
export function checkUserNetwork(c: AgentClient): void {
|
||||
if (!c.userNetworkInfo.online) throw new AgentError({tag: "BROKER", addr: "", err: "NETWORK"})
|
||||
}
|
||||
|
||||
// -- Locking (Lock.hs, Client.hs:1003-1030)
|
||||
|
||||
// withConnLock (Client.hs:1003-1006)
|
||||
export async function withConnLock<T>(c: AgentClient, connId: Uint8Array, fn: () => Promise<T>): Promise<T> {
|
||||
const key = toHex(connId)
|
||||
return withLock(c.connLocks, key, fn)
|
||||
}
|
||||
|
||||
// withInvLock (Client.hs:1012-1015)
|
||||
export async function withInvLock<T>(c: AgentClient, invKey: Uint8Array, fn: () => Promise<T>): Promise<T> {
|
||||
return withLock(c.invLocks, toHex(invKey), fn)
|
||||
}
|
||||
|
||||
async function withLock<T>(locks: Map<string, Sem>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let sem = locks.get(key)
|
||||
if (!sem) { sem = new Sem(1); locks.set(key, sem) }
|
||||
await sem.wait()
|
||||
try { return await fn() } finally { sem.signal() }
|
||||
}
|
||||
|
||||
// -- Server selection (Client.hs:2312-2394)
|
||||
|
||||
// getNextServer (Client.hs:2325-2334)
|
||||
export function getNextServer(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
srvsSel: (us: UserServers) => Array<[number | null, SMPServerWithAuth]>,
|
||||
usedSrvs: string[],
|
||||
): SMPServerWithAuth {
|
||||
const us = c.smpServers.get(userId)
|
||||
if (!us) throw new AgentError({tag: "INTERNAL", msg: "unknown userId - no user servers"})
|
||||
const srvs = srvsSel(us)
|
||||
if (srvs.length === 0) throw new AgentError({tag: "INTERNAL", msg: "no servers configured"})
|
||||
const usedHosts = new Set(usedSrvs)
|
||||
// Prefer servers with unused hosts
|
||||
const unused = srvs.filter(([, s]) => !usedHosts.has(s.server))
|
||||
const pool = unused.length > 0 ? unused : srvs
|
||||
return pickServer(pool, c.randomServer)
|
||||
}
|
||||
|
||||
// pickServer (Client.hs:2318-2323)
|
||||
function pickServer(
|
||||
srvs: Array<[number | null, SMPServerWithAuth]>,
|
||||
rng: {gen: () => number},
|
||||
): SMPServerWithAuth {
|
||||
if (srvs.length === 1) return srvs[0][1]
|
||||
const idx = Math.floor(rng.gen() * srvs.length)
|
||||
return srvs[idx][1]
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Agent message encoding/decoding.
|
||||
// Mirrors: Simplex.Messaging.Agent.Protocol (AgentMsgEnvelope, AgentMessage, APrivHeader, AMessage)
|
||||
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeInt64, decodeInt64,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeMaybe, decodeMaybe,
|
||||
encodeNonEmpty, decodeNonEmpty,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Constants (Agent/Protocol.hs:318-319)
|
||||
|
||||
export const currentSMPAgentVersion = 7
|
||||
|
||||
// -- AMessage (Agent/Protocol.hs:1001-1020)
|
||||
|
||||
export type AMessage =
|
||||
| {type: "HELLO"}
|
||||
| {type: "A_MSG", body: Uint8Array}
|
||||
| {type: "A_RCVD", receipts: AMessageReceipt[]} // NonEmpty
|
||||
| {type: "EREADY", lastDecryptedMsgId: bigint}
|
||||
|
||||
// Agent/Protocol.hs:1040-1045
|
||||
export interface AMessageReceipt {
|
||||
agentMsgId: bigint // Int64
|
||||
msgHash: Uint8Array // ByteString (32-byte SHA-256)
|
||||
rcptInfo: Uint8Array // MsgReceiptInfo (ByteString, Large-encoded)
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1078-1100
|
||||
export function encodeAMessage(msg: AMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "HELLO": return new Uint8Array([0x48]) // "H"
|
||||
case "A_MSG": return concatBytes(new Uint8Array([0x4D]), msg.body) // "M" + Tail
|
||||
case "A_RCVD": return concatBytes(new Uint8Array([0x56]), encodeNonEmpty(encodeAMessageReceipt, msg.receipts)) // "V" + NonEmpty
|
||||
case "EREADY": return concatBytes(new Uint8Array([0x45]), encodeInt64(msg.lastDecryptedMsgId)) // "E" + Int64
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAMessage(d: Decoder): AMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x48: return {type: "HELLO"} // 'H'
|
||||
case 0x4D: return {type: "A_MSG", body: d.takeAll()} // 'M' + Tail
|
||||
case 0x56: return {type: "A_RCVD", receipts: decodeNonEmpty(decodeAMessageReceipt, d)} // 'V'
|
||||
case 0x45: return {type: "EREADY", lastDecryptedMsgId: decodeInt64(d)} // 'E'
|
||||
// Queue management tags (not needed for chat messages, but recognized for decoding)
|
||||
case 0x51: { // 'Q'
|
||||
const sub = d.anyByte()
|
||||
switch (sub) {
|
||||
case 0x43: // 'C' = A_QCONT
|
||||
case 0x41: // 'A' = QADD
|
||||
case 0x4B: // 'K' = QKEY
|
||||
case 0x55: // 'U' = QUSE
|
||||
case 0x54: // 'T' = QTEST
|
||||
throw new Error("decodeAMessage: queue management message (Q" + String.fromCharCode(sub) + ") not implemented")
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown Q-subtag " + sub)
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:1106-1111
|
||||
function encodeAMessageReceipt(r: AMessageReceipt): Uint8Array {
|
||||
return concatBytes(encodeInt64(r.agentMsgId), encodeBytes(r.msgHash), encodeLarge(r.rcptInfo))
|
||||
}
|
||||
|
||||
function decodeAMessageReceipt(d: Decoder): AMessageReceipt {
|
||||
return {agentMsgId: decodeInt64(d), msgHash: decodeBytes(d), rcptInfo: decodeLarge(d)}
|
||||
}
|
||||
|
||||
// -- APrivHeader (Agent/Protocol.hs:946-957)
|
||||
|
||||
export interface APrivHeader {
|
||||
sndMsgId: bigint // AgentMsgId = Int64
|
||||
prevMsgHash: Uint8Array // MsgHash = ByteString
|
||||
}
|
||||
|
||||
export function encodeAPrivHeader(h: APrivHeader): Uint8Array {
|
||||
return concatBytes(encodeInt64(h.sndMsgId), encodeBytes(h.prevMsgHash))
|
||||
}
|
||||
|
||||
export function decodeAPrivHeader(d: Decoder): APrivHeader {
|
||||
return {sndMsgId: decodeInt64(d), prevMsgHash: decodeBytes(d)}
|
||||
}
|
||||
|
||||
// -- AgentMessage (Agent/Protocol.hs:866-888)
|
||||
|
||||
export type AgentMessage =
|
||||
| {type: "connInfo", cInfo: Uint8Array}
|
||||
| {type: "connInfoReply", smpQueues: Uint8Array[], cInfo: Uint8Array} // NonEmpty raw-encoded SMPQueueInfo
|
||||
| {type: "ratchetInfo", info: Uint8Array}
|
||||
| {type: "message", header: APrivHeader, msg: AMessage}
|
||||
|
||||
export function encodeAgentMessage(msg: AgentMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "connInfo":
|
||||
return concatBytes(new Uint8Array([0x49]), msg.cInfo) // 'I' + Tail
|
||||
case "connInfoReply":
|
||||
// 'D' + NonEmpty SMPQueueInfo + Tail cInfo
|
||||
// SMPQueueInfo encoding is complex; for now encode the raw bytes
|
||||
return concatBytes(
|
||||
new Uint8Array([0x44]),
|
||||
encodeNonEmpty(b => b, msg.smpQueues),
|
||||
msg.cInfo,
|
||||
)
|
||||
case "ratchetInfo":
|
||||
return concatBytes(new Uint8Array([0x52]), msg.info) // 'R' + Tail
|
||||
case "message":
|
||||
return concatBytes(new Uint8Array([0x4D]), encodeAPrivHeader(msg.header), encodeAMessage(msg.msg)) // 'M' + header + msg
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeAgentMessage(d: Decoder): AgentMessage {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x49: return {type: "connInfo", cInfo: d.takeAll()} // 'I' + Tail
|
||||
case 0x44: { // 'D'
|
||||
// NonEmpty SMPQueueInfo is complex to decode; skip for now, just capture raw
|
||||
throw new Error("decodeAgentMessage: connInfoReply ('D') not implemented")
|
||||
}
|
||||
case 0x52: return {type: "ratchetInfo", info: d.takeAll()} // 'R' + Tail
|
||||
case 0x4D: return {type: "message", header: decodeAPrivHeader(d), msg: decodeAMessage(d)} // 'M'
|
||||
default:
|
||||
throw new Error("decodeAgentMessage: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// -- AgentMsgEnvelope (Agent/Protocol.hs:812-861)
|
||||
|
||||
export type AgentMsgEnvelope =
|
||||
| {type: "confirmation", agentVersion: number, e2eEncryption: Uint8Array | null, encConnInfo: Uint8Array}
|
||||
| {type: "envelope", agentVersion: number, encAgentMessage: Uint8Array}
|
||||
| {type: "invitation", agentVersion: number, connReqBytes: Uint8Array, connInfo: Uint8Array}
|
||||
| {type: "ratchetKey", agentVersion: number, e2eEncryption: Uint8Array, info: Uint8Array}
|
||||
|
||||
// Agent/Protocol.hs:835-843
|
||||
export function encodeAgentMsgEnvelope(env: AgentMsgEnvelope): Uint8Array {
|
||||
switch (env.type) {
|
||||
case "confirmation":
|
||||
// (agentVersion, 'C', Maybe SndE2ERatchetParams, Tail encConnInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x43]), // 'C'
|
||||
encodeMaybe(b => b, env.e2eEncryption), // e2eEncryption is already smpEncoded bytes or null
|
||||
env.encConnInfo, // Tail
|
||||
)
|
||||
case "envelope":
|
||||
// (agentVersion, 'M', Tail encAgentMessage)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x4D]), // 'M'
|
||||
env.encAgentMessage, // Tail
|
||||
)
|
||||
case "invitation":
|
||||
// (agentVersion, 'I', Large connReqBytes, Tail connInfo)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x49]), // 'I'
|
||||
encodeLarge(env.connReqBytes),
|
||||
env.connInfo, // Tail
|
||||
)
|
||||
case "ratchetKey":
|
||||
// (agentVersion, 'R', e2eEncryption, Tail info)
|
||||
return concatBytes(
|
||||
encodeWord16(env.agentVersion),
|
||||
new Uint8Array([0x52]), // 'R'
|
||||
env.e2eEncryption, // already smpEncoded
|
||||
env.info, // Tail
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Agent/Protocol.hs:844-861
|
||||
export function decodeAgentMsgEnvelope(d: Decoder): AgentMsgEnvelope {
|
||||
const agentVersion = decodeWord16(d)
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x43: // 'C' Confirmation
|
||||
// e2eEncryption_ is Maybe (SndE2ERatchetParams 'X448), encConnInfo is Tail
|
||||
// Full parsing of E2ERatchetParams needed to split the boundary — not implemented in spike
|
||||
throw new Error("decodeAgentMsgEnvelope: confirmation ('C') not fully implemented")
|
||||
case 0x4D: // 'M' Message envelope
|
||||
return {type: "envelope", agentVersion, encAgentMessage: d.takeAll()} // Tail
|
||||
case 0x49: { // 'I' Invitation
|
||||
const connReqBytes = decodeLarge(d)
|
||||
const connInfo = d.takeAll() // Tail
|
||||
return {type: "invitation", agentVersion, connReqBytes, connInfo}
|
||||
}
|
||||
case 0x52: { // 'R' RatchetKey
|
||||
// e2eEncryption is an E2ERatchetParams — variable-length, not Tail
|
||||
// For now, capture remaining minus nothing (since info is Tail and comes last)
|
||||
// This is tricky: e2eEncryption is smpEncoded E2ERatchetParams, info is Tail
|
||||
// We can't easily split without knowing the E2ERatchetParams length
|
||||
// For the spike, just capture all remaining as raw
|
||||
throw new Error("decodeAgentMsgEnvelope: ratchetKey ('R') not fully implemented")
|
||||
}
|
||||
default:
|
||||
throw new Error("decodeAgentMsgEnvelope: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
// 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, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeBool, decodeBool,
|
||||
encodeMaybe, decodeMaybe,
|
||||
encodeNonEmpty, decodeNonEmpty,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {encodeProtocolServer} from "../protocol.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}
|
||||
}
|
||||
|
||||
// -- SMPQueueAddress (Agent/Protocol.hs:1350-1356)
|
||||
|
||||
export interface SMPQueueAddress {
|
||||
smpServer: {hosts: string[], port: string, keyHash: Uint8Array} // ProtocolServer
|
||||
senderId: Uint8Array // EntityId (ByteString)
|
||||
dhPublicKey: Uint8Array // PublicKeyX25519 (DER-encoded ByteString)
|
||||
queueMode: string | null // Maybe QueueMode: 'M' = Messaging, 'C' = Contact
|
||||
}
|
||||
|
||||
// -- SMPQueueInfo (Agent/Protocol.hs:1310-1327)
|
||||
// Version-dependent encoding
|
||||
|
||||
// SMP client version constants (Protocol.hs:281-294)
|
||||
const initialSMPClientVersion = 1
|
||||
const sndAuthKeySMPClientVersion = 3
|
||||
const shortLinksSMPClientVersion = 4
|
||||
|
||||
export interface SMPQueueInfo {
|
||||
clientVersion: number // VersionSMPC (Word16)
|
||||
queueAddress: SMPQueueAddress
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1313-1321)
|
||||
export function encodeSMPQueueInfo(q: SMPQueueInfo): Uint8Array {
|
||||
const {clientVersion, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}} = q
|
||||
const addrEnc = concatBytes(
|
||||
encodeWord16(clientVersion),
|
||||
encodeProtocolServer(smpServer.hosts, smpServer.port, smpServer.keyHash),
|
||||
encodeBytes(senderId),
|
||||
encodeBytes(dhPublicKey),
|
||||
)
|
||||
if (clientVersion >= shortLinksSMPClientVersion) {
|
||||
// encode queueMode directly (Maybe QueueMode as char or empty)
|
||||
const qmBytes = queueMode ? new Uint8Array([queueMode.charCodeAt(0)]) : new Uint8Array(0)
|
||||
return concatBytes(addrEnc, qmBytes)
|
||||
}
|
||||
if (clientVersion >= sndAuthKeySMPClientVersion && senderCanSecure(queueMode)) {
|
||||
return concatBytes(addrEnc, encodeBool(true))
|
||||
}
|
||||
if (clientVersion > initialSMPClientVersion) {
|
||||
return addrEnc
|
||||
}
|
||||
// v1 legacy — not supported by web widget
|
||||
throw new Error("encodeSMPQueueInfo: legacy v1 not supported")
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1322-1327)
|
||||
export function decodeSMPQueueInfo(d: Decoder): SMPQueueInfo {
|
||||
const clientVersion = decodeWord16(d)
|
||||
// v1 legacy server encoding not supported
|
||||
if (clientVersion <= initialSMPClientVersion) throw new Error("decodeSMPQueueInfo: legacy v1 not supported")
|
||||
const smpServer = decodeProtocolServerTyped(d)
|
||||
const senderId = decodeBytes(d)
|
||||
const dhPublicKey = decodeBytes(d)
|
||||
const queueMode = decodeQueueMode(d)
|
||||
return {clientVersion, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}}
|
||||
}
|
||||
|
||||
// -- SMPQueueUri (Agent/Protocol.hs:1347-1431)
|
||||
|
||||
export interface SMPQueueUri {
|
||||
clientVRange: {min: number, max: number} // VersionRangeSMPC
|
||||
queueAddress: SMPQueueAddress
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1417-1427)
|
||||
export function encodeSMPQueueUri(q: SMPQueueUri): Uint8Array {
|
||||
const {clientVRange: {min: minV, max: maxV}, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}} = q
|
||||
const addrEnc = concatBytes(
|
||||
encodeWord16(minV), encodeWord16(maxV),
|
||||
encodeProtocolServer(smpServer.hosts, smpServer.port, smpServer.keyHash),
|
||||
encodeBytes(senderId),
|
||||
encodeBytes(dhPublicKey),
|
||||
)
|
||||
if (minV >= shortLinksSMPClientVersion) {
|
||||
const qmBytes = queueMode ? new Uint8Array([queueMode.charCodeAt(0)]) : new Uint8Array(0)
|
||||
return concatBytes(addrEnc, qmBytes)
|
||||
}
|
||||
if (minV >= sndAuthKeySMPClientVersion || (maxV >= sndAuthKeySMPClientVersion && senderCanSecure(queueMode))) {
|
||||
return concatBytes(addrEnc, encodeBool(senderCanSecure(queueMode)))
|
||||
}
|
||||
return addrEnc
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1428-1431)
|
||||
export function decodeSMPQueueUri(d: Decoder): SMPQueueUri {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const smpServer = decodeProtocolServerTyped(d)
|
||||
const senderId = decodeBytes(d)
|
||||
const dhPublicKey = decodeBytes(d)
|
||||
const queueMode = decodeQueueMode(d)
|
||||
return {clientVRange: {min, max}, queueAddress: {smpServer, senderId, dhPublicKey, queueMode}}
|
||||
}
|
||||
|
||||
// -- ConnReqUriData (Agent/Protocol.hs:1728-1734, 1145-1158)
|
||||
|
||||
export interface ConnReqUriData {
|
||||
crAgentVRange: {min: number, max: number} // VersionRangeSMPA
|
||||
crSmpQueues: SMPQueueUri[] // NonEmpty SMPQueueUri
|
||||
crClientData: string | null // Maybe CRClientData (Text)
|
||||
}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1145-1147)
|
||||
export function encodeConnReqUriData(d: ConnReqUriData): Uint8Array {
|
||||
const vr = concatBytes(encodeWord16(d.crAgentVRange.min), encodeWord16(d.crAgentVRange.max))
|
||||
const queues = encodeNonEmpty(encodeSMPQueueUri, d.crSmpQueues)
|
||||
const clientData = d.crClientData !== null
|
||||
? concatBytes(new Uint8Array([0x31]), encodeLarge(new TextEncoder().encode(d.crClientData)))
|
||||
: new Uint8Array([0x30])
|
||||
return concatBytes(vr, queues, clientData)
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1148-1158)
|
||||
export function decodeConnReqUriData(d: Decoder): ConnReqUriData {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const crSmpQueues = decodeNonEmpty(decodeSMPQueueUri, d)
|
||||
// Patch queueMode: if Nothing, set to QMContact (Agent/Protocol.hs:1156-1158)
|
||||
for (const q of crSmpQueues) {
|
||||
if (q.queueAddress.queueMode === null) q.queueAddress.queueMode = "C"
|
||||
}
|
||||
const clientData = decodeMaybe((dd) => {
|
||||
const large = decodeLarge(dd)
|
||||
return new TextDecoder().decode(large)
|
||||
}, d)
|
||||
return {crAgentVRange: {min, max}, crSmpQueues, crClientData: clientData}
|
||||
}
|
||||
|
||||
// -- ConnectionRequestUri (Agent/Protocol.hs:1130-1143, 1436-1441)
|
||||
|
||||
export type ConnectionRequestUri =
|
||||
| {mode: "invitation", crData: ConnReqUriData, e2eParams: Uint8Array} // raw smpEncoded E2ERatchetParams
|
||||
| {mode: "contact", crData: ConnReqUriData}
|
||||
|
||||
// smpEncode (Agent/Protocol.hs:1130-1133)
|
||||
export function encodeConnectionRequestUri(cr: ConnectionRequestUri): Uint8Array {
|
||||
switch (cr.mode) {
|
||||
case "invitation":
|
||||
return concatBytes(new Uint8Array([0x49]), encodeConnReqUriData(cr.crData), cr.e2eParams) // 'I' + crData + e2eParams
|
||||
case "contact":
|
||||
return concatBytes(new Uint8Array([0x43]), encodeConnReqUriData(cr.crData)) // 'C' + crData
|
||||
}
|
||||
}
|
||||
|
||||
// smpP (Agent/Protocol.hs:1140-1143)
|
||||
export function decodeConnectionRequestUri(d: Decoder): ConnectionRequestUri {
|
||||
const mode = d.anyByte()
|
||||
if (mode === 0x49) { // 'I' Invitation
|
||||
const crData = decodeConnReqUriData(d)
|
||||
const e2eParams = d.takeAll() // E2ERatchetParams consumes rest
|
||||
return {mode: "invitation", crData, e2eParams}
|
||||
}
|
||||
if (mode === 0x43) { // 'C' Contact
|
||||
const crData = decodeConnReqUriData(d)
|
||||
return {mode: "contact", crData}
|
||||
}
|
||||
throw new Error("decodeConnectionRequestUri: unknown mode 0x" + mode.toString(16))
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
function senderCanSecure(queueMode: string | null): boolean {
|
||||
return queueMode === "M"
|
||||
}
|
||||
|
||||
// queueModeP (Agent/Protocol.hs:1433-1434)
|
||||
// Just <$> smpP <|> optional ((\case True -> QMMessaging; _ -> QMContact) <$> smpP)
|
||||
function decodeQueueMode(d: Decoder): string | null {
|
||||
if (d.remaining() === 0) return null
|
||||
const b = d.anyByte()
|
||||
if (b === 0x4D) return "M" // QMMessaging
|
||||
if (b === 0x43) return "C" // QMContact
|
||||
// Could be a Bool (sndSecure) for older versions — True='T'(0x54) → QMMessaging, False='F'(0x46) → QMContact
|
||||
if (b === 0x54) return "M" // True → QMMessaging
|
||||
if (b === 0x46) return null // False → no queueMode (not secured)
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode ProtocolServer into typed format with string hosts
|
||||
function decodeProtocolServerTyped(d: Decoder): {hosts: string[], port: string, keyHash: Uint8Array} {
|
||||
const hostCount = d.anyByte()
|
||||
if (hostCount === 0) throw new Error("empty server host list")
|
||||
const hosts: string[] = []
|
||||
for (let i = 0; i < hostCount; i++) hosts.push(new TextDecoder().decode(decodeBytes(d)))
|
||||
const port = new TextDecoder().decode(decodeBytes(d))
|
||||
const keyHash = decodeBytes(d)
|
||||
return {hosts, port, keyHash}
|
||||
}
|
||||
|
||||
// -- 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,82 @@
|
||||
// Copied verbatim from simplex-chat/packages/simplex-chat-client/typescript/src/queue.ts
|
||||
|
||||
export class Sem {
|
||||
private readonly promises: ((x: unknown) => void)[] = []
|
||||
|
||||
constructor(private permits: number) {}
|
||||
|
||||
signal(): void {
|
||||
this.permits += 1
|
||||
if (this.promises.length > 0) (this.promises.pop() as () => void)()
|
||||
}
|
||||
|
||||
async wait(): Promise<void> {
|
||||
if (this.permits === 0 || this.promises.length > 0) {
|
||||
await new Promise((r) => this.promises.unshift(r))
|
||||
}
|
||||
this.permits -= 1
|
||||
}
|
||||
}
|
||||
|
||||
export type NextIter<T> = {value: T | Promise<T>; done?: false} | {value?: undefined; done: true}
|
||||
|
||||
const queueClosed = Symbol()
|
||||
|
||||
type QueueItem<T> = T | typeof queueClosed
|
||||
|
||||
export class ABQueueError extends Error {}
|
||||
|
||||
export class ABQueue<T> {
|
||||
private readonly queue: QueueItem<T>[] = []
|
||||
private readonly enq: Sem
|
||||
private readonly deq: Sem
|
||||
private enqClosed = false
|
||||
private deqClosed = false
|
||||
|
||||
constructor(readonly maxSize: number) {
|
||||
this.enq = new Sem(0)
|
||||
this.deq = new Sem(maxSize)
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): ABQueue<T> {
|
||||
return this
|
||||
}
|
||||
|
||||
enqueue(x: T): Promise<void> {
|
||||
return this._enqueue(x)
|
||||
}
|
||||
|
||||
private async _enqueue(x: QueueItem<T>): Promise<void> {
|
||||
if (this.enqClosed) throw new ABQueueError("enqueue: queue closed")
|
||||
await this.deq.wait()
|
||||
this.queue.push(x)
|
||||
this.enq.signal()
|
||||
}
|
||||
|
||||
async dequeue(): Promise<T> {
|
||||
if (this.deqClosed) throw new ABQueueError("dequeue: queue closed")
|
||||
this.deq.signal()
|
||||
await this.enq.wait()
|
||||
const x = this.queue.shift() as QueueItem<T>
|
||||
if (x === queueClosed) {
|
||||
this.deqClosed = true
|
||||
throw new ABQueueError("dequeue: queue closed")
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this._enqueue(queueClosed)
|
||||
this.enqClosed = true
|
||||
}
|
||||
|
||||
async next(): Promise<NextIter<T>> {
|
||||
if (this.deqClosed) return {done: true}
|
||||
try {
|
||||
return {value: await this.dequeue()}
|
||||
} catch (e) {
|
||||
if (e instanceof ABQueueError) return {done: true}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Retry interval logic.
|
||||
// Transpilation of Agent/RetryInterval.hs (lines 27-118).
|
||||
// withRetryForeground is skipped (uses registerDelay + STM retry, Haskell-specific).
|
||||
|
||||
import {TMVar} from "./tmvar.js"
|
||||
|
||||
// RetryInterval (RetryInterval.hs:27-31)
|
||||
// All intervals in microseconds (matching Haskell Int64).
|
||||
export interface RetryInterval {
|
||||
initialInterval: number
|
||||
increaseAfter: number
|
||||
maxInterval: number
|
||||
}
|
||||
|
||||
// RetryInterval2 (RetryInterval.hs:33-36)
|
||||
export interface RetryInterval2 {
|
||||
riSlow: RetryInterval
|
||||
riFast: RetryInterval
|
||||
}
|
||||
|
||||
// RI2State (RetryInterval.hs:38-41)
|
||||
export interface RI2State {
|
||||
slowInterval: number
|
||||
fastInterval: number
|
||||
}
|
||||
|
||||
// RetryIntervalMode (RetryInterval.hs:51)
|
||||
export type RetryIntervalMode = "RISlow" | "RIFast"
|
||||
|
||||
// nextRetryDelay (RetryInterval.hs:114-118)
|
||||
export function nextRetryDelay(elapsed: number, delay: number, ri: RetryInterval): number {
|
||||
if (elapsed < ri.increaseAfter || delay === ri.maxInterval) return delay
|
||||
return Math.min(Math.floor(delay * 3 / 2), ri.maxInterval)
|
||||
}
|
||||
|
||||
// updateRetryInterval2 (RetryInterval.hs:44-49)
|
||||
export function updateRetryInterval2(state: RI2State, ri2: RetryInterval2): RetryInterval2 {
|
||||
return {
|
||||
riSlow: {...ri2.riSlow, initialInterval: state.slowInterval, increaseAfter: 0},
|
||||
riFast: {...ri2.riFast, initialInterval: state.fastInterval, increaseAfter: 0},
|
||||
}
|
||||
}
|
||||
|
||||
function delay(us: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, us / 1000))
|
||||
}
|
||||
|
||||
// withRetryInterval (RetryInterval.hs:54-55)
|
||||
export function withRetryInterval(
|
||||
ri: RetryInterval,
|
||||
action: (delay: number, loop: () => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
return withRetryIntervalCount(ri, (_n, d, loop) => action(d, loop))
|
||||
}
|
||||
|
||||
// withRetryIntervalCount (RetryInterval.hs:57-66)
|
||||
export function withRetryIntervalCount(
|
||||
ri: RetryInterval,
|
||||
action: (n: number, delay: number, loop: () => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
function callAction(n: number, elapsed: number, d: number): Promise<void> {
|
||||
return action(n, d, async () => {
|
||||
await delay(d)
|
||||
const elapsed_ = elapsed + d
|
||||
return callAction(n + 1, elapsed_, nextRetryDelay(elapsed_, d, ri))
|
||||
})
|
||||
}
|
||||
return callAction(0, 0, ri.initialInterval)
|
||||
}
|
||||
|
||||
// withRetryLock2 (RetryInterval.hs:90-112)
|
||||
// Two-mode retry with lock. The lock (TMVar<void>) can be released early
|
||||
// by an external signal (e.g., QCONT message), cancelling the timer wait.
|
||||
//
|
||||
// The action receives the current RI2State and a loop function.
|
||||
// Calling loop(mode) sleeps for the appropriate interval, then recurses.
|
||||
// During sleep, if the lock is released externally, sleep ends early.
|
||||
export function withRetryLock2(
|
||||
ri2: RetryInterval2,
|
||||
lock: TMVar<void>,
|
||||
action: (state: RI2State, loop: (mode: RetryIntervalMode) => Promise<void>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
function callAction(slow: [number, number], fast: [number, number]): Promise<void> {
|
||||
return action({slowInterval: slow[1], fastInterval: fast[1]}, (mode) => {
|
||||
if (mode === "RISlow") return run(slow, ri2.riSlow, (s) => callAction(s, fast))
|
||||
return run(fast, ri2.riFast, (f) => callAction(slow, f))
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
[elapsed, d]: [number, number],
|
||||
ri: RetryInterval,
|
||||
call: (state: [number, number]) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await wait(d)
|
||||
const elapsed_ = elapsed + d
|
||||
const delay_ = nextRetryDelay(elapsed_, d, ri)
|
||||
return call([elapsed_, delay_])
|
||||
}
|
||||
|
||||
// wait (RetryInterval.hs:105-112)
|
||||
// Race between timer expiry and external lock release.
|
||||
// In Haskell: forkIO sets a timer that puts () into the lock TMVar,
|
||||
// then the main thread takes from the lock (blocking until timer or external signal).
|
||||
async function wait(d: number): Promise<void> {
|
||||
let waiting = true
|
||||
// Start timer that will release the lock after delay
|
||||
const timer = setTimeout(() => {
|
||||
if (waiting) lock.tryPut(undefined as any)
|
||||
}, d / 1000)
|
||||
// Block until lock is released (by timer or externally)
|
||||
await lock.take()
|
||||
waiting = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
return callAction([0, ri2.riSlow.initialInterval], [0, ri2.riFast.initialInterval])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SessionVar — pending protocol client connection tracking.
|
||||
// Transpilation of Simplex.Messaging.Session (Session.hs:18-42).
|
||||
//
|
||||
// SessionVar wraps a Promise that resolves when the client connection is established.
|
||||
// First caller creates it (Left/new), subsequent callers get the existing one (Right/existing)
|
||||
// and await the same Promise.
|
||||
|
||||
export interface SessionVar<T> {
|
||||
id: number
|
||||
ts: number // creation timestamp (ms)
|
||||
promise: Promise<T>
|
||||
resolve: (v: T) => void
|
||||
reject: (e: Error) => void
|
||||
value: T | undefined // set after resolve, for tryRead
|
||||
}
|
||||
|
||||
// getSessVar (Session.hs:24-33)
|
||||
// Get existing SessionVar for key, or create a new empty one.
|
||||
// Returns {isNew: true, v} for new, {isNew: false, v} for existing.
|
||||
// Mirrors Haskell Left (new) / Right (existing).
|
||||
export function getSessVar<T>(
|
||||
seq: {val: number},
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): {isNew: boolean, v: SessionVar<T>} {
|
||||
const existing = vars.get(key)
|
||||
if (existing) return {isNew: false, v: existing}
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
// When resolved, store value for tryRead
|
||||
const v: SessionVar<T> = {
|
||||
id: seq.val++,
|
||||
ts: Date.now(),
|
||||
promise,
|
||||
resolve: (val: T) => { v.value = val; resolve(val) },
|
||||
reject,
|
||||
value: undefined,
|
||||
}
|
||||
vars.set(key, v)
|
||||
return {isNew: true, v}
|
||||
}
|
||||
|
||||
// removeSessVar (Session.hs:35-39)
|
||||
// Remove only if the ID matches — guards against removing a replaced session.
|
||||
export function removeSessVar<T>(
|
||||
v: SessionVar<T>,
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): void {
|
||||
const current = vars.get(key)
|
||||
if (current && current.id === v.id) vars.delete(key)
|
||||
}
|
||||
|
||||
// tryReadSessVar (Session.hs:41-42)
|
||||
// Non-blocking read of resolved value. Returns undefined if not yet resolved.
|
||||
export function tryReadSessVar<T>(
|
||||
key: string,
|
||||
vars: Map<string, SessionVar<T>>,
|
||||
): T | undefined {
|
||||
return vars.get(key)?.value
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// SMP session management and queue operations.
|
||||
// Transpilation of Agent/Client.hs: getSMPServerClient, agentCbEncrypt/Decrypt,
|
||||
// sendConfirmation, sendAgentMessage, newRcvQueue, subscribeQueues, etc.
|
||||
|
||||
import type {SMPClient, ProxiedRelay} from "../client.js"
|
||||
import {createSMPClient} from "../client.js"
|
||||
import type {AuthKey, SMPResponse} from "../protocol.js"
|
||||
import {
|
||||
encodeClientMsgEnvelope, encodeClientMessage,
|
||||
type ClientMsgEnvelope, type ClientMessage, type PubHeader, type PrivHeader,
|
||||
} from "../protocol.js"
|
||||
import {getSessVar, removeSessVar, tryReadSessVar, type SessionVar} from "./session.js"
|
||||
import {AgentError, type AgentClient, type AgentErrorType} from "./client.js"
|
||||
import {ABQueue} from "./queue.js"
|
||||
import type {RcvQueueSub} from "./subscriptions.js"
|
||||
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {generateX25519KeyPair, generateEd25519KeyPair, dh, encodePubKeyX25519, encodePubKeyEd25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {concatBytes, encodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Transport session key (simplified: one session per server, no TSMEntity)
|
||||
|
||||
export function tSessKey(userId: number, server: string): string {
|
||||
return `${userId}:${server}`
|
||||
}
|
||||
|
||||
// -- SMP connected client with proxy relay sessions
|
||||
|
||||
export interface SMPConnectedClient {
|
||||
client: SMPClient
|
||||
proxiedRelays: Map<string, SessionVar<ProxiedRelay | AgentErrorType>>
|
||||
}
|
||||
|
||||
// -- Server message for msgQ (dispatched by subscriber loop)
|
||||
|
||||
export interface ServerMsg {
|
||||
userId: number
|
||||
server: string
|
||||
sessionId: Uint8Array
|
||||
entityId: Uint8Array
|
||||
msg: SMPResponse
|
||||
}
|
||||
|
||||
// -- getSMPServerClient (Client.hs:642-651)
|
||||
// Get or create SMP client for the given server.
|
||||
// Returns existing if connected, waits if pending, connects if new.
|
||||
export async function getSMPServerClient(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
): Promise<SMPConnectedClient> {
|
||||
if (!c.active) throw new AgentError({tag: "INACTIVE"})
|
||||
const key = tSessKey(userId, server)
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
const {isNew, v} = getSessVar(c.workerSeq, key, clients)
|
||||
if (isNew) {
|
||||
return smpConnectClient(c, userId, server, keyHash, wsUrl, key, v)
|
||||
}
|
||||
return waitForSMPClient(server, v)
|
||||
}
|
||||
|
||||
// smpConnectClient (Client.hs:704-718)
|
||||
async function smpConnectClient(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
key: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
): Promise<SMPConnectedClient> {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
try {
|
||||
const smp = await createSMPClient(
|
||||
wsUrl, keyHash,
|
||||
// onMessage (Client.hs:716 — messages go to msgQ)
|
||||
(entityId: Uint8Array, msg: SMPResponse) => {
|
||||
const serverMsg: ServerMsg = {userId, server, sessionId: smp.sessionId, entityId, msg}
|
||||
c.msgQ.enqueue(serverMsg)
|
||||
},
|
||||
// onDisconnected (Client.hs:720-754 — smpClientDisconnected)
|
||||
() => smpClientDisconnected(c, userId, server, key, v, smp),
|
||||
)
|
||||
// setSessionId in subscription tracker (Client.hs:717)
|
||||
c.currentSubs.setSessionId(tSessKey(userId, server), smp.sessionId)
|
||||
const connected: SMPConnectedClient = {client: smp, proxiedRelays: new Map()}
|
||||
v.resolve(connected)
|
||||
// Notify CONNECT (Client.hs:884)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "CONNECT", server}])
|
||||
return connected
|
||||
} catch (e) {
|
||||
// Connection failed (Client.hs:886-895)
|
||||
removeSessVar(v, key, clients)
|
||||
v.reject(e instanceof Error ? e : new Error(String(e)))
|
||||
throw new AgentError({tag: "BROKER", addr: server, err: "NETWORK"})
|
||||
}
|
||||
}
|
||||
|
||||
// waitForProtocolClient (Client.hs:847-868)
|
||||
async function waitForSMPClient(
|
||||
server: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
): Promise<SMPConnectedClient> {
|
||||
try {
|
||||
return await v.promise
|
||||
} catch {
|
||||
throw new AgentError({tag: "BROKER", addr: server, err: "NETWORK"})
|
||||
}
|
||||
}
|
||||
|
||||
// smpClientDisconnected (Client.hs:720-754)
|
||||
// Handle WebSocket disconnect: move subs to pending, notify DOWN, remove client.
|
||||
function smpClientDisconnected(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
key: string,
|
||||
v: SessionVar<SMPConnectedClient>,
|
||||
smp: SMPClient,
|
||||
): void {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
// removeSessVar (only if this is still the current client)
|
||||
removeSessVar(v, key, clients)
|
||||
if (!c.active) return
|
||||
// Move active subs to pending (Client.hs:731-737)
|
||||
const tSess = tSessKey(userId, server)
|
||||
const moved = c.currentSubs.setSubsPending(tSess, smp.sessionId)
|
||||
// Notify DISCONNECT (Client.hs:746)
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "DISCONNECT", server}])
|
||||
if (moved.size > 0) {
|
||||
// Notify DOWN with affected connIds (Client.hs:747)
|
||||
const connIds = [...new Set([...moved.values()].map(rq => toHex(rq.connId)))]
|
||||
c.subQ.enqueue(["", new Uint8Array(0), {tag: "DOWN", server, connIds}])
|
||||
// TODO: trigger resubscription (Client.hs:750-754)
|
||||
}
|
||||
}
|
||||
|
||||
// -- agentCbEncrypt (Client.hs:2074-2082)
|
||||
// Per-queue E2E encrypt with stored DH secret.
|
||||
// e2ePubKey is the RAW 32-byte X25519 public key (or null for messages).
|
||||
// Haskell smpEncode of PubHeader's `Maybe C.PublicKeyX25519` DER-encodes the key
|
||||
// (Crypto.hs:568-570), so we DER-encode it before placing it in the header.
|
||||
// Returns encoded ClientMsgEnvelope.
|
||||
export function agentCbEncrypt(
|
||||
e2eDhSecret: Uint8Array,
|
||||
smpClientVersion: number,
|
||||
e2ePubKey: Uint8Array | null,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const cmNonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
// paddedLen: e2eEncConfirmationLength (15904) for confirmations, e2eEncMessageLength (16000) for messages
|
||||
// Protocol.hs:316-320
|
||||
const paddedLen = e2ePubKey !== null ? 15904 : 16000
|
||||
const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen)
|
||||
const env: ClientMsgEnvelope = {
|
||||
cmHeader: {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey !== null ? encodePubKeyX25519(e2ePubKey) : null},
|
||||
cmNonce,
|
||||
cmEncBody,
|
||||
}
|
||||
return encodeClientMsgEnvelope(env)
|
||||
}
|
||||
|
||||
// agentCbEncryptOnce (Client.hs:2085-2095)
|
||||
// Per-queue E2E encrypt with ephemeral DH key (for invitations).
|
||||
export function agentCbEncryptOnce(
|
||||
clientVersion: number,
|
||||
dhRcvPubKey: Uint8Array,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const {publicKey: dhSndPubKey, privateKey: dhSndPrivKey} = generateX25519KeyPair()
|
||||
const e2eDhSecret = dh(dhRcvPubKey, dhSndPrivKey)
|
||||
return agentCbEncrypt(e2eDhSecret, clientVersion, dhSndPubKey, msg)
|
||||
}
|
||||
|
||||
// agentCbDecrypt (Client.hs:2099-2102)
|
||||
export function agentCbDecrypt(
|
||||
dhSecret: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
msg: Uint8Array,
|
||||
): Uint8Array {
|
||||
const result = cbDecrypt(dhSecret, nonce, msg)
|
||||
if (result === null) throw new AgentError({tag: "AGENT", err: {tag: "A_CRYPTO", err: "DECRYPT_CB"}})
|
||||
return result
|
||||
}
|
||||
|
||||
// -- sendAgentMessage (Client.hs:1948-1952)
|
||||
// Per-queue E2E encrypt message + SEND.
|
||||
// sq is raw IDB SndQueue row.
|
||||
export async function sendAgentMessage(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
msgFlags: {notification: boolean},
|
||||
agentMsg: Uint8Array,
|
||||
): Promise<void> {
|
||||
const clientMsg: ClientMessage = {privHeader: {type: "PHEmpty"}, body: agentMsg}
|
||||
const msg = agentCbEncrypt(sq.e2e_dh_secret, sq.smp_client_version, null, encodeClientMessage(clientMsg))
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
const privKey: AuthKey = {type: "ed25519", key: sq.snd_private_key}
|
||||
await smp.sendMessage(privKey, sq.snd_id, msgFlags.notification, msg)
|
||||
}
|
||||
|
||||
// sendConfirmation (Client.hs:1788-1794)
|
||||
export async function sendConfirmation(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
agentConfirmation: Uint8Array,
|
||||
): Promise<void> {
|
||||
if (!sq.e2e_pub_key) throw new AgentError({tag: "INTERNAL", msg: "sendConfirmation: no e2e pub key"})
|
||||
const senderCanSecure_ = sq.queue_mode === "M"
|
||||
// PHConfirmation carries C.toPublic sndPrivateKey, DER-encoded by smpEncode (Crypto.hs:568-570).
|
||||
// (Only used for non-messaging queues; messaging queues use PHEmpty.)
|
||||
const privHeader: PrivHeader = senderCanSecure_
|
||||
? {type: "PHEmpty"}
|
||||
: {type: "PHConfirmation", key: encodePubKeyEd25519(toPublicEd25519(sq.snd_private_key))}
|
||||
const spKey: AuthKey | null = senderCanSecure_ ? {type: "ed25519", key: sq.snd_private_key} : null
|
||||
const clientMsg: ClientMessage = {privHeader, body: agentConfirmation}
|
||||
const msg = agentCbEncrypt(sq.e2e_dh_secret, sq.smp_client_version, sq.e2e_pub_key, encodeClientMessage(clientMsg))
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
await smp.sendMessage(spKey, sq.snd_id, true, msg)
|
||||
}
|
||||
|
||||
// secureQueue (Client.hs:1830-1833)
|
||||
export async function secureQueue(
|
||||
c: AgentClient,
|
||||
rq: any,
|
||||
senderKey: Uint8Array,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, rq)
|
||||
await smp.secureQueue({type: "ed25519", key: rq.rcv_private_key}, rq.rcv_id, senderKey)
|
||||
}
|
||||
|
||||
// secureSndQueue (Client.hs:1835-1841)
|
||||
export async function secureSndQueue(
|
||||
c: AgentClient,
|
||||
sq: any,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, sq)
|
||||
await smp.secureSndQueue({type: "ed25519", key: sq.snd_private_key}, sq.snd_id)
|
||||
}
|
||||
|
||||
// sendAck (Client.hs:1904-1907)
|
||||
export async function sendAck(
|
||||
c: AgentClient,
|
||||
rq: any,
|
||||
msgId: Uint8Array,
|
||||
): Promise<void> {
|
||||
const smp = await getClientForQueue(c, rq)
|
||||
await smp.ackMessage({type: "ed25519", key: rq.rcv_private_key}, rq.rcv_id, msgId)
|
||||
}
|
||||
|
||||
// -- subscribeQueues (Client.hs:1543-1556)
|
||||
export async function subscribeQueues(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
queues: RcvQueueSub[],
|
||||
): Promise<void> {
|
||||
const byServer = new Map<string, RcvQueueSub[]>()
|
||||
for (const q of queues) {
|
||||
const list = byServer.get(q.server) ?? []
|
||||
list.push(q)
|
||||
byServer.set(q.server, list)
|
||||
}
|
||||
for (const [server, qs] of byServer) {
|
||||
c.currentSubs.batchAddPendingSubs(tSessKey(userId, server), qs)
|
||||
}
|
||||
for (const [server, qs] of byServer) {
|
||||
await subscribeServerQueues(c, userId, server, qs)
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeServerQueues(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
server: string,
|
||||
queues: RcvQueueSub[],
|
||||
): Promise<void> {
|
||||
const key = tSessKey(userId, server)
|
||||
const existing = tryReadSessVar(key, c.smpClients as Map<string, SessionVar<SMPConnectedClient>>)
|
||||
if (!existing) return
|
||||
const smp = existing.client
|
||||
const subReqs = queues.map(q => ({rcvId: q.rcvId, privKey: {type: "ed25519" as const, key: q.rcvPrivateKey}}))
|
||||
try {
|
||||
await smp.subscribeQueues(subReqs)
|
||||
c.currentSubs.batchAddActiveSubs(key, smp.sessionId, queues)
|
||||
} catch {
|
||||
// On error, subs stay pending
|
||||
}
|
||||
}
|
||||
|
||||
// addNewQueueSubscription (Client.hs:1724-1728)
|
||||
export function addNewQueueSubscription(
|
||||
c: AgentClient,
|
||||
rq: RcvQueueSub,
|
||||
userId: number,
|
||||
server: string,
|
||||
sessionId: Uint8Array,
|
||||
): void {
|
||||
c.currentSubs.addActiveSub(tSessKey(userId, server), sessionId, rq)
|
||||
}
|
||||
|
||||
// -- newRcvQueue (Client.hs:1373-1435, simplified: no short links, no ntf credentials)
|
||||
|
||||
export interface NewRcvQueueResult {
|
||||
rcvQueue: any
|
||||
sndId: Uint8Array
|
||||
e2eDhKey: Uint8Array
|
||||
sessionId: Uint8Array
|
||||
}
|
||||
|
||||
export async function newRcvQueue(
|
||||
c: AgentClient,
|
||||
userId: number,
|
||||
connId: Uint8Array,
|
||||
server: string,
|
||||
keyHash: Uint8Array,
|
||||
wsUrl: string,
|
||||
subscribe: boolean,
|
||||
): Promise<NewRcvQueueResult> {
|
||||
const {publicKey: rcvPubKey, privateKey: rcvPrivateKey} = generateEd25519KeyPair()
|
||||
const {publicKey: dhPubKey, privateKey: dhPrivKey} = generateX25519KeyPair()
|
||||
const {publicKey: e2eDhKey, privateKey: e2ePrivKey} = generateX25519KeyPair()
|
||||
|
||||
const smpConn = await getSMPServerClient(c, userId, server, keyHash, wsUrl)
|
||||
const smp = smpConn.client
|
||||
|
||||
const ids = await smp.createQueue({publicKey: rcvPubKey, privateKey: rcvPrivateKey}, dhPubKey, subscribe)
|
||||
|
||||
const rcvDhSecret = dh(ids.srvDhKey, dhPrivKey)
|
||||
const rcvQueue = {
|
||||
host: server, port: "443",
|
||||
rcv_id: ids.rcvId,
|
||||
conn_id: connId,
|
||||
rcv_private_key: rcvPrivateKey,
|
||||
rcv_dh_secret: rcvDhSecret,
|
||||
e2e_priv_key: e2ePrivKey,
|
||||
e2e_dh_secret: null,
|
||||
snd_id: ids.sndId,
|
||||
snd_key: null,
|
||||
status: "new",
|
||||
// Haskell newRcvQueue_: smpClientVersion = maxVersion vRange (= maxVersion smpClientVRange).
|
||||
// This is VersionSMPC (used in the per-queue PubHeader), NOT the SMP transport version.
|
||||
smp_client_version: c.config.smpClientVRange[1],
|
||||
rcv_queue_id: 0,
|
||||
rcv_primary: 1,
|
||||
replace_rcv_queue_id: null,
|
||||
queue_mode: ids.queueMode,
|
||||
server_key_hash: keyHash,
|
||||
last_broker_ts: null,
|
||||
to_subscribe: subscribe ? 0 : 1,
|
||||
deleted: 0,
|
||||
}
|
||||
|
||||
return {rcvQueue, sndId: ids.sndId, e2eDhKey, sessionId: smp.sessionId}
|
||||
}
|
||||
|
||||
// -- Helpers
|
||||
|
||||
async function getClientForQueue(c: AgentClient, q: any): Promise<SMPClient> {
|
||||
const clients = c.smpClients as Map<string, SessionVar<SMPConnectedClient>>
|
||||
for (const [key, sv] of clients) {
|
||||
if (sv.value && key.endsWith(":" + q.host)) return sv.value.client
|
||||
}
|
||||
throw new AgentError({tag: "INTERNAL", msg: "no SMP client for " + q.host})
|
||||
}
|
||||
|
||||
// Ed25519 public key from 64-byte private key (NaCl convention: last 32 bytes)
|
||||
function toPublicEd25519(privateKey: Uint8Array): Uint8Array {
|
||||
return privateKey.slice(32, 64)
|
||||
}
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
// Agent store interface for IndexedDB.
|
||||
// Each method mirrors a Haskell function in AgentStore.hs.
|
||||
// Implementation in store-idb.ts.
|
||||
|
||||
// -- Types matching Haskell store types
|
||||
|
||||
export type ConnId = Uint8Array
|
||||
export type UserId = number
|
||||
export type EntityId = Uint8Array
|
||||
export type InternalId = number
|
||||
export type InternalRcvId = number
|
||||
export type InternalSndId = number
|
||||
|
||||
export type QueueStatus = "new" | "confirmed" | "secured" | "active" | "disabled" | "deleted"
|
||||
|
||||
// Matches Haskell SkippedMsgDiff (Crypto/Ratchet.hs:584-587)
|
||||
export type SkippedMsgDiff =
|
||||
| {type: "noChange"}
|
||||
| {type: "remove", headerKey: Uint8Array, msgN: number}
|
||||
| {type: "add", keys: Map<Uint8Array, Map<number, any>>} // Map<HeaderKey, Map<MsgN, MessageKey>>
|
||||
export type ConnectionMode = "INV" | "CON" // SCMInvitation | SCMContact
|
||||
export type RatchetSyncState = "ok" | "allowed" | "required" | "started" | "agreed"
|
||||
|
||||
export interface ConnData {
|
||||
connId: ConnId
|
||||
connMode: ConnectionMode
|
||||
userId: UserId
|
||||
smpAgentVersion: number
|
||||
enableNtfs: boolean
|
||||
duplexHandshake: boolean
|
||||
deleted: boolean
|
||||
ratchetSyncState: RatchetSyncState
|
||||
pqSupport: boolean
|
||||
// Message ID counters
|
||||
lastInternalMsgId: number
|
||||
lastInternalRcvMsgId: number
|
||||
lastInternalSndMsgId: number
|
||||
lastExternalSndMsgId: number
|
||||
lastRcvMsgHash: Uint8Array
|
||||
lastSndMsgHash: Uint8Array
|
||||
}
|
||||
|
||||
export interface RcvQueue {
|
||||
host: string
|
||||
port: string
|
||||
rcvId: Uint8Array
|
||||
connId: ConnId
|
||||
rcvPrivateKey: Uint8Array
|
||||
rcvDhSecret: Uint8Array
|
||||
e2ePrivKey: Uint8Array
|
||||
e2eDhSecret: Uint8Array | null
|
||||
sndId: Uint8Array
|
||||
sndKey: Uint8Array | null
|
||||
status: QueueStatus
|
||||
smpClientVersion: number | null
|
||||
dbQueueId: number
|
||||
primary: boolean
|
||||
replaceRcvQueueId: number | null
|
||||
queueMode: string | null
|
||||
serverKeyHash: Uint8Array | null
|
||||
lastBrokerTs: string | null
|
||||
}
|
||||
|
||||
export interface SndQueue {
|
||||
host: string
|
||||
port: string
|
||||
sndId: Uint8Array
|
||||
connId: ConnId
|
||||
sndPrivateKey: Uint8Array
|
||||
e2eDhSecret: Uint8Array
|
||||
status: QueueStatus
|
||||
smpClientVersion: number
|
||||
sndPublicKey: Uint8Array | null
|
||||
e2ePubKey: Uint8Array | null
|
||||
dbQueueId: number
|
||||
primary: boolean
|
||||
queueMode: string | null
|
||||
serverKeyHash: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface MsgMeta {
|
||||
integrity: string // "OK" or error
|
||||
recipient: [number, string] // (internalId, internalTs)
|
||||
broker: [Uint8Array, string] // (brokerId/msgId, brokerTs)
|
||||
sndMsgId: number
|
||||
pqEncryption: boolean
|
||||
}
|
||||
|
||||
export interface RcvMsgData {
|
||||
msgMeta: MsgMeta
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
internalRcvId: number
|
||||
internalHash: Uint8Array
|
||||
externalPrevSndHash: Uint8Array
|
||||
encryptedMsgHash: Uint8Array
|
||||
}
|
||||
|
||||
export interface SndMsgData {
|
||||
internalId: number
|
||||
internalSndId: number
|
||||
internalTs: string
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
pqEncryption: boolean
|
||||
internalHash: Uint8Array
|
||||
prevMsgHash: Uint8Array
|
||||
msgEncryptKey: Uint8Array | null
|
||||
paddedMsgLen: number | null
|
||||
sndMessageBodyId: number | null
|
||||
}
|
||||
|
||||
export interface Confirmation {
|
||||
confirmationId: Uint8Array
|
||||
connId: ConnId
|
||||
e2eSndPubKey: Uint8Array
|
||||
senderKey: Uint8Array | null
|
||||
ratchetState: Uint8Array
|
||||
senderConnInfo: Uint8Array
|
||||
accepted: boolean
|
||||
ownConnInfo: Uint8Array | null
|
||||
smpReplyQueues: Uint8Array | null // serialized
|
||||
smpClientVersion: number | null
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
invitationId: Uint8Array
|
||||
contactConnId: ConnId | null
|
||||
crInvitation: Uint8Array
|
||||
recipientConnInfo: Uint8Array
|
||||
accepted: boolean
|
||||
ownConnInfo: Uint8Array | null
|
||||
}
|
||||
|
||||
export interface RcvMsg {
|
||||
internalId: number
|
||||
msgMeta: MsgMeta
|
||||
msgType: string
|
||||
msgBody: Uint8Array
|
||||
internalHash: Uint8Array
|
||||
userAck: boolean
|
||||
msgReceipt: {agentMsgId: number, msgRcptStatus: string} | null
|
||||
}
|
||||
|
||||
export interface PendingQueueMsg {
|
||||
connId: ConnId
|
||||
sndQueueId: number
|
||||
internalId: number
|
||||
internalTs: string
|
||||
internalSndId: number
|
||||
msgType: string
|
||||
msgFlags: number
|
||||
msgBody: Uint8Array
|
||||
internalHash: Uint8Array
|
||||
prevMsgHash: Uint8Array
|
||||
pqEncryption: boolean
|
||||
retryIntSlow: number | null
|
||||
retryIntFast: number | null
|
||||
msgEncryptKey: Uint8Array | null
|
||||
paddedMsgLen: number | null
|
||||
sndMsgBody: Uint8Array | null // agent_msg from snd_message_bodies (joined)
|
||||
}
|
||||
|
||||
export interface AsyncCommand {
|
||||
commandId: number
|
||||
connId: ConnId
|
||||
host: string | null
|
||||
port: string | null
|
||||
corrId: Uint8Array
|
||||
commandTag: string
|
||||
command: Uint8Array
|
||||
agentVersion: number
|
||||
serverKeyHash: Uint8Array | null
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
// -- Store interface
|
||||
// Each method name matches the Haskell function in AgentStore.hs.
|
||||
|
||||
export interface AgentStore {
|
||||
// -- Users (AgentStore.hs:201-230)
|
||||
createUserRecord(): Promise<UserId>
|
||||
getUserIds(): Promise<UserId[]>
|
||||
deleteUserRecord(userId: UserId): Promise<void>
|
||||
setUserDeleted(userId: UserId): Promise<ConnId[]>
|
||||
|
||||
// -- Servers (AgentStore.hs:233-240)
|
||||
createServer(host: string, port: string, keyHash: Uint8Array): Promise<void>
|
||||
|
||||
// -- Connections (AgentStore.hs:242-500)
|
||||
createNewConn(connData: ConnData, connMode: ConnectionMode): Promise<ConnId>
|
||||
getConn(connId: ConnId): Promise<{connData: ConnData, rcvQueues: RcvQueue[], sndQueues: SndQueue[]} | null>
|
||||
getRcvConn(host: string, port: string, rcvId: Uint8Array): Promise<{connData: ConnData, rcvQueue: RcvQueue} | null>
|
||||
getConnSubs(connIds: ConnId[]): Promise<Map<string, ConnData>>
|
||||
getConnsData(connIds: ConnId[]): Promise<Map<string, ConnData>>
|
||||
lockConnForUpdate(connId: ConnId): Promise<void> // no-op in IndexedDB (single-threaded)
|
||||
setConnDeleted(connId: ConnId, waitDelivery: boolean): Promise<void>
|
||||
setConnUserId(oldUserId: UserId, connId: ConnId, newUserId: UserId): Promise<void>
|
||||
setConnAgentVersion(connId: ConnId, version: number): Promise<void>
|
||||
setConnPQSupport(connId: ConnId, pqSupport: boolean): Promise<void>
|
||||
setConnRatchetSync(connId: ConnId, state: RatchetSyncState): Promise<void>
|
||||
updateNewConnJoin(connId: ConnId, agentVersion: number, pqSupport: boolean, enableNtfs: boolean): Promise<void>
|
||||
updateNewConnRcv(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
getDeletedConnIds(): Promise<ConnId[]>
|
||||
getDeletedWaitingDeliveryConnIds(): Promise<ConnId[]>
|
||||
getConnIds(): Promise<ConnId[]>
|
||||
|
||||
// -- Queues (AgentStore.hs:500-700)
|
||||
addConnRcvQueue(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
addConnSndQueue(connId: ConnId, sndQueue: SndQueue): Promise<SndQueue>
|
||||
setRcvQueueStatus(rcvQueue: RcvQueue, status: QueueStatus): Promise<void>
|
||||
setSndQueueStatus(sndQueue: SndQueue, status: QueueStatus): Promise<void>
|
||||
setRcvQueueConfirmedE2E(rcvQueue: RcvQueue, dhSecret: Uint8Array, smpClientVersion: number): Promise<void>
|
||||
setRcvQueuePrimary(connId: ConnId, rcvQueue: RcvQueue): Promise<void>
|
||||
deleteConnRcvQueue(rcvQueue: RcvQueue): Promise<void>
|
||||
deleteConnRecord(connId: ConnId): Promise<void>
|
||||
upgradeRcvConnToDuplex(connId: ConnId, sndQueue: SndQueue): Promise<SndQueue>
|
||||
upgradeSndConnToDuplex(connId: ConnId, rcvQueue: RcvQueue, subMode: string): Promise<RcvQueue>
|
||||
getPrimaryRcvQueue(connId: ConnId): Promise<RcvQueue | null>
|
||||
getRcvQueue(connId: ConnId, host: string, port: string, rcvId: Uint8Array): Promise<RcvQueue | null>
|
||||
getDeletedRcvQueue(connId: ConnId, host: string, port: string, rcvId: Uint8Array): Promise<RcvQueue | null>
|
||||
setConnectionNtfs(connId: ConnId, enable: boolean): Promise<void>
|
||||
|
||||
// -- Subscriptions (AgentStore.hs:700-800)
|
||||
getSubscriptionServers(onlyNeeded: boolean): Promise<Array<{userId: UserId, host: string, port: string, keyHash: Uint8Array}>>
|
||||
getUserServerRcvQueueSubs(userId: UserId, host: string, port: string, keyHash: Uint8Array, onlyNeeded: boolean, batchSize: number, cursor: number | null): Promise<{queues: RcvQueue[], nextCursor: number | null}>
|
||||
unsetQueuesToSubscribe(): Promise<void>
|
||||
getConnectionsForDelivery(): Promise<ConnId[]>
|
||||
getAllSndQueuesForDelivery(): Promise<SndQueue[]>
|
||||
|
||||
// -- Confirmations (AgentStore.hs:800-870)
|
||||
createConfirmation(confirmation: Confirmation): Promise<Uint8Array>
|
||||
acceptConfirmation(confirmationId: Uint8Array, ownConnInfo: Uint8Array): Promise<Confirmation>
|
||||
getAcceptedConfirmation(connId: ConnId): Promise<Confirmation | null>
|
||||
removeConfirmations(connId: ConnId): Promise<void>
|
||||
|
||||
// -- Invitations (AgentStore.hs:870-920)
|
||||
createInvitation(invitation: Invitation): Promise<Uint8Array>
|
||||
getInvitation(invitationId: Uint8Array): Promise<Invitation | null>
|
||||
acceptInvitation(invitationId: Uint8Array, ownConnInfo: Uint8Array): Promise<void>
|
||||
unacceptInvitation(invitationId: Uint8Array): Promise<void>
|
||||
deleteInvitation(invitationId: Uint8Array): Promise<void>
|
||||
|
||||
// -- Messages (AgentStore.hs:873-1050)
|
||||
updateRcvIds(connId: ConnId): Promise<{internalId: number, internalRcvId: number, prevExternalSndId: number, prevRcvMsgHash: Uint8Array}>
|
||||
createRcvMsg(connId: ConnId, rcvQueue: RcvQueue, rcvMsgData: RcvMsgData): Promise<void>
|
||||
setLastBrokerTs(connId: ConnId, dbQueueId: number, brokerTs: string): Promise<void>
|
||||
updateRcvMsgHash(connId: ConnId, sndMsgId: number, internalRcvId: number, hash: Uint8Array): Promise<void>
|
||||
createSndMsgBody(agentMsg: Uint8Array): Promise<number>
|
||||
updateSndIds(connId: ConnId): Promise<{internalId: number, internalSndId: number, prevSndMsgHash: Uint8Array}>
|
||||
createSndMsg(connId: ConnId, sndMsgData: SndMsgData): Promise<void>
|
||||
updateSndMsgHash(connId: ConnId, internalSndId: number, hash: Uint8Array): Promise<void>
|
||||
createSndMsgDelivery(connId: ConnId, sndQueue: SndQueue, internalId: number): Promise<void>
|
||||
getPendingQueueMsg(connId: ConnId, sndQueue: SndQueue): Promise<{rcvQueue: RcvQueue | null, msg: PendingQueueMsg} | null>
|
||||
updatePendingMsgRIState(connId: ConnId, msgId: number, retryIntSlow: number | null, retryIntFast: number | null): Promise<void>
|
||||
setMsgUserAck(connId: ConnId, internalId: number): Promise<{rcvQueue: RcvQueue, brokerId: Uint8Array}>
|
||||
getRcvMsg(connId: ConnId, internalId: number): Promise<RcvMsg | null>
|
||||
getLastMsg(connId: ConnId, brokerId: Uint8Array): Promise<RcvMsg | null>
|
||||
incMsgRcvAttempts(connId: ConnId, internalId: number): Promise<number>
|
||||
checkRcvMsgHashExists(connId: ConnId, hash: Uint8Array): Promise<boolean>
|
||||
getRcvMsgBrokerTs(connId: ConnId, brokerId: Uint8Array): Promise<string | null>
|
||||
deleteMsg(connId: ConnId, internalId: number): Promise<void>
|
||||
deleteDeliveredSndMsg(connId: ConnId, internalId: number): Promise<void>
|
||||
deleteSndMsgDelivery(connId: ConnId, sndQueue: SndQueue, msgId: number, keepForReceipt: boolean): Promise<void>
|
||||
getSndMsgViaRcpt(connId: ConnId, sndMsgId: number): Promise<{internalId: number, msgType: string, internalHash: Uint8Array, msgReceipt: {agentMsgId: number, msgRcptStatus: string} | null} | null>
|
||||
updateSndMsgRcpt(connId: ConnId, sndMsgId: number, receipt: {agentMsgId: number, msgRcptStatus: string}): Promise<void>
|
||||
|
||||
// -- Ratchet (AgentStore.hs:1300-1400)
|
||||
createRatchetX3dhKeys(connId: ConnId, privKey1: Uint8Array, privKey2: Uint8Array, pqKem: Uint8Array | null): Promise<void>
|
||||
getRatchetX3dhKeys(connId: ConnId): Promise<{privKey1: Uint8Array, privKey2: Uint8Array, pqKem: Uint8Array | null} | null>
|
||||
createRatchet(connId: ConnId, ratchetState: Uint8Array): Promise<void>
|
||||
getRatchet(connId: ConnId): Promise<Uint8Array | null>
|
||||
getRatchetForUpdate(connId: ConnId): Promise<Uint8Array | null> // same as getRatchet in IndexedDB (single-threaded)
|
||||
getSkippedMsgKeys(connId: ConnId): Promise<Map<string, Map<number, {mk: Uint8Array, iv: Uint8Array}>>>
|
||||
updateRatchet(connId: ConnId, ratchetState: Uint8Array, skippedMsgDiff: SkippedMsgDiff): Promise<void>
|
||||
|
||||
// -- Commands (AgentStore.hs:1400-1480)
|
||||
createCommand(corrId: Uint8Array, connId: ConnId, host: string | null, port: string | null, command: AsyncCommand): Promise<number>
|
||||
getPendingCommandServers(connIds: ConnId[]): Promise<Array<{connId: ConnId, host: string, port: string}>>
|
||||
getAllPendingCommandConns(): Promise<Array<{connId: ConnId, host: string, port: string}>>
|
||||
getPendingServerCommand(connId: ConnId, host: string | null, port: string | null): Promise<AsyncCommand | null>
|
||||
updateCommandServer(commandId: number, host: string, port: string): Promise<void>
|
||||
deleteCommand(commandId: number): Promise<void>
|
||||
|
||||
// -- Encrypted message hash dedup (AgentStore.hs:1200-1220)
|
||||
checkRcvMsgHashExists_encrypted(connId: ConnId, hash: Uint8Array): Promise<boolean>
|
||||
addEncryptedRcvMsgHash(connId: ConnId, hash: Uint8Array): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// TSessionSubs — subscription tracking.
|
||||
// Transpilation of Agent/TSessionSubs.hs (lines 49-201).
|
||||
//
|
||||
// Transport session key is (userId, server) serialized as string.
|
||||
// One session per server (no TSMEntity mode).
|
||||
// RecipientId key is hex-encoded Uint8Array.
|
||||
|
||||
// RcvQueueSub — subset of RcvQueue fields needed for subscription management.
|
||||
// Transpilation of Agent/Store.hs:183-196.
|
||||
export interface RcvQueueSub {
|
||||
userId: number
|
||||
connId: Uint8Array
|
||||
server: string // serialized SMPServer
|
||||
rcvId: Uint8Array
|
||||
rcvPrivateKey: Uint8Array
|
||||
status: string
|
||||
enableNtfs: boolean
|
||||
clientNoticeId: number | null
|
||||
dbQueueId: number
|
||||
primary: boolean
|
||||
dbReplaceQueueId: number | null
|
||||
}
|
||||
|
||||
function toHex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function rcvIdKey(rq: RcvQueueSub): string {
|
||||
return toHex(rq.rcvId)
|
||||
}
|
||||
|
||||
// SessSubs (TSessionSubs.hs:53-57)
|
||||
export interface SessSubs {
|
||||
sessId: Uint8Array | null // SessionId
|
||||
activeSubs: Map<string, RcvQueueSub> // keyed by rcvId hex
|
||||
pendingSubs: Map<string, RcvQueueSub> // keyed by rcvId hex
|
||||
}
|
||||
|
||||
// TSessionSubs (TSessionSubs.hs:49-51)
|
||||
export class TSessionSubs {
|
||||
readonly sessionSubs: Map<string, SessSubs> = new Map()
|
||||
|
||||
// -- Construction
|
||||
|
||||
// clear (TSessionSubs.hs:63-65)
|
||||
clear(): void {
|
||||
this.sessionSubs.clear()
|
||||
}
|
||||
|
||||
// -- Lookup helpers
|
||||
|
||||
// lookupSubs (TSessionSubs.hs:67-69)
|
||||
private lookupSubs(tSess: string): SessSubs | undefined {
|
||||
return this.sessionSubs.get(tSess)
|
||||
}
|
||||
|
||||
// getSessSubs (TSessionSubs.hs:71-77)
|
||||
private getSessSubs(tSess: string): SessSubs {
|
||||
const existing = this.sessionSubs.get(tSess)
|
||||
if (existing) return existing
|
||||
const s: SessSubs = {sessId: null, activeSubs: new Map(), pendingSubs: new Map()}
|
||||
this.sessionSubs.set(tSess, s)
|
||||
return s
|
||||
}
|
||||
|
||||
// -- Query
|
||||
|
||||
// hasActiveSub (TSessionSubs.hs:79-81)
|
||||
hasActiveSub(tSess: string, rcvId: string): boolean {
|
||||
const s = this.lookupSubs(tSess)
|
||||
return s ? s.activeSubs.has(rcvId) : false
|
||||
}
|
||||
|
||||
// hasPendingSub (TSessionSubs.hs:83-85)
|
||||
hasPendingSub(tSess: string, rcvId: string): boolean {
|
||||
const s = this.lookupSubs(tSess)
|
||||
return s ? s.pendingSubs.has(rcvId) : false
|
||||
}
|
||||
|
||||
// hasPendingSubs (TSessionSubs.hs:145-146)
|
||||
hasPendingSubs(tSess: string): boolean {
|
||||
const s = this.lookupSubs(tSess)
|
||||
return s ? s.pendingSubs.size > 0 : false
|
||||
}
|
||||
|
||||
// getPendingSubs (TSessionSubs.hs:148-150)
|
||||
getPendingSubs(tSess: string): Map<string, RcvQueueSub> {
|
||||
return this.lookupSubs(tSess)?.pendingSubs ?? new Map()
|
||||
}
|
||||
|
||||
// getActiveSubs (TSessionSubs.hs:152-154)
|
||||
getActiveSubs(tSess: string): Map<string, RcvQueueSub> {
|
||||
return this.lookupSubs(tSess)?.activeSubs ?? new Map()
|
||||
}
|
||||
|
||||
// -- Mutation
|
||||
|
||||
// addPendingSub (TSessionSubs.hs:91-92)
|
||||
addPendingSub(tSess: string, rq: RcvQueueSub): void {
|
||||
this.getSessSubs(tSess).pendingSubs.set(rcvIdKey(rq), rq)
|
||||
}
|
||||
|
||||
// setSessionId (TSessionSubs.hs:94-99)
|
||||
setSessionId(tSess: string, sessId: Uint8Array): void {
|
||||
const s = this.getSessSubs(tSess)
|
||||
if (s.sessId === null) {
|
||||
s.sessId = sessId
|
||||
} else if (toHex(s.sessId) !== toHex(sessId)) {
|
||||
this.setSubsPending_(s, sessId)
|
||||
}
|
||||
}
|
||||
|
||||
// addActiveSub (TSessionSubs.hs:101-110)
|
||||
addActiveSub(tSess: string, sessId: Uint8Array, rq: RcvQueueSub): void {
|
||||
const s = this.getSessSubs(tSess)
|
||||
const rId = rcvIdKey(rq)
|
||||
if (s.sessId && toHex(s.sessId) === toHex(sessId)) {
|
||||
s.activeSubs.set(rId, rq)
|
||||
s.pendingSubs.delete(rId)
|
||||
} else {
|
||||
s.pendingSubs.set(rId, rq)
|
||||
}
|
||||
}
|
||||
|
||||
// batchAddActiveSubs (TSessionSubs.hs:112-121)
|
||||
batchAddActiveSubs(tSess: string, sessId: Uint8Array, rqs: RcvQueueSub[]): void {
|
||||
const s = this.getSessSubs(tSess)
|
||||
if (s.sessId && toHex(s.sessId) === toHex(sessId)) {
|
||||
for (const rq of rqs) {
|
||||
const rId = rcvIdKey(rq)
|
||||
s.activeSubs.set(rId, rq)
|
||||
s.pendingSubs.delete(rId)
|
||||
}
|
||||
} else {
|
||||
for (const rq of rqs) s.pendingSubs.set(rcvIdKey(rq), rq)
|
||||
}
|
||||
}
|
||||
|
||||
// batchAddPendingSubs (TSessionSubs.hs:123-126)
|
||||
batchAddPendingSubs(tSess: string, rqs: RcvQueueSub[]): void {
|
||||
const s = this.getSessSubs(tSess)
|
||||
for (const rq of rqs) s.pendingSubs.set(rcvIdKey(rq), rq)
|
||||
}
|
||||
|
||||
// deletePendingSub (TSessionSubs.hs:128-129)
|
||||
deletePendingSub(tSess: string, rcvId: string): void {
|
||||
this.lookupSubs(tSess)?.pendingSubs.delete(rcvId)
|
||||
}
|
||||
|
||||
// batchDeletePendingSubs (TSessionSubs.hs:131-134)
|
||||
batchDeletePendingSubs(tSess: string, rcvIds: Set<string>): void {
|
||||
const s = this.lookupSubs(tSess)
|
||||
if (s) for (const rId of rcvIds) s.pendingSubs.delete(rId)
|
||||
}
|
||||
|
||||
// deleteSub (TSessionSubs.hs:136-137)
|
||||
deleteSub(tSess: string, rcvId: string): void {
|
||||
const s = this.lookupSubs(tSess)
|
||||
if (s) {
|
||||
s.activeSubs.delete(rcvId)
|
||||
s.pendingSubs.delete(rcvId)
|
||||
}
|
||||
}
|
||||
|
||||
// batchDeleteSubs (TSessionSubs.hs:139-143)
|
||||
batchDeleteSubs(tSess: string, rcvIds: string[]): void {
|
||||
const s = this.lookupSubs(tSess)
|
||||
if (s) for (const rId of rcvIds) {
|
||||
s.activeSubs.delete(rId)
|
||||
s.pendingSubs.delete(rId)
|
||||
}
|
||||
}
|
||||
|
||||
// setSubsPending (TSessionSubs.hs:159-177)
|
||||
// Simplified: no TSMEntity mode. Session key is always (userId, server) with no entity ID.
|
||||
// So the mode check `entitySession == isJust connId_` always equals `false == false` = true,
|
||||
// taking the first branch: lookup + setSubsPending_ with Nothing.
|
||||
setSubsPending(tSess: string, sessId: Uint8Array): Map<string, RcvQueueSub> {
|
||||
const s = this.lookupSubs(tSess)
|
||||
if (!s) return new Map()
|
||||
if (!s.sessId || toHex(s.sessId) !== toHex(sessId)) return new Map()
|
||||
return this.setSubsPending_(s, null)
|
||||
}
|
||||
|
||||
// setSubsPending_ (TSessionSubs.hs:179-187)
|
||||
private setSubsPending_(s: SessSubs, newSessId: Uint8Array | null): Map<string, RcvQueueSub> {
|
||||
s.sessId = newSessId
|
||||
const subs = new Map(s.activeSubs)
|
||||
if (subs.size > 0) {
|
||||
s.activeSubs.clear()
|
||||
for (const [rId, rq] of subs) s.pendingSubs.set(rId, rq)
|
||||
}
|
||||
return subs
|
||||
}
|
||||
|
||||
// updateClientNotices (TSessionSubs.hs:189-192)
|
||||
// Skip for MVP — client notices are not implemented.
|
||||
updateClientNotices(_tSess: string, _noticeIds: Array<[string, number | null]>): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
// foldSessionSubs (TSessionSubs.hs:194-195)
|
||||
foldSessionSubs<A>(f: (acc: A, entry: [string, SessSubs]) => A, initial: A): A {
|
||||
let acc = initial
|
||||
for (const entry of this.sessionSubs.entries()) acc = f(acc, entry)
|
||||
return acc
|
||||
}
|
||||
|
||||
// mapSubs (TSessionSubs.hs:197-201)
|
||||
mapSubs<A>(f: (subs: Map<string, RcvQueueSub>) => A, s: SessSubs): [A, A] {
|
||||
return [f(s.activeSubs), f(s.pendingSubs)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// TMVar — transactional mutable variable, empty or full.
|
||||
// Transpilation of Haskell's Control.Concurrent.STM.TMVar for single-threaded JS.
|
||||
//
|
||||
// Operations:
|
||||
// take — block until full, take value (leaves empty)
|
||||
// put — block until empty, put value (leaves full)
|
||||
// read — block until full, return value without taking
|
||||
// tryTake — non-blocking take, returns undefined if empty
|
||||
// tryPut — non-blocking put, returns false if full
|
||||
// tryRead — non-blocking read, returns undefined if empty
|
||||
//
|
||||
// Used for:
|
||||
// doWork :: TMVar () — worker signaling
|
||||
// action :: TMVar ... — worker running state
|
||||
// retry lock — delivery retry coordination
|
||||
|
||||
export class TMVar<T> {
|
||||
private val: T | undefined
|
||||
private full: boolean
|
||||
private takeQ: Array<(v: T) => void> = []
|
||||
private putQ: Array<() => void> = []
|
||||
|
||||
private constructor(val: T | undefined, full: boolean) {
|
||||
this.val = val
|
||||
this.full = full
|
||||
}
|
||||
|
||||
static empty<T>(): TMVar<T> {
|
||||
return new TMVar<T>(undefined, false)
|
||||
}
|
||||
|
||||
static new<T>(v: T): TMVar<T> {
|
||||
return new TMVar<T>(v, true)
|
||||
}
|
||||
|
||||
// Block until full, take value, leave empty.
|
||||
// Haskell: takeTMVar
|
||||
take(): Promise<T> {
|
||||
if (this.full) {
|
||||
const v = this.val as T
|
||||
this.val = undefined
|
||||
this.full = false
|
||||
// Wake one blocked putter
|
||||
const putter = this.putQ.shift()
|
||||
if (putter) putter()
|
||||
return Promise.resolve(v)
|
||||
}
|
||||
return new Promise<T>(resolve => this.takeQ.push(resolve))
|
||||
}
|
||||
|
||||
// Block until empty, put value.
|
||||
// Haskell: putTMVar
|
||||
put(v: T): Promise<void> {
|
||||
if (!this.full) {
|
||||
// Check if a taker is waiting — hand off directly
|
||||
const taker = this.takeQ.shift()
|
||||
if (taker) {
|
||||
taker(v)
|
||||
} else {
|
||||
this.val = v
|
||||
this.full = true
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
this.putQ.push(() => {
|
||||
const taker = this.takeQ.shift()
|
||||
if (taker) {
|
||||
taker(v)
|
||||
} else {
|
||||
this.val = v
|
||||
this.full = true
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Block until full, return value without taking.
|
||||
// Haskell: readTMVar
|
||||
//
|
||||
// In Haskell STM, readTMVar is atomic (take + put in one transaction).
|
||||
// In single-threaded JS, we read the value and leave it in place — no interleaving
|
||||
// between the read and the next synchronous operation.
|
||||
read(): Promise<T> {
|
||||
if (this.full) return Promise.resolve(this.val as T)
|
||||
return new Promise<T>(resolve => {
|
||||
this.takeQ.push(v => {
|
||||
// Put back immediately — single-threaded, no interleaving here
|
||||
this.val = v
|
||||
this.full = true
|
||||
resolve(v)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Non-blocking take. Returns undefined if empty.
|
||||
// Haskell: tryTakeTMVar
|
||||
tryTake(): T | undefined {
|
||||
if (!this.full) return undefined
|
||||
const v = this.val as T
|
||||
this.val = undefined
|
||||
this.full = false
|
||||
const putter = this.putQ.shift()
|
||||
if (putter) putter()
|
||||
return v
|
||||
}
|
||||
|
||||
// Non-blocking put. Returns false if full.
|
||||
// Haskell: tryPutTMVar
|
||||
tryPut(v: T): boolean {
|
||||
if (this.full) return false
|
||||
const taker = this.takeQ.shift()
|
||||
if (taker) {
|
||||
taker(v)
|
||||
} else {
|
||||
this.val = v
|
||||
this.full = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Non-blocking read. Returns undefined if empty.
|
||||
// Haskell: tryReadTMVar
|
||||
tryRead(): T | undefined {
|
||||
return this.full ? (this.val as T) : undefined
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return !this.full
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
// SMP client: command/response correlation, authentication, typed async API.
|
||||
// Mirrors: Simplex.Messaging.Client
|
||||
|
||||
import {
|
||||
encodeTransmission, encodeTransmissionForAuth, authTransmission,
|
||||
tEncodeBatch1, tEncodeForBatch, batchTransmissions, tEncode,
|
||||
tParse, tDecodeClient, protocolError, encodePING,
|
||||
decodeResponse, paddedProxiedTLength, encodePRXY, encodePFWD,
|
||||
type AuthKey, type SMPResponse, type RawTransmission,
|
||||
encodeNEW, encodeKEY, encodeSKEY, encodeSUB, encodeACK,
|
||||
encodeSEND, encodeOFF, encodeDEL, encodeGET, encodeQUE, encodeLGET,
|
||||
type IDSResponse, type MSGResponse,
|
||||
} from "./protocol.js"
|
||||
import {
|
||||
connectSMP,
|
||||
type SMPConnection,
|
||||
} from "./transport/websockets.js"
|
||||
import {SMP_BLOCK_SIZE} from "./transport.js"
|
||||
import {sbEncryptBlock, sbDecryptBlock, cbAuthenticator, reverseNonce, cbDecryptNoPad} from "./crypto.js"
|
||||
import {blockPad, blockUnpad} from "@simplex-chat/xftp-web/dist/protocol/transmission.js"
|
||||
import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {generateX25519KeyPair, x25519KeyPairFromPrivate, dh, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {extractSignedKey} from "@simplex-chat/xftp-web/dist/protocol/handshake.js"
|
||||
|
||||
// -- Error types (Client.hs:741-770)
|
||||
|
||||
// ProxiedRelay (Client.hs:1095-1100)
|
||||
export interface ProxiedRelay {
|
||||
sessionId: Uint8Array
|
||||
version: number // negotiated version with relay
|
||||
basicAuth: Uint8Array | null
|
||||
relayKey: Uint8Array // relay's X25519 public key (raw 32 bytes)
|
||||
}
|
||||
|
||||
// ProxyClientError (Client.hs:1102-1109)
|
||||
export type ProxyClientError =
|
||||
| {type: "ProxyProtocolError", error: string}
|
||||
| {type: "ProxyUnexpectedResponse", response: string}
|
||||
| {type: "ProxyResponseError", error: string}
|
||||
|
||||
export type SMPClientError =
|
||||
| {type: "PROTOCOL", error: string} // ERR response from server
|
||||
| {type: "RESPONSE", error: string} // failed to parse response
|
||||
| {type: "UNEXPECTED", raw: string} // wrong response type for command
|
||||
| {type: "TIMEOUT"} // response timeout
|
||||
| {type: "NETWORK", error: string} // connection failure
|
||||
| {type: "TRANSPORT", error: string} // handshake/transport error
|
||||
|
||||
// -- SMPClient
|
||||
|
||||
export interface SMPClient {
|
||||
readonly sessionId: Uint8Array
|
||||
readonly smpVersion: number
|
||||
readonly serverPubKey: Uint8Array
|
||||
|
||||
// Core: send pre-encoded command, await correlated response
|
||||
sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse>
|
||||
|
||||
// High-level commands
|
||||
createQueue(authKeyPair: {publicKey: Uint8Array, privateKey: Uint8Array}, dhKey: Uint8Array, subscribe: boolean): Promise<IDSResponse>
|
||||
subscribeQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
|
||||
getMessage(privKey: AuthKey, rcvId: Uint8Array): Promise<MSGResponse | null>
|
||||
sendMessage(privKey: AuthKey | null, sndId: Uint8Array, notification: boolean, msg: Uint8Array): Promise<void>
|
||||
ackMessage(privKey: AuthKey, rcvId: Uint8Array, msgId: Uint8Array): Promise<void>
|
||||
secureQueue(privKey: AuthKey, rcvId: Uint8Array, senderKey: Uint8Array): Promise<void>
|
||||
secureSndQueue(privKey: AuthKey, sndId: Uint8Array): Promise<void>
|
||||
getQueueLink(linkId: Uint8Array): Promise<SMPResponse>
|
||||
deleteQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
|
||||
suspendQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void>
|
||||
|
||||
// Batch commands (Client.hs:840-845, 1062-1065)
|
||||
subscribeQueues(queues: Array<{rcvId: Uint8Array, privKey: AuthKey}>): Promise<void[]>
|
||||
deleteQueues(queues: Array<{rcvId: Uint8Array, privKey: AuthKey}>): Promise<void[]>
|
||||
|
||||
// Proxy commands (Client.hs:1069-1206)
|
||||
connectProxiedRelay(relayHosts: string[], relayPort: string, relayKeyHash: Uint8Array, basicAuth: Uint8Array | null): Promise<ProxiedRelay>
|
||||
proxySMPCommand(relay: ProxiedRelay, privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse>
|
||||
proxySendMessage(relay: ProxiedRelay, privKey: AuthKey | null, sndId: Uint8Array, notification: boolean, msg: Uint8Array): Promise<void>
|
||||
|
||||
close(): void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (resp: SMPResponse) => void
|
||||
reject: (err: SMPClientError) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export async function createSMPClient(
|
||||
url: string,
|
||||
keyHash: Uint8Array,
|
||||
onMessage: (entityId: Uint8Array, msg: SMPResponse) => void,
|
||||
onDisconnected: () => void,
|
||||
config?: {timeout?: number, pingInterval?: number, pingMaxCount?: number, wsOptions?: object},
|
||||
): Promise<SMPClient> {
|
||||
const timeout_ = config?.timeout ?? 10_000
|
||||
const pingInterval = config?.pingInterval ?? 600_000
|
||||
const pingMaxCount = config?.pingMaxCount ?? 3
|
||||
|
||||
const conn = await connectSMP(url, keyHash, config?.wsOptions)
|
||||
if (!conn.serverPubKey) throw new Error("createSMPClient: server has no auth key")
|
||||
|
||||
const serverPubKey = conn.serverPubKey
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
let closed = false
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let timeoutCount = 0
|
||||
|
||||
// -- Receive loop
|
||||
|
||||
function onBlock(data: ArrayBuffer | Buffer) {
|
||||
if (closed) return
|
||||
timeoutCount = 0
|
||||
try {
|
||||
const raw = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
const block = new Uint8Array(raw)
|
||||
// Decrypt block
|
||||
const decrypted = decryptBlock(block)
|
||||
// Parse batch
|
||||
const transmissions = tParse(decrypted)
|
||||
for (const raw of transmissions) {
|
||||
dispatch(raw)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Parse error — log to stderr
|
||||
process.stderr.write("SMP client receive error: " + e.message + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
function decryptBlock(block: Uint8Array): Uint8Array {
|
||||
if (conn.rcvKey) {
|
||||
const {decrypted, nextChainKey} = sbDecryptBlock(conn.rcvKey, block)
|
||||
conn.rcvKey = nextChainKey
|
||||
return decrypted
|
||||
}
|
||||
// No block encryption — strip padding
|
||||
return blockUnpad(block)
|
||||
}
|
||||
|
||||
function dispatch(raw: RawTransmission) {
|
||||
// Parse response
|
||||
let response: SMPResponse
|
||||
try {
|
||||
response = decodeResponse(new Decoder(raw.command))
|
||||
} catch (e: any) {
|
||||
process.stderr.write("dispatch parse error: " + e.message + " command=" + toHex(raw.command) + "\n")
|
||||
// If we can correlate, reject the pending request
|
||||
const key = toHex(raw.corrId)
|
||||
const req = pending.get(key)
|
||||
if (req) {
|
||||
pending.delete(key)
|
||||
clearTimeout(req.timer)
|
||||
req.reject({type: "RESPONSE", error: e.message})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Classify: ERR → PCEProtocolError
|
||||
const err = protocolError(response)
|
||||
|
||||
// Correlate by corrId
|
||||
const corrIdBytes = raw.corrId
|
||||
if (corrIdBytes.length === 0) {
|
||||
// Server push (no corrId) — deliver to event callback
|
||||
onMessage(raw.entityId, response)
|
||||
return
|
||||
}
|
||||
|
||||
const key = toHex(corrIdBytes)
|
||||
const req = pending.get(key)
|
||||
if (req) {
|
||||
pending.delete(key)
|
||||
clearTimeout(req.timer)
|
||||
if (err) {
|
||||
req.reject({type: "PROTOCOL", error: err})
|
||||
} else {
|
||||
req.resolve(response)
|
||||
}
|
||||
} else {
|
||||
// No pending request — might be a late response or server push with corrId
|
||||
// Deliver as event
|
||||
if (!err) onMessage(raw.entityId, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up WebSocket receive
|
||||
conn.ws.onmessage = (event) => onBlock(event.data as ArrayBuffer)
|
||||
conn.ws.onclose = () => {
|
||||
if (!closed) {
|
||||
closed = true
|
||||
cleanup()
|
||||
onDisconnected()
|
||||
}
|
||||
}
|
||||
conn.ws.onerror = () => {}
|
||||
|
||||
// -- Ping
|
||||
|
||||
function startPing() {
|
||||
if (pingInterval <= 0) return
|
||||
pingTimer = setInterval(async () => {
|
||||
try {
|
||||
await client.sendCommand(null, new Uint8Array(0), encodePING())
|
||||
} catch {
|
||||
timeoutCount++
|
||||
if (pingMaxCount > 0 && timeoutCount >= pingMaxCount) {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}, pingInterval)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer)
|
||||
pingTimer = null
|
||||
}
|
||||
// Reject all pending requests
|
||||
for (const [, req] of pending) {
|
||||
clearTimeout(req.timer)
|
||||
req.reject({type: "NETWORK", error: "disconnected"})
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
// -- Send
|
||||
|
||||
// mkTransmission (Client.hs:1349-1370)
|
||||
// Encode, authenticate, register pending request. Returns encoded transmission + promise.
|
||||
// nonce_ parameter: if provided, used as corrId (for proxy commands where nonce = corrId)
|
||||
function mkTransmission(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array, nonce_?: Uint8Array): {auth: Uint8Array | null, tToSend: Uint8Array, promise: Promise<SMPResponse>} {
|
||||
const nonce = nonce_ ?? crypto.getRandomValues(new Uint8Array(24))
|
||||
const {tForAuth, tToSend} = encodeTransmissionForAuth(conn.sessionId, nonce, entityId, command)
|
||||
const auth = authTransmission(serverPubKey, privKey, nonce, tForAuth)
|
||||
const promise = new Promise<SMPResponse>((resolve, reject) => {
|
||||
const key = toHex(nonce)
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(key)
|
||||
timeoutCount++
|
||||
reject({type: "TIMEOUT"} as SMPClientError)
|
||||
}, timeout_)
|
||||
pending.set(key, {resolve, reject, timer})
|
||||
})
|
||||
return {auth, tToSend, promise}
|
||||
}
|
||||
|
||||
// Send a pre-encoded block (encrypt + write to WebSocket)
|
||||
function sendBlock(block: Uint8Array): void {
|
||||
if (conn.sndKey) {
|
||||
const {encrypted, nextChainKey} = sbEncryptBlock(conn.sndKey, block, SMP_BLOCK_SIZE - 16)
|
||||
conn.sndKey = nextChainKey
|
||||
conn.ws.send(encrypted)
|
||||
} else {
|
||||
conn.ws.send(blockPad(block, SMP_BLOCK_SIZE))
|
||||
}
|
||||
}
|
||||
|
||||
// sendProtocolCommand (Client.hs:1300-1326) — single command
|
||||
// nonce_: if provided, used as corrId (for proxy where nonce = corrId)
|
||||
function sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array, nonce_?: Uint8Array): Promise<SMPResponse> {
|
||||
if (closed) return Promise.reject({type: "NETWORK", error: "closed"} as SMPClientError)
|
||||
const {auth, tToSend, promise} = mkTransmission(privKey, entityId, command, nonce_)
|
||||
sendBlock(tEncodeBatch1(auth, tToSend))
|
||||
return promise
|
||||
}
|
||||
|
||||
// sendProtocolCommands (Client.hs:1262-1298) — batch multiple commands
|
||||
function sendCommands(commands: Array<{privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array}>): Promise<SMPResponse>[] {
|
||||
if (closed) return commands.map(() => Promise.reject({type: "NETWORK", error: "closed"} as SMPClientError))
|
||||
// mkTransmission for each
|
||||
const transmissions = commands.map(c => mkTransmission(c.privKey, c.entityId, c.command))
|
||||
// Encode for batching: tEncodeForBatch each
|
||||
const encoded = transmissions.map(t => tEncodeForBatch(t.auth, t.tToSend))
|
||||
// Pack into blocks
|
||||
const blocks = batchTransmissions(SMP_BLOCK_SIZE, encoded)
|
||||
// Send each block
|
||||
for (const block of blocks) sendBlock(block)
|
||||
// Return all promises
|
||||
return transmissions.map(t => t.promise)
|
||||
}
|
||||
|
||||
// -- High-level commands
|
||||
|
||||
// okSMPCommand (Client.hs:1239-1243) — only accepts OK, not SOK
|
||||
async function okCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<void> {
|
||||
const resp = await sendCommand(privKey, entityId, command)
|
||||
if (resp.type !== "OK") {
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
}
|
||||
}
|
||||
|
||||
const client: SMPClient = {
|
||||
sessionId: conn.sessionId,
|
||||
smpVersion: conn.smpVersion,
|
||||
serverPubKey,
|
||||
sendCommand,
|
||||
|
||||
// createQueue (Client.hs:813-827)
|
||||
async createQueue(authKeyPair, dhKey, subscribe) {
|
||||
const command = encodeNEW(authKeyPair.publicKey, dhKey, null, subscribe)
|
||||
// Auth with the X25519 private key from the keypair
|
||||
const privKey: AuthKey = {type: "x25519", key: authKeyPair.privateKey}
|
||||
const resp = await sendCommand(privKey, new Uint8Array(0), command)
|
||||
if (resp.type !== "IDS") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
return resp.response
|
||||
},
|
||||
|
||||
// subscribeSMPQueue (Client.hs:833-836)
|
||||
async subscribeQueue(privKey, rcvId) {
|
||||
const resp = await sendCommand(privKey, rcvId, encodeSUB())
|
||||
// SUB can return MSG (queued message) — push to onMessage
|
||||
if (resp.type === "MSG") {
|
||||
onMessage(rcvId, resp)
|
||||
return
|
||||
}
|
||||
if (resp.type !== "OK" && resp.type !== "SOK") {
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
}
|
||||
},
|
||||
|
||||
// getSMPMessage (Client.hs:875-880)
|
||||
async getMessage(privKey, rcvId) {
|
||||
const resp = await sendCommand(privKey, rcvId, encodeGET())
|
||||
if (resp.type === "OK") return null
|
||||
if (resp.type === "MSG") {
|
||||
onMessage(rcvId, resp)
|
||||
return resp.response
|
||||
}
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
},
|
||||
|
||||
// sendSMPMessage (Client.hs:1027-1031)
|
||||
async sendMessage(privKey, sndId, notification, msg) {
|
||||
await okCommand(privKey, sndId, encodeSEND(notification, msg))
|
||||
},
|
||||
|
||||
// ackSMPMessage (Client.hs:1040-1045)
|
||||
async ackMessage(privKey, rcvId, msgId) {
|
||||
const resp = await sendCommand(privKey, rcvId, encodeACK(msgId))
|
||||
// ACK can return MSG — push to onMessage
|
||||
if (resp.type === "MSG") {
|
||||
onMessage(rcvId, resp)
|
||||
return
|
||||
}
|
||||
if (resp.type !== "OK") {
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
}
|
||||
},
|
||||
|
||||
// secureSMPQueue (Client.hs:938-939)
|
||||
async secureQueue(privKey, rcvId, senderKey) {
|
||||
await okCommand(privKey, rcvId, encodeKEY(senderKey))
|
||||
},
|
||||
|
||||
// secureSndSMPQueue (Client.hs:943-944)
|
||||
// SKEY sends the public key derived from the private key
|
||||
async secureSndQueue(privKey, sndId) {
|
||||
// x25519KeyPairFromPrivate derives public from private
|
||||
const pubKey = x25519KeyPairFromPrivate(privKey.key).publicKey
|
||||
await okCommand(privKey, sndId, encodeSKEY(encodePubKeyX25519(pubKey)))
|
||||
},
|
||||
|
||||
// getSMPQueueLink (Client.hs:976-980)
|
||||
async getQueueLink(linkId) {
|
||||
return sendCommand(null, linkId, encodeLGET())
|
||||
},
|
||||
|
||||
// deleteSMPQueue (Client.hs:1058-1059)
|
||||
async deleteQueue(privKey, rcvId) {
|
||||
await okCommand(privKey, rcvId, encodeDEL())
|
||||
},
|
||||
|
||||
// suspendSMPQueue (Client.hs:1051-1052)
|
||||
async suspendQueue(privKey, rcvId) {
|
||||
await okCommand(privKey, rcvId, encodeOFF())
|
||||
},
|
||||
|
||||
// subscribeSMPQueues (Client.hs:840-845)
|
||||
async subscribeQueues(queues) {
|
||||
const commands = queues.map(q => ({privKey: q.privKey, entityId: q.rcvId, command: encodeSUB()}))
|
||||
const promises = sendCommands(commands)
|
||||
return Promise.all(promises.map(async (p, i) => {
|
||||
const resp = await p
|
||||
// processSUBResponse_ (Client.hs:857-862)
|
||||
if (resp.type === "MSG") {
|
||||
onMessage(queues[i].rcvId, resp)
|
||||
return
|
||||
}
|
||||
if (resp.type !== "OK" && resp.type !== "SOK") {
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// deleteSMPQueues (Client.hs:1062-1065) via okSMPCommands (Client.hs:1245-1253)
|
||||
async deleteQueues(queues) {
|
||||
const commands = queues.map(q => ({privKey: q.privKey, entityId: q.rcvId, command: encodeDEL()}))
|
||||
const promises = sendCommands(commands)
|
||||
return Promise.all(promises.map(async (p) => {
|
||||
const resp = await p
|
||||
if (resp.type !== "OK") {
|
||||
throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// connectSMPProxiedRelay (Client.hs:1069-1093)
|
||||
async connectProxiedRelay(relayHosts, relayPort, relayKeyHash, basicAuth) {
|
||||
// Send PRXY to proxy server
|
||||
const command = encodePRXY(relayHosts, relayPort, relayKeyHash, basicAuth)
|
||||
const resp = await sendCommand(null, new Uint8Array(0), command)
|
||||
if (resp.type !== "PKEY") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
const {sessionId: relaySessId, versionRange, signedKeyDer} = resp.response
|
||||
// Check version compatibility
|
||||
const version = Math.min(versionRange.max, conn.smpVersion)
|
||||
if (version < versionRange.min) throw {type: "TRANSPORT", error: "incompatible relay version"} as SMPClientError
|
||||
// Extract relay's X25519 DH key from signed key (same as connectSMP handshake)
|
||||
const relayKey = extractSignedKey(signedKeyDer).dhKey
|
||||
// TODO: full certificate chain validation against relayKeyHash
|
||||
// For now we trust the proxy's PKEY response (proxy already validated the relay)
|
||||
return {sessionId: relaySessId, version, basicAuth, relayKey}
|
||||
},
|
||||
|
||||
// proxySMPCommand (Client.hs:1157-1206)
|
||||
async proxySMPCommand(relay, privKey, entityId, command) {
|
||||
// Prepare relay params — encode as if sending directly to relay
|
||||
const relaySessionId = relay.sessionId
|
||||
// Generate ephemeral X25519 keypair for this command
|
||||
const cmdKp = generateX25519KeyPair()
|
||||
const cmdSecret = dh(relay.relayKey, cmdKp.privateKey)
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
// Encode transmission for relay (using relay's sessionId)
|
||||
const {tForAuth, tToSend} = encodeTransmissionForAuth(relaySessionId, nonce, entityId, command)
|
||||
// Authenticate against relay's key
|
||||
const auth = privKey
|
||||
? cbAuthenticator(relay.relayKey, privKey.key, nonce, tForAuth)
|
||||
: null
|
||||
// Batch into single block (for relay)
|
||||
const batchBlock = tEncodeBatch1(auth, tToSend)
|
||||
// Encrypt for relay: cbEncrypt(cmdSecret, nonce, batchBlock, paddedProxiedTLength)
|
||||
const encTransmission = cbEncrypt(cmdSecret, nonce, batchBlock, paddedProxiedTLength)
|
||||
// Send PFWD to proxy (entityId = relay sessionId from PKEY)
|
||||
// IMPORTANT: nonce is also used as corrId for PFWD (Client.hs:1175,1188)
|
||||
// The relay extracts it from FwdTransmission.fwdCorrId to decrypt
|
||||
const cmdPubKeyDer = encodePubKeyX25519(cmdKp.publicKey)
|
||||
const pfwdCommand = encodePFWD(relay.version, cmdPubKeyDer, encTransmission)
|
||||
const pfwdResp = await sendCommand(null, relay.sessionId, pfwdCommand, nonce)
|
||||
// Handle response
|
||||
if (pfwdResp.type === "PRES") {
|
||||
// Decrypt relay's response: cbDecrypt(cmdSecret, reverseNonce(nonce), encResponse)
|
||||
const decrypted = cbDecrypt(cmdSecret, reverseNonce(nonce), pfwdResp.encResponse)
|
||||
// Parse as relay's response
|
||||
const transmissions = tParse(decrypted)
|
||||
if (transmissions.length !== 1) throw {type: "TRANSPORT", error: "bad proxy response block"} as SMPClientError
|
||||
const decoded = tDecodeClient(transmissions[0])
|
||||
const relayResp = decoded.response
|
||||
const err = protocolError(relayResp)
|
||||
if (err) throw {type: "PROTOCOL", error: err} as SMPClientError
|
||||
return relayResp
|
||||
}
|
||||
if (pfwdResp.type === "ERR") {
|
||||
throw {type: "PROTOCOL", error: pfwdResp.error} as SMPClientError
|
||||
}
|
||||
throw {type: "UNEXPECTED", raw: pfwdResp.type} as SMPClientError
|
||||
},
|
||||
|
||||
// proxySMPMessage — convenience for SEND via proxy
|
||||
async proxySendMessage(relay, privKey, sndId, notification, msg) {
|
||||
const command = encodeSEND(notification, msg)
|
||||
const resp = await client.proxySMPCommand(relay, privKey, sndId, command)
|
||||
if (resp.type !== "OK") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError
|
||||
},
|
||||
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
cleanup()
|
||||
conn.ws.close()
|
||||
},
|
||||
}
|
||||
|
||||
startPing()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Crypto primitives.
|
||||
// Mirrors: Simplex.Messaging.Crypto
|
||||
|
||||
import {hkdf as nobleHkdf} from "@noble/hashes/hkdf"
|
||||
import {sha512} from "@noble/hashes/sha512"
|
||||
import {gcm} from "@noble/ciphers/aes.js"
|
||||
import {cbEncrypt, cbDecrypt, cryptoBox, sbInit, sbDecryptChunk, sbAuth} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {dh} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {concatBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {pad, unPad} from "@simplex-chat/xftp-web/dist/crypto/padding.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}
|
||||
}
|
||||
|
||||
// -- AES-256-GCM authenticated encryption (Crypto.hs:1035-1061)
|
||||
// Uses 16-byte IVs (GCM with GHASH path per NIST SP 800-38D for IVs != 96 bits)
|
||||
|
||||
export const AUTH_TAG_SIZE = 16
|
||||
|
||||
// encryptAEAD (Crypto.hs:1035-1039)
|
||||
export function encryptAEAD(
|
||||
key: Uint8Array, // 32 bytes
|
||||
iv: Uint8Array, // 16 bytes
|
||||
paddedLen: number,
|
||||
ad: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
): {authTag: Uint8Array; ciphertext: Uint8Array} {
|
||||
const padded = pad(plaintext, paddedLen)
|
||||
const cipher = gcm(key, iv, ad)
|
||||
const encrypted = cipher.encrypt(padded)
|
||||
return {
|
||||
ciphertext: encrypted.subarray(0, encrypted.length - AUTH_TAG_SIZE),
|
||||
authTag: encrypted.subarray(encrypted.length - AUTH_TAG_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
// decryptAEAD (Crypto.hs:1058-1061)
|
||||
export function decryptAEAD(
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
ad: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
authTag: Uint8Array,
|
||||
): Uint8Array {
|
||||
const cipher = gcm(key, iv, ad)
|
||||
const encrypted = concatBytes(ciphertext, authTag)
|
||||
const padded = cipher.decrypt(encrypted)
|
||||
return unPad(padded)
|
||||
}
|
||||
|
||||
// -- SHA-512 hash (Crypto.hs:1016)
|
||||
|
||||
export function sha512Hash(msg: Uint8Array): Uint8Array {
|
||||
return sha512(msg)
|
||||
}
|
||||
|
||||
// -- Command authentication (Crypto.hs:1366-1367)
|
||||
|
||||
// cbAuthenticate (Crypto.hs:1367)
|
||||
// cryptoBox(dh(serverPubKey, entityPrivKey), nonce, sha512Hash(msg)) → 80 bytes (16 tag + 64 hash)
|
||||
export function cbAuthenticator(serverPubKey: Uint8Array, entityPrivKey: Uint8Array, nonce: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||
const dhSecret = dh(serverPubKey, entityPrivKey)
|
||||
return cryptoBox(dhSecret, nonce, sha512Hash(msg))
|
||||
}
|
||||
|
||||
// -- reverseNonce (Crypto.hs:1409-1410)
|
||||
|
||||
export function reverseNonce(nonce: Uint8Array): Uint8Array {
|
||||
const reversed = new Uint8Array(nonce.length)
|
||||
for (let i = 0; i < nonce.length; i++) reversed[i] = nonce[nonce.length - 1 - i]
|
||||
return reversed
|
||||
}
|
||||
|
||||
// -- cbDecryptNoPad (Crypto.hs:1330-1331)
|
||||
// Decrypt without unpadding. Used for proxy responses.
|
||||
// Same as cbDecrypt but returns raw decrypted bytes without unPad.
|
||||
|
||||
export function cbDecryptNoPad(dhSecret: Uint8Array, nonce: Uint8Array, packet: Uint8Array): Uint8Array {
|
||||
const tag = packet.subarray(0, 16)
|
||||
const cipher = packet.subarray(16)
|
||||
const state = sbInit(dhSecret, nonce)
|
||||
const plaintext = sbDecryptChunk(state, cipher)
|
||||
const computedTag = sbAuth(state)
|
||||
// constant-time compare
|
||||
let diff = 0
|
||||
for (let i = 0; i < 16; i++) diff |= tag[i] ^ computedTag[i]
|
||||
if (diff !== 0) throw new Error("cbDecryptNoPad: authentication failed")
|
||||
return plaintext
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
// Double ratchet with X3DH key agreement and PQ KEM.
|
||||
// Faithful transpilation of Simplex.Messaging.Crypto.Ratchet
|
||||
//
|
||||
// Every type, field, and function mirrors the Haskell source.
|
||||
// Line references are to src/Simplex/Messaging/Crypto/Ratchet.hs
|
||||
|
||||
import {x448} from "@noble/curves/ed448.js"
|
||||
import {hkdf, encryptAEAD, decryptAEAD, AUTH_TAG_SIZE} from "../crypto.js"
|
||||
import {sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "./sntrup761.js"
|
||||
import type {KEMKeyPair} from "./sntrup761.js"
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes, decodeWord16, decodeWord32,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeMaybe,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Version constants (lines 134-155)
|
||||
|
||||
export const pqRatchetE2EEncryptVersion = 3
|
||||
export const currentE2EEncryptVersion = 3
|
||||
|
||||
// -- X448 key operations
|
||||
|
||||
export interface X448KeyPair {
|
||||
publicKey: Uint8Array // 56 bytes
|
||||
privateKey: Uint8Array // 56 bytes
|
||||
}
|
||||
|
||||
export function generateX448KeyPair(): X448KeyPair {
|
||||
const privateKey = x448.utils.randomSecretKey()
|
||||
const publicKey = x448.getPublicKey(privateKey)
|
||||
return {publicKey, privateKey}
|
||||
}
|
||||
|
||||
export function x448DH(publicKey: Uint8Array, privateKey: Uint8Array): Uint8Array {
|
||||
return x448.getSharedSecret(privateKey, publicKey)
|
||||
}
|
||||
|
||||
// DER encoding for X448 public keys (RFC 8410, SubjectPublicKeyInfo)
|
||||
// SEQUENCE { SEQUENCE { OID 1.3.101.111 } BIT STRING { 0x00 <56 bytes> } }
|
||||
const X448_PUBKEY_DER_PREFIX = new Uint8Array([
|
||||
0x30, 0x42, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6f, 0x03, 0x39, 0x00,
|
||||
])
|
||||
|
||||
export function encodePubKeyX448(rawPubKey: Uint8Array): Uint8Array {
|
||||
return concatBytes(X448_PUBKEY_DER_PREFIX, rawPubKey)
|
||||
}
|
||||
|
||||
export function decodePubKeyX448(der: Uint8Array): Uint8Array {
|
||||
if (der.length !== 68) throw new Error("decodePubKeyX448: invalid length " + der.length)
|
||||
for (let i = 0; i < X448_PUBKEY_DER_PREFIX.length; i++) {
|
||||
if (der[i] !== X448_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyX448: invalid DER prefix")
|
||||
}
|
||||
return der.subarray(12)
|
||||
}
|
||||
|
||||
// -- KEM types (lines 567-577)
|
||||
// KEMKeyPair imported from ./sntrup761.js
|
||||
|
||||
export interface RatchetKEMAccepted {
|
||||
rcPQRr: Uint8Array // KEMPublicKey - received key (1158 bytes)
|
||||
rcPQRss: Uint8Array // KEMSharedKey - computed shared secret (32 bytes)
|
||||
rcPQRct: Uint8Array // KEMCiphertext - sent encaps (1039 bytes)
|
||||
}
|
||||
|
||||
export interface RatchetKEM {
|
||||
rcPQRs: KEMKeyPair
|
||||
rcKEMs: RatchetKEMAccepted | null
|
||||
}
|
||||
|
||||
// -- RatchetInitParams (lines 457-464)
|
||||
|
||||
export interface RatchetInitParams {
|
||||
assocData: Uint8Array // Str (raw bytes)
|
||||
ratchetKey: Uint8Array // RatchetKey (32 bytes)
|
||||
sndHK: Uint8Array // HeaderKey (32 bytes)
|
||||
rcvNextHK: Uint8Array // HeaderKey (32 bytes)
|
||||
kemAccepted: RatchetKEMAccepted | null // Maybe RatchetKEMAccepted
|
||||
}
|
||||
|
||||
// -- hkdf3 (lines 1174-1179)
|
||||
|
||||
function hkdf3(salt: Uint8Array, ikm: Uint8Array, info: string): [Uint8Array, Uint8Array, Uint8Array] {
|
||||
const out = hkdf(salt, ikm, info, 96)
|
||||
return [out.slice(0, 32), out.slice(32, 64), out.slice(64, 96)]
|
||||
}
|
||||
|
||||
// -- pqX3dh (lines 499-508)
|
||||
|
||||
const X3DH_SALT = new Uint8Array(64)
|
||||
|
||||
function pqX3dh(
|
||||
sk1: Uint8Array, rk1: Uint8Array,
|
||||
dh1: Uint8Array, dh2: Uint8Array, dh3: Uint8Array,
|
||||
kemAccepted: RatchetKEMAccepted | null,
|
||||
): RatchetInitParams {
|
||||
const assocData = concatBytes(sk1, rk1)
|
||||
const pq = kemAccepted ? kemAccepted.rcPQRss : new Uint8Array(0)
|
||||
const dhs = concatBytes(dh1, dh2, dh3, pq)
|
||||
const [hk, nhk, sk] = hkdf3(X3DH_SALT, dhs, "SimpleXX3DH")
|
||||
return {assocData, ratchetKey: sk, sndHK: hk, rcvNextHK: nhk, kemAccepted}
|
||||
}
|
||||
|
||||
// -- pqX3dhSnd (lines 467-480)
|
||||
// Used by joiner (Alice in PQDR spec, Bob in DR spec) to init SENDING ratchet.
|
||||
|
||||
export function pqX3dhSnd(
|
||||
spk1: Uint8Array, spk2: Uint8Array, // our private keys
|
||||
rk1: Uint8Array, rk2: Uint8Array, // their public keys (raw)
|
||||
kemAccepted: RatchetKEMAccepted | null = null,
|
||||
): RatchetInitParams {
|
||||
const sk1Pub = x448.getPublicKey(spk1)
|
||||
const dh1 = x448DH(rk1, spk2)
|
||||
const dh2 = x448DH(rk2, spk1)
|
||||
const dh3 = x448DH(rk2, spk2)
|
||||
return pqX3dh(sk1Pub, rk1, dh1, dh2, dh3, kemAccepted)
|
||||
}
|
||||
|
||||
// -- pqX3dhRcv (lines 483-497)
|
||||
// Used by initiator (Bob in PQDR spec, Alice in DR spec) to init RECEIVING ratchet.
|
||||
|
||||
export function pqX3dhRcv(
|
||||
rpk1: Uint8Array, rpk2: Uint8Array, // our private keys
|
||||
sk1: Uint8Array, sk2: Uint8Array, // their public keys (raw)
|
||||
kemAccepted: RatchetKEMAccepted | null = null,
|
||||
): RatchetInitParams {
|
||||
const rk1Pub = x448.getPublicKey(rpk1)
|
||||
const dh1 = x448DH(sk2, rpk1)
|
||||
const dh2 = x448DH(sk1, rpk2)
|
||||
const dh3 = x448DH(sk2, rpk2)
|
||||
return pqX3dh(sk1, rk1Pub, dh1, dh2, dh3, kemAccepted)
|
||||
}
|
||||
|
||||
// -- rootKdf (lines 1159-1166)
|
||||
|
||||
export function rootKdf(
|
||||
rk: Uint8Array, // RatchetKey (32 bytes)
|
||||
peerPubKey: Uint8Array, // PublicKey a (raw, 56 bytes for X448)
|
||||
ownPrivKey: Uint8Array, // PrivateKey a (raw, 56 bytes for X448)
|
||||
kemSecret: Uint8Array | null, // Maybe KEMSharedKey
|
||||
): {rk: Uint8Array; ck: Uint8Array; nhk: Uint8Array} {
|
||||
const dhOut = x448DH(peerPubKey, ownPrivKey)
|
||||
const ss = kemSecret ? concatBytes(dhOut, kemSecret) : dhOut
|
||||
const [rk_, ck, nhk] = hkdf3(rk, ss, "SimpleXRootRatchet")
|
||||
return {rk: rk_, ck, nhk}
|
||||
}
|
||||
|
||||
// -- chainKdf (lines 1168-1172)
|
||||
|
||||
export function chainKdf(ck: Uint8Array): {ck: Uint8Array; mk: Uint8Array; iv: Uint8Array; ehIV: Uint8Array} {
|
||||
const EMPTY = new Uint8Array(0)
|
||||
const [ck_, mk, ivs] = hkdf3(EMPTY, ck, "SimpleXChainRatchet")
|
||||
return {ck: ck_, mk, iv: ivs.slice(0, 16), ehIV: ivs.slice(16, 32)}
|
||||
}
|
||||
|
||||
// -- Header padding (lines 716-719)
|
||||
|
||||
export function paddedHeaderLen(v: number, pqSupport: boolean): number {
|
||||
if (pqSupport && v >= pqRatchetE2EEncryptVersion) return 2310
|
||||
return 88
|
||||
}
|
||||
|
||||
// -- SndRatchet (lines 554-559)
|
||||
|
||||
export interface SndRatchet {
|
||||
rcDHRr: Uint8Array // peer's public key (raw, 56 bytes)
|
||||
rcCKs: Uint8Array // sending chain key (32 bytes)
|
||||
rcHKs: Uint8Array // sending header key (32 bytes)
|
||||
}
|
||||
|
||||
// -- RcvRatchet (lines 561-565)
|
||||
|
||||
export interface RcvRatchet {
|
||||
rcCKr: Uint8Array // receiving chain key (32 bytes)
|
||||
rcHKr: Uint8Array // receiving header key (32 bytes)
|
||||
}
|
||||
|
||||
// -- MessageKey (lines 608-609)
|
||||
|
||||
export interface MessageKey {
|
||||
mk: Uint8Array // Key (32 bytes)
|
||||
iv: Uint8Array // IV (16 bytes)
|
||||
}
|
||||
|
||||
// -- RatchetVersions (lines 534-538)
|
||||
|
||||
export interface RatchetVersions {
|
||||
current: number
|
||||
maxSupported: number
|
||||
}
|
||||
|
||||
// -- Ratchet (lines 512-532)
|
||||
|
||||
export interface Ratchet {
|
||||
rcVersion: RatchetVersions
|
||||
rcAD: Uint8Array // Str (associated data, raw bytes)
|
||||
rcDHRs: Uint8Array // PrivateKey a (raw, 56 bytes)
|
||||
rcKEM: RatchetKEM | null
|
||||
rcSupportKEM: boolean // PQSupport
|
||||
rcEnableKEM: boolean // PQEncryption
|
||||
rcSndKEM: boolean // PQEncryption
|
||||
rcRcvKEM: boolean // PQEncryption
|
||||
rcRK: Uint8Array // RatchetKey (32 bytes)
|
||||
rcSnd: SndRatchet | null
|
||||
rcRcv: RcvRatchet | null
|
||||
rcNs: number // Word32
|
||||
rcNr: number // Word32
|
||||
rcPN: number // Word32
|
||||
rcNHKs: Uint8Array // HeaderKey (32 bytes)
|
||||
rcNHKr: Uint8Array // HeaderKey (32 bytes)
|
||||
}
|
||||
|
||||
// -- SkippedMsgKeys (lines 580-582)
|
||||
|
||||
export type SkippedMsgKeys = Map<string, Map<number, MessageKey>>
|
||||
|
||||
const MAX_SKIP = 512
|
||||
|
||||
function hexKey(k: Uint8Array): string {
|
||||
return Array.from(k, b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// -- initSndRatchet (lines 643-666)
|
||||
|
||||
export function initSndRatchet(
|
||||
rcVersion: RatchetVersions,
|
||||
rcDHRr: Uint8Array, // peer's public key (raw)
|
||||
rcDHRs: Uint8Array, // our private key (raw)
|
||||
initParams: RatchetInitParams,
|
||||
rcPQRs_: KEMKeyPair | null,
|
||||
): Ratchet {
|
||||
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
|
||||
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss)
|
||||
const kemSecret = kemAccepted ? kemAccepted.rcPQRss : null
|
||||
const {rk: rcRK, ck: rcCKs, nhk: rcNHKs} = rootKdf(ratchetKey, rcDHRr, rcDHRs, kemSecret)
|
||||
const pqOn = rcPQRs_ !== null
|
||||
return {
|
||||
rcVersion,
|
||||
rcAD: assocData,
|
||||
rcDHRs,
|
||||
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
|
||||
rcSupportKEM: pqOn,
|
||||
rcEnableKEM: pqOn,
|
||||
rcSndKEM: kemAccepted !== null,
|
||||
rcRcvKEM: false,
|
||||
rcRK,
|
||||
rcSnd: {rcDHRr, rcCKs, rcHKs: sndHK},
|
||||
rcRcv: null,
|
||||
rcPN: 0,
|
||||
rcNs: 0,
|
||||
rcNr: 0,
|
||||
rcNHKs,
|
||||
rcNHKr: rcvNextHK,
|
||||
}
|
||||
}
|
||||
|
||||
// -- initRcvRatchet (lines 674-699)
|
||||
|
||||
export function initRcvRatchet(
|
||||
rcVersion: RatchetVersions,
|
||||
rcDHRs: Uint8Array, // our private key (raw)
|
||||
initParams: RatchetInitParams,
|
||||
rcPQRs_: KEMKeyPair | null,
|
||||
pqSupport: boolean,
|
||||
): Ratchet {
|
||||
const {assocData, ratchetKey, sndHK, rcvNextHK, kemAccepted} = initParams
|
||||
return {
|
||||
rcVersion,
|
||||
rcAD: assocData,
|
||||
rcDHRs,
|
||||
rcKEM: rcPQRs_ ? {rcPQRs: rcPQRs_, rcKEMs: kemAccepted} : null,
|
||||
rcSupportKEM: pqSupport,
|
||||
rcEnableKEM: pqSupport,
|
||||
rcSndKEM: false,
|
||||
rcRcvKEM: false,
|
||||
rcRK: ratchetKey,
|
||||
rcSnd: null,
|
||||
rcRcv: null,
|
||||
rcPN: 0,
|
||||
rcNs: 0,
|
||||
rcNr: 0,
|
||||
rcNHKs: rcvNextHK,
|
||||
rcNHKr: sndHK,
|
||||
}
|
||||
}
|
||||
|
||||
// -- RKEMParams (lines 188-190) - parsed KEM params from message header
|
||||
|
||||
export type RKEMParams =
|
||||
| {type: "proposed", kemPk: Uint8Array} // RKParamsProposed KEMPublicKey
|
||||
| {type: "accepted", kemCt: Uint8Array, kemPk: Uint8Array} // RKParamsAccepted KEMCiphertext KEMPublicKey
|
||||
|
||||
// -- MsgHeader (lines 703-711)
|
||||
|
||||
interface MsgHeader {
|
||||
msgMaxVersion: number
|
||||
msgDHRs: Uint8Array // PublicKey a (raw, 56 bytes)
|
||||
msgKEM: RKEMParams | null
|
||||
msgPN: number // Word32
|
||||
msgNs: number // Word32
|
||||
}
|
||||
|
||||
// -- encodeMsgHeader (lines 727-730)
|
||||
|
||||
function encodeMsgHeader(v: number, hdr: MsgHeader): Uint8Array {
|
||||
const vBytes = new Uint8Array(2)
|
||||
vBytes[0] = (hdr.msgMaxVersion >> 8) & 0xff
|
||||
vBytes[1] = hdr.msgMaxVersion & 0xff
|
||||
const dhDer = encodePubKeyX448(hdr.msgDHRs)
|
||||
const pn = encodeWord32(hdr.msgPN)
|
||||
const ns = encodeWord32(hdr.msgNs)
|
||||
if (v >= pqRatchetE2EEncryptVersion) {
|
||||
// smpEncode (msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs)
|
||||
// msgKEM :: Maybe ARKEMParams
|
||||
const kemBytes = hdr.msgKEM ? encodeRKEMParams(hdr.msgKEM) : new Uint8Array([0x30]) // Nothing
|
||||
return concatBytes(vBytes, encodeBytes(dhDer), kemBytes, pn, ns)
|
||||
}
|
||||
// smpEncode (msgMaxVersion, msgDHRs, msgPN, msgNs)
|
||||
return concatBytes(vBytes, encodeBytes(dhDer), pn, ns)
|
||||
}
|
||||
|
||||
// Encode Maybe ARKEMParams: '1' + encoded params, or nothing (handled at call site with '0')
|
||||
function encodeRKEMParams(params: RKEMParams): Uint8Array {
|
||||
if (params.type === "proposed") {
|
||||
// Just ('P', kemPk) - smpEncode ('P', k) where k is KEMPublicKey (Large)
|
||||
return concatBytes(new Uint8Array([0x31, 0x50]), encodeLarge(params.kemPk))
|
||||
}
|
||||
// Just ('A', ct, kemPk) - smpEncode ('A', ct, k)
|
||||
return concatBytes(new Uint8Array([0x31, 0x41]), encodeLarge(params.kemCt), encodeLarge(params.kemPk))
|
||||
}
|
||||
|
||||
function encodeWord32(n: number): Uint8Array {
|
||||
const buf = new Uint8Array(4)
|
||||
buf[0] = (n >> 24) & 0xff; buf[1] = (n >> 16) & 0xff
|
||||
buf[2] = (n >> 8) & 0xff; buf[3] = n & 0xff
|
||||
return buf
|
||||
}
|
||||
|
||||
// -- msgHeaderP (lines 733-740)
|
||||
|
||||
function decodeMsgHeader(v: number, data: Uint8Array): MsgHeader {
|
||||
const d = new Decoder(data)
|
||||
const msgMaxVersion = decodeWord16(d)
|
||||
const dhDer = decodeBytes(d)
|
||||
const msgDHRs = decodePubKeyX448(dhDer)
|
||||
let msgKEM: RKEMParams | null = null
|
||||
if (v >= pqRatchetE2EEncryptVersion) {
|
||||
// Maybe ARKEMParams
|
||||
const maybeByte = d.anyByte()
|
||||
if (maybeByte === 0x31) {
|
||||
// Just - parse ARKEMParams
|
||||
const tag = d.anyByte()
|
||||
if (tag === 0x50) { // 'P' - Proposed: KEMPublicKey (Large)
|
||||
msgKEM = {type: "proposed", kemPk: decodeLarge(d)}
|
||||
} else if (tag === 0x41) { // 'A' - Accepted: KEMCiphertext (Large) + KEMPublicKey (Large)
|
||||
const kemCt = decodeLarge(d)
|
||||
const kemPk = decodeLarge(d)
|
||||
msgKEM = {type: "accepted", kemCt, kemPk}
|
||||
} else {
|
||||
throw new Error("decodeMsgHeader: unknown KEM tag " + tag)
|
||||
}
|
||||
}
|
||||
// else '0' = Nothing, msgKEM stays null
|
||||
}
|
||||
const msgPN = decodeWord32(d)
|
||||
const msgNs = decodeWord32(d)
|
||||
return {msgMaxVersion, msgDHRs, msgKEM, msgPN, msgNs}
|
||||
}
|
||||
|
||||
// -- EncMessageHeader (lines 742-756)
|
||||
|
||||
interface EncMessageHeader {
|
||||
ehVersion: number // current ratchet version
|
||||
ehIV: Uint8Array // IV (raw 16 bytes)
|
||||
ehAuthTag: Uint8Array // AuthTag (raw 16 bytes)
|
||||
ehBody: Uint8Array // encrypted header body
|
||||
}
|
||||
|
||||
// smpEncode (lines 751-752)
|
||||
function encodeEncMessageHeader(emh: EncMessageHeader): Uint8Array {
|
||||
const vBytes = new Uint8Array(2)
|
||||
vBytes[0] = (emh.ehVersion >> 8) & 0xff
|
||||
vBytes[1] = emh.ehVersion & 0xff
|
||||
// smpEncode (ehVersion, ehIV, ehAuthTag) <> encodeLarge ehVersion ehBody
|
||||
const bodyEnc = emh.ehVersion >= pqRatchetE2EEncryptVersion
|
||||
? encodeLarge(emh.ehBody)
|
||||
: encodeBytes(emh.ehBody)
|
||||
return concatBytes(vBytes, emh.ehIV, emh.ehAuthTag, bodyEnc)
|
||||
}
|
||||
|
||||
// smpP (lines 753-756)
|
||||
function decodeEncMessageHeader(data: Uint8Array): EncMessageHeader {
|
||||
const d = new Decoder(data)
|
||||
const ehVersion = decodeWord16(d)
|
||||
const ehIV = d.take(16) // IV is raw 16 bytes
|
||||
const ehAuthTag = d.take(16) // AuthTag is raw 16 bytes
|
||||
// largeP: peek first byte, if < 32 then Large (2-byte len), else ByteString (1-byte len)
|
||||
const firstByte = data[d.offset()]
|
||||
const ehBody = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
|
||||
return {ehVersion, ehIV, ehAuthTag, ehBody}
|
||||
}
|
||||
|
||||
// -- EncRatchetMessage (lines 772-787)
|
||||
|
||||
interface EncRatchetMessage {
|
||||
emHeader: Uint8Array // smpEncoded EncMessageHeader
|
||||
emAuthTag: Uint8Array // AuthTag (raw 16 bytes)
|
||||
emBody: Uint8Array // encrypted message body
|
||||
}
|
||||
|
||||
// encodeEncRatchetMessage (lines 779-781)
|
||||
function encodeEncRatchetMessage(v: number, msg: EncRatchetMessage): Uint8Array {
|
||||
// encodeLarge v emHeader <> smpEncode (emAuthTag, Tail emBody)
|
||||
const headerEnc = v >= pqRatchetE2EEncryptVersion
|
||||
? encodeLarge(msg.emHeader)
|
||||
: encodeBytes(msg.emHeader)
|
||||
return concatBytes(headerEnc, msg.emAuthTag, msg.emBody)
|
||||
}
|
||||
|
||||
// encRatchetMessageP (lines 783-787)
|
||||
function decodeEncRatchetMessage(data: Uint8Array): EncRatchetMessage {
|
||||
const d = new Decoder(data)
|
||||
// largeP
|
||||
const firstByte = data[d.offset()]
|
||||
const emHeader = firstByte < 32 ? decodeLarge(d) : decodeBytes(d)
|
||||
// smpEncode (emAuthTag, Tail emBody) → raw 16 bytes + rest
|
||||
const emAuthTag = d.take(16)
|
||||
const emBody = d.takeAll()
|
||||
return {emHeader, emAuthTag, emBody}
|
||||
}
|
||||
|
||||
// -- MsgEncryptKey (lines 962-968)
|
||||
|
||||
interface MsgEncryptKey {
|
||||
msgRcVersion: number
|
||||
msgKey: MessageKey
|
||||
msgRcAD: Uint8Array
|
||||
msgEncHeader: Uint8Array
|
||||
}
|
||||
|
||||
// -- msgKEMParams (lines 956-958) - build KEM params from ratchet state for message header
|
||||
|
||||
function msgKEMParams(kem: RatchetKEM): RKEMParams {
|
||||
const {rcPQRs, rcKEMs} = kem
|
||||
if (!rcKEMs) {
|
||||
return {type: "proposed", kemPk: rcPQRs.publicKey}
|
||||
}
|
||||
return {type: "accepted", kemCt: rcKEMs.rcPQRct, kemPk: rcPQRs.publicKey}
|
||||
}
|
||||
|
||||
// -- pqEnableSupport (line 836-837)
|
||||
|
||||
function pqEnableSupport(v: number, sup: boolean, enc: boolean): boolean {
|
||||
return sup || (v >= pqRatchetE2EEncryptVersion && enc)
|
||||
}
|
||||
|
||||
// -- rcEncryptHeader + rcEncryptMsg (lines 902-975)
|
||||
|
||||
export interface EncryptResult {
|
||||
ciphertext: Uint8Array
|
||||
state: Ratchet
|
||||
}
|
||||
|
||||
export function rcEncrypt(
|
||||
rc: Ratchet,
|
||||
plaintext: Uint8Array,
|
||||
paddedMsgLen: number,
|
||||
): EncryptResult {
|
||||
if (!rc.rcSnd) throw new Error("rcEncrypt: no sending ratchet (CERatchetState)")
|
||||
const snd = rc.rcSnd
|
||||
const v = rc.rcVersion.current
|
||||
|
||||
// state.CKs, mk = KDF_CK(state.CKs)
|
||||
const chain = chainKdf(snd.rcCKs)
|
||||
|
||||
// header
|
||||
const headerPlain = encodeMsgHeader(v, {
|
||||
msgMaxVersion: rc.rcVersion.maxSupported,
|
||||
msgDHRs: x448.getPublicKey(rc.rcDHRs),
|
||||
msgKEM: rc.rcKEM ? msgKEMParams(rc.rcKEM) : null,
|
||||
msgPN: rc.rcPN,
|
||||
msgNs: rc.rcNs,
|
||||
})
|
||||
|
||||
// enc_header = HENCRYPT(state.HKs, header)
|
||||
const phl = paddedHeaderLen(v, rc.rcSupportKEM)
|
||||
const {authTag: ehAuthTag, ciphertext: ehBody} = encryptAEAD(snd.rcHKs, chain.ehIV, phl, rc.rcAD, headerPlain)
|
||||
|
||||
// smpEncode EncMessageHeader
|
||||
const emHeader = encodeEncMessageHeader({ehVersion: v, ehBody, ehAuthTag, ehIV: chain.ehIV})
|
||||
|
||||
// ENCRYPT(mk, plaintext, CONCAT(AD, enc_header))
|
||||
const bodyAD = concatBytes(rc.rcAD, emHeader)
|
||||
const {authTag: emAuthTag, ciphertext: emBody} = encryptAEAD(chain.mk, chain.iv, paddedMsgLen, bodyAD, plaintext)
|
||||
|
||||
// encodeEncRatchetMessage
|
||||
const ciphertext = encodeEncRatchetMessage(v, {emHeader, emBody, emAuthTag})
|
||||
|
||||
// Update state
|
||||
const newState: Ratchet = {
|
||||
...rc,
|
||||
rcSnd: {...snd, rcCKs: chain.ck},
|
||||
rcNs: rc.rcNs + 1,
|
||||
}
|
||||
|
||||
return {ciphertext, state: newState}
|
||||
}
|
||||
|
||||
// -- rcDecrypt (lines 990-1157)
|
||||
|
||||
export interface DecryptResult {
|
||||
plaintext: Uint8Array
|
||||
state: Ratchet
|
||||
skippedKeys: SkippedMsgKeys
|
||||
}
|
||||
|
||||
export function rcDecrypt(
|
||||
rc: Ratchet,
|
||||
skippedKeys: SkippedMsgKeys,
|
||||
ciphertext: Uint8Array,
|
||||
): DecryptResult {
|
||||
const encMsg = decodeEncRatchetMessage(ciphertext)
|
||||
const encHdr = decodeEncMessageHeader(encMsg.emHeader)
|
||||
|
||||
// TrySkippedMessageKeysHE
|
||||
const skipped = tryDecryptSkipped(rc, skippedKeys, encHdr, encMsg)
|
||||
if (skipped) return skipped
|
||||
|
||||
// DecryptHeader
|
||||
let ratchetStep: "same" | "advance" = "advance"
|
||||
let hdr: MsgHeader | null = null
|
||||
|
||||
if (rc.rcRcv) {
|
||||
hdr = tryDecryptHeader(rc.rcRcv.rcHKr, rc.rcAD, encHdr)
|
||||
if (hdr) ratchetStep = "same"
|
||||
}
|
||||
if (!hdr) {
|
||||
hdr = tryDecryptHeader(rc.rcNHKr, rc.rcAD, encHdr)
|
||||
if (!hdr) throw new Error("rcDecrypt: header decryption failed (CERatchetHeader)")
|
||||
ratchetStep = "advance"
|
||||
}
|
||||
|
||||
// Version upgrade
|
||||
let state = rc
|
||||
const {current, maxSupported} = rc.rcVersion
|
||||
if (hdr.msgMaxVersion > current) {
|
||||
state = {...state, rcVersion: {...state.rcVersion, current: Math.max(current, Math.min(hdr.msgMaxVersion, maxSupported))}}
|
||||
}
|
||||
|
||||
let newSkipped = new Map(skippedKeys)
|
||||
|
||||
if (ratchetStep === "advance") {
|
||||
// SkipMessageKeysHE(state, header.pn)
|
||||
const skip1 = skipMessageKeys(state, newSkipped, hdr.msgPN)
|
||||
state = skip1.state; newSkipped = skip1.skippedKeys
|
||||
|
||||
// DHRatchetPQ2HE(state, header) - ratchet step (lines 1043-1071)
|
||||
const {kemSS, kemSS2, rcKEM: rcKEM_} = pqRatchetStep(state, hdr.msgKEM)
|
||||
const newDHRs = generateX448KeyPair()
|
||||
// state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss)
|
||||
const kdf1 = rootKdf(state.rcRK, hdr.msgDHRs, state.rcDHRs, kemSS)
|
||||
// state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs', state.DHRr) || state.PQRss)
|
||||
const kdf2 = rootKdf(kdf1.rk, hdr.msgDHRs, newDHRs.privateKey, kemSS2)
|
||||
const sndKEM = kemSS2 !== null
|
||||
const rcvKEM = kemSS !== null
|
||||
const rcEnableKEM_ = sndKEM || rcvKEM || rcKEM_ !== null
|
||||
|
||||
state = {
|
||||
...state,
|
||||
rcDHRs: newDHRs.privateKey,
|
||||
rcKEM: rcKEM_,
|
||||
rcSupportKEM: pqEnableSupport(state.rcVersion.current, state.rcSupportKEM, rcEnableKEM_),
|
||||
rcEnableKEM: rcEnableKEM_,
|
||||
rcSndKEM: sndKEM,
|
||||
rcRcvKEM: rcvKEM,
|
||||
rcRK: kdf2.rk,
|
||||
rcSnd: {rcDHRr: hdr.msgDHRs, rcCKs: kdf2.ck, rcHKs: state.rcNHKs},
|
||||
rcRcv: {rcCKr: kdf1.ck, rcHKr: state.rcNHKr},
|
||||
rcPN: rc.rcNs,
|
||||
rcNs: 0,
|
||||
rcNr: 0,
|
||||
rcNHKs: kdf2.nhk,
|
||||
rcNHKr: kdf1.nhk,
|
||||
}
|
||||
}
|
||||
|
||||
// SkipMessageKeysHE(state, header.n)
|
||||
const skip2 = skipMessageKeys(state, newSkipped, hdr.msgNs)
|
||||
state = skip2.state; newSkipped = skip2.skippedKeys
|
||||
|
||||
if (!state.rcRcv) throw new Error("rcDecrypt: no receiving ratchet after skip")
|
||||
|
||||
// state.CKr, mk = KDF_CK(state.CKr)
|
||||
const chain = chainKdf(state.rcRcv.rcCKr)
|
||||
|
||||
// DECRYPT(mk, cipher-text, CONCAT(AD, enc_header))
|
||||
const bodyAD = concatBytes(state.rcAD, encMsg.emHeader)
|
||||
const plaintext = decryptAEAD(chain.mk, chain.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
|
||||
|
||||
// state.Nr += 1
|
||||
state = {
|
||||
...state,
|
||||
rcRcv: {...state.rcRcv, rcCKr: chain.ck},
|
||||
rcNr: state.rcNr + 1,
|
||||
}
|
||||
|
||||
return {plaintext, state, skippedKeys: newSkipped}
|
||||
}
|
||||
|
||||
// -- skipMessageKeys (lines 1105-1121)
|
||||
|
||||
function skipMessageKeys(
|
||||
rc: Ratchet,
|
||||
skippedKeys: SkippedMsgKeys,
|
||||
untilN: number,
|
||||
): {state: Ratchet; skippedKeys: SkippedMsgKeys} {
|
||||
if (!rc.rcRcv) return {state: rc, skippedKeys}
|
||||
const rcv = rc.rcRcv
|
||||
const rcNr = rc.rcNr
|
||||
|
||||
if (rcNr > untilN + 1) throw new Error("rcDecrypt: earlier message (CERatchetEarlierMessage)")
|
||||
if (rcNr === untilN + 1) throw new Error("rcDecrypt: duplicate message (CERatchetDuplicateMessage)")
|
||||
if (rcNr + MAX_SKIP < untilN) throw new Error("rcDecrypt: too many skipped (CERatchetTooManySkipped)")
|
||||
if (rcNr === untilN) return {state: rc, skippedKeys}
|
||||
|
||||
// advanceRcvRatchet
|
||||
let ck = rcv.rcCKr
|
||||
let nr = rcNr
|
||||
const hkHex = hexKey(rcv.rcHKr)
|
||||
const msgKeys = new Map(skippedKeys.get(hkHex) || new Map())
|
||||
|
||||
while (nr < untilN) {
|
||||
const chain = chainKdf(ck)
|
||||
msgKeys.set(nr, {mk: chain.mk, iv: chain.iv})
|
||||
ck = chain.ck
|
||||
nr++
|
||||
}
|
||||
|
||||
const newSkipped = new Map(skippedKeys)
|
||||
newSkipped.set(hkHex, msgKeys)
|
||||
|
||||
return {
|
||||
state: {...rc, rcRcv: {...rcv, rcCKr: ck}, rcNr: nr},
|
||||
skippedKeys: newSkipped,
|
||||
}
|
||||
}
|
||||
|
||||
// -- tryDecryptSkipped (lines 1122-1141)
|
||||
|
||||
function tryDecryptSkipped(
|
||||
rc: Ratchet,
|
||||
skippedKeys: SkippedMsgKeys,
|
||||
encHdr: EncMessageHeader,
|
||||
encMsg: EncRatchetMessage,
|
||||
): DecryptResult | null {
|
||||
for (const [hkHex, msgKeys] of skippedKeys) {
|
||||
const hk = hexToBytes(hkHex)
|
||||
const hdr = tryDecryptHeader(hk, rc.rcAD, encHdr)
|
||||
if (hdr) {
|
||||
const mk = msgKeys.get(hdr.msgNs)
|
||||
if (mk) {
|
||||
const bodyAD = concatBytes(rc.rcAD, encMsg.emHeader)
|
||||
const plaintext = decryptAEAD(mk.mk, mk.iv, bodyAD, encMsg.emBody, encMsg.emAuthTag)
|
||||
const newMsgKeys = new Map(msgKeys)
|
||||
newMsgKeys.delete(hdr.msgNs)
|
||||
const newSkipped = new Map(skippedKeys)
|
||||
if (newMsgKeys.size === 0) newSkipped.delete(hkHex)
|
||||
else newSkipped.set(hkHex, newMsgKeys)
|
||||
return {plaintext, state: rc, skippedKeys: newSkipped}
|
||||
}
|
||||
// Header decrypted but msgNs not in skipped keys - check if same/advance ratchet
|
||||
// For now, fall through to normal decrypt
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// -- pqRatchetStep (lines 1072-1104)
|
||||
// Returns (kemSS for receive rootKdf, kemSS' for send rootKdf, new RatchetKEM state)
|
||||
|
||||
function pqRatchetStep(
|
||||
rc: Ratchet,
|
||||
msgKEM: RKEMParams | null,
|
||||
): {kemSS: Uint8Array | null; kemSS2: Uint8Array | null; rcKEM: RatchetKEM | null} {
|
||||
const pqEnc = rc.rcEnableKEM
|
||||
const v = rc.rcVersion.current
|
||||
|
||||
if (!msgKEM) {
|
||||
// Received message does not have KEM in header
|
||||
if (!rc.rcKEM && pqEnc && v >= pqRatchetE2EEncryptVersion) {
|
||||
// User enabled KEM but no KEM state yet - generate new keypair
|
||||
const rcPQRs = sntrup761Keypair()
|
||||
return {kemSS: null, kemSS2: null, rcKEM: {rcPQRs, rcKEMs: null}}
|
||||
}
|
||||
return {kemSS: null, kemSS2: null, rcKEM: null}
|
||||
}
|
||||
|
||||
// Received message has KEM in header
|
||||
if (pqEnc && v >= pqRatchetE2EEncryptVersion) {
|
||||
// Get shared secret from received KEM params
|
||||
const {ss, rcPQRr} = kemSharedSecret(rc.rcKEM, msgKEM)
|
||||
// state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss)
|
||||
const kemEncResult = sntrup761Enc(rcPQRr)
|
||||
// state.PQRs = GENERATE_PQKEM()
|
||||
const rcPQRs = sntrup761Keypair()
|
||||
const kem: RatchetKEM = {
|
||||
rcPQRs,
|
||||
rcKEMs: {rcPQRr, rcPQRss: kemEncResult.sharedSecret, rcPQRct: kemEncResult.ciphertext},
|
||||
}
|
||||
return {kemSS: ss, kemSS2: kemEncResult.sharedSecret, rcKEM: kem}
|
||||
}
|
||||
|
||||
// PQ not enabled but message has KEM - extract shared secret only (no new KEM state)
|
||||
const {ss} = kemSharedSecret(rc.rcKEM, msgKEM)
|
||||
return {kemSS: ss, kemSS2: null, rcKEM: null}
|
||||
}
|
||||
|
||||
// Extract shared secret from received KEM params (lines 1097-1104)
|
||||
function kemSharedSecret(
|
||||
rcKEM: RatchetKEM | null,
|
||||
params: RKEMParams,
|
||||
): {ss: Uint8Array | null; rcPQRr: Uint8Array} {
|
||||
if (params.type === "proposed") {
|
||||
// RKParamsProposed k -> no shared secret yet, just received the public key
|
||||
return {ss: null, rcPQRr: params.kemPk}
|
||||
}
|
||||
// RKParamsAccepted ct k -> decapsulate ct with our private KEM key
|
||||
if (!rcKEM) throw new Error("pqRatchetStep: CERatchetKEMState - no KEM state for accepted params")
|
||||
const ss = sntrup761Dec(params.kemCt, rcKEM.rcPQRs.secretKey)
|
||||
return {ss, rcPQRr: params.kemPk}
|
||||
}
|
||||
|
||||
// -- decryptHeader helper (lines 1151-1153)
|
||||
|
||||
function tryDecryptHeader(headerKey: Uint8Array, ad: Uint8Array, encHdr: EncMessageHeader): MsgHeader | null {
|
||||
try {
|
||||
const plainHeader = decryptAEAD(headerKey, encHdr.ehIV, ad, encHdr.ehBody, encHdr.ehAuthTag)
|
||||
return decodeMsgHeader(encHdr.ehVersion, plainHeader)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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,89 @@
|
||||
// SNTRUP761 post-quantum KEM.
|
||||
// Mirrors: Simplex.Messaging.Crypto.SNTRUP761
|
||||
//
|
||||
// Uses WASM compiled from the same C source as the Haskell build
|
||||
// (cbits/sntrup761.c by djb et al., public domain).
|
||||
// SHA-512 from SUPERCOP/NaCl (djb, public domain).
|
||||
|
||||
// Key sizes (from sntrup761.h)
|
||||
export const SNTRUP761_PUBLICKEY_SIZE = 1158
|
||||
export const SNTRUP761_SECRETKEY_SIZE = 1763
|
||||
export const SNTRUP761_CIPHERTEXT_SIZE = 1039
|
||||
export const SNTRUP761_SIZE = 32 // shared secret
|
||||
|
||||
export interface KEMKeyPair {
|
||||
publicKey: Uint8Array // 1158 bytes
|
||||
secretKey: Uint8Array // 1763 bytes
|
||||
}
|
||||
|
||||
export interface KEMEncResult {
|
||||
ciphertext: Uint8Array // 1039 bytes
|
||||
sharedSecret: Uint8Array // 32 bytes
|
||||
}
|
||||
|
||||
// WASM module instance
|
||||
let wasmModule: any = null
|
||||
|
||||
export async function initSntrup761(): Promise<void> {
|
||||
if (wasmModule) return
|
||||
const createSntrup761 = (await import("../../dist/wasm/sntrup761.mjs")).default
|
||||
wasmModule = await createSntrup761()
|
||||
}
|
||||
|
||||
function getModule(): any {
|
||||
if (!wasmModule) throw new Error("sntrup761 WASM not initialized - call initSntrup761() first")
|
||||
return wasmModule
|
||||
}
|
||||
|
||||
export function sntrup761Keypair(): KEMKeyPair {
|
||||
const m = getModule()
|
||||
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
|
||||
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
|
||||
try {
|
||||
m._sntrup761_wasm_keypair(pkPtr, skPtr)
|
||||
const publicKey = new Uint8Array(m.HEAPU8.buffer, pkPtr, SNTRUP761_PUBLICKEY_SIZE).slice()
|
||||
const secretKey = new Uint8Array(m.HEAPU8.buffer, skPtr, SNTRUP761_SECRETKEY_SIZE).slice()
|
||||
return {publicKey, secretKey}
|
||||
} finally {
|
||||
m._free(pkPtr)
|
||||
m._free(skPtr)
|
||||
}
|
||||
}
|
||||
|
||||
export function sntrup761Enc(publicKey: Uint8Array): KEMEncResult {
|
||||
if (publicKey.length !== SNTRUP761_PUBLICKEY_SIZE) throw new Error("bad public key length")
|
||||
const m = getModule()
|
||||
const pkPtr = m._malloc(SNTRUP761_PUBLICKEY_SIZE)
|
||||
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
|
||||
const ssPtr = m._malloc(SNTRUP761_SIZE)
|
||||
try {
|
||||
m.HEAPU8.set(publicKey, pkPtr)
|
||||
m._sntrup761_wasm_enc(ctPtr, ssPtr, pkPtr)
|
||||
const ciphertext = new Uint8Array(m.HEAPU8.buffer, ctPtr, SNTRUP761_CIPHERTEXT_SIZE).slice()
|
||||
const sharedSecret = new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
|
||||
return {ciphertext, sharedSecret}
|
||||
} finally {
|
||||
m._free(pkPtr)
|
||||
m._free(ctPtr)
|
||||
m._free(ssPtr)
|
||||
}
|
||||
}
|
||||
|
||||
export function sntrup761Dec(ciphertext: Uint8Array, secretKey: Uint8Array): Uint8Array {
|
||||
if (ciphertext.length !== SNTRUP761_CIPHERTEXT_SIZE) throw new Error("bad ciphertext length")
|
||||
if (secretKey.length !== SNTRUP761_SECRETKEY_SIZE) throw new Error("bad secret key length")
|
||||
const m = getModule()
|
||||
const ctPtr = m._malloc(SNTRUP761_CIPHERTEXT_SIZE)
|
||||
const skPtr = m._malloc(SNTRUP761_SECRETKEY_SIZE)
|
||||
const ssPtr = m._malloc(SNTRUP761_SIZE)
|
||||
try {
|
||||
m.HEAPU8.set(ciphertext, ctPtr)
|
||||
m.HEAPU8.set(secretKey, skPtr)
|
||||
m._sntrup761_wasm_dec(ssPtr, ctPtr, skPtr)
|
||||
return new Uint8Array(m.HEAPU8.buffer, ssPtr, SNTRUP761_SIZE).slice()
|
||||
} finally {
|
||||
m._free(ctPtr)
|
||||
m._free(skPtr)
|
||||
m._free(ssPtr)
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
// Override IDBValidKey to accept Uint8Array (supported in modern browsers)
|
||||
interface IDBObjectStore {
|
||||
get(query: any): IDBRequest<any>
|
||||
getAll(query?: any, count?: number): IDBRequest<any[]>
|
||||
getAllKeys(query?: any, count?: number): IDBRequest<any[]>
|
||||
add(value: any, key?: any): IDBRequest<any>
|
||||
put(value: any, key?: any): IDBRequest<any>
|
||||
delete(query: any): IDBRequest<undefined>
|
||||
}
|
||||
|
||||
interface IDBIndex {
|
||||
get(query: any): IDBRequest<any>
|
||||
getAll(query?: any, count?: number): IDBRequest<any[]>
|
||||
getAllKeys(query?: any, count?: number): IDBRequest<any[]>
|
||||
}
|
||||
@@ -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,571 @@
|
||||
// SMP protocol commands and transmission format.
|
||||
// Mirrors: Simplex.Messaging.Protocol + Simplex.Messaging.Client (auth)
|
||||
|
||||
import {
|
||||
Decoder, concatBytes,
|
||||
encodeBytes, decodeBytes,
|
||||
encodeLarge, decodeLarge,
|
||||
encodeWord16, decodeWord16,
|
||||
encodeBool, decodeBool,
|
||||
encodeMaybe, decodeMaybe,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {sign} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
|
||||
import {cbAuthenticator} from "./crypto.js"
|
||||
|
||||
// -- Auth key type for command authentication (Client.hs:1372-1391)
|
||||
|
||||
export type AuthKey =
|
||||
| {type: "x25519", key: Uint8Array} // raw 32-byte private key → cbAuthenticator
|
||||
| {type: "ed25519", key: Uint8Array} // raw 64-byte private key → sign
|
||||
|
||||
// -- Transmission encoding (Protocol.hs:2186-2198)
|
||||
|
||||
// encodeTransmission_ (Protocol.hs:2194-2198)
|
||||
// smpEncode (corrId, entityId) <> encodeProtocol v command
|
||||
// (command is pre-encoded bytes)
|
||||
export function encodeTransmission(corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array): Uint8Array {
|
||||
return concatBytes(encodeBytes(corrId), encodeBytes(entityId), command)
|
||||
}
|
||||
|
||||
// encodeTransmissionForAuth (Protocol.hs:2186-2192)
|
||||
// implySessId = true for v>=7 (always true for web client v19)
|
||||
// tForAuth = sessionId <> encodeTransmission_(...)
|
||||
// tToSend = encodeTransmission_(...)
|
||||
export function encodeTransmissionForAuth(
|
||||
sessionId: Uint8Array, corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array,
|
||||
): {tForAuth: Uint8Array, tToSend: Uint8Array} {
|
||||
const tToSend = encodeTransmission(corrId, entityId, command)
|
||||
const tForAuth = concatBytes(encodeBytes(sessionId), tToSend)
|
||||
return {tForAuth, tToSend}
|
||||
}
|
||||
|
||||
// -- Command authentication (Client.hs:1372-1391)
|
||||
|
||||
// authTransmission: produce auth bytes for a transmission
|
||||
// Returns null for unauthenticated commands, Uint8Array of auth bytes otherwise
|
||||
export function authTransmission(
|
||||
serverPubKey: Uint8Array, // server's X25519 public key from handshake
|
||||
privKey: AuthKey | null, // null for unauthenticated commands (LGET, SEND without key)
|
||||
nonce: Uint8Array, // 24-byte CorrId/nonce (same bytes)
|
||||
tForAuth: Uint8Array, // transmission bytes to authenticate
|
||||
): Uint8Array | null {
|
||||
if (privKey === null) return null
|
||||
switch (privKey.type) {
|
||||
case "x25519":
|
||||
// TAAuthenticator: cbAuthenticate(serverPubKey, entityPrivKey, nonce, tForAuth)
|
||||
return cbAuthenticator(serverPubKey, privKey.key, nonce, tForAuth)
|
||||
case "ed25519":
|
||||
// TASignature: sign(entityPrivKey, tForAuth)
|
||||
return sign(privKey.key, tForAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// tEncodeAuth (Protocol.hs:507-516)
|
||||
// For v16+ (serviceAuth=true): when auth is present, encode serviceSig as Nothing (0x30) after auth.
|
||||
// When auth is absent: just empty ByteString.
|
||||
export function tEncodeAuth(auth: Uint8Array | null): Uint8Array {
|
||||
if (auth === null) return encodeBytes(new Uint8Array(0)) // empty ByteString: [0x00]
|
||||
// serviceAuth=true for v16+: smpEncode (authBytes, serviceSig) where serviceSig = Nothing
|
||||
return concatBytes(encodeBytes(auth), new Uint8Array([0x30])) // auth + Nothing
|
||||
}
|
||||
|
||||
// tEncode (Protocol.hs:2171-2172)
|
||||
export function tEncode(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
|
||||
return concatBytes(tEncodeAuth(auth), tToSend)
|
||||
}
|
||||
|
||||
// tEncodeBatch1 (Protocol.hs:2179-2180)
|
||||
// Single-command batch: count=1 + Large(tEncode(...))
|
||||
export function tEncodeBatch1(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
|
||||
return concatBytes(new Uint8Array([1]), encodeLarge(tEncode(auth, tToSend)))
|
||||
}
|
||||
|
||||
// tEncodeForBatch (Protocol.hs:2175-2176)
|
||||
// Large(tEncode(...)) — for multi-command batches
|
||||
export function tEncodeForBatch(auth: Uint8Array | null, tToSend: Uint8Array): Uint8Array {
|
||||
return encodeLarge(tEncode(auth, tToSend))
|
||||
}
|
||||
|
||||
// batchTransmissions (Protocol.hs:2151-2168)
|
||||
// Pack multiple encoded transmissions into ≤blockSize blocks.
|
||||
// Each input is an already-encoded Large-wrapped transmission.
|
||||
// Returns array of blocks, each prefixed with count byte.
|
||||
export function batchTransmissions(blockSize: number, transmissions: Uint8Array[]): Uint8Array[] {
|
||||
const maxPayload = blockSize - 19 // 2 pad + 1 count + 16 auth tag
|
||||
const blocks: Uint8Array[] = []
|
||||
let currentParts: Uint8Array[] = []
|
||||
let currentLen = 0
|
||||
let count = 0
|
||||
for (const t of transmissions) {
|
||||
const tLen = t.length
|
||||
if (tLen > maxPayload) throw new Error("batchTransmissions: transmission too large")
|
||||
if (currentLen + tLen > maxPayload || count >= 255) {
|
||||
if (count > 0) blocks.push(concatBytes(new Uint8Array([count]), ...currentParts))
|
||||
currentParts = [t]
|
||||
currentLen = tLen
|
||||
count = 1
|
||||
} else {
|
||||
currentParts.push(t)
|
||||
currentLen += tLen
|
||||
count++
|
||||
}
|
||||
}
|
||||
if (count > 0) blocks.push(concatBytes(new Uint8Array([count]), ...currentParts))
|
||||
return blocks
|
||||
}
|
||||
|
||||
// -- Transmission parsing (Protocol.hs:1629-1643, 2211-2267)
|
||||
|
||||
export interface RawTransmission {
|
||||
corrId: Uint8Array
|
||||
entityId: Uint8Array
|
||||
command: Uint8Array
|
||||
}
|
||||
|
||||
// transmissionP (Protocol.hs:1629-1642)
|
||||
// Parse a single transmission from block bytes.
|
||||
// implySessId=true, serviceAuth=false for web client.
|
||||
export function transmissionP(data: Uint8Array): RawTransmission {
|
||||
const d = new Decoder(data)
|
||||
const auth = decodeBytes(d) // authenticator
|
||||
// serviceAuth=true for v16+: if auth is non-empty, skip serviceSig (Maybe Signature)
|
||||
if (auth.length > 0) {
|
||||
decodeMaybe(decodeBytes, d) // skip serviceSig
|
||||
}
|
||||
const rest = d.takeAll() // authorized bytes
|
||||
// re-parse authorized: corrId + entityId + command
|
||||
const d2 = new Decoder(rest)
|
||||
// implySessId=true: no sessionId in wire format
|
||||
const corrId = decodeBytes(d2)
|
||||
const entityId = decodeBytes(d2)
|
||||
const command = d2.takeAll()
|
||||
return {corrId, entityId, command}
|
||||
}
|
||||
|
||||
// tParse (Protocol.hs:2211-2217)
|
||||
// Parse a received block into individual transmissions.
|
||||
// batch=true: count byte + N Large-wrapped transmissions
|
||||
export function tParse(block: Uint8Array): RawTransmission[] {
|
||||
const d = new Decoder(block)
|
||||
const count = d.anyByte()
|
||||
const transmissions: RawTransmission[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const data = decodeLarge(d)
|
||||
transmissions.push(transmissionP(data))
|
||||
}
|
||||
return transmissions
|
||||
}
|
||||
|
||||
// tDecodeClient (Protocol.hs:2256-2266)
|
||||
// Parse command bytes into typed response.
|
||||
export function tDecodeClient(raw: RawTransmission): {corrId: Uint8Array, entityId: Uint8Array, response: SMPResponse} {
|
||||
const response = decodeResponse(new Decoder(raw.command))
|
||||
return {corrId: raw.corrId, entityId: raw.entityId, response}
|
||||
}
|
||||
|
||||
// -- 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 interface PKEYResponse {
|
||||
sessionId: Uint8Array
|
||||
versionRange: {min: number, max: number}
|
||||
certChainDer: Uint8Array // Large-encoded DER certificate chain
|
||||
signedKeyDer: Uint8Array // Large-encoded DER signed public key
|
||||
}
|
||||
|
||||
export type SMPResponse =
|
||||
| {type: "LNK", response: LNKResponse}
|
||||
| {type: "IDS", response: IDSResponse}
|
||||
| {type: "MSG", response: MSGResponse}
|
||||
| {type: "OK"}
|
||||
| {type: "SOK", serviceId: Uint8Array | null}
|
||||
| {type: "PKEY", response: PKEYResponse}
|
||||
| {type: "PRES", encResponse: Uint8Array}
|
||||
| {type: "PONG"}
|
||||
| {type: "END"}
|
||||
| {type: "DELD"}
|
||||
| {type: "ERR", error: string}
|
||||
|
||||
// protocolError check (Client.hs:710-712)
|
||||
// Returns the error string if this is an ERR response, null otherwise
|
||||
export function protocolError(resp: SMPResponse): string | null {
|
||||
return resp.type === "ERR" ? resp.error : null
|
||||
}
|
||||
|
||||
export function decodeResponse(d: Decoder): SMPResponse {
|
||||
const tag = readTag(d)
|
||||
switch (tag) {
|
||||
case "LNK": {
|
||||
readSpace(d)
|
||||
return {type: "LNK", response: decodeLNK(d)}
|
||||
}
|
||||
case "IDS": {
|
||||
readSpace(d)
|
||||
return {type: "IDS", response: decodeIDS(d)}
|
||||
}
|
||||
case "MSG": {
|
||||
readSpace(d)
|
||||
return {type: "MSG", response: decodeMSG(d)}
|
||||
}
|
||||
case "OK": return {type: "OK"}
|
||||
case "SOK": {
|
||||
// SOK serviceId_ → e(SOK_, ' ', serviceId_)
|
||||
readSpace(d)
|
||||
const serviceId = d.remaining() > 0 ? decodeMaybe(decodeBytes, d) : null
|
||||
return {type: "SOK", serviceId}
|
||||
}
|
||||
case "PKEY": {
|
||||
// PKEY sessionId versionRange certChainPubKey (Protocol.hs:1894)
|
||||
// PKEY_ -> PKEY <$> _smpP <*> smpP <*> smpP
|
||||
// sessionId: ByteString, versionRange: (Word16, Word16)
|
||||
// certChainPubKey: (NonEmpty Large, SignedObject) (Transport.hs:663-664)
|
||||
// certChain = NonEmpty Large = 1-byte count + N × Large(2-byte len + DER)
|
||||
// signedKey = Large(2-byte len + DER)
|
||||
readSpace(d)
|
||||
const sessionId = decodeBytes(d)
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
// certChain: NonEmpty Large (1-byte count + N × Large-encoded DER certs)
|
||||
const certCount = d.anyByte()
|
||||
const certChainDers: Uint8Array[] = []
|
||||
for (let i = 0; i < certCount; i++) certChainDers.push(decodeLarge(d))
|
||||
// signedKey: Large-encoded DER
|
||||
const signedKeyDer = decodeLarge(d)
|
||||
return {type: "PKEY", response: {sessionId, versionRange: {min, max}, certChainDer: certChainDers[0] ?? new Uint8Array(0), signedKeyDer}}
|
||||
}
|
||||
case "PRES": {
|
||||
// PRES (EncResponse encBlock) (Protocol.hs:1896)
|
||||
// PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP)
|
||||
readSpace(d)
|
||||
return {type: "PRES", encResponse: d.takeAll()}
|
||||
}
|
||||
case "PONG": return {type: "PONG"}
|
||||
case "END": return {type: "END"}
|
||||
case "DELD": return {type: "DELD"}
|
||||
case "ERR": {
|
||||
readSpace(d)
|
||||
// Read the full error string (may be multi-word like "AUTH" or "QUOTA")
|
||||
const errBytes = d.takeAll()
|
||||
return {type: "ERR", error: new TextDecoder().decode(errBytes)}
|
||||
}
|
||||
default: throw new Error("unknown SMP response: " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// -- SMP command encoders (Protocol.hs:1679-1715)
|
||||
|
||||
// MsgFlags (Protocol.hs:884-892)
|
||||
// Single byte: Bool encoding of notification flag
|
||||
export function encodeMsgFlags(notification: boolean): Uint8Array {
|
||||
return encodeBool(notification)
|
||||
}
|
||||
|
||||
// SubscriptionMode (Protocol.hs:651-659)
|
||||
// 'S' = SMSubscribe, 'C' = SMOnlyCreate
|
||||
export function encodeSubMode(subscribe: boolean): Uint8Array {
|
||||
return ascii(subscribe ? "S" : "C")
|
||||
}
|
||||
|
||||
// NEW (Protocol.hs:1682-1689)
|
||||
// For v19: e(NEW_, ' ', rKey, dhKey) <> e(auth_, subMode, queueReqData, ntfCreds)
|
||||
// QueueReqData: QRMessaging Nothing = 'M' + Nothing(0x30)
|
||||
export function encodeNEW(
|
||||
rcvAuthKey: Uint8Array, // DER-encoded Ed25519 or X25519 public key
|
||||
rcvDhKey: Uint8Array, // DER-encoded X25519 public key
|
||||
basicAuth: Uint8Array | null, // Maybe BasicAuth (server auth, not a crypto key)
|
||||
subscribe: boolean,
|
||||
): Uint8Array {
|
||||
// QRMessaging Nothing: Just('M', Nothing) = 0x31 0x4D 0x30
|
||||
const queueReqData = new Uint8Array([0x31, 0x4D, 0x30])
|
||||
return concatBytes(
|
||||
ascii("NEW "),
|
||||
encodeBytes(rcvAuthKey),
|
||||
encodeBytes(rcvDhKey),
|
||||
encodeMaybe(encodeBytes, basicAuth),
|
||||
encodeSubMode(subscribe),
|
||||
queueReqData,
|
||||
new Uint8Array([0x30]), // ntfCreds = Nothing
|
||||
)
|
||||
}
|
||||
|
||||
// KEY (Protocol.hs:1692)
|
||||
// KEY k -> e(KEY_, ' ', k)
|
||||
export function encodeKEY(senderKey: Uint8Array): Uint8Array {
|
||||
return concatBytes(ascii("KEY "), encodeBytes(senderKey))
|
||||
}
|
||||
|
||||
// SKEY (Protocol.hs:1703)
|
||||
// SKEY k -> e(SKEY_, ' ', k)
|
||||
export function encodeSKEY(senderKey: Uint8Array): Uint8Array {
|
||||
return concatBytes(ascii("SKEY "), encodeBytes(senderKey))
|
||||
}
|
||||
|
||||
// SUB (Protocol.hs:1690)
|
||||
export function encodeSUB(): Uint8Array {
|
||||
return ascii("SUB")
|
||||
}
|
||||
|
||||
// ACK (Protocol.hs:1699)
|
||||
// ACK msgId -> e(ACK_, ' ', msgId)
|
||||
export function encodeACK(msgId: Uint8Array): Uint8Array {
|
||||
return concatBytes(ascii("ACK "), encodeBytes(msgId))
|
||||
}
|
||||
|
||||
// SEND (Protocol.hs:1704)
|
||||
// SEND flags msg -> e(SEND_, ' ', flags, ' ', Tail msg)
|
||||
export function encodeSEND(notification: boolean, msgBody: Uint8Array): Uint8Array {
|
||||
return concatBytes(
|
||||
ascii("SEND "),
|
||||
encodeMsgFlags(notification),
|
||||
ascii(" "),
|
||||
msgBody, // Tail - no length prefix
|
||||
)
|
||||
}
|
||||
|
||||
// OFF (Protocol.hs:1700)
|
||||
export function encodeOFF(): Uint8Array {
|
||||
return ascii("OFF")
|
||||
}
|
||||
|
||||
// DEL (Protocol.hs:1701)
|
||||
export function encodeDEL(): Uint8Array {
|
||||
return ascii("DEL")
|
||||
}
|
||||
|
||||
// GET (Protocol.hs:1698)
|
||||
export function encodeGET(): Uint8Array {
|
||||
return ascii("GET")
|
||||
}
|
||||
|
||||
// QUE (Protocol.hs:1702)
|
||||
export function encodeQUE(): Uint8Array {
|
||||
return ascii("QUE")
|
||||
}
|
||||
|
||||
// PING (Protocol.hs:1705)
|
||||
export function encodePING(): Uint8Array {
|
||||
return ascii("PING")
|
||||
}
|
||||
|
||||
// -- Proxy commands (Protocol.hs:1710-1711)
|
||||
|
||||
// encodeProtocolServer (Protocol.hs:1264-1266)
|
||||
// smpEncode ProtocolServer {host, port, keyHash} = smpEncode (host, port, keyHash)
|
||||
// host :: NonEmpty TransportHost → smpEncodeList (1-byte count + encodeBytes(strEncode(host)) for each)
|
||||
// port :: ServiceName = ByteString → encodeBytes
|
||||
// keyHash :: KeyHash = ByteString → encodeBytes
|
||||
export function encodeProtocolServer(hosts: string[], port: string, keyHash: Uint8Array): Uint8Array {
|
||||
const encodedHosts = hosts.map(h => encodeBytes(ascii(h)))
|
||||
const hostList = concatBytes(new Uint8Array([hosts.length]), ...encodedHosts)
|
||||
return concatBytes(hostList, encodeBytes(ascii(port)), encodeBytes(keyHash))
|
||||
}
|
||||
|
||||
// PRXY (Protocol.hs:1710)
|
||||
// PRXY host auth_ -> e(PRXY_, ' ', host, auth_)
|
||||
export function encodePRXY(hosts: string[], port: string, keyHash: Uint8Array, basicAuth: Uint8Array | null): Uint8Array {
|
||||
return concatBytes(
|
||||
ascii("PRXY "),
|
||||
encodeProtocolServer(hosts, port, keyHash),
|
||||
encodeMaybe(encodeBytes, basicAuth),
|
||||
)
|
||||
}
|
||||
|
||||
// PFWD (Protocol.hs:1711)
|
||||
// PFWD fwdV pubKey (EncTransmission s) -> e(PFWD_, ' ', fwdV, pubKey, Tail s)
|
||||
export function encodePFWD(version: number, pubKeyDer: Uint8Array, encTransmission: Uint8Array): Uint8Array {
|
||||
return concatBytes(
|
||||
ascii("PFWD "),
|
||||
encodeWord16(version),
|
||||
encodeBytes(pubKeyDer),
|
||||
encTransmission, // Tail — no length prefix
|
||||
)
|
||||
}
|
||||
|
||||
// paddedProxiedTLength (Protocol.hs:306-307)
|
||||
export const paddedProxiedTLength = 16226
|
||||
|
||||
// -- SMP response decoders
|
||||
|
||||
// IDS (Protocol.hs:1914-1921)
|
||||
// For v19: e(IDS_, ' ', rcvId, sndId, srvDh) <> e(queueMode, linkId, serviceId, ntfCreds)
|
||||
export interface IDSResponse {
|
||||
rcvId: Uint8Array
|
||||
sndId: Uint8Array
|
||||
srvDhKey: Uint8Array
|
||||
queueMode: string | null // 'M' = Messaging, 'C' = Contact
|
||||
linkId: Uint8Array | null
|
||||
}
|
||||
|
||||
export function decodeIDS(d: Decoder): IDSResponse {
|
||||
const rcvId = decodeBytes(d)
|
||||
const sndId = decodeBytes(d)
|
||||
const srvDhKey = decodeBytes(d)
|
||||
// v19: queueMode (Maybe QueueMode), linkId (Maybe ByteString), serviceId, ntfCreds
|
||||
// QueueMode is encoded as Maybe Char ('M'/'C'), not Maybe ByteString
|
||||
let queueMode: string | null = null
|
||||
if (d.remaining() > 0) {
|
||||
const qmByte = d.anyByte()
|
||||
if (qmByte === 0x31) { // '1' = Just
|
||||
queueMode = String.fromCharCode(d.anyByte())
|
||||
}
|
||||
// '0' = Nothing, queueMode stays null
|
||||
}
|
||||
let linkId: Uint8Array | null = null
|
||||
if (d.remaining() > 0) {
|
||||
linkId = decodeMaybe(decodeBytes, d)
|
||||
}
|
||||
// serviceId and ntfCreds - skip remaining
|
||||
return {rcvId, sndId, srvDhKey, queueMode, linkId}
|
||||
}
|
||||
|
||||
// MSG (Protocol.hs:1927-1928)
|
||||
// MSG RcvMessage {msgId, msgBody = EncRcvMsgBody body} -> e(MSG_, ' ', msgId, Tail body)
|
||||
export interface MSGResponse {
|
||||
msgId: Uint8Array
|
||||
msgBody: Uint8Array
|
||||
}
|
||||
|
||||
export function decodeMSG(d: Decoder): MSGResponse {
|
||||
const msgId = decodeBytes(d)
|
||||
const msgBody = d.takeAll()
|
||||
return {msgId, msgBody}
|
||||
}
|
||||
|
||||
// -- Per-queue E2E encryption (Protocol.hs:1071-1114)
|
||||
|
||||
// Protocol.hs:316-320
|
||||
export const e2eEncMessageLength = 16000
|
||||
export const e2eEncConfirmationLength = 15904
|
||||
|
||||
// Protocol.hs:1078-1086
|
||||
export interface PubHeader {
|
||||
phVersion: number // VersionSMPC (Word16)
|
||||
phE2ePubDhKey: Uint8Array | null // Maybe PublicKeyX25519 (DER-encoded ByteString)
|
||||
}
|
||||
|
||||
export function encodePubHeader(h: PubHeader): Uint8Array {
|
||||
return concatBytes(encodeWord16(h.phVersion), encodeMaybe(encodeBytes, h.phE2ePubDhKey))
|
||||
}
|
||||
|
||||
export function decodePubHeader(d: Decoder): PubHeader {
|
||||
return {phVersion: decodeWord16(d), phE2ePubDhKey: decodeMaybe(decodeBytes, d)}
|
||||
}
|
||||
|
||||
// Protocol.hs:1097-1110
|
||||
export type PrivHeader =
|
||||
| {type: "PHConfirmation", key: Uint8Array} // 'K' + DER-encoded APublicAuthKey
|
||||
| {type: "PHEmpty"} // '_'
|
||||
|
||||
export function encodePrivHeader(h: PrivHeader): Uint8Array {
|
||||
switch (h.type) {
|
||||
case "PHConfirmation": return concatBytes(new Uint8Array([0x4B]), encodeBytes(h.key)) // 'K' + encodeBytes
|
||||
case "PHEmpty": return new Uint8Array([0x5F]) // '_'
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePrivHeader(d: Decoder): PrivHeader {
|
||||
const tag = d.anyByte()
|
||||
switch (tag) {
|
||||
case 0x4B: return {type: "PHConfirmation", key: decodeBytes(d)} // 'K'
|
||||
case 0x5F: return {type: "PHEmpty"} // '_'
|
||||
default: throw new Error("decodePrivHeader: unknown tag " + tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol.hs:1095, 1112-1114
|
||||
export interface ClientMessage {
|
||||
privHeader: PrivHeader
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
// smpEncode (ClientMessage h msg) = smpEncode h <> msg
|
||||
export function encodeClientMessage(msg: ClientMessage): Uint8Array {
|
||||
return concatBytes(encodePrivHeader(msg.privHeader), msg.body)
|
||||
}
|
||||
|
||||
export function decodeClientMessage(d: Decoder): ClientMessage {
|
||||
const privHeader = decodePrivHeader(d)
|
||||
const body = d.takeAll()
|
||||
return {privHeader, body}
|
||||
}
|
||||
|
||||
// Protocol.hs:1071-1093
|
||||
export interface ClientMsgEnvelope {
|
||||
cmHeader: PubHeader
|
||||
cmNonce: Uint8Array // CbNonce: raw 24 bytes
|
||||
cmEncBody: Uint8Array // encrypted body (Tail)
|
||||
}
|
||||
|
||||
// smpEncode (cmHeader, cmNonce, Tail cmEncBody)
|
||||
export function encodeClientMsgEnvelope(env: ClientMsgEnvelope): Uint8Array {
|
||||
return concatBytes(encodePubHeader(env.cmHeader), env.cmNonce, env.cmEncBody)
|
||||
}
|
||||
|
||||
export function decodeClientMsgEnvelope(d: Decoder): ClientMsgEnvelope {
|
||||
const cmHeader = decodePubHeader(d)
|
||||
const cmNonce = d.take(24) // CbNonce is raw 24 bytes
|
||||
const cmEncBody = d.takeAll()
|
||||
return {cmHeader, cmNonce, cmEncBody}
|
||||
}
|
||||
|
||||
// -- Per-queue E2E encrypt/decrypt (Agent/Client.hs:2074-2102)
|
||||
|
||||
// agentCbEncrypt: encrypt a ClientMessage and wrap in ClientMsgEnvelope
|
||||
export function agentCbEncrypt(
|
||||
e2eDhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
|
||||
smpClientVersion: number, // Word16
|
||||
e2ePubKey: Uint8Array | null, // DER-encoded X25519 public key, null for normal messages
|
||||
msg: Uint8Array, // smpEncode(ClientMessage)
|
||||
): Uint8Array {
|
||||
const cmNonce = crypto.getRandomValues(new Uint8Array(24))
|
||||
const paddedLen = e2ePubKey !== null ? e2eEncConfirmationLength : e2eEncMessageLength
|
||||
const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen)
|
||||
const cmHeader: PubHeader = {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey}
|
||||
return encodeClientMsgEnvelope({cmHeader, cmNonce, cmEncBody})
|
||||
}
|
||||
|
||||
// agentCbDecrypt: decrypt a ClientMsgEnvelope
|
||||
export function agentCbDecrypt(
|
||||
dhSecret: Uint8Array, // X25519 DH shared secret (32 bytes)
|
||||
data: Uint8Array, // raw ClientMsgEnvelope bytes
|
||||
): {pubHeader: PubHeader, clientMessage: ClientMessage} {
|
||||
const env = decodeClientMsgEnvelope(new Decoder(data))
|
||||
const plaintext = cbDecrypt(dhSecret, env.cmNonce, env.cmEncBody)
|
||||
const clientMessage = decodeClientMessage(new Decoder(plaintext))
|
||||
return {pubHeader: env.cmHeader, clientMessage}
|
||||
}
|
||||
@@ -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,132 @@
|
||||
// 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
|
||||
// Server's raw X25519 public key — needed for command auth (cbAuthenticate)
|
||||
serverPubKey: 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
|
||||
let serverPubKey: 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+)
|
||||
serverPubKey = extractSignedKey(serverHs.authPubKey.signedKeyDer).dhKey
|
||||
const clientKp = generateX25519KeyPair()
|
||||
clientAuthPubKey = encodePubKeyX25519(clientKp.publicKey)
|
||||
const dhSecret = dh(serverPubKey, 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, serverPubKey}
|
||||
}
|
||||
|
||||
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,167 @@
|
||||
// Agent-level REPL for cross-language testing.
|
||||
// Uses AgentClient + smp-ops layer (not raw SMP client).
|
||||
// Reads commands from stdin, writes results to stdout.
|
||||
//
|
||||
// Commands:
|
||||
// INIT <wsUrl> <keyHashHex> <userId>
|
||||
// CREATE_QUEUE <connIdHex>
|
||||
// SUBSCRIBE <connIdHex>
|
||||
// RECV [timeoutMs]
|
||||
// CB_ENCRYPT <dhSecretHex> <version> <bodyHex>
|
||||
// CB_DECRYPT <dhSecretHex> <nonceHex> <ciphertextHex>
|
||||
// CLOSE
|
||||
|
||||
import "fake-indexeddb/auto"
|
||||
import {createInterface} from "readline"
|
||||
import {newAgentClient, defaultAgentConfig, type AgentClient as AC} from "../dist/agent/client.js"
|
||||
import {getSMPServerClient, agentCbEncrypt, agentCbDecrypt, newRcvQueue, subscribeQueues, type ServerMsg} from "../dist/agent/smp-ops.js"
|
||||
import {openAgentStore} from "../dist/agent/store-idb.js"
|
||||
import type {AgentStore} from "../dist/agent/store.js"
|
||||
import type {RcvQueueSub} from "../dist/agent/subscriptions.js"
|
||||
|
||||
let agentClient: AC | null = null
|
||||
let store: AgentStore | null = null
|
||||
let serverUrl = ""
|
||||
let serverKeyHash: Uint8Array = new Uint8Array(0)
|
||||
let userId = 1
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2)
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
return bytes
|
||||
}
|
||||
|
||||
async function waitForMsg(c: AC, timeoutMs: number): Promise<ServerMsg> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs)
|
||||
c.msgQ.dequeue().then(msg => {
|
||||
clearTimeout(timer)
|
||||
resolve(msg as ServerMsg)
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function parseLine(line: string): Promise<string> {
|
||||
const parts = line.trim().split(" ")
|
||||
const cmd = parts[0]
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "INIT": {
|
||||
serverUrl = parts[1]
|
||||
serverKeyHash = fromHex(parts[2])
|
||||
userId = parseInt(parts[3], 10)
|
||||
store = await openAgentStore()
|
||||
await store.createUserRecord()
|
||||
agentClient = newAgentClient(defaultAgentConfig, store, new Map([[userId, {
|
||||
storageSrvs: [[null, {server: parts[1], auth: null}]],
|
||||
proxySrvs: [[null, {server: parts[1], auth: null}]],
|
||||
knownHosts: new Set(),
|
||||
}]]) as any)
|
||||
const conn = await getSMPServerClient(agentClient, userId, parts[1], serverKeyHash, parts[1])
|
||||
return "ok: " + toHex(conn.client.sessionId)
|
||||
}
|
||||
|
||||
case "CREATE_QUEUE": {
|
||||
if (!agentClient || !store) return "error: not initialized"
|
||||
const connId = fromHex(parts[1])
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
const result = await newRcvQueue(agentClient, userId, connId, serverUrl, serverKeyHash, serverUrl, true)
|
||||
await store.addConnRcvQueue(connId, {
|
||||
host: result.rcvQueue.host, port: result.rcvQueue.port,
|
||||
rcvId: result.rcvQueue.rcv_id, connId,
|
||||
rcvPrivateKey: result.rcvQueue.rcv_private_key,
|
||||
rcvDhSecret: result.rcvQueue.rcv_dh_secret,
|
||||
e2ePrivKey: result.rcvQueue.e2e_priv_key,
|
||||
e2eDhSecret: null,
|
||||
sndId: result.rcvQueue.snd_id,
|
||||
sndKey: null, status: "new",
|
||||
smpClientVersion: result.rcvQueue.smp_client_version,
|
||||
dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: result.rcvQueue.queue_mode,
|
||||
serverKeyHash, lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
return "ok: " + toHex(result.rcvQueue.rcv_id) + " " + toHex(result.sndId) + " " + toHex(result.e2eDhKey)
|
||||
}
|
||||
|
||||
case "SUBSCRIBE": {
|
||||
if (!agentClient || !store) return "error: not initialized"
|
||||
const connId = fromHex(parts[1])
|
||||
const conn = await store.getConn(connId)
|
||||
if (!conn) return "error: connection not found"
|
||||
const rcvQueues = conn.rcvQueues as any[]
|
||||
if (rcvQueues.length === 0) return "error: no rcv queues"
|
||||
const subs: RcvQueueSub[] = rcvQueues.map((rq: any) => ({
|
||||
userId, connId: rq.conn_id, server: serverUrl,
|
||||
rcvId: rq.rcv_id, rcvPrivateKey: rq.rcv_private_key,
|
||||
status: rq.status, enableNtfs: true, clientNoticeId: null,
|
||||
dbQueueId: rq.rcv_queue_id, primary: !!rq.rcv_primary,
|
||||
dbReplaceQueueId: null,
|
||||
}))
|
||||
await subscribeQueues(agentClient, userId, subs)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "RECV": {
|
||||
if (!agentClient) return "error: not initialized"
|
||||
const timeoutMs = parts[1] ? parseInt(parts[1], 10) : 5000
|
||||
const msg = await waitForMsg(agentClient, timeoutMs)
|
||||
return "ok: " + toHex(msg.entityId) + " " + JSON.stringify(msg.msg)
|
||||
}
|
||||
|
||||
case "CB_ENCRYPT": {
|
||||
// CB_ENCRYPT <dhSecretHex> <version> <bodyHex> [e2ePubKeyHex]
|
||||
// If e2ePubKeyHex is given (raw 32 bytes), it goes in the PubHeader (confirmation mode).
|
||||
const dhSecret = fromHex(parts[1])
|
||||
const version = parseInt(parts[2], 10)
|
||||
const body = fromHex(parts[3])
|
||||
const e2ePubKey = parts[4] ? fromHex(parts[4]) : null
|
||||
const envelope = agentCbEncrypt(dhSecret, version, e2ePubKey, body)
|
||||
return "ok: " + toHex(envelope)
|
||||
}
|
||||
|
||||
case "CB_DECRYPT": {
|
||||
const dhSecret = fromHex(parts[1])
|
||||
const nonce = fromHex(parts[2])
|
||||
const ct = fromHex(parts[3])
|
||||
const pt = agentCbDecrypt(dhSecret, nonce, ct)
|
||||
return "ok: " + toHex(pt)
|
||||
}
|
||||
|
||||
case "CLOSE": {
|
||||
if (agentClient) {
|
||||
agentClient.active = false
|
||||
for (const [, sv] of agentClient.smpClients as Map<string, any>) {
|
||||
if (sv.value?.client) sv.value.client.close()
|
||||
}
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
default:
|
||||
return "error: unknown command: " + cmd
|
||||
}
|
||||
} catch (e: any) {
|
||||
return "error: " + (e?.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const rl = createInterface({input: process.stdin, output: process.stdout, terminal: false})
|
||||
|
||||
rl.on("line", async (line: string) => {
|
||||
const result = await parseLine(line)
|
||||
process.stdout.write(result + "\n")
|
||||
})
|
||||
|
||||
rl.on("close", () => process.exit(0))
|
||||
@@ -0,0 +1,257 @@
|
||||
// SMP client REPL for cross-language testing.
|
||||
// Holds one SMPClient, reads commands from stdin, writes results to stdout.
|
||||
//
|
||||
// Commands:
|
||||
// CONNECT <url> <keyHashHex> [wsOptionsJson]
|
||||
// NEW <rcvAuthKeyHex> <rcvDhKeyHex> <rcvPrivKeyHex>
|
||||
// SUB <rcvIdHex> <rcvPrivKeyHex>
|
||||
// SEND <sndIdHex> <sndPrivKeyHex|none> <notification 0|1> <bodyHex>
|
||||
// ACK <rcvIdHex> <rcvPrivKeyHex> <msgIdHex>
|
||||
// KEY <rcvIdHex> <rcvPrivKeyHex> <senderKeyHex>
|
||||
// SKEY <sndIdHex> <sndPrivKeyHex>
|
||||
// DEL <rcvIdHex> <rcvPrivKeyHex>
|
||||
// OFF <rcvIdHex> <rcvPrivKeyHex>
|
||||
// PING
|
||||
// RECV [timeoutMs]
|
||||
// CLOSE
|
||||
|
||||
import {createInterface} from "readline"
|
||||
import {createSMPClient, type SMPClient} from "../dist/client.js"
|
||||
import type {SMPResponse, AuthKey} from "../dist/protocol.js"
|
||||
import {generateX25519KeyPair, dh, encodePubKeyX25519, decodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {Decoder, decodeBytes, decodeBool} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
import type {ProxiedRelay} from "../dist/client.js"
|
||||
|
||||
// -- State
|
||||
|
||||
let client: SMPClient | null = null
|
||||
let proxiedRelay: ProxiedRelay | null = null
|
||||
// Per-queue DH shared secrets for decrypting received messages (keyed by rcvId hex)
|
||||
const queueSecrets = new Map<string, Uint8Array>()
|
||||
const messageQueue: Array<{entityId: Uint8Array, msg: SMPResponse}> = []
|
||||
let messageWaiter: {resolve: (m: {entityId: Uint8Array, msg: SMPResponse}) => void, timer: ReturnType<typeof setTimeout>} | null = null
|
||||
|
||||
// -- Hex helpers
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2)
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// -- Message delivery
|
||||
|
||||
function onMessage(entityId: Uint8Array, msg: SMPResponse): void {
|
||||
if (messageWaiter) {
|
||||
const w = messageWaiter
|
||||
messageWaiter = null
|
||||
clearTimeout(w.timer)
|
||||
w.resolve({entityId, msg})
|
||||
} else {
|
||||
messageQueue.push({entityId, msg})
|
||||
}
|
||||
}
|
||||
|
||||
function waitForMessage(timeoutMs: number): Promise<{entityId: Uint8Array, msg: SMPResponse}> {
|
||||
if (messageQueue.length > 0) {
|
||||
return Promise.resolve(messageQueue.shift()!)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
messageWaiter = null
|
||||
reject(new Error("timeout"))
|
||||
}, timeoutMs)
|
||||
messageWaiter = {resolve, timer}
|
||||
})
|
||||
}
|
||||
|
||||
function makeAuthKey(hexKey: string): AuthKey {
|
||||
return {type: "x25519", key: fromHex(hexKey)}
|
||||
}
|
||||
|
||||
// -- Command parser
|
||||
|
||||
async function parseLine(line: string): Promise<string> {
|
||||
const parts = line.split(" ")
|
||||
const cmd = parts[0]
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "CONNECT": {
|
||||
const url = parts[1]
|
||||
const keyHash = fromHex(parts[2])
|
||||
const wsOptions = parts[3] ? JSON.parse(parts[3]) : undefined
|
||||
client = await createSMPClient(url, keyHash, onMessage, () => {
|
||||
process.stderr.write("disconnected\n")
|
||||
}, {wsOptions, timeout: 15000})
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "NEW": {
|
||||
if (!client) return "error: not connected"
|
||||
const rcvAuthKey = fromHex(parts[1])
|
||||
const rcvPrivKey = fromHex(parts[2])
|
||||
// Generate DH keypair for per-queue E2E
|
||||
const dhKp = generateX25519KeyPair()
|
||||
const dhPubDer = encodePubKeyX25519(dhKp.publicKey)
|
||||
const resp = await client.createQueue(
|
||||
{publicKey: rcvAuthKey, privateKey: rcvPrivKey},
|
||||
dhPubDer,
|
||||
true,
|
||||
)
|
||||
// Compute and store DH shared secret for decrypting received messages
|
||||
const srvDhRaw = decodePubKeyX25519(resp.srvDhKey)
|
||||
const dhShared = dh(srvDhRaw, dhKp.privateKey)
|
||||
queueSecrets.set(toHex(resp.rcvId), dhShared)
|
||||
return "ok: " + toHex(resp.rcvId) + " " + toHex(resp.sndId) + " " + toHex(resp.srvDhKey)
|
||||
}
|
||||
|
||||
case "SUB": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.subscribeQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "SEND": {
|
||||
if (!client) return "error: not connected"
|
||||
const sndId = fromHex(parts[1])
|
||||
const privKey: AuthKey | null = parts[2] === "none" ? null : makeAuthKey(parts[2])
|
||||
const notification = parts[3] === "1"
|
||||
const body = fromHex(parts[4])
|
||||
await client.sendMessage(privKey, sndId, notification, body)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "ACK": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.ackMessage(makeAuthKey(parts[2]), fromHex(parts[1]), fromHex(parts[3]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "KEY": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.secureQueue(makeAuthKey(parts[2]), fromHex(parts[1]), fromHex(parts[3]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "SKEY": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.secureSndQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "DEL": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.deleteQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "OFF": {
|
||||
if (!client) return "error: not connected"
|
||||
await client.suspendQueue(makeAuthKey(parts[2]), fromHex(parts[1]))
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "PING": {
|
||||
if (!client) return "error: not connected"
|
||||
// Optional auth key as second arg: PING <privKeyHex>
|
||||
const pingKey: AuthKey | null = parts[1] ? makeAuthKey(parts[1]) : null
|
||||
const resp = await client.sendCommand(pingKey, new Uint8Array(0), new TextEncoder().encode("PING"))
|
||||
return resp.type === "PONG" ? "ok" : "error: unexpected " + resp.type
|
||||
}
|
||||
|
||||
case "RECV": {
|
||||
if (!client) return "error: not connected"
|
||||
const timeoutMs = parts[1] ? parseInt(parts[1]) : 5000
|
||||
const m = await waitForMessage(timeoutMs)
|
||||
if (m.msg.type === "MSG") {
|
||||
const {msgId, msgBody} = m.msg.response
|
||||
// Decrypt per-queue E2E: cbDecrypt(dhShared, cbNonce(msgId), body)
|
||||
const dhShared = queueSecrets.get(toHex(m.entityId))
|
||||
if (dhShared) {
|
||||
// decryptMsgV3: cbDecrypt then parse ClientRcvMsgBody (msgTs + msgFlags + space + Tail msgBody)
|
||||
const decrypted = cbDecrypt(dhShared, msgId, msgBody)
|
||||
const dd = new Decoder(decrypted)
|
||||
dd.take(8) // skip msgTs (SystemTime = Int64 = 8 bytes)
|
||||
dd.take(1) // skip msgFlags (Bool = 1 byte)
|
||||
dd.take(1) // skip space (0x20)
|
||||
const body = dd.takeAll()
|
||||
return "ok: " + toHex(m.entityId) + " " + toHex(msgId) + " " + toHex(body)
|
||||
}
|
||||
// No DH secret (sender queue) — return raw
|
||||
return "ok: " + toHex(m.entityId) + " " + toHex(msgId) + " " + toHex(msgBody)
|
||||
}
|
||||
return "ok: " + toHex(m.entityId) + " " + m.msg.type
|
||||
}
|
||||
|
||||
// BSUB <rcvId1Hex>:<privKey1Hex> <rcvId2Hex>:<privKey2Hex> ...
|
||||
case "BSUB": {
|
||||
if (!client) return "error: not connected"
|
||||
const queues = parts.slice(1).map(p => {
|
||||
const [rcvIdHex, privKeyHex] = p.split(":")
|
||||
return {rcvId: fromHex(rcvIdHex), privKey: makeAuthKey(privKeyHex)}
|
||||
})
|
||||
await client.subscribeQueues(queues)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// PRXY <host1,host2,...> <port> <keyHashHex> [basicAuthHex]
|
||||
case "PRXY": {
|
||||
if (!client) return "error: not connected"
|
||||
const hosts = parts[1].split(",")
|
||||
const port = parts[2]
|
||||
const keyHash = fromHex(parts[3])
|
||||
const auth = parts[4] ? fromHex(parts[4]) : null
|
||||
proxiedRelay = await client.connectProxiedRelay(hosts, port, keyHash, auth)
|
||||
return "ok: " + toHex(proxiedRelay.sessionId) + " " + proxiedRelay.version
|
||||
}
|
||||
|
||||
// PSEND <sndIdHex> <sndPrivKeyHex|none> <notification 0|1> <bodyHex>
|
||||
case "PSEND": {
|
||||
if (!client || !proxiedRelay) return "error: not connected or no proxy session"
|
||||
const sndId = fromHex(parts[1])
|
||||
const privKey: AuthKey | null = parts[2] === "none" ? null : makeAuthKey(parts[2])
|
||||
const notification = parts[3] === "1"
|
||||
const body = fromHex(parts[4])
|
||||
await client.proxySendMessage(proxiedRelay, privKey, sndId, notification, body)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
case "CLOSE": {
|
||||
if (client) client.close()
|
||||
client = null
|
||||
return "ok"
|
||||
}
|
||||
|
||||
default:
|
||||
return "error: unknown command: " + cmd
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.type) return "error: " + e.type + (e.error ? " " + e.error : "")
|
||||
return "error: " + (e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// -- Main
|
||||
|
||||
async function main() {
|
||||
const rl = createInterface({input: process.stdin, terminal: false})
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
const response = await parseLine(trimmed)
|
||||
process.stdout.write(response + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
process.stderr.write("FATAL: " + e.message + "\n")
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,539 @@
|
||||
// Tests for concurrency primitives, session, retry, and subscriptions.
|
||||
|
||||
import {TMVar} from "../dist/agent/tmvar.js"
|
||||
import {Sem, ABQueue} from "../dist/agent/queue.js"
|
||||
import {getSessVar, removeSessVar, tryReadSessVar} from "../dist/agent/session.js"
|
||||
import {nextRetryDelay, type RetryInterval} from "../dist/agent/retry.js"
|
||||
import {TSessionSubs, type RcvQueueSub} from "../dist/agent/subscriptions.js"
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
function assert(cond: boolean, msg: string) {
|
||||
if (!cond) { console.error("FAIL:", msg); failed++ } else { passed++ }
|
||||
}
|
||||
|
||||
function assertEq(a: any, b: any, msg: string) {
|
||||
const av = JSON.stringify(a), bv = JSON.stringify(b)
|
||||
assert(av === bv, `${msg}: expected ${bv}, got ${av}`)
|
||||
}
|
||||
|
||||
function hex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function bytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) b[i] = i & 0xff
|
||||
return b
|
||||
}
|
||||
|
||||
// -- TMVar tests
|
||||
|
||||
async function testTMVar() {
|
||||
console.log(" TMVar...")
|
||||
|
||||
// new + tryRead + tryTake
|
||||
const mv1 = TMVar.new(42)
|
||||
assertEq(mv1.tryRead(), 42, "new: tryRead returns value")
|
||||
assertEq(mv1.isEmpty(), false, "new: not empty")
|
||||
assertEq(mv1.tryTake(), 42, "tryTake returns value")
|
||||
assertEq(mv1.isEmpty(), true, "after tryTake: empty")
|
||||
assertEq(mv1.tryTake(), undefined, "tryTake on empty: undefined")
|
||||
|
||||
// empty + tryPut
|
||||
const mv2 = TMVar.empty<number>()
|
||||
assertEq(mv2.isEmpty(), true, "empty: isEmpty")
|
||||
assert(mv2.tryPut(7), "tryPut on empty: true")
|
||||
assert(!mv2.tryPut(8), "tryPut on full: false")
|
||||
assertEq(mv2.tryRead(), 7, "tryRead after tryPut: 7")
|
||||
|
||||
// take blocks until put
|
||||
const mv3 = TMVar.empty<string>()
|
||||
let taken = ""
|
||||
const takePromise = mv3.take().then(v => { taken = v })
|
||||
assertEq(taken, "", "take blocks: not yet resolved")
|
||||
await mv3.put("hello")
|
||||
await takePromise
|
||||
assertEq(taken, "hello", "take unblocks after put")
|
||||
assertEq(mv3.isEmpty(), true, "after take: empty")
|
||||
|
||||
// read blocks until put, doesn't take
|
||||
const mv4 = TMVar.empty<number>()
|
||||
let readVal = 0
|
||||
const readPromise = mv4.read().then(v => { readVal = v })
|
||||
await mv4.put(99)
|
||||
await readPromise
|
||||
assertEq(readVal, 99, "read unblocks after put")
|
||||
assertEq(mv4.isEmpty(), false, "read doesn't take: still full")
|
||||
assertEq(mv4.tryRead(), 99, "value still there after read")
|
||||
|
||||
// doWork pattern: tryPut (signal), read (wait), tryTake (clear)
|
||||
const doWork = TMVar.new<void>(undefined) // starts with work
|
||||
await doWork.read() // should not block
|
||||
doWork.tryTake() // clear
|
||||
assertEq(doWork.isEmpty(), true, "doWork cleared")
|
||||
doWork.tryPut(undefined) // signal new work
|
||||
assertEq(doWork.isEmpty(), false, "doWork signaled")
|
||||
// double signal is no-op
|
||||
assert(!doWork.tryPut(undefined), "double signal returns false")
|
||||
}
|
||||
|
||||
// -- Sem tests
|
||||
|
||||
async function testSem() {
|
||||
console.log(" Sem...")
|
||||
|
||||
const sem = new Sem(1)
|
||||
await sem.wait()
|
||||
// Now permits = 0, next wait should block
|
||||
let acquired = false
|
||||
const p = sem.wait().then(() => { acquired = true })
|
||||
assertEq(acquired, false, "sem blocks when permits=0")
|
||||
sem.signal()
|
||||
await p
|
||||
assertEq(acquired, true, "sem unblocks after signal")
|
||||
}
|
||||
|
||||
// -- ABQueue tests
|
||||
|
||||
async function testABQueue() {
|
||||
console.log(" ABQueue...")
|
||||
|
||||
const q = new ABQueue<number>(3)
|
||||
await q.enqueue(1)
|
||||
await q.enqueue(2)
|
||||
await q.enqueue(3)
|
||||
// Queue is full (size 3), next enqueue should block
|
||||
let enqueued = false
|
||||
const ep = q.enqueue(4).then(() => { enqueued = true })
|
||||
assertEq(enqueued, false, "enqueue blocks when full")
|
||||
const v = await q.dequeue()
|
||||
assertEq(v, 1, "dequeue returns first item")
|
||||
await ep
|
||||
assertEq(enqueued, true, "enqueue unblocks after dequeue")
|
||||
|
||||
// dequeue remaining
|
||||
assertEq(await q.dequeue(), 2, "dequeue 2")
|
||||
assertEq(await q.dequeue(), 3, "dequeue 3")
|
||||
assertEq(await q.dequeue(), 4, "dequeue 4")
|
||||
}
|
||||
|
||||
// -- SessionVar tests
|
||||
|
||||
async function testSessionVar() {
|
||||
console.log(" SessionVar...")
|
||||
|
||||
const seq = {val: 0}
|
||||
const vars = new Map<string, any>()
|
||||
|
||||
// getSessVar: new
|
||||
const r1 = getSessVar(seq, "srv1", vars)
|
||||
assert(r1.isNew, "first getSessVar is new")
|
||||
assertEq(r1.v.id, 0, "first id is 0")
|
||||
|
||||
// getSessVar: existing
|
||||
const r2 = getSessVar(seq, "srv1", vars)
|
||||
assert(!r2.isNew, "second getSessVar is existing")
|
||||
assertEq(r2.v.id, 0, "same id")
|
||||
|
||||
// different key: new
|
||||
const r3 = getSessVar(seq, "srv2", vars)
|
||||
assert(r3.isNew, "different key is new")
|
||||
assertEq(r3.v.id, 1, "incremented id")
|
||||
|
||||
// tryReadSessVar before resolve
|
||||
assertEq(tryReadSessVar("srv1", vars), undefined, "tryRead before resolve: undefined")
|
||||
|
||||
// resolve + tryRead
|
||||
r1.v.resolve("client1")
|
||||
await r1.v.promise
|
||||
assertEq(tryReadSessVar("srv1", vars), "client1", "tryRead after resolve")
|
||||
|
||||
// multiple readers get same value
|
||||
const v1 = await r1.v.promise
|
||||
const v2 = await r2.v.promise
|
||||
assertEq(v1, "client1", "reader 1")
|
||||
assertEq(v2, "client1", "reader 2 (same promise)")
|
||||
|
||||
// removeSessVar: wrong id doesn't remove
|
||||
removeSessVar({...r1.v, id: 999}, "srv1", vars)
|
||||
assert(vars.has("srv1"), "removeSessVar with wrong id: not removed")
|
||||
|
||||
// removeSessVar: correct id removes
|
||||
removeSessVar(r1.v, "srv1", vars)
|
||||
assert(!vars.has("srv1"), "removeSessVar with correct id: removed")
|
||||
}
|
||||
|
||||
// -- RetryInterval tests
|
||||
|
||||
async function testRetryInterval() {
|
||||
console.log(" RetryInterval...")
|
||||
|
||||
const ri: RetryInterval = {initialInterval: 2_000_000, increaseAfter: 10_000_000, maxInterval: 180_000_000}
|
||||
|
||||
// Before increaseAfter: delay unchanged
|
||||
assertEq(nextRetryDelay(0, 2_000_000, ri), 2_000_000, "before increaseAfter: unchanged")
|
||||
assertEq(nextRetryDelay(4_000_000, 2_000_000, ri), 2_000_000, "still before increaseAfter")
|
||||
|
||||
// After increaseAfter: delay * 3/2
|
||||
assertEq(nextRetryDelay(10_000_000, 2_000_000, ri), 3_000_000, "after increaseAfter: 2M -> 3M")
|
||||
assertEq(nextRetryDelay(13_000_000, 3_000_000, ri), 4_500_000, "3M -> 4.5M")
|
||||
|
||||
// At maxInterval: stays at max
|
||||
assertEq(nextRetryDelay(999_000_000, 180_000_000, ri), 180_000_000, "at max: unchanged")
|
||||
|
||||
// Approaching max: capped
|
||||
assertEq(nextRetryDelay(100_000_000, 150_000_000, ri), 180_000_000, "capped at max")
|
||||
}
|
||||
|
||||
// -- TSessionSubs tests
|
||||
|
||||
async function testTSessionSubs() {
|
||||
console.log(" TSessionSubs...")
|
||||
|
||||
const ss = new TSessionSubs()
|
||||
const tSess = "user1:smp1.example.com"
|
||||
const sessId1 = bytes(32)
|
||||
const sessId2 = new Uint8Array(32).fill(0xff)
|
||||
|
||||
const rq1: RcvQueueSub = {
|
||||
userId: 1, connId: bytes(24), server: "smp1.example.com",
|
||||
rcvId: new Uint8Array([1, 2, 3]), rcvPrivateKey: bytes(32),
|
||||
status: "new", enableNtfs: true, clientNoticeId: null,
|
||||
dbQueueId: 1, primary: true, dbReplaceQueueId: null,
|
||||
}
|
||||
const rq2: RcvQueueSub = {
|
||||
...rq1, rcvId: new Uint8Array([4, 5, 6]), dbQueueId: 2,
|
||||
connId: new Uint8Array(24).fill(0xaa),
|
||||
}
|
||||
const rq1Key = hex(rq1.rcvId)
|
||||
const rq2Key = hex(rq2.rcvId)
|
||||
|
||||
// addPendingSub
|
||||
ss.addPendingSub(tSess, rq1)
|
||||
assert(ss.hasPendingSub(tSess, rq1Key), "addPendingSub: hasPendingSub")
|
||||
assert(!ss.hasActiveSub(tSess, rq1Key), "addPendingSub: not active")
|
||||
assert(ss.hasPendingSubs(tSess), "hasPendingSubs")
|
||||
|
||||
// setSessionId
|
||||
ss.setSessionId(tSess, sessId1)
|
||||
const sessSubs = ss.sessionSubs.get(tSess)!
|
||||
assertEq(hex(sessSubs.sessId!), hex(sessId1), "setSessionId sets sessId")
|
||||
|
||||
// addActiveSub with matching sessId: moves from pending to active
|
||||
ss.addActiveSub(tSess, sessId1, rq1)
|
||||
assert(ss.hasActiveSub(tSess, rq1Key), "addActiveSub: now active")
|
||||
assert(!ss.hasPendingSub(tSess, rq1Key), "addActiveSub: no longer pending")
|
||||
|
||||
// addActiveSub with wrong sessId: goes to pending
|
||||
ss.addActiveSub(tSess, sessId2, rq2)
|
||||
assert(!ss.hasActiveSub(tSess, rq2Key), "wrong sessId: not active")
|
||||
assert(ss.hasPendingSub(tSess, rq2Key), "wrong sessId: goes to pending")
|
||||
|
||||
// batchAddActiveSubs
|
||||
ss.batchAddActiveSubs(tSess, sessId1, [rq2])
|
||||
assert(ss.hasActiveSub(tSess, rq2Key), "batchAddActiveSubs: now active")
|
||||
assert(!ss.hasPendingSub(tSess, rq2Key), "batchAddActiveSubs: removed from pending")
|
||||
|
||||
// getPendingSubs / getActiveSubs
|
||||
assertEq(ss.getActiveSubs(tSess).size, 2, "getActiveSubs: 2 active")
|
||||
assertEq(ss.getPendingSubs(tSess).size, 0, "getPendingSubs: 0 pending")
|
||||
|
||||
// setSubsPending: moves active to pending
|
||||
const moved = ss.setSubsPending(tSess, sessId1)
|
||||
assertEq(moved.size, 2, "setSubsPending: returned 2 moved subs")
|
||||
assertEq(ss.getActiveSubs(tSess).size, 0, "after setSubsPending: 0 active")
|
||||
assertEq(ss.getPendingSubs(tSess).size, 2, "after setSubsPending: 2 pending")
|
||||
assertEq(sessSubs.sessId, null, "after setSubsPending: sessId cleared")
|
||||
|
||||
// setSubsPending with wrong sessId: no-op
|
||||
ss.setSessionId(tSess, sessId1)
|
||||
ss.batchAddActiveSubs(tSess, sessId1, [rq1, rq2])
|
||||
const moved2 = ss.setSubsPending(tSess, sessId2)
|
||||
assertEq(moved2.size, 0, "setSubsPending wrong sessId: no-op")
|
||||
assertEq(ss.getActiveSubs(tSess).size, 2, "still 2 active")
|
||||
|
||||
// deleteSub
|
||||
ss.deleteSub(tSess, rq1Key)
|
||||
assert(!ss.hasActiveSub(tSess, rq1Key), "deleteSub: removed from active")
|
||||
assert(!ss.hasPendingSub(tSess, rq1Key), "deleteSub: removed from pending")
|
||||
assertEq(ss.getActiveSubs(tSess).size, 1, "1 active remains")
|
||||
|
||||
// batchDeleteSubs
|
||||
ss.batchDeleteSubs(tSess, [rq2Key])
|
||||
assertEq(ss.getActiveSubs(tSess).size, 0, "batchDeleteSubs: 0 active")
|
||||
|
||||
// batchAddPendingSubs
|
||||
ss.batchAddPendingSubs(tSess, [rq1, rq2])
|
||||
assertEq(ss.getPendingSubs(tSess).size, 2, "batchAddPendingSubs: 2 pending")
|
||||
|
||||
// deletePendingSub
|
||||
ss.deletePendingSub(tSess, rq1Key)
|
||||
assertEq(ss.getPendingSubs(tSess).size, 1, "deletePendingSub: 1 pending")
|
||||
|
||||
// batchDeletePendingSubs
|
||||
ss.batchDeletePendingSubs(tSess, new Set([rq2Key]))
|
||||
assertEq(ss.getPendingSubs(tSess).size, 0, "batchDeletePendingSubs: 0 pending")
|
||||
|
||||
// setSessionId with change: moves active to pending
|
||||
ss.setSessionId(tSess, sessId1)
|
||||
ss.batchAddActiveSubs(tSess, sessId1, [rq1])
|
||||
ss.setSessionId(tSess, sessId2) // different sessId → moves active to pending
|
||||
assert(!ss.hasActiveSub(tSess, rq1Key), "sessId change: not active")
|
||||
assert(ss.hasPendingSub(tSess, rq1Key), "sessId change: moved to pending")
|
||||
|
||||
// clear
|
||||
ss.clear()
|
||||
assertEq(ss.sessionSubs.size, 0, "clear: empty")
|
||||
|
||||
// foldSessionSubs
|
||||
ss.addPendingSub("a", rq1)
|
||||
ss.addPendingSub("b", rq2)
|
||||
const count = ss.foldSessionSubs((acc, _) => acc + 1, 0)
|
||||
assertEq(count, 2, "foldSessionSubs: 2 sessions")
|
||||
|
||||
// mapSubs
|
||||
const s = ss.sessionSubs.get("a")!
|
||||
const [activeCount, pendingCount] = ss.mapSubs(m => m.size, s)
|
||||
assertEq(activeCount, 0, "mapSubs: 0 active")
|
||||
assertEq(pendingCount, 1, "mapSubs: 1 pending")
|
||||
}
|
||||
|
||||
// -- Worker tests
|
||||
|
||||
import {
|
||||
newAgentClient, newWorker, waitForWork, noWorkToDo, hasWorkToDo, hasWorkToDo_,
|
||||
getAgentWorker, withWork, withConnLock, getNextServer, throwWhenInactive,
|
||||
beginAgentOperation, endAgentOperation, defaultAgentConfig, AgentError,
|
||||
type AgentClient as AC, type Worker as W,
|
||||
} from "../dist/agent/client.js"
|
||||
|
||||
async function testWorker() {
|
||||
console.log(" Worker...")
|
||||
|
||||
const store = null as any // workers don't use store directly
|
||||
const c = newAgentClient(defaultAgentConfig, store, new Map())
|
||||
|
||||
// newWorker starts with doWork full (has work)
|
||||
const w = newWorker(c)
|
||||
assertEq(w.doWork.isEmpty(), false, "newWorker: doWork has work")
|
||||
assertEq(w.action.tryRead(), null, "newWorker: action is null (not running)")
|
||||
assertEq(w.workerId, 0, "newWorker: first id is 0")
|
||||
|
||||
// waitForWork / noWorkToDo / hasWorkToDo cycle
|
||||
await waitForWork(w.doWork) // should resolve immediately (doWork is full)
|
||||
noWorkToDo(w.doWork)
|
||||
assertEq(w.doWork.isEmpty(), true, "noWorkToDo: cleared")
|
||||
hasWorkToDo(w)
|
||||
assertEq(w.doWork.isEmpty(), false, "hasWorkToDo: signaled")
|
||||
// double signal is idempotent
|
||||
hasWorkToDo(w)
|
||||
assertEq(w.doWork.isEmpty(), false, "double hasWorkToDo: still signaled")
|
||||
|
||||
// workerSeq increments
|
||||
const w2 = newWorker(c)
|
||||
assertEq(w2.workerId, 1, "second worker id is 1")
|
||||
}
|
||||
|
||||
async function testWithWork() {
|
||||
console.log(" withWork...")
|
||||
|
||||
const store = null as any
|
||||
const c = newAgentClient(defaultAgentConfig, store, new Map())
|
||||
|
||||
const doWork = TMVar.new<void>(undefined)
|
||||
let actionRan = false
|
||||
|
||||
// withWork: clear signal, get work, if found re-signal and run action
|
||||
await withWork(c, doWork, async () => "item", async (item) => {
|
||||
assertEq(item, "item", "withWork action receives item")
|
||||
actionRan = true
|
||||
})
|
||||
assert(actionRan, "withWork: action ran")
|
||||
assertEq(doWork.isEmpty(), false, "withWork: re-signaled after finding work")
|
||||
|
||||
// withWork: no work — action doesn't run, signal stays cleared
|
||||
let actionRan2 = false
|
||||
hasWorkToDo_(doWork)
|
||||
await withWork(c, doWork, async () => null, async () => { actionRan2 = true })
|
||||
assert(!actionRan2, "withWork: action did not run (no work)")
|
||||
assertEq(doWork.isEmpty(), true, "withWork: signal cleared when no work")
|
||||
}
|
||||
|
||||
async function testLocking() {
|
||||
console.log(" Locking...")
|
||||
|
||||
const store = null as any
|
||||
const c = newAgentClient(defaultAgentConfig, store, new Map())
|
||||
const connId = bytes(24)
|
||||
|
||||
// withConnLock serializes
|
||||
const order: number[] = []
|
||||
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const p1 = withConnLock(c, connId, async () => {
|
||||
order.push(1)
|
||||
await delay(20)
|
||||
order.push(2)
|
||||
})
|
||||
const p2 = withConnLock(c, connId, async () => {
|
||||
order.push(3)
|
||||
})
|
||||
await Promise.all([p1, p2])
|
||||
assertEq(JSON.stringify(order), JSON.stringify([1, 2, 3]), "withConnLock serializes: 1,2,3")
|
||||
}
|
||||
|
||||
async function testServerSelection() {
|
||||
console.log(" Server selection...")
|
||||
|
||||
const store = null as any
|
||||
const srv1: any = {server: "smp1.example.com:5223", auth: null}
|
||||
const srv2: any = {server: "smp2.example.com:5223", auth: null}
|
||||
const srv3: any = {server: "smp3.example.com:5223", auth: null}
|
||||
const us: any = {
|
||||
storageSrvs: [[null, srv1], [null, srv2], [null, srv3]],
|
||||
proxySrvs: [[null, srv1]],
|
||||
knownHosts: new Set(["smp1.example.com", "smp2.example.com", "smp3.example.com"]),
|
||||
}
|
||||
const servers = new Map([[1, us]])
|
||||
const c = newAgentClient(defaultAgentConfig, store, servers)
|
||||
// Force deterministic selection
|
||||
c.randomServer = {gen: () => 0}
|
||||
|
||||
// getNextServer avoids used servers
|
||||
const s = getNextServer(c, 1, u => u.storageSrvs, ["smp1.example.com:5223"])
|
||||
assert(s.server !== "smp1.example.com:5223", "getNextServer avoids used server")
|
||||
|
||||
// getNextServer with all used: falls back to any
|
||||
const s2 = getNextServer(c, 1, u => u.storageSrvs, ["smp1.example.com:5223", "smp2.example.com:5223", "smp3.example.com:5223"])
|
||||
assert(s2 !== undefined, "getNextServer with all used: returns something")
|
||||
|
||||
// getNextServer with unknown userId throws
|
||||
let threw = false
|
||||
try { getNextServer(c, 999, u => u.storageSrvs, []) } catch (e) {
|
||||
if (e instanceof AgentError) threw = true
|
||||
}
|
||||
assert(threw, "getNextServer unknown userId throws")
|
||||
}
|
||||
|
||||
async function testOperationState() {
|
||||
console.log(" Operation state...")
|
||||
|
||||
const store = null as any
|
||||
const c = newAgentClient(defaultAgentConfig, store, new Map())
|
||||
|
||||
beginAgentOperation(c, "AOSndNetwork")
|
||||
assertEq(c.sndNetworkOp.opsInProgress, 1, "beginAgentOperation: incremented")
|
||||
beginAgentOperation(c, "AOSndNetwork")
|
||||
assertEq(c.sndNetworkOp.opsInProgress, 2, "beginAgentOperation: incremented again")
|
||||
endAgentOperation(c, "AOSndNetwork")
|
||||
assertEq(c.sndNetworkOp.opsInProgress, 1, "endAgentOperation: decremented")
|
||||
endAgentOperation(c, "AOSndNetwork")
|
||||
assertEq(c.sndNetworkOp.opsInProgress, 0, "endAgentOperation: zero")
|
||||
endAgentOperation(c, "AOSndNetwork")
|
||||
assertEq(c.sndNetworkOp.opsInProgress, 0, "endAgentOperation: clamped at 0")
|
||||
|
||||
// throwWhenInactive
|
||||
c.active = false
|
||||
let threw = false
|
||||
try { throwWhenInactive(c) } catch { threw = true }
|
||||
assert(threw, "throwWhenInactive throws when inactive")
|
||||
c.active = true
|
||||
throwWhenInactive(c) // should not throw
|
||||
}
|
||||
|
||||
// -- agentCbEncrypt / ClientMsgEnvelope round-trip tests
|
||||
|
||||
import {agentCbEncrypt, agentCbDecrypt, agentCbEncryptOnce} from "../dist/agent/smp-ops.js"
|
||||
import {decodeClientMsgEnvelope, decodeClientMessage, type ClientMsgEnvelope} from "../dist/protocol.js"
|
||||
import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
|
||||
import {generateX25519KeyPair, dh, decodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
|
||||
|
||||
async function testAgentCbEncrypt() {
|
||||
console.log(" agentCbEncrypt...")
|
||||
|
||||
// Generate a DH secret (simulating queue E2E setup)
|
||||
const {publicKey: rcvPub, privateKey: rcvPriv} = generateX25519KeyPair()
|
||||
const {publicKey: sndPub, privateKey: sndPriv} = generateX25519KeyPair()
|
||||
const dhSecret = dh(rcvPub, sndPriv)
|
||||
const dhSecretRcv = dh(sndPub, rcvPriv)
|
||||
// Both sides should compute the same secret
|
||||
assertEq(hex(dhSecret), hex(dhSecretRcv), "DH secrets match")
|
||||
|
||||
const plaintext = new Uint8Array([1, 2, 3, 4, 5])
|
||||
const smpVersion = 18
|
||||
|
||||
// Encrypt: agentCbEncrypt wraps plaintext in ClientMsgEnvelope
|
||||
const envelope = agentCbEncrypt(dhSecret, smpVersion, null, plaintext)
|
||||
assert(envelope.length > 0, "agentCbEncrypt produces output")
|
||||
|
||||
// Decode the ClientMsgEnvelope
|
||||
const d = new Decoder(envelope)
|
||||
const env = decodeClientMsgEnvelope(d)
|
||||
assertEq(env.cmHeader.phVersion, smpVersion, "envelope version matches")
|
||||
assertEq(env.cmHeader.phE2ePubDhKey, null, "no pub key for message (not confirmation)")
|
||||
assertEq(env.cmNonce.length, 24, "nonce is 24 bytes")
|
||||
assert(env.cmEncBody.length > 0, "encrypted body is non-empty")
|
||||
|
||||
// Decrypt with receiver's DH secret
|
||||
const decrypted = cbDecrypt(dhSecretRcv, env.cmNonce, env.cmEncBody)
|
||||
assert(decrypted !== null, "cbDecrypt succeeds")
|
||||
// The decrypted content is the padded plaintext (with padding)
|
||||
// First bytes should match our plaintext
|
||||
let match = true
|
||||
for (let i = 0; i < plaintext.length; i++) {
|
||||
if (decrypted![i] !== plaintext[i]) { match = false; break }
|
||||
}
|
||||
assert(match, "decrypted content starts with plaintext")
|
||||
|
||||
// Encrypt with e2ePubKey (confirmation mode — has DH pub key in header)
|
||||
const envConf = agentCbEncrypt(dhSecret, smpVersion, sndPub, plaintext)
|
||||
const d2 = new Decoder(envConf)
|
||||
const env2 = decodeClientMsgEnvelope(d2)
|
||||
assertEq(env2.cmHeader.phVersion, smpVersion, "confirmation envelope version")
|
||||
assert(env2.cmHeader.phE2ePubDhKey !== null, "confirmation has pub key")
|
||||
// DER-encoded X25519 pubkey is 44 bytes (12-byte prefix + 32 raw)
|
||||
assertEq(env2.cmHeader.phE2ePubDhKey!.length, 44, "pub key is DER-encoded (44 bytes)")
|
||||
assertEq(hex(decodePubKeyX25519(env2.cmHeader.phE2ePubDhKey!)), hex(sndPub), "DER decodes to raw sndPub")
|
||||
|
||||
// agentCbDecrypt
|
||||
const decrypted2 = agentCbDecrypt(dhSecretRcv, env2.cmNonce, env2.cmEncBody)
|
||||
for (let i = 0; i < plaintext.length; i++) {
|
||||
if (decrypted2[i] !== plaintext[i]) { match = false; break }
|
||||
}
|
||||
assert(match, "agentCbDecrypt matches plaintext")
|
||||
|
||||
// agentCbEncryptOnce — ephemeral DH
|
||||
const envOnce = agentCbEncryptOnce(smpVersion, rcvPub, plaintext)
|
||||
const d3 = new Decoder(envOnce)
|
||||
const env3 = decodeClientMsgEnvelope(d3)
|
||||
assert(env3.cmHeader.phE2ePubDhKey !== null, "encryptOnce has ephemeral pub key")
|
||||
// Receiver can decrypt using their private key + sender's ephemeral pub key (DER-decoded)
|
||||
const ephDhSecret = dh(decodePubKeyX25519(env3.cmHeader.phE2ePubDhKey!), rcvPriv)
|
||||
const decrypted3 = cbDecrypt(ephDhSecret, env3.cmNonce, env3.cmEncBody)
|
||||
assert(decrypted3 !== null, "encryptOnce: receiver can decrypt")
|
||||
}
|
||||
|
||||
// -- Run all
|
||||
|
||||
async function main() {
|
||||
console.log("Infrastructure tests")
|
||||
await testTMVar()
|
||||
await testSem()
|
||||
await testABQueue()
|
||||
await testSessionVar()
|
||||
await testRetryInterval()
|
||||
await testTSessionSubs()
|
||||
await testWorker()
|
||||
await testWithWork()
|
||||
await testLocking()
|
||||
await testServerSelection()
|
||||
await testOperationState()
|
||||
await testAgentCbEncrypt()
|
||||
console.log(`\n${passed} passed, ${failed} failed`)
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("FATAL:", e?.message || e, e?.stack); process.exit(1) })
|
||||
@@ -0,0 +1,326 @@
|
||||
// Double ratchet REPL for cross-language testing.
|
||||
// Holds one ratchet state, reads commands from stdin, writes results to stdout.
|
||||
//
|
||||
// Init protocol:
|
||||
// INIT_RCV <version> <pqSupport 0|1>
|
||||
// → ok: <hex E2E params>
|
||||
// COMPLETE <hex peer E2E params>
|
||||
// → ok
|
||||
// INIT_SND <version> <kemMode> <hex peer E2E params>
|
||||
// → ok: <hex E2E params>
|
||||
// kemMode: none | propose | accept
|
||||
//
|
||||
// Encrypt/decrypt operators (same syntax as Haskell DoubleRatchetTests):
|
||||
// \#> <plaintext> encrypt, assert noSndKEM
|
||||
// !#> <plaintext> encrypt, assert hasSndKEM
|
||||
// \#>! <plaintext> encrypt PQEncOn, assert noSndKEM
|
||||
// !#>! <plaintext> encrypt PQEncOn, assert hasSndKEM
|
||||
// !#>\ <plaintext> encrypt PQEncOff, assert hasSndKEM
|
||||
// \#>\ <plaintext> encrypt PQEncOff, assert noSndKEM
|
||||
// <#\ <hex ct> <expected> decrypt, assert noRcvKEM
|
||||
// <#! <hex ct> <expected> decrypt, assert hasRcvKEM
|
||||
//
|
||||
// Plain encrypt/decrypt (no assertions):
|
||||
// E <plaintext> → ok: <hex ciphertext>
|
||||
// D <hex ciphertext> → ok: <plaintext>
|
||||
//
|
||||
// Response format: ok: <data> or error: <message>
|
||||
|
||||
import {createInterface} from "readline"
|
||||
import {
|
||||
generateX448KeyPair, pqX3dhSnd, pqX3dhRcv,
|
||||
encodePubKeyX448, decodePubKeyX448,
|
||||
initSndRatchet, initRcvRatchet,
|
||||
rcEncrypt, rcDecrypt,
|
||||
rootKdf,
|
||||
type Ratchet, type SkippedMsgKeys, type RatchetVersions,
|
||||
type RatchetInitParams, type RatchetKEMAccepted,
|
||||
} from "../dist/crypto/ratchet.js"
|
||||
import {initSntrup761, sntrup761Keypair, sntrup761Enc, sntrup761Dec} from "../dist/crypto/sntrup761.js"
|
||||
import type {KEMKeyPair} from "../dist/crypto/sntrup761.js"
|
||||
import {
|
||||
Decoder, decodeBytes, decodeLarge, encodeBytes, encodeWord16, concatBytes,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- State
|
||||
|
||||
let ratchet: Ratchet | null = null
|
||||
let skippedKeys: SkippedMsgKeys = new Map()
|
||||
const PADDED_MSG_LEN = 16000
|
||||
|
||||
// Intermediate state for RCV init (between INIT_RCV and COMPLETE)
|
||||
let rcvInitState: {
|
||||
privKey1: Uint8Array
|
||||
privKey2: Uint8Array
|
||||
kemKeyPair: KEMKeyPair | null
|
||||
pqSupport: boolean
|
||||
} | null = null
|
||||
|
||||
// -- Hex helpers
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2)
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// -- E2E params helpers
|
||||
|
||||
// Parse E2ERatchetParams: version(Word16) + pk1(ByteString) + pk2(ByteString) + Maybe KEMParams
|
||||
interface ParsedE2EParams {
|
||||
version: number
|
||||
pk1Raw: Uint8Array // raw X448 public key
|
||||
pk2Raw: Uint8Array // raw X448 public key
|
||||
kemPk: Uint8Array | null // KEM public key if proposed
|
||||
kemCt: Uint8Array | null // KEM ciphertext if accepted
|
||||
kemAcceptPk: Uint8Array | null // KEM public key in accepted
|
||||
}
|
||||
|
||||
function parseE2EParams(data: Uint8Array): ParsedE2EParams {
|
||||
const d = new Decoder(data)
|
||||
const version = d.anyByte() * 256 + d.anyByte()
|
||||
const pk1Raw = decodePubKeyX448(decodeBytes(d))
|
||||
const pk2Raw = decodePubKeyX448(decodeBytes(d))
|
||||
let kemPk: Uint8Array | null = null
|
||||
let kemCt: Uint8Array | null = null
|
||||
let kemAcceptPk: Uint8Array | null = null
|
||||
if (version >= 3 && d.remaining() > 0) {
|
||||
const maybeByte = d.anyByte()
|
||||
if (maybeByte === 0x31) { // Just
|
||||
const tag = d.anyByte()
|
||||
if (tag === 0x50) { // 'P' Proposed
|
||||
kemPk = decodeLarge(d)
|
||||
} else if (tag === 0x41) { // 'A' Accepted
|
||||
kemCt = decodeLarge(d)
|
||||
kemAcceptPk = decodeLarge(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return {version, pk1Raw, pk2Raw, kemPk, kemCt, kemAcceptPk}
|
||||
}
|
||||
|
||||
// Encode E2ERatchetParams for sending to peer
|
||||
function encodeE2EParams(
|
||||
version: number,
|
||||
pk1Raw: Uint8Array, pk2Raw: Uint8Array,
|
||||
kemPk: Uint8Array | null, // for proposed
|
||||
kemCt: Uint8Array | null, // for accepted
|
||||
kemAcceptPk: Uint8Array | null, // public key in accepted
|
||||
): Uint8Array {
|
||||
const vBytes = new Uint8Array(2)
|
||||
vBytes[0] = (version >> 8) & 0xff
|
||||
vBytes[1] = version & 0xff
|
||||
const parts = [vBytes, encodeBytes(encodePubKeyX448(pk1Raw)), encodeBytes(encodePubKeyX448(pk2Raw))]
|
||||
if (version >= 3) {
|
||||
if (kemCt && kemAcceptPk) {
|
||||
// Just Accepted
|
||||
parts.push(new Uint8Array([0x31, 0x41])) // Just + 'A'
|
||||
parts.push(new Uint8Array([(kemCt.length >> 8) & 0xff, kemCt.length & 0xff]))
|
||||
parts.push(kemCt)
|
||||
parts.push(new Uint8Array([(kemAcceptPk.length >> 8) & 0xff, kemAcceptPk.length & 0xff]))
|
||||
parts.push(kemAcceptPk)
|
||||
} else if (kemPk) {
|
||||
// Just Proposed
|
||||
parts.push(new Uint8Array([0x31, 0x50])) // Just + 'P'
|
||||
parts.push(new Uint8Array([(kemPk.length >> 8) & 0xff, kemPk.length & 0xff]))
|
||||
parts.push(kemPk)
|
||||
} else {
|
||||
// Nothing
|
||||
parts.push(new Uint8Array([0x30]))
|
||||
}
|
||||
}
|
||||
return concatBytes(...parts)
|
||||
}
|
||||
|
||||
// -- Init handlers
|
||||
|
||||
function handleInitRcv(version: number, pqSupport: boolean): string {
|
||||
const kp1 = generateX448KeyPair()
|
||||
const kp2 = generateX448KeyPair()
|
||||
let kemKeyPair: KEMKeyPair | null = null
|
||||
let kemPk: Uint8Array | null = null
|
||||
if (pqSupport) {
|
||||
kemKeyPair = sntrup761Keypair()
|
||||
kemPk = kemKeyPair.publicKey
|
||||
}
|
||||
rcvInitState = {privKey1: kp1.privateKey, privKey2: kp2.privateKey, kemKeyPair, pqSupport}
|
||||
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, kemPk, null, null)
|
||||
return "ok: " + toHex(params)
|
||||
}
|
||||
|
||||
function handleComplete(peerParamsHex: string): string {
|
||||
if (!rcvInitState) return "error: not in RCV init state"
|
||||
const {privKey1, privKey2, kemKeyPair, pqSupport} = rcvInitState
|
||||
const peerParams = parseE2EParams(fromHex(peerParamsHex))
|
||||
|
||||
// Build kemAccepted for X3DH if peer accepted our KEM proposal
|
||||
let kemAccepted: RatchetKEMAccepted | null = null
|
||||
if (peerParams.kemCt && peerParams.kemAcceptPk && kemKeyPair) {
|
||||
const ss = sntrup761Dec(peerParams.kemCt, kemKeyPair.secretKey)
|
||||
kemAccepted = {rcPQRr: peerParams.kemAcceptPk, rcPQRss: ss, rcPQRct: peerParams.kemCt}
|
||||
}
|
||||
|
||||
// X3DH (receiver side)
|
||||
const initParams = pqX3dhRcv(privKey1, privKey2, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
|
||||
|
||||
// Init receiving ratchet
|
||||
const vs: RatchetVersions = {current: peerParams.version, maxSupported: peerParams.version}
|
||||
ratchet = initRcvRatchet(vs, privKey2, initParams, kemKeyPair, pqSupport)
|
||||
skippedKeys = new Map()
|
||||
rcvInitState = null
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function handleInitSnd(version: number, kemMode: string, peerParamsHex: string): string {
|
||||
const peerParams = parseE2EParams(fromHex(peerParamsHex))
|
||||
|
||||
const kp1 = generateX448KeyPair()
|
||||
const kp2 = generateX448KeyPair()
|
||||
const kp3 = generateX448KeyPair() // fresh DH key for ratchet
|
||||
|
||||
// KEM handling
|
||||
let kemAccepted: RatchetKEMAccepted | null = null
|
||||
let ownKemKp: KEMKeyPair | null = null
|
||||
let outKemPk: Uint8Array | null = null
|
||||
let outKemCt: Uint8Array | null = null
|
||||
let outKemAcceptPk: Uint8Array | null = null
|
||||
|
||||
if (kemMode === "accept" && peerParams.kemPk) {
|
||||
// Accept peer's KEM proposal
|
||||
const encResult = sntrup761Enc(peerParams.kemPk)
|
||||
ownKemKp = sntrup761Keypair()
|
||||
kemAccepted = {rcPQRr: peerParams.kemPk, rcPQRss: encResult.sharedSecret, rcPQRct: encResult.ciphertext}
|
||||
outKemCt = encResult.ciphertext
|
||||
outKemAcceptPk = ownKemKp.publicKey
|
||||
} else if (kemMode === "propose") {
|
||||
ownKemKp = sntrup761Keypair()
|
||||
outKemPk = ownKemKp.publicKey
|
||||
}
|
||||
|
||||
// X3DH (sender side)
|
||||
const initParams = pqX3dhSnd(kp1.privateKey, kp2.privateKey, peerParams.pk1Raw, peerParams.pk2Raw, kemAccepted)
|
||||
|
||||
// Init sending ratchet
|
||||
const vs: RatchetVersions = {current: version, maxSupported: version}
|
||||
ratchet = initSndRatchet(vs, peerParams.pk2Raw, kp3.privateKey, initParams, ownKemKp)
|
||||
skippedKeys = new Map()
|
||||
|
||||
const params = encodeE2EParams(version, kp1.publicKey, kp2.publicKey, outKemPk, outKemCt, outKemAcceptPk)
|
||||
return "ok: " + toHex(params)
|
||||
}
|
||||
|
||||
// -- Encrypt/decrypt handlers
|
||||
|
||||
function handleEncrypt(kemAssert: boolean | null, _pqPref: boolean | null, plaintext: string): string {
|
||||
if (!ratchet) return "error: not initialized"
|
||||
try {
|
||||
const result = rcEncrypt(ratchet, new TextEncoder().encode(plaintext), PADDED_MSG_LEN)
|
||||
ratchet = result.state
|
||||
if (kemAssert === true && !ratchet.rcSndKEM) return "error: expected hasSndKEM"
|
||||
if (kemAssert === false && ratchet.rcSndKEM) return "error: expected noSndKEM"
|
||||
return "ok: " + toHex(result.ciphertext)
|
||||
} catch (e: any) {
|
||||
return "error: " + e.message
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecrypt(kemAssert: boolean | null, hexCt: string, expectedPlaintext: string | null): string {
|
||||
if (!ratchet) return "error: not initialized"
|
||||
try {
|
||||
const ct = fromHex(hexCt)
|
||||
const result = rcDecrypt(ratchet, skippedKeys, ct)
|
||||
ratchet = result.state
|
||||
skippedKeys = result.skippedKeys
|
||||
const plaintext = new TextDecoder().decode(result.plaintext)
|
||||
if (kemAssert === true && !ratchet.rcRcvKEM) return "error: expected hasRcvKEM"
|
||||
if (kemAssert === false && ratchet.rcRcvKEM) return "error: expected noRcvKEM"
|
||||
if (expectedPlaintext !== null && plaintext !== expectedPlaintext)
|
||||
return "error: expected '" + expectedPlaintext + "', got '" + plaintext + "'"
|
||||
return "ok: " + plaintext
|
||||
} catch (e: any) {
|
||||
return "error: " + e.message
|
||||
}
|
||||
}
|
||||
|
||||
// -- Command parser
|
||||
|
||||
function parseLine(line: string): string {
|
||||
// Init commands
|
||||
if (line.startsWith("INIT_RCV ")) {
|
||||
const parts = line.split(" ")
|
||||
return handleInitRcv(parseInt(parts[1]), parts[2] === "1")
|
||||
}
|
||||
if (line.startsWith("COMPLETE ")) {
|
||||
return handleComplete(line.substring(9).trim())
|
||||
}
|
||||
if (line.startsWith("INIT_SND ")) {
|
||||
const parts = line.split(" ")
|
||||
return handleInitSnd(parseInt(parts[1]), parts[2], parts[3])
|
||||
}
|
||||
|
||||
// Query commands
|
||||
if (line === "SNDKEM") {
|
||||
if (!ratchet) return "error: not initialized"
|
||||
return "ok: " + (ratchet.rcSndKEM ? "1" : "0")
|
||||
}
|
||||
if (line === "RCVKEM") {
|
||||
if (!ratchet) return "error: not initialized"
|
||||
return "ok: " + (ratchet.rcRcvKEM ? "1" : "0")
|
||||
}
|
||||
|
||||
// Encrypt operators: \#> !#> \#>! !#>! !#>\ \#>\
|
||||
const encMatch = line.match(/^([!\\])#(>[!\\]?)\s+(.+)$/)
|
||||
if (encMatch) {
|
||||
const [, kemChar, arrow, msg] = encMatch
|
||||
const kemAssert = kemChar === "!" ? true : false
|
||||
let pqPref: boolean | null = null
|
||||
if (arrow === ">!") pqPref = true
|
||||
else if (arrow === ">\\") pqPref = false
|
||||
return handleEncrypt(kemAssert, pqPref, msg)
|
||||
}
|
||||
|
||||
// Decrypt operators: <#\ <#!
|
||||
const decMatch = line.match(/^<#([!\\])\s+(\S+)\s+(.+)$/)
|
||||
if (decMatch) {
|
||||
const [, kemChar, hexCt, expected] = decMatch
|
||||
const kemAssert = kemChar === "!" ? true : false
|
||||
return handleDecrypt(kemAssert, hexCt, expected)
|
||||
}
|
||||
|
||||
// Plain encrypt (no assertion)
|
||||
if (line.startsWith("E ")) {
|
||||
return handleEncrypt(null, null, line.substring(2))
|
||||
}
|
||||
|
||||
// Plain decrypt (no assertion, no expected)
|
||||
if (line.startsWith("D ")) {
|
||||
return handleDecrypt(null, line.substring(2), null)
|
||||
}
|
||||
|
||||
return "error: unknown command: " + line
|
||||
}
|
||||
|
||||
// -- Main
|
||||
|
||||
async function main() {
|
||||
await initSntrup761()
|
||||
|
||||
const rl = createInterface({input: process.stdin, terminal: false})
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
const response = parseLine(trimmed)
|
||||
process.stdout.write(response + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
process.stderr.write("FATAL: " + e.message + "\n")
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,818 @@
|
||||
// Agent store scenario tests using fake-indexeddb.
|
||||
// Each scenario exercises a full lifecycle: create → update → read → delete → verify gone.
|
||||
// Every store method is called from at least one test.
|
||||
|
||||
import "fake-indexeddb/auto"
|
||||
import {openAgentStore} from "../dist/agent/store-idb.js"
|
||||
import type {AgentStore} from "../dist/agent/store.js"
|
||||
|
||||
// The store returns raw IndexedDB rows with snake_case field names.
|
||||
// The TypeScript interface types use camelCase but the underlying data is snake_case.
|
||||
// We use `any` casts in assertions to access the actual field names.
|
||||
type Row = any
|
||||
|
||||
// -- Helpers
|
||||
|
||||
function bytes(hex: string): Uint8Array {
|
||||
const b = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
||||
return b
|
||||
}
|
||||
|
||||
function hex(b: Uint8Array): string {
|
||||
return Array.from(b, x => x.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) b[i] = Math.floor(Math.random() * 256)
|
||||
return b
|
||||
}
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
function assert(cond: boolean, msg: string) {
|
||||
if (!cond) {
|
||||
console.error("FAIL:", msg)
|
||||
failed++
|
||||
} else {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
|
||||
function assertEq(a: any, b: any, msg: string) {
|
||||
const av = a instanceof Uint8Array ? hex(a) : JSON.stringify(a)
|
||||
const bv = b instanceof Uint8Array ? hex(b) : JSON.stringify(b)
|
||||
assert(av === bv, `${msg}: expected ${bv}, got ${av}`)
|
||||
}
|
||||
|
||||
async function assertThrows(fn: () => Promise<any>, msg: string) {
|
||||
try {
|
||||
await fn()
|
||||
assert(false, `${msg}: expected throw`)
|
||||
} catch {
|
||||
passed++
|
||||
}
|
||||
}
|
||||
|
||||
// -- Scenarios
|
||||
|
||||
async function testUsers(store: AgentStore) {
|
||||
console.log(" users...")
|
||||
// createUserRecord, getUserIds
|
||||
const uid1 = await store.createUserRecord()
|
||||
const uid2 = await store.createUserRecord()
|
||||
let ids = await store.getUserIds()
|
||||
assert(ids.includes(uid1) && ids.includes(uid2), "getUserIds returns both users")
|
||||
|
||||
// setUserDeleted — marks user as deleted, getUserIds should exclude it
|
||||
await store.setUserDeleted(uid1)
|
||||
ids = await store.getUserIds()
|
||||
assert(!ids.includes(uid1), "deleted user not in getUserIds")
|
||||
assert(ids.includes(uid2), "non-deleted user still in getUserIds")
|
||||
|
||||
// deleteUserRecord — hard delete
|
||||
await store.deleteUserRecord(uid2)
|
||||
ids = await store.getUserIds()
|
||||
assert(!ids.includes(uid2), "hard-deleted user gone")
|
||||
}
|
||||
|
||||
async function testServers(store: AgentStore) {
|
||||
console.log(" servers...")
|
||||
// createServer — insert or ignore
|
||||
const kh = randomBytes(32)
|
||||
await store.createServer("smp1.example.com", "5223", kh)
|
||||
// Duplicate should not throw
|
||||
await store.createServer("smp1.example.com", "5223", kh)
|
||||
}
|
||||
|
||||
async function testConnectionsAndQueues(store: AgentStore) {
|
||||
console.log(" connections and queues...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
const connId2 = randomBytes(24)
|
||||
|
||||
// createNewConn
|
||||
const created = await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: true,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
assertEq(created, connId, "createNewConn returns connId")
|
||||
|
||||
// getConn — should find it
|
||||
const got = await store.getConn(connId)
|
||||
assert(got !== null, "getConn finds connection")
|
||||
assertEq((got!.connData as Row).conn_id, connId, "getConn connId matches")
|
||||
|
||||
// getConnIds
|
||||
const allIds = await store.getConnIds()
|
||||
assert(allIds.some(id => hex(id) === hex(connId)), "getConnIds includes new conn")
|
||||
|
||||
// setConnAgentVersion
|
||||
await store.setConnAgentVersion(connId, 8)
|
||||
const got2 = await store.getConn(connId)
|
||||
assertEq((got2!.connData as Row).smp_agent_version, 8, "setConnAgentVersion updated")
|
||||
|
||||
// setConnPQSupport
|
||||
await store.setConnPQSupport(connId, false)
|
||||
const got3 = await store.getConn(connId)
|
||||
assertEq((got3!.connData as Row).pq_support, 0, "setConnPQSupport updated")
|
||||
|
||||
// setConnRatchetSync
|
||||
await store.setConnRatchetSync(connId, "required")
|
||||
const got4 = await store.getConn(connId)
|
||||
assertEq((got4!.connData as Row).ratchet_sync_state, "required", "setConnRatchetSync updated")
|
||||
|
||||
// setConnectionNtfs
|
||||
await store.setConnectionNtfs(connId, false)
|
||||
const got5 = await store.getConn(connId)
|
||||
assertEq((got5!.connData as Row).enable_ntfs, 0, "setConnectionNtfs updated")
|
||||
|
||||
// lockConnForUpdate — no-op, should not throw
|
||||
await store.lockConnForUpdate(connId)
|
||||
|
||||
// addConnRcvQueue
|
||||
const rcvQ = await store.addConnRcvQueue(connId, {
|
||||
host: "smp1.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "new" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: randomBytes(32), lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
assert(rcvQ.dbQueueId >= 1, "addConnRcvQueue assigns dbQueueId")
|
||||
|
||||
// getPrimaryRcvQueue
|
||||
const primary = await store.getPrimaryRcvQueue(connId)
|
||||
assert(primary !== null, "getPrimaryRcvQueue finds queue")
|
||||
assertEq((primary as Row).rcv_primary, 1, "primary queue is marked primary")
|
||||
|
||||
// getRcvConn
|
||||
const rcvConn = await store.getRcvConn(rcvQ.host, rcvQ.port, rcvQ.rcvId)
|
||||
assert(rcvConn !== null, "getRcvConn finds by host/port/rcvId")
|
||||
|
||||
// getRcvQueue
|
||||
const rq = await store.getRcvQueue(connId, rcvQ.host, rcvQ.port, rcvQ.rcvId)
|
||||
assert(rq !== null, "getRcvQueue finds queue")
|
||||
|
||||
// setRcvQueueStatus
|
||||
await store.setRcvQueueStatus(rcvQ, "confirmed")
|
||||
const rq2 = await store.getRcvQueue(connId, rcvQ.host, rcvQ.port, rcvQ.rcvId)
|
||||
assertEq(rq2!.status, "confirmed", "setRcvQueueStatus updated")
|
||||
|
||||
// setRcvQueueConfirmedE2E
|
||||
const dhSecret = randomBytes(32)
|
||||
await store.setRcvQueueConfirmedE2E(rcvQ, dhSecret, 7)
|
||||
const rq3 = await store.getRcvQueue(connId, rcvQ.host, rcvQ.port, rcvQ.rcvId)
|
||||
assertEq((rq3 as Row).e2e_dh_secret, dhSecret, "setRcvQueueConfirmedE2E updated dh secret")
|
||||
assertEq((rq3 as Row).status, "confirmed", "setRcvQueueConfirmedE2E sets confirmed")
|
||||
|
||||
// addConnSndQueue + upgradeRcvConnToDuplex (same operation)
|
||||
const sndQ = {
|
||||
host: "smp2.example.com", port: "5223", sndId: randomBytes(24),
|
||||
connId, sndPrivateKey: randomBytes(32), e2eDhSecret: randomBytes(32),
|
||||
status: "confirmed" as const, smpClientVersion: 7,
|
||||
sndPublicKey: randomBytes(32), e2ePubKey: randomBytes(32),
|
||||
dbQueueId: 0, primary: true, queueMode: null, serverKeyHash: randomBytes(32),
|
||||
}
|
||||
await store.upgradeRcvConnToDuplex(connId, sndQ)
|
||||
const gotDuplex = await store.getConn(connId)
|
||||
assert(gotDuplex!.sndQueues.length >= 1, "upgradeRcvConnToDuplex added snd queue")
|
||||
|
||||
// setSndQueueStatus
|
||||
await store.setSndQueueStatus(sndQ, "active")
|
||||
|
||||
// getConnSubs, getConnsData
|
||||
const subs = await store.getConnSubs([connId])
|
||||
assert(subs.size === 1, "getConnSubs returns 1 entry")
|
||||
const connsData = await store.getConnsData([connId])
|
||||
assert(connsData.size === 1, "getConnsData returns 1 entry")
|
||||
|
||||
// Create second connection for setConnUserId
|
||||
await store.createNewConn({
|
||||
connId: connId2, connMode: "CON", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "CON")
|
||||
const userId2 = await store.createUserRecord()
|
||||
await store.setConnUserId(userId, connId2, userId2)
|
||||
const got6 = await store.getConn(connId2)
|
||||
assertEq((got6!.connData as Row).user_id, userId2, "setConnUserId updated")
|
||||
|
||||
// updateNewConnJoin
|
||||
await store.updateNewConnJoin(connId, 9, true, false)
|
||||
const got7 = await store.getConn(connId)
|
||||
assertEq((got7!.connData as Row).smp_agent_version, 9, "updateNewConnJoin updated version")
|
||||
|
||||
// updateNewConnRcv — adds a rcv queue (same as addConnRcvQueue)
|
||||
const rcvQ2 = await store.updateNewConnRcv(connId2, {
|
||||
host: "smp3.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId: connId2, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "new" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: randomBytes(32), lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
assert(rcvQ2.dbQueueId >= 1, "updateNewConnRcv assigns dbQueueId")
|
||||
|
||||
// upgradeSndConnToDuplex — add rcv queue to a snd-only connection
|
||||
const connId3 = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId: connId3, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
await store.addConnSndQueue(connId3, {
|
||||
host: "smp4.example.com", port: "5223", sndId: randomBytes(24),
|
||||
connId: connId3, sndPrivateKey: randomBytes(32), e2eDhSecret: randomBytes(32),
|
||||
status: "confirmed" as const, smpClientVersion: 7,
|
||||
sndPublicKey: null, e2ePubKey: null, dbQueueId: 0, primary: true,
|
||||
queueMode: null, serverKeyHash: randomBytes(32),
|
||||
})
|
||||
const rcvQ3 = await store.upgradeSndConnToDuplex(connId3, {
|
||||
host: "smp4.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId: connId3, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "new" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: randomBytes(32), lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
assert(rcvQ3.dbQueueId >= 1, "upgradeSndConnToDuplex added rcv queue")
|
||||
|
||||
// setRcvQueuePrimary — add second rcv queue, make it primary
|
||||
const rcvQ4 = await store.addConnRcvQueue(connId, {
|
||||
host: "smp5.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "new" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: false,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: randomBytes(32), lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
await store.setRcvQueuePrimary(connId, rcvQ4)
|
||||
const newPrimary = await store.getPrimaryRcvQueue(connId)
|
||||
assertEq((newPrimary as Row).rcv_queue_id, rcvQ4.dbQueueId, "setRcvQueuePrimary changed primary")
|
||||
|
||||
// getDeletedRcvQueue — first delete a queue, then find it
|
||||
// deleteConnRcvQueue physically deletes, so getDeletedRcvQueue won't find it
|
||||
// We need to test the soft-delete path — but deleteConnRcvQueue does hard delete
|
||||
// Just verify it returns null for non-deleted
|
||||
const drq = await store.getDeletedRcvQueue(connId, rcvQ.host, rcvQ.port, rcvQ.rcvId)
|
||||
assert(drq === null, "getDeletedRcvQueue returns null for non-deleted queue")
|
||||
|
||||
// deleteConnRcvQueue
|
||||
await store.deleteConnRcvQueue(rcvQ4)
|
||||
const afterDel = await store.getRcvQueue(connId, rcvQ4.host, rcvQ4.port, rcvQ4.rcvId)
|
||||
assert(afterDel === null, "deleteConnRcvQueue removes queue")
|
||||
|
||||
// setConnDeleted (waitDelivery=true)
|
||||
await store.setConnDeleted(connId2, true)
|
||||
const waitDel = await store.getDeletedWaitingDeliveryConnIds()
|
||||
assert(waitDel.some(id => hex(id) === hex(connId2)), "setConnDeleted waitDelivery appears in getDeletedWaitingDeliveryConnIds")
|
||||
|
||||
// setConnDeleted (waitDelivery=false)
|
||||
await store.setConnDeleted(connId3, false)
|
||||
const delIds = await store.getDeletedConnIds()
|
||||
assert(delIds.some(id => hex(id) === hex(connId3)), "setConnDeleted appears in getDeletedConnIds")
|
||||
|
||||
// deleteConnRecord
|
||||
await store.deleteConnRecord(connId3)
|
||||
const gone = await store.getConn(connId3)
|
||||
assert(gone === null, "deleteConnRecord removes connection")
|
||||
}
|
||||
|
||||
async function testSubscriptions(store: AgentStore) {
|
||||
console.log(" subscriptions...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
const subKeyHash = randomBytes(32)
|
||||
await store.createServer("sub.example.com", "5223", subKeyHash)
|
||||
const rcvQ = await store.addConnRcvQueue(connId, {
|
||||
host: "sub.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "new" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: subKeyHash, lastBrokerTs: null,
|
||||
}, "SMOnlyCreate")
|
||||
|
||||
// getSubscriptionServers — onlyNeeded=true should find our to_subscribe=1 queue
|
||||
const srvs = await store.getSubscriptionServers(true)
|
||||
assert(srvs.some(s => s.host === "sub.example.com"), "getSubscriptionServers finds queue with to_subscribe")
|
||||
|
||||
// getSubscriptionServers — onlyNeeded=false
|
||||
const allSrvs = await store.getSubscriptionServers(false)
|
||||
assert(allSrvs.some(s => s.host === "sub.example.com"), "getSubscriptionServers(false) finds all")
|
||||
|
||||
// getUserServerRcvQueueSubs
|
||||
const {queues} = await store.getUserServerRcvQueueSubs(userId, "sub.example.com", "5223", subKeyHash, true, 10, null)
|
||||
assert(queues.length >= 1, "getUserServerRcvQueueSubs finds queues")
|
||||
|
||||
// unsetQueuesToSubscribe
|
||||
await store.unsetQueuesToSubscribe()
|
||||
const srvsAfter = await store.getSubscriptionServers(true)
|
||||
assert(!srvsAfter.some(s => s.host === "sub.example.com"), "unsetQueuesToSubscribe clears to_subscribe")
|
||||
|
||||
// getConnectionsForDelivery, getAllSndQueuesForDelivery — need snd deliveries
|
||||
// Add snd queue and delivery
|
||||
const sndQ = {
|
||||
host: "sub.example.com", port: "5223", sndId: randomBytes(24),
|
||||
connId, sndPrivateKey: randomBytes(32), e2eDhSecret: randomBytes(32),
|
||||
status: "active" as const, smpClientVersion: 7,
|
||||
sndPublicKey: null, e2ePubKey: null, dbQueueId: 0, primary: true,
|
||||
queueMode: null, serverKeyHash: randomBytes(32),
|
||||
}
|
||||
await store.addConnSndQueue(connId, sndQ)
|
||||
// Need to create a message first
|
||||
const {internalId, internalSndId, prevSndMsgHash} = await store.updateSndIds(connId)
|
||||
await store.createSndMsg(connId, {
|
||||
internalId, internalSndId, internalTs: new Date().toISOString(),
|
||||
msgType: "HELLO", msgFlags: 0, msgBody: randomBytes(10),
|
||||
pqEncryption: false, internalHash: randomBytes(32),
|
||||
prevMsgHash: prevSndMsgHash, msgEncryptKey: null, paddedMsgLen: null, sndMessageBodyId: null,
|
||||
})
|
||||
// Get the snd queue with its actual dbQueueId
|
||||
const gotConn = await store.getConn(connId)
|
||||
const actualSndQ = gotConn!.sndQueues[0] as Row
|
||||
// Raw IDB row has snd_queue_id, interface expects dbQueueId
|
||||
await store.createSndMsgDelivery(connId, {dbQueueId: actualSndQ.snd_queue_id} as any, internalId)
|
||||
|
||||
const deliveryConns = await store.getConnectionsForDelivery()
|
||||
assert(deliveryConns.some(id => hex(id) === hex(connId)), "getConnectionsForDelivery finds conn with delivery")
|
||||
|
||||
const deliverySndQs = await store.getAllSndQueuesForDelivery()
|
||||
assert(deliverySndQs.length >= 1, "getAllSndQueuesForDelivery finds queues")
|
||||
}
|
||||
|
||||
async function testConfirmations(store: AgentStore) {
|
||||
console.log(" confirmations...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
|
||||
const confId = randomBytes(16)
|
||||
// createConfirmation
|
||||
const returned = await store.createConfirmation({
|
||||
confirmationId: confId, connId,
|
||||
e2eSndPubKey: randomBytes(32), senderKey: randomBytes(32),
|
||||
ratchetState: randomBytes(64), senderConnInfo: randomBytes(50),
|
||||
accepted: false, ownConnInfo: null,
|
||||
smpReplyQueues: randomBytes(100), smpClientVersion: 7,
|
||||
})
|
||||
assertEq(returned, confId, "createConfirmation returns confirmationId")
|
||||
|
||||
// getAcceptedConfirmation — not accepted yet
|
||||
const notAccepted = await store.getAcceptedConfirmation(connId)
|
||||
assert(notAccepted === null, "getAcceptedConfirmation returns null before acceptance")
|
||||
|
||||
// acceptConfirmation
|
||||
const ownInfo = randomBytes(40)
|
||||
const accepted = await store.acceptConfirmation(confId, ownInfo)
|
||||
assert(accepted !== null, "acceptConfirmation returns confirmation")
|
||||
assertEq(accepted.accepted, 1, "acceptConfirmation sets accepted=1")
|
||||
|
||||
// getAcceptedConfirmation — now accepted
|
||||
const gotAccepted = await store.getAcceptedConfirmation(connId)
|
||||
assert(gotAccepted !== null, "getAcceptedConfirmation finds accepted confirmation")
|
||||
assertEq((gotAccepted as Row).own_conn_info, ownInfo, "accepted confirmation has ownConnInfo")
|
||||
|
||||
// removeConfirmations
|
||||
await store.removeConfirmations(connId)
|
||||
const afterRemove = await store.getAcceptedConfirmation(connId)
|
||||
assert(afterRemove === null, "removeConfirmations deletes all confirmations for conn")
|
||||
}
|
||||
|
||||
async function testInvitations(store: AgentStore) {
|
||||
console.log(" invitations...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "CON", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "CON")
|
||||
|
||||
const invId = randomBytes(16)
|
||||
// createInvitation
|
||||
const returned = await store.createInvitation({
|
||||
invitationId: invId, contactConnId: connId,
|
||||
crInvitation: randomBytes(200), recipientConnInfo: randomBytes(50),
|
||||
accepted: false, ownConnInfo: null,
|
||||
})
|
||||
assertEq(returned, invId, "createInvitation returns invitationId")
|
||||
|
||||
// getInvitation — not accepted, should find
|
||||
const got = await store.getInvitation(invId)
|
||||
assert(got !== null, "getInvitation finds unaccepted invitation")
|
||||
|
||||
// acceptInvitation
|
||||
const ownInfo = randomBytes(40)
|
||||
await store.acceptInvitation(invId, ownInfo)
|
||||
// getInvitation — accepted, should NOT find (WHERE accepted = 0)
|
||||
const gotAfterAccept = await store.getInvitation(invId)
|
||||
assert(gotAfterAccept === null, "getInvitation returns null for accepted invitation")
|
||||
|
||||
// unacceptInvitation
|
||||
await store.unacceptInvitation(invId)
|
||||
const gotAfterUnaccept = await store.getInvitation(invId)
|
||||
assert(gotAfterUnaccept !== null, "unacceptInvitation resets accepted to 0")
|
||||
assert((gotAfterUnaccept as Row).own_conn_info === null, "unacceptInvitation clears ownConnInfo")
|
||||
|
||||
// deleteInvitation
|
||||
await store.deleteInvitation(invId)
|
||||
const gotAfterDelete = await store.getInvitation(invId)
|
||||
assert(gotAfterDelete === null, "deleteInvitation removes invitation")
|
||||
}
|
||||
|
||||
async function testReceiveMessages(store: AgentStore) {
|
||||
console.log(" receive messages...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
await store.createServer("rcv.example.com", "5223", randomBytes(32))
|
||||
const rcvQ = await store.addConnRcvQueue(connId, {
|
||||
host: "rcv.example.com", port: "5223", rcvId: randomBytes(24),
|
||||
connId, rcvPrivateKey: randomBytes(32), rcvDhSecret: randomBytes(32),
|
||||
e2ePrivKey: randomBytes(32), e2eDhSecret: null,
|
||||
sndId: randomBytes(24), sndKey: null, status: "active" as const,
|
||||
smpClientVersion: 7, dbQueueId: 0, primary: true,
|
||||
replaceRcvQueueId: null, queueMode: null,
|
||||
serverKeyHash: randomBytes(32), lastBrokerTs: null,
|
||||
}, "SMSubscribe")
|
||||
|
||||
// updateRcvIds
|
||||
const {internalId, internalRcvId, prevExternalSndId, prevRcvMsgHash} = await store.updateRcvIds(connId)
|
||||
assertEq(internalId, 1, "updateRcvIds first internalId=1")
|
||||
assertEq(internalRcvId, 1, "updateRcvIds first internalRcvId=1")
|
||||
assertEq(prevExternalSndId, 0, "updateRcvIds prevExternalSndId=0")
|
||||
|
||||
const brokerId = randomBytes(24)
|
||||
const brokerTs = new Date().toISOString()
|
||||
const internalHash = randomBytes(32)
|
||||
const encryptedMsgHash = randomBytes(32)
|
||||
const msgBody = randomBytes(100)
|
||||
|
||||
// createRcvMsg — exercises insertRcvMsgBase_, insertRcvMsgDetails_, updateRcvMsgHash, setLastBrokerTs
|
||||
await store.createRcvMsg(connId, rcvQ, {
|
||||
msgMeta: {
|
||||
integrity: "OK",
|
||||
recipient: [internalId, new Date().toISOString()],
|
||||
broker: [brokerId, brokerTs],
|
||||
sndMsgId: 1,
|
||||
pqEncryption: false,
|
||||
},
|
||||
msgType: "MSG",
|
||||
msgFlags: 0,
|
||||
msgBody,
|
||||
internalRcvId,
|
||||
internalHash,
|
||||
externalPrevSndHash: randomBytes(32),
|
||||
encryptedMsgHash,
|
||||
})
|
||||
|
||||
// getRcvMsg
|
||||
const rcvMsg = await store.getRcvMsg(connId, internalId)
|
||||
assert(rcvMsg !== null, "getRcvMsg finds message")
|
||||
assertEq(rcvMsg!.msgType, "MSG", "getRcvMsg msgType matches")
|
||||
|
||||
// getLastMsg — verify the msg is "last" (conn.last_internal_msg_id matches)
|
||||
const lastMsg = await store.getLastMsg(connId, brokerId)
|
||||
assert(lastMsg !== null, "getLastMsg finds message by brokerId")
|
||||
|
||||
// getRcvMsgBrokerTs
|
||||
const ts = await store.getRcvMsgBrokerTs(connId, brokerId)
|
||||
assert(ts !== null, "getRcvMsgBrokerTs finds broker ts")
|
||||
|
||||
// checkRcvMsgHashExists — encrypted hash was inserted by createRcvMsg
|
||||
const hashExists = await store.checkRcvMsgHashExists(connId, encryptedMsgHash)
|
||||
assert(hashExists, "checkRcvMsgHashExists finds hash inserted by createRcvMsg")
|
||||
|
||||
// incMsgRcvAttempts
|
||||
const attempts = await store.incMsgRcvAttempts(connId, internalId)
|
||||
assertEq(attempts, 1, "incMsgRcvAttempts returns 1 after first increment")
|
||||
const attempts2 = await store.incMsgRcvAttempts(connId, internalId)
|
||||
assertEq(attempts2, 2, "incMsgRcvAttempts returns 2 after second increment")
|
||||
|
||||
// setMsgUserAck
|
||||
const {rcvQueue: ackQ, brokerId: ackBrokerId} = await store.setMsgUserAck(connId, internalId)
|
||||
assert(ackQ !== null, "setMsgUserAck returns rcvQueue")
|
||||
assertEq(ackBrokerId, brokerId, "setMsgUserAck returns correct brokerId")
|
||||
|
||||
// Verify user_ack was set
|
||||
const rcvMsgAfterAck = await store.getRcvMsg(connId, internalId)
|
||||
assert(rcvMsgAfterAck!.userAck, "setMsgUserAck sets userAck=true")
|
||||
|
||||
// setLastBrokerTs — standalone call
|
||||
const newTs = new Date().toISOString()
|
||||
await store.setLastBrokerTs(connId, rcvQ.dbQueueId, newTs)
|
||||
|
||||
// updateRcvMsgHash — standalone call
|
||||
await store.updateRcvMsgHash(connId, 2, internalRcvId, randomBytes(32))
|
||||
|
||||
// deleteMsg
|
||||
await store.deleteMsg(connId, internalId)
|
||||
const deletedMsg = await store.getRcvMsg(connId, internalId)
|
||||
assert(deletedMsg === null, "deleteMsg removes message")
|
||||
}
|
||||
|
||||
async function testSendMessages(store: AgentStore) {
|
||||
console.log(" send messages...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
await store.createServer("snd.example.com", "5223", randomBytes(32))
|
||||
await store.addConnSndQueue(connId, {
|
||||
host: "snd.example.com", port: "5223", sndId: randomBytes(24),
|
||||
connId, sndPrivateKey: randomBytes(32), e2eDhSecret: randomBytes(32),
|
||||
status: "active" as const, smpClientVersion: 7,
|
||||
sndPublicKey: null, e2ePubKey: null, dbQueueId: 0, primary: true,
|
||||
queueMode: null, serverKeyHash: randomBytes(32),
|
||||
})
|
||||
const gotConn = await store.getConn(connId)
|
||||
const sndQueue = gotConn!.sndQueues[0]
|
||||
|
||||
// createSndMsgBody
|
||||
const agentMsg = randomBytes(200)
|
||||
const bodyId = await store.createSndMsgBody(agentMsg)
|
||||
assert(bodyId >= 1, "createSndMsgBody returns positive id")
|
||||
|
||||
// updateSndIds
|
||||
const {internalId, internalSndId, prevSndMsgHash} = await store.updateSndIds(connId)
|
||||
assertEq(internalId, 1, "updateSndIds first internalId=1")
|
||||
assertEq(internalSndId, 1, "updateSndIds first internalSndId=1")
|
||||
|
||||
const internalHash = randomBytes(32)
|
||||
|
||||
// createSndMsg
|
||||
await store.createSndMsg(connId, {
|
||||
internalId, internalSndId, internalTs: new Date().toISOString(),
|
||||
msgType: "SEND", msgFlags: 0, msgBody: randomBytes(100),
|
||||
pqEncryption: false, internalHash,
|
||||
prevMsgHash: prevSndMsgHash, msgEncryptKey: randomBytes(32),
|
||||
paddedMsgLen: 16384, sndMessageBodyId: bodyId,
|
||||
})
|
||||
|
||||
// updateSndMsgHash — standalone
|
||||
await store.updateSndMsgHash(connId, internalSndId, internalHash)
|
||||
|
||||
// createSndMsgDelivery
|
||||
await store.createSndMsgDelivery(connId, sndQueue, internalId)
|
||||
|
||||
// getPendingQueueMsg
|
||||
const pendingResult = await store.getPendingQueueMsg(connId, sndQueue)
|
||||
assert(pendingResult !== null, "getPendingQueueMsg finds pending message")
|
||||
assertEq(pendingResult!.msg.msgType, "SEND", "getPendingQueueMsg msgType matches")
|
||||
assertEq(pendingResult!.msg.internalId, internalId, "getPendingQueueMsg internalId matches")
|
||||
|
||||
// updatePendingMsgRIState
|
||||
await store.updatePendingMsgRIState(connId, internalId, 30, 5)
|
||||
|
||||
// getSndMsgViaRcpt
|
||||
const sndMsg = await store.getSndMsgViaRcpt(connId, internalSndId)
|
||||
assert(sndMsg !== null, "getSndMsgViaRcpt finds message")
|
||||
assertEq(sndMsg!.internalId, internalId, "getSndMsgViaRcpt internalId matches")
|
||||
assertEq(sndMsg!.internalHash, internalHash, "getSndMsgViaRcpt hash matches")
|
||||
|
||||
// updateSndMsgRcpt
|
||||
await store.updateSndMsgRcpt(connId, internalSndId, {agentMsgId: internalId, msgRcptStatus: "ok"})
|
||||
|
||||
// deleteSndMsgDelivery — deletes delivery, then msg if no more deliveries
|
||||
await store.deleteSndMsgDelivery(connId, sndQueue, internalId, false)
|
||||
|
||||
// Create a second message for deleteDeliveredSndMsg
|
||||
const ids2 = await store.updateSndIds(connId)
|
||||
await store.createSndMsg(connId, {
|
||||
internalId: ids2.internalId, internalSndId: ids2.internalSndId,
|
||||
internalTs: new Date().toISOString(),
|
||||
msgType: "SEND", msgFlags: 0, msgBody: randomBytes(50),
|
||||
pqEncryption: false, internalHash: randomBytes(32),
|
||||
prevMsgHash: ids2.prevSndMsgHash, msgEncryptKey: null, paddedMsgLen: null, sndMessageBodyId: null,
|
||||
})
|
||||
|
||||
// deleteDeliveredSndMsg — no deliveries exist, so should delete msg
|
||||
await store.deleteDeliveredSndMsg(connId, ids2.internalId)
|
||||
}
|
||||
|
||||
async function testRatchet(store: AgentStore) {
|
||||
console.log(" ratchet...")
|
||||
const connId = randomBytes(24)
|
||||
const userId = await store.createUserRecord()
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: true,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
|
||||
const privKey1 = randomBytes(56)
|
||||
const privKey2 = randomBytes(56)
|
||||
const pqKem = randomBytes(100)
|
||||
|
||||
// createRatchetX3dhKeys
|
||||
await store.createRatchetX3dhKeys(connId, privKey1, privKey2, pqKem)
|
||||
|
||||
// getRatchetX3dhKeys
|
||||
const keys = await store.getRatchetX3dhKeys(connId)
|
||||
assert(keys !== null, "getRatchetX3dhKeys finds keys")
|
||||
assertEq(keys!.privKey1, privKey1, "x3dh privKey1 matches")
|
||||
assertEq(keys!.privKey2, privKey2, "x3dh privKey2 matches")
|
||||
assertEq(keys!.pqKem, pqKem, "x3dh pqKem matches")
|
||||
|
||||
// getRatchet — no ratchet state yet
|
||||
const noRatchet = await store.getRatchet(connId)
|
||||
assert(noRatchet === null, "getRatchet returns null before createRatchet")
|
||||
|
||||
// createRatchet — upserts, clearing x3dh keys
|
||||
const ratchetState = randomBytes(200)
|
||||
await store.createRatchet(connId, ratchetState)
|
||||
|
||||
// getRatchet
|
||||
const gotRatchet = await store.getRatchet(connId)
|
||||
assertEq(gotRatchet, ratchetState, "getRatchet returns stored state")
|
||||
|
||||
// getRatchetForUpdate — same as getRatchet in IndexedDB
|
||||
const gotForUpdate = await store.getRatchetForUpdate(connId)
|
||||
assertEq(gotForUpdate, ratchetState, "getRatchetForUpdate returns stored state")
|
||||
|
||||
// x3dh keys should be cleared after createRatchet
|
||||
const keysAfter = await store.getRatchetX3dhKeys(connId)
|
||||
assert(keysAfter === null, "createRatchet clears x3dh keys")
|
||||
|
||||
// getSkippedMsgKeys — empty initially
|
||||
const noSkipped = await store.getSkippedMsgKeys(connId)
|
||||
assertEq(noSkipped.size, 0, "getSkippedMsgKeys empty initially")
|
||||
|
||||
// updateRatchet with SMDAdd
|
||||
const headerKey = randomBytes(32)
|
||||
const msgKey = randomBytes(32)
|
||||
const newRatchetState = randomBytes(200)
|
||||
const addKeys = new Map<Uint8Array, Map<number, Uint8Array>>()
|
||||
const inner = new Map<number, Uint8Array>()
|
||||
inner.set(0, msgKey)
|
||||
inner.set(1, randomBytes(32))
|
||||
addKeys.set(headerKey, inner)
|
||||
await store.updateRatchet(connId, newRatchetState, {type: "add", keys: addKeys})
|
||||
|
||||
// Verify ratchet state updated
|
||||
const updatedRatchet = await store.getRatchet(connId)
|
||||
assertEq(updatedRatchet, newRatchetState, "updateRatchet updates state")
|
||||
|
||||
// Verify skipped keys added
|
||||
const skipped = await store.getSkippedMsgKeys(connId)
|
||||
assert(skipped.size >= 1, "updateRatchet SMDAdd adds skipped keys")
|
||||
|
||||
// updateRatchet with SMDRemove
|
||||
const hkHex = Array.from(headerKey, x => x.toString(16).padStart(2, "0")).join("")
|
||||
await store.updateRatchet(connId, randomBytes(200), {type: "remove", headerKey, msgN: 0})
|
||||
const afterRemove = await store.getSkippedMsgKeys(connId)
|
||||
// Should have 1 key remaining (msgN=1) instead of 2
|
||||
let totalKeys = 0
|
||||
for (const [, m] of afterRemove) totalKeys += m.size
|
||||
assertEq(totalKeys, 1, "updateRatchet SMDRemove removes specific key")
|
||||
|
||||
// updateRatchet with noChange
|
||||
const stateBeforeNoChange = randomBytes(200)
|
||||
await store.updateRatchet(connId, stateBeforeNoChange, {type: "noChange"})
|
||||
const afterNoChange = await store.getRatchet(connId)
|
||||
assertEq(afterNoChange, stateBeforeNoChange, "updateRatchet SMDNoChange only updates state")
|
||||
}
|
||||
|
||||
async function testCommands(store: AgentStore) {
|
||||
console.log(" commands...")
|
||||
const userId = await store.createUserRecord()
|
||||
const connId = randomBytes(24)
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
|
||||
const corrId = randomBytes(24)
|
||||
// createCommand
|
||||
const cmdId = await store.createCommand(corrId, connId, "cmd.example.com", "5223", {
|
||||
commandId: 0, connId, host: "cmd.example.com", port: "5223",
|
||||
corrId, commandTag: "NEW", command: randomBytes(50),
|
||||
agentVersion: 7, serverKeyHash: randomBytes(32), failed: false,
|
||||
})
|
||||
assert(cmdId >= 1, "createCommand returns positive id")
|
||||
|
||||
// getPendingCommandServers
|
||||
const servers = await store.getPendingCommandServers([connId])
|
||||
assert(servers.some(s => s.host === "cmd.example.com"), "getPendingCommandServers finds command server")
|
||||
|
||||
// getAllPendingCommandConns
|
||||
const allConns = await store.getAllPendingCommandConns()
|
||||
assert(allConns.some(c => hex(c.connId) === hex(connId)), "getAllPendingCommandConns finds conn")
|
||||
|
||||
// getPendingServerCommand
|
||||
const pendingCmd = await store.getPendingServerCommand(connId, "cmd.example.com", "5223")
|
||||
assert(pendingCmd !== null, "getPendingServerCommand finds command")
|
||||
|
||||
// updateCommandServer
|
||||
await store.updateCommandServer(cmdId, "cmd2.example.com", "5224")
|
||||
const updated = await store.getPendingServerCommand(connId, "cmd2.example.com", "5224")
|
||||
assert(updated !== null, "updateCommandServer changes server")
|
||||
const oldHost = await store.getPendingServerCommand(connId, "cmd.example.com", "5223")
|
||||
assert(oldHost === null, "updateCommandServer — old host returns nothing")
|
||||
|
||||
// deleteCommand
|
||||
await store.deleteCommand(cmdId)
|
||||
const deleted = await store.getPendingServerCommand(connId, "cmd2.example.com", "5224")
|
||||
assert(deleted === null, "deleteCommand removes command")
|
||||
}
|
||||
|
||||
async function testHashDedup(store: AgentStore) {
|
||||
console.log(" hash dedup...")
|
||||
const connId = randomBytes(24)
|
||||
const userId = await store.createUserRecord()
|
||||
await store.createNewConn({
|
||||
connId, connMode: "INV", userId, smpAgentVersion: 7,
|
||||
enableNtfs: true, duplexHandshake: true, deleted: false,
|
||||
ratchetSyncState: "ok", pqSupport: false,
|
||||
lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0,
|
||||
lastExternalSndMsgId: 0, lastRcvMsgHash: new Uint8Array(0), lastSndMsgHash: new Uint8Array(0),
|
||||
}, "INV")
|
||||
|
||||
const hash = randomBytes(32)
|
||||
|
||||
// checkRcvMsgHashExists_encrypted — not yet
|
||||
const before = await store.checkRcvMsgHashExists_encrypted(connId, hash)
|
||||
assert(!before, "checkRcvMsgHashExists_encrypted returns false before add")
|
||||
|
||||
// addEncryptedRcvMsgHash
|
||||
await store.addEncryptedRcvMsgHash(connId, hash)
|
||||
|
||||
// checkRcvMsgHashExists_encrypted — now exists
|
||||
const after = await store.checkRcvMsgHashExists_encrypted(connId, hash)
|
||||
assert(after, "checkRcvMsgHashExists_encrypted returns true after add")
|
||||
|
||||
// Different hash should not exist
|
||||
const other = await store.checkRcvMsgHashExists_encrypted(connId, randomBytes(32))
|
||||
assert(!other, "checkRcvMsgHashExists_encrypted returns false for different hash")
|
||||
}
|
||||
|
||||
// -- Run all
|
||||
|
||||
async function main() {
|
||||
console.log("Agent store tests")
|
||||
const store = await openAgentStore()
|
||||
|
||||
await testUsers(store)
|
||||
await testServers(store)
|
||||
await testConnectionsAndQueues(store)
|
||||
await testSubscriptions(store)
|
||||
await testConfirmations(store)
|
||||
await testInvitations(store)
|
||||
await testReceiveMessages(store)
|
||||
await testSendMessages(store)
|
||||
await testRatchet(store)
|
||||
await testCommands(store)
|
||||
await testHashDedup(store)
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`)
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("FATAL:", e?.message || e, e?.stack); process.exit(1) })
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "dist-test",
|
||||
"rootDir": "tests",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
@@ -40,6 +40,7 @@ module Simplex.Messaging.Server
|
||||
dummyVerifyCmd,
|
||||
randomId,
|
||||
AttachHTTP,
|
||||
WSHandler,
|
||||
MessageStats (..),
|
||||
)
|
||||
where
|
||||
@@ -121,6 +122,7 @@ import qualified Simplex.Messaging.TMap as TM
|
||||
import Simplex.Messaging.Transport
|
||||
import Simplex.Messaging.Transport.Buffer (trimCR)
|
||||
import Simplex.Messaging.Transport.Server
|
||||
import Simplex.Messaging.Transport.WebSockets (WS (..))
|
||||
import Simplex.Messaging.Util
|
||||
import Simplex.Messaging.Version
|
||||
import System.Environment (lookupEnv)
|
||||
@@ -160,7 +162,8 @@ runSMPServerBlocking :: MsgStoreClass s => TMVar Bool -> ServerConfig s -> Maybe
|
||||
runSMPServerBlocking started cfg attachHTTP_ = newEnv cfg >>= runReaderT (smpServer started cfg attachHTTP_)
|
||||
|
||||
type M s a = ReaderT (Env s) IO a
|
||||
type AttachHTTP = Socket -> TLS.Context -> IO ()
|
||||
type AttachHTTP = Socket -> TLS 'TServer -> Maybe WSHandler -> IO ()
|
||||
type WSHandler = WS 'TServer -> IO ()
|
||||
|
||||
-- actions used in serverThread to reduce STM transaction scope
|
||||
data ClientSubAction
|
||||
@@ -211,10 +214,11 @@ smpServer started cfg@ServerConfig {transports, transportConfig = tCfg, startOpt
|
||||
(Just httpCreds, Just attachHTTP) | addHTTP ->
|
||||
runTransportServerState_ ss started tcpPort defaultSupportedParamsHTTPS combinedCreds tCfg $ \s (sniUsed, h) ->
|
||||
case cast h of
|
||||
Just (TLS {tlsContext} :: TLS 'TServer) | sniUsed -> labelMyThread "https client" >> attachHTTP s tlsContext
|
||||
Just (tls :: TLS 'TServer) | sniUsed -> labelMyThread "https client" >> attachHTTP s tls (Just wsHandler)
|
||||
_ -> runClient srvCert srvSignKey t h `runReaderT` env
|
||||
where
|
||||
combinedCreds = TLSServerCredential {credential = smpCreds, sniCredential = Just httpCreds}
|
||||
wsHandler ws = runClient srvCert srvSignKey (TProxy :: TProxy WS 'TServer) ws `runReaderT` env
|
||||
_ ->
|
||||
runTransportServerState ss started tcpPort defaultSupportedParams smpCreds tCfg $ \h -> runClient srvCert srvSignKey t h `runReaderT` env
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ import System.Directory (renameFile)
|
||||
#endif
|
||||
|
||||
smpServerCLI :: FilePath -> FilePath -> IO ()
|
||||
smpServerCLI = smpServerCLI_ (\_ _ _ -> pure ()) (\_ -> pure ()) (\_ -> error "attachStaticFiles not available")
|
||||
smpServerCLI = smpServerCLI_ (\_ _ _ -> pure ()) (\_ -> pure ()) (\_ -> error "attachStaticAndWS not available")
|
||||
|
||||
smpServerCLI_ ::
|
||||
(ServerInformation -> Maybe TransportHost -> FilePath -> IO ()) ->
|
||||
@@ -115,7 +115,7 @@ smpServerCLI_ ::
|
||||
FilePath ->
|
||||
FilePath ->
|
||||
IO ()
|
||||
smpServerCLI_ generateSite serveStaticFiles attachStaticFiles cfgPath logPath =
|
||||
smpServerCLI_ generateSite serveStaticFiles attachStaticAndWS cfgPath logPath =
|
||||
getCliCommand' (cliCommandP cfgPath logPath iniFile) serverVersion >>= \case
|
||||
Init opts ->
|
||||
doesFileExist iniFile >>= \case
|
||||
@@ -489,7 +489,7 @@ smpServerCLI_ generateSite serveStaticFiles attachStaticFiles cfgPath logPath =
|
||||
case webStaticPath' of
|
||||
Just path | sharedHTTP -> do
|
||||
runWebServer path Nothing ServerInformation {config, information}
|
||||
attachStaticFiles path $ \attachHTTP -> do
|
||||
attachStaticAndWS path $ \attachHTTP -> do
|
||||
logDebug "Allocated web server resources"
|
||||
runSMPServer cfg (Just attachHTTP) `finally` logDebug "Releasing web server resources..."
|
||||
Just path -> do
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
@@ -8,7 +9,7 @@ module Simplex.Messaging.Server.Web
|
||||
WebHttpsParams (..),
|
||||
EmbeddedContent (..),
|
||||
serveStaticFiles,
|
||||
attachStaticFiles,
|
||||
attachStaticAndWS,
|
||||
serveStaticPageH2,
|
||||
generateSite,
|
||||
serverInfoSubsts,
|
||||
@@ -41,11 +42,14 @@ import qualified Network.Wai.Application.Static as S
|
||||
import qualified Network.Wai.Handler.Warp as W
|
||||
import qualified Network.Wai.Handler.Warp.Internal as WI
|
||||
import qualified Network.Wai.Handler.WarpTLS as WT
|
||||
import qualified Network.Wai.Handler.WebSockets as WaiWS
|
||||
import Network.WebSockets (defaultConnectionOptions, ConnectionOptions(..), SizeLimit(..), PendingConnection)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server (AttachHTTP)
|
||||
import Simplex.Messaging.Server (AttachHTTP, WSHandler)
|
||||
import Simplex.Messaging.Server.CLI (simplexmqCommit)
|
||||
import Simplex.Messaging.Server.Information
|
||||
import Simplex.Messaging.Transport (simplexMQVersion)
|
||||
import Simplex.Messaging.Transport (TLS (..), smpBlockSize, simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.WebSockets (WS (..), acceptWSConnection)
|
||||
import Simplex.Messaging.Util (tshow)
|
||||
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesFileExist)
|
||||
import System.FilePath
|
||||
@@ -84,20 +88,23 @@ serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams}
|
||||
where
|
||||
mkSettings port = W.setPort port warpSettings
|
||||
|
||||
-- | Prepare context and prepare HTTP handler for TLS connections that already passed TLS.handshake and ALPN check.
|
||||
attachStaticFiles :: FilePath -> (AttachHTTP -> IO ()) -> IO ()
|
||||
attachStaticFiles path action = do
|
||||
app <- staticFiles path
|
||||
-- Initialize global internal state for http server.
|
||||
attachStaticAndWS :: FilePath -> (AttachHTTP -> IO a) -> IO a
|
||||
attachStaticAndWS path action =
|
||||
WI.withII warpSettings $ \ii -> do
|
||||
action $ \socket cxt -> do
|
||||
-- Initialize internal per-connection resources.
|
||||
action $ \socket tls wsHandler_ -> do
|
||||
app <- case wsHandler_ of
|
||||
Just wsHandler ->
|
||||
WaiWS.websocketsOr wsOpts (acceptWSConnection tls >=> wsHandler) <$> staticFiles path
|
||||
Nothing -> staticFiles path
|
||||
addr <- getPeerName socket
|
||||
withConnection addr cxt $ \(conn, transport) ->
|
||||
withConnection addr (tlsContext tls) $ \(conn, transport) ->
|
||||
withTimeout ii conn $ \th ->
|
||||
-- Run Warp connection handler to process HTTP requests for static files.
|
||||
WI.serveConnection conn ii th addr transport warpSettings app
|
||||
where
|
||||
wsOpts = defaultConnectionOptions
|
||||
{ connectionFramePayloadSizeLimit = SizeLimit $ fromIntegral smpBlockSize,
|
||||
connectionMessageDataSizeLimit = SizeLimit 65536
|
||||
}
|
||||
-- from warp-tls
|
||||
withConnection socket cxt = bracket (WT.attachConn socket cxt) (terminate . fst)
|
||||
-- from warp
|
||||
@@ -105,7 +112,6 @@ attachStaticFiles path action = do
|
||||
bracket
|
||||
(WI.registerKillThread (WI.timeoutManager ii) (WI.connClose conn))
|
||||
WI.cancel
|
||||
-- shared clean up
|
||||
terminate conn = WI.connClose conn `finally` (readIORef (WI.connWriteBuffer conn) >>= WI.bufFree)
|
||||
|
||||
warpSettings :: W.Settings
|
||||
|
||||
@@ -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 #-}
|
||||
@@ -7,10 +8,11 @@
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
|
||||
module Simplex.Messaging.Transport.WebSockets (WS (..)) where
|
||||
module Simplex.Messaging.Transport.WebSockets (WS (..), acceptWSConnection) where
|
||||
|
||||
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
|
||||
@@ -20,6 +22,7 @@ import Network.WebSockets.Stream (Stream)
|
||||
import qualified Network.WebSockets.Stream as S
|
||||
import Simplex.Messaging.Transport
|
||||
( ALPN,
|
||||
TLS (TLS, tlsContext, tlsPeerCert, tlsTransportConfig),
|
||||
Transport (..),
|
||||
TransportConfig (..),
|
||||
TransportError (..),
|
||||
@@ -40,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
|
||||
@@ -64,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
|
||||
@@ -93,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
|
||||
@@ -101,6 +107,25 @@ getWS cfg wsCertSent wsPeerCert cxt = withTlsUnique @WS @p cxt connectWS
|
||||
acceptClientRequest s = makePendingConnectionFromStream s websocketsOpts >>= acceptRequest
|
||||
sendClientRequest s = newClientConnection s "" "/" websocketsOpts []
|
||||
|
||||
acceptWSConnection :: TLS 'TServer -> PendingConnection -> IO (WS 'TServer)
|
||||
acceptWSConnection tls pending = withTlsUnique @WS @'TServer cxt $ \wsUniq -> do
|
||||
wsStream <- makeTLSContextStream cxt
|
||||
wsConnection <- acceptRequest pending
|
||||
wsALPN <- T.getNegotiatedProtocol cxt
|
||||
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 =
|
||||
S.makeStream readStream writeStream
|
||||
|
||||
@@ -73,15 +73,19 @@ runMessageTests ::
|
||||
Bool ->
|
||||
Spec
|
||||
runMessageTests initRatchets_ agreeRatchetKEMs = do
|
||||
it "should encrypt and decrypt messages" $ run $ testEncryptDecrypt agreeRatchetKEMs
|
||||
it "should encrypt and decrypt skipped messages" $ run $ testSkippedMessages agreeRatchetKEMs
|
||||
it "should encrypt and decrypt many messages" $ run $ testManyMessages agreeRatchetKEMs
|
||||
it "should allow skipped after ratchet advance" $ run $ testSkippedAfterRatchetAdvance agreeRatchetKEMs
|
||||
it "should encrypt and decrypt messages" $ run testEncryptDecrypt
|
||||
it "should encrypt and decrypt skipped messages" $ run testSkippedMessages
|
||||
it "should encrypt and decrypt many messages" $ run testManyMessages
|
||||
it "should allow skipped after ratchet advance" $ run testSkippedAfterRatchetAdvance
|
||||
where
|
||||
run :: (forall a. (AlgorithmI a, DhAlgorithm a) => TestRatchets a) -> IO ()
|
||||
run test = do
|
||||
withRatchets_ @X25519 initRatchets_ test
|
||||
withRatchets_ @X448 initRatchets_ test
|
||||
withRatchets_ @X25519 initRatchets_ (withKEM test)
|
||||
withRatchets_ @X448 initRatchets_ (withKEM test)
|
||||
withKEM :: (AlgorithmI a, DhAlgorithm a) => TestRatchets a -> TestRatchets a
|
||||
withKEM test alice bob encrypt decrypt (#>) = do
|
||||
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
|
||||
test alice bob encrypt decrypt (#>)
|
||||
|
||||
testAlgs :: (forall a. (AlgorithmI a, DhAlgorithm a) => C.SAlgorithm a -> IO ()) -> IO ()
|
||||
testAlgs test = test C.SX25519 >> test C.SX448
|
||||
@@ -146,6 +150,12 @@ type TestRatchets a =
|
||||
EncryptDecryptSpec a ->
|
||||
IO ()
|
||||
|
||||
-- Peer-polymorphic types for cross-language testing
|
||||
type EncryptP p = p -> ByteString -> IO (Either CryptoError ByteString)
|
||||
type DecryptP p = p -> ByteString -> IO (Either CryptoError (Either CryptoError ByteString))
|
||||
type EncryptDecryptSpecP p = (p, ByteString) -> p -> Expectation
|
||||
type TestRatchetsP p = p -> p -> EncryptP p -> DecryptP p -> EncryptDecryptSpecP p -> IO ()
|
||||
|
||||
deriving instance Eq (Ratchet a)
|
||||
|
||||
deriving instance Eq (SndRatchet a)
|
||||
@@ -170,9 +180,8 @@ deriving instance Eq (MsgHeader a)
|
||||
initRatchetKEM :: (AlgorithmI a, DhAlgorithm a) => TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> TVar (TVar ChaChaDRG, Ratchet a, SkippedMsgKeys) -> IO ()
|
||||
initRatchetKEM s r = encryptDecrypt (Just $ PQEncOn) (const ()) (const ()) (s, "initialising ratchet") r
|
||||
|
||||
testEncryptDecrypt :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
|
||||
testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
|
||||
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
|
||||
testEncryptDecrypt :: TestRatchetsP p
|
||||
testEncryptDecrypt alice bob encrypt decrypt (#>) = do
|
||||
(bob, "hello alice") #> alice
|
||||
(alice, "hello bob") #> bob
|
||||
Right b1 <- encrypt bob "how are you, alice?"
|
||||
@@ -191,9 +200,8 @@ testEncryptDecrypt agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
|
||||
(alice, "I'm here too, same") #> bob
|
||||
pure ()
|
||||
|
||||
testSkippedMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
|
||||
testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
|
||||
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
|
||||
testSkippedMessages :: TestRatchetsP p
|
||||
testSkippedMessages alice bob encrypt decrypt _ = do
|
||||
Right msg1 <- encrypt bob "hello alice"
|
||||
Right msg2 <- encrypt bob "hello there again"
|
||||
Right msg3 <- encrypt bob "are you there?"
|
||||
@@ -203,9 +211,8 @@ testSkippedMessages agreeRatchetKEMs alice bob encrypt decrypt _ = do
|
||||
Decrypted "hello alice" <- decrypt alice msg1
|
||||
pure ()
|
||||
|
||||
testManyMessages :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
|
||||
testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
|
||||
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
|
||||
testManyMessages :: TestRatchetsP p
|
||||
testManyMessages alice bob _ _ (#>) = do
|
||||
(bob, "b1") #> alice
|
||||
(bob, "b2") #> alice
|
||||
(bob, "b3") #> alice
|
||||
@@ -222,9 +229,8 @@ testManyMessages agreeRatchetKEMs alice bob _ _ (#>) = do
|
||||
(bob, "b15") #> alice
|
||||
(bob, "b16") #> alice
|
||||
|
||||
testSkippedAfterRatchetAdvance :: (AlgorithmI a, DhAlgorithm a) => Bool -> TestRatchets a
|
||||
testSkippedAfterRatchetAdvance agreeRatchetKEMs alice bob encrypt decrypt (#>) = do
|
||||
when agreeRatchetKEMs $ initRatchetKEM bob alice >> initRatchetKEM alice bob
|
||||
testSkippedAfterRatchetAdvance :: TestRatchetsP p
|
||||
testSkippedAfterRatchetAdvance alice bob encrypt decrypt (#>) = do
|
||||
(bob, "b1") #> alice
|
||||
Right b2 <- encrypt bob "b2"
|
||||
Right b3 <- encrypt bob "b3"
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ import qualified Simplex.Messaging.Transport.HTTP2.Client as HC
|
||||
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
|
||||
import Simplex.Messaging.Util (catchAll_)
|
||||
import qualified SMPWeb
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticFiles)
|
||||
import Simplex.Messaging.Server.Web (serveStaticFiles, attachStaticAndWS)
|
||||
import System.Directory (doesFileExist)
|
||||
import System.Environment (withArgs)
|
||||
import System.FilePath ((</>))
|
||||
@@ -152,7 +152,7 @@ smpServerTestStatic = do
|
||||
Right ini_ <- readIniFile iniFile
|
||||
lookupValue "WEB" "https" ini_ `shouldBe` Right "5223"
|
||||
|
||||
let smpServerCLI' = smpServerCLI_ SMPWeb.smpGenerateSite serveStaticFiles attachStaticFiles
|
||||
let smpServerCLI' = smpServerCLI_ SMPWeb.smpGenerateSite serveStaticFiles attachStaticAndWS
|
||||
let server = capture_ (withArgs ["start"] $ smpServerCLI' cfgPath logPath `catchAny` print)
|
||||
bracket (async server) cancel $ \_t -> do
|
||||
threadDelay 1000000
|
||||
|
||||
+23
-5
@@ -26,13 +26,16 @@ import Simplex.Messaging.Client.Agent (SMPClientAgentConfig (..), defaultSMPClie
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Protocol
|
||||
import Simplex.Messaging.Server (runSMPServerBlocking)
|
||||
import Simplex.Messaging.Server (runSMPServerBlocking, AttachHTTP)
|
||||
import Simplex.Messaging.Server.Env.STM
|
||||
import Simplex.Messaging.Server.MsgStore.Types (MsgStoreClass (..), SMSType (..), SQSType (..))
|
||||
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.Server
|
||||
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)
|
||||
import Simplex.Messaging.Version
|
||||
import Simplex.Messaging.Version.Internal
|
||||
@@ -155,7 +158,8 @@ testSMPClientVR vr client = do
|
||||
|
||||
testSMPClient_ :: Transport c => TransportHost -> ServiceName -> VersionRangeSMP -> (THandleSMP c 'TClient -> IO a) -> IO a
|
||||
testSMPClient_ host port vr client = do
|
||||
let tcConfig = defaultTransportClientConfig {clientALPN} :: TransportClientConfig
|
||||
-- SMP clients use useSNI = False (matches defaultSMPClientConfig)
|
||||
let tcConfig = defaultTransportClientConfig {clientALPN, useSNI = False} :: TransportClientConfig
|
||||
runTransportClient tcConfig Nothing host port (Just testKeyHash) $ \h ->
|
||||
runExceptT (smpClientHandshake h Nothing testKeyHash vr False Nothing) >>= \case
|
||||
Right th -> client th
|
||||
@@ -283,6 +287,17 @@ serverStoreConfig_ useDbStoreLog = \case
|
||||
dbStoreLogPath = if useDbStoreLog then Just testStoreLogFile else Nothing
|
||||
storeCfg = PostgresStoreCfg {dbOpts = testStoreDBOpts, dbStoreLogPath, confirmMigrations = MCYesUp, deletedTTL = 86400}
|
||||
|
||||
cfgWebOn :: AStoreType -> ServiceName -> AServerConfig
|
||||
cfgWebOn msType port' = updateCfg (cfgMS msType) $ \cfg' ->
|
||||
cfg' { transports = [(port', transport @TLS, True)],
|
||||
httpCredentials = Just ServerCredentials
|
||||
{ caCertificateFile = Nothing,
|
||||
privateKeyFile = "tests/fixtures/web.key",
|
||||
certificateFile = "tests/fixtures/web.crt"
|
||||
},
|
||||
transportConfig = mkTransportServerConfig True (Just $ alpnSupportedSMPHandshakes <> httpALPN) True
|
||||
}
|
||||
|
||||
cfgV7 :: AServerConfig
|
||||
cfgV7 = updateCfg cfg $ \cfg' -> cfg' {smpServerVRange = mkVersionRange minServerSMPRelayVersion authCmdsSMPVersion}
|
||||
|
||||
@@ -333,9 +348,12 @@ withServerCfg :: AServerConfig -> (forall s. ServerConfig s -> a) -> a
|
||||
withServerCfg (ASrvCfg _ _ cfg') f = f cfg'
|
||||
|
||||
withSmpServerConfigOn :: HasCallStack => ASrvTransport -> AServerConfig -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a
|
||||
withSmpServerConfigOn t (ASrvCfg _ _ cfg') port' =
|
||||
withSmpServerConfigOn t cfg' port' = withSmpServerConfig (updateCfg cfg' $ \c -> c {transports = [(port', t, False)]}) Nothing
|
||||
|
||||
withSmpServerConfig :: HasCallStack => AServerConfig -> Maybe AttachHTTP -> (HasCallStack => ThreadId -> IO a) -> IO a
|
||||
withSmpServerConfig (ASrvCfg _ _ cfg') attachHTTP_ =
|
||||
serverBracket
|
||||
(\started -> runSMPServerBlocking started cfg' {transports = [(port', t, False)]} Nothing)
|
||||
(\started -> runSMPServerBlocking started cfg' attachHTTP_)
|
||||
(threadDelay 10000)
|
||||
|
||||
withSmpServerThreadOn :: HasCallStack => (ASrvTransport, AStoreType) -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import Control.Concurrent.Async (concurrently_)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Exception (SomeException, throwIO, try)
|
||||
import Control.Monad
|
||||
import Control.Monad.Except (runExceptT)
|
||||
import Control.Monad.IO.Class
|
||||
import CoreTests.MsgStoreTests (testJournalStoreCfg)
|
||||
import Data.Bifunctor (first)
|
||||
@@ -42,6 +43,7 @@ import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll, parseString)
|
||||
import Simplex.Messaging.Protocol
|
||||
import Simplex.Messaging.Client (chooseTransportHost, defaultNetworkConfig)
|
||||
import Simplex.Messaging.Server (exportMessages)
|
||||
import Simplex.Messaging.Server.Env.STM (AStoreType (..), MsgStore (..), ServerConfig (..), ServerStoreCfg (..), readWriteQueueStore)
|
||||
import Simplex.Messaging.Server.Expiration
|
||||
@@ -50,6 +52,11 @@ import Simplex.Messaging.Server.MsgStore.Types (MsgStoreClass (..), QSType (..),
|
||||
import Simplex.Messaging.Server.Stats (PeriodStatsData (..), ServerStatsData (..))
|
||||
import Simplex.Messaging.Server.StoreLog (StoreLogRecord (..), closeStoreLog)
|
||||
import Simplex.Messaging.Transport
|
||||
import Simplex.Messaging.Transport.Client (TransportClientConfig (..), defaultTransportClientConfig, runTLSTransportClient)
|
||||
import Simplex.Messaging.Transport.WebSockets (WS)
|
||||
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
|
||||
import Simplex.Messaging.Server.Web (attachStaticAndWS)
|
||||
import Data.X509.Validation (Fingerprint (..))
|
||||
import Simplex.Messaging.Util (whenM)
|
||||
import Simplex.Messaging.Version (mkVersionRange)
|
||||
import System.Directory (doesDirectoryExist, doesFileExist, removeDirectoryRecursive, removeFile)
|
||||
@@ -101,6 +108,7 @@ serverTests = do
|
||||
describe "Short links" $ do
|
||||
testInvQueueLinkData
|
||||
testContactQueueLinkData
|
||||
describe "WebSocket and TLS on same port" testWebSocketAndTLS
|
||||
|
||||
pattern Resp :: CorrId -> QueueId -> BrokerMsg -> Transmission (Either ErrorType BrokerMsg)
|
||||
pattern Resp corrId queueId command <- (corrId, queueId, Right command)
|
||||
@@ -1484,3 +1492,41 @@ serverSyntaxTests (ATransport t) = do
|
||||
(Maybe TAuthorizations, ByteString, ByteString, BrokerMsg) ->
|
||||
Expectation
|
||||
command >#> response = withFrozenCallStack $ smpServerTest t command `shouldReturn` response
|
||||
|
||||
-- | Test that both native TLS and WebSocket clients can connect to the same port.
|
||||
-- Native TLS uses useSNI=False, WebSocket uses useSNI=True for routing.
|
||||
testWebSocketAndTLS :: SpecWith (ASrvTransport, AStoreType)
|
||||
testWebSocketAndTLS =
|
||||
it "native TLS and WebSocket clients work on same port" $ \(_t, msType) -> do
|
||||
Fingerprint fpHTTP <- loadFileFingerprint "tests/fixtures/web_ca.crt"
|
||||
let httpKeyHash = C.KeyHash fpHTTP
|
||||
attachStaticAndWS "tests/fixtures" $ \attachHTTP ->
|
||||
withSmpServerConfig (cfgWebOn msType testPort) (Just attachHTTP) $ \_ -> do
|
||||
g <- C.newRandom
|
||||
(rPub, rKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
|
||||
(sPub, sKey) <- atomically $ C.generateAuthKeyPair C.SEd25519 g
|
||||
(dhPub, dhPriv :: C.PrivateKeyX25519) <- atomically $ C.generateKeyPair g
|
||||
|
||||
-- Connect via native TLS (useSNI=False, default) and create a queue
|
||||
(sId, rId, srvDh) <- testSMPClient @TLS $ \rh -> do
|
||||
Resp "1" _ (Ids rId sId srvDh) <- signSendRecv rh rKey ("1", NoEntity, New rPub dhPub)
|
||||
Resp "2" _ OK <- signSendRecv rh rKey ("2", rId, KEY sPub)
|
||||
pure (sId, rId, srvDh)
|
||||
let dec = decryptMsgV3 $ C.dh' srvDh dhPriv
|
||||
|
||||
-- Connect via WebSocket (useSNI=True) and send a message
|
||||
Right useHost <- pure $ chooseTransportHost defaultNetworkConfig testHost
|
||||
let wsTcConfig = defaultTransportClientConfig {useSNI = True} :: TransportClientConfig
|
||||
runTLSTransportClient defaultSupportedParamsHTTPS Nothing wsTcConfig Nothing useHost testPort (Just httpKeyHash) $ \(h :: WS 'TClient) ->
|
||||
runExceptT (smpClientHandshake h Nothing testKeyHash supportedClientSMPRelayVRange False Nothing) >>= \case
|
||||
Right sh -> do
|
||||
Resp "3" _ OK <- signSendRecv sh sKey ("3", sId, _SEND "hello from websocket")
|
||||
pure ()
|
||||
Left e -> error $ show e
|
||||
|
||||
-- Verify message received via native TLS
|
||||
testSMPClient @TLS $ \rh -> do
|
||||
(Resp "4" _ (SOK Nothing), Resp "" _ (Msg mId msg)) <- signSendRecv2 rh rKey ("4", rId, SUB)
|
||||
dec mId msg `shouldBe` Right "hello from websocket"
|
||||
Resp "5" _ OK <- signSendRecv rh rKey ("5", rId, ACK mId)
|
||||
pure ()
|
||||
|
||||
@@ -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