mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-30 18:28:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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` |
|
||||
@@ -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,35 @@
|
||||
{
|
||||
"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",
|
||||
"typescript": "^5.4.0",
|
||||
"ws": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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,247 @@
|
||||
// Agent protocol types and short link parsing.
|
||||
// Mirrors: Simplex.Messaging.Agent.Protocol
|
||||
|
||||
import {base64urlDecode} from "@simplex-chat/xftp-web/dist/protocol/description.js"
|
||||
import {
|
||||
Decoder, decodeBytes, decodeLarge, decodeWord16, decodeBool,
|
||||
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
|
||||
|
||||
// -- Short link types (Agent/Protocol.hs:1462-1470)
|
||||
|
||||
export type ShortLinkScheme = "simplex" | "https"
|
||||
|
||||
export type ContactConnType = "contact" | "channel" | "group" | "relay"
|
||||
|
||||
export interface ProtocolServer {
|
||||
hosts: Uint8Array[] // NonEmpty, each is the strEncoded host bytes
|
||||
port: Uint8Array
|
||||
keyHash: Uint8Array
|
||||
}
|
||||
|
||||
export type ConnShortLink =
|
||||
| {mode: "invitation", scheme: ShortLinkScheme, server: ProtocolServer, linkId: Uint8Array, linkKey: Uint8Array}
|
||||
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ProtocolServer, linkKey: Uint8Array}
|
||||
|
||||
// -- ProtocolServer binary encoding (Protocol.hs:1264-1269)
|
||||
// smpEncode (host, port, keyHash)
|
||||
// host: NonEmpty TransportHost = smpEncodeList (1-byte count + each as ByteString)
|
||||
// port: String = ByteString (1-byte len + bytes)
|
||||
// keyHash: KeyHash = ByteString (1-byte len + bytes)
|
||||
|
||||
export function decodeProtocolServer(d: Decoder): ProtocolServer {
|
||||
const hostCount = d.anyByte()
|
||||
if (hostCount === 0) throw new Error("empty server host list")
|
||||
const hosts: Uint8Array[] = []
|
||||
for (let i = 0; i < hostCount; i++) hosts.push(decodeBytes(d))
|
||||
const port = decodeBytes(d)
|
||||
const keyHash = decodeBytes(d)
|
||||
return {hosts, port, keyHash}
|
||||
}
|
||||
|
||||
// -- ConnShortLink binary encoding (Agent/Protocol.hs:1631-1649)
|
||||
// Contact: smpEncode (CMContact, ctTypeChar, srv, linkKey)
|
||||
// Invitation: smpEncode (CMInvitation, srv, linkId, linkKey)
|
||||
|
||||
export interface ConnShortLinkBinary {
|
||||
mode: "contact" | "invitation"
|
||||
connType?: ContactConnType
|
||||
server: ProtocolServer
|
||||
linkId?: Uint8Array
|
||||
linkKey: Uint8Array
|
||||
}
|
||||
|
||||
const ctTypeFromByte: Record<number, ContactConnType> = {
|
||||
0x41: "contact", // 'A'
|
||||
0x43: "channel", // 'C'
|
||||
0x47: "group", // 'G'
|
||||
0x52: "relay", // 'R'
|
||||
}
|
||||
|
||||
export function decodeConnShortLink(d: Decoder): ConnShortLinkBinary {
|
||||
const mode = d.anyByte()
|
||||
if (mode === 0x49) {
|
||||
// Invitation: (srv, linkId, linkKey)
|
||||
const server = decodeProtocolServer(d)
|
||||
const linkId = decodeBytes(d)
|
||||
const linkKey = decodeBytes(d)
|
||||
return {mode: "invitation", server, linkId, linkKey}
|
||||
} else if (mode === 0x43) {
|
||||
// Contact: (ctTypeChar, srv, linkKey)
|
||||
const ctByte = d.anyByte()
|
||||
const connType = ctTypeFromByte[ctByte]
|
||||
if (!connType) throw new Error("unknown contact type: 0x" + ctByte.toString(16))
|
||||
const server = decodeProtocolServer(d)
|
||||
const linkKey = decodeBytes(d)
|
||||
return {mode: "contact", connType, server, linkKey}
|
||||
}
|
||||
throw new Error("unknown ConnShortLink mode: 0x" + mode.toString(16))
|
||||
}
|
||||
|
||||
// -- OwnerAuth (Agent/Protocol.hs:1793-1800)
|
||||
// Outer ByteString wrapping inner: (ownerId, ownerKey, authOwnerSig)
|
||||
|
||||
export interface OwnerAuth {
|
||||
ownerId: Uint8Array
|
||||
ownerKey: Uint8Array
|
||||
authOwnerSig: Uint8Array
|
||||
}
|
||||
|
||||
export function decodeOwnerAuth(d: Decoder): OwnerAuth {
|
||||
const inner = decodeBytes(d)
|
||||
const id = new Decoder(inner)
|
||||
const ownerId = decodeBytes(id)
|
||||
const ownerKey = decodeBytes(id)
|
||||
const authOwnerSig = decodeBytes(id)
|
||||
return {ownerId, ownerKey, authOwnerSig}
|
||||
}
|
||||
|
||||
// -- UserLinkData (Agent/Protocol.hs:1891-1894)
|
||||
// If first byte is 0xFF, read Large; otherwise it's a ByteString (1-byte length)
|
||||
|
||||
export function decodeUserLinkData(d: Decoder): Uint8Array {
|
||||
const firstByte = d.anyByte()
|
||||
if (firstByte === 0xFF) return decodeLarge(d)
|
||||
return d.take(firstByte)
|
||||
}
|
||||
|
||||
// -- UserContactData (Agent/Protocol.hs:1881-1889)
|
||||
|
||||
export interface UserContactData {
|
||||
direct: boolean
|
||||
owners: OwnerAuth[]
|
||||
relays: ConnShortLinkBinary[]
|
||||
userData: Uint8Array
|
||||
}
|
||||
|
||||
export function decodeUserContactData(d: Decoder): UserContactData {
|
||||
const direct = decodeBool(d)
|
||||
const ownerCount = d.anyByte()
|
||||
const owners: OwnerAuth[] = []
|
||||
for (let i = 0; i < ownerCount; i++) owners.push(decodeOwnerAuth(d))
|
||||
const relayCount = d.anyByte()
|
||||
const relays: ConnShortLinkBinary[] = []
|
||||
for (let i = 0; i < relayCount; i++) relays.push(decodeConnShortLink(d))
|
||||
const userData = decodeUserLinkData(d)
|
||||
return {direct, owners, relays, userData}
|
||||
}
|
||||
|
||||
// -- ConnLinkData (Agent/Protocol.hs:1838-1855)
|
||||
// Contact: 'C' + versionRange + UserContactData
|
||||
|
||||
export interface ConnLinkDataContact {
|
||||
mode: "contact"
|
||||
agentVRange: {min: number; max: number}
|
||||
userContactData: UserContactData
|
||||
}
|
||||
|
||||
export function decodeConnLinkData(d: Decoder): ConnLinkDataContact {
|
||||
const modeChar = d.anyByte()
|
||||
if (modeChar !== 0x43) throw new Error("expected Contact mode 'C' (0x43), got 0x" + modeChar.toString(16))
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const userContactData = decodeUserContactData(d)
|
||||
return {mode: "contact", agentVRange: {min, max}, userContactData}
|
||||
}
|
||||
|
||||
// -- FixedLinkData (Agent/Protocol.hs:1830-1836)
|
||||
// Encoding: smpEncode (agentVRange, rootKey, linkConnReq) <> maybe "" smpEncode linkEntityId
|
||||
// rootKey is DER-encoded Ed25519 public key (ByteString: 1-byte len + 44 bytes DER)
|
||||
// linkConnReq is ConnectionRequestUri (variable length, not length-prefixed)
|
||||
// For now, we parse agentVRange + rootKey and keep the rest as raw bytes.
|
||||
// Full ConnectionRequestUri parsing is future work.
|
||||
|
||||
export interface FixedLinkData {
|
||||
agentVRange: {min: number; max: number}
|
||||
rootKey: Uint8Array // DER-encoded Ed25519 public key (44 bytes)
|
||||
rest: Uint8Array // raw linkConnReq + linkEntityId bytes
|
||||
}
|
||||
|
||||
export function decodeFixedLinkData(d: Decoder): FixedLinkData {
|
||||
const min = decodeWord16(d)
|
||||
const max = decodeWord16(d)
|
||||
const rootKey = decodeBytes(d)
|
||||
const rest = d.takeAll()
|
||||
return {agentVRange: {min, max}, rootKey, rest}
|
||||
}
|
||||
|
||||
// -- Profile extraction
|
||||
|
||||
export function parseProfile(userData: Uint8Array): unknown {
|
||||
if (userData.length > 0 && userData[0] === 0x58) {
|
||||
throw new Error("zstd-compressed profile not yet supported")
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(userData))
|
||||
}
|
||||
|
||||
// -- Short link URI parsing (below) --
|
||||
|
||||
export interface ShortLinkServer {
|
||||
hosts: string[]
|
||||
port: string
|
||||
keyHash: Uint8Array
|
||||
}
|
||||
|
||||
export type ConnShortLinkURI =
|
||||
| {mode: "invitation", scheme: ShortLinkScheme, server: ShortLinkServer, linkId: Uint8Array, linkKey: Uint8Array}
|
||||
| {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ShortLinkServer, linkKey: Uint8Array}
|
||||
|
||||
const ctTypeFromChar: Record<string, ContactConnType> = {
|
||||
a: "contact",
|
||||
c: "channel",
|
||||
g: "group",
|
||||
r: "relay",
|
||||
}
|
||||
|
||||
// Mirrors strP for AConnShortLink (Agent/Protocol.hs:1596-1629)
|
||||
export function connShortLinkStrP(uri: string): ConnShortLinkURI {
|
||||
let scheme: ShortLinkScheme
|
||||
let firstHost: string | null = null
|
||||
let rest: string
|
||||
|
||||
if (uri.startsWith("simplex:")) {
|
||||
scheme = "simplex"
|
||||
rest = uri.slice("simplex:".length)
|
||||
} else if (uri.startsWith("https://")) {
|
||||
scheme = "https"
|
||||
const afterScheme = uri.slice("https://".length)
|
||||
const slashIdx = afterScheme.indexOf("/")
|
||||
if (slashIdx < 0) throw new Error("bad short link: no path")
|
||||
firstHost = afterScheme.slice(0, slashIdx)
|
||||
rest = afterScheme.slice(slashIdx)
|
||||
} else {
|
||||
throw new Error("bad short link scheme")
|
||||
}
|
||||
|
||||
if (rest[0] !== "/") throw new Error("bad short link: expected /")
|
||||
const typeChar = rest[1]
|
||||
const hashIdx = rest.indexOf("#")
|
||||
if (hashIdx < 0) throw new Error("bad short link: no #")
|
||||
const afterHash = rest.slice(hashIdx + 1)
|
||||
|
||||
const qIdx = afterHash.indexOf("?")
|
||||
const fragment = qIdx >= 0 ? afterHash.slice(0, qIdx) : afterHash
|
||||
const queryStr = qIdx >= 0 ? afterHash.slice(qIdx + 1) : ""
|
||||
const params = new URLSearchParams(queryStr)
|
||||
|
||||
const hParam = params.get("h")
|
||||
const additionalHosts = hParam ? hParam.split(",") : []
|
||||
const allHosts = firstHost ? [firstHost, ...additionalHosts] : additionalHosts
|
||||
if (allHosts.length === 0) throw new Error("short link without server")
|
||||
|
||||
const port = params.get("p") ?? ""
|
||||
const keyHash = params.has("c") ? base64urlDecode(params.get("c")!) : new Uint8Array(0)
|
||||
const server: ShortLinkServer = {hosts: allHosts, port, keyHash}
|
||||
|
||||
if (typeChar === "i") {
|
||||
const slashIdx = fragment.indexOf("/")
|
||||
if (slashIdx < 0) throw new Error("invitation link must have linkId/linkKey")
|
||||
const linkId = base64urlDecode(fragment.slice(0, slashIdx))
|
||||
const linkKey = base64urlDecode(fragment.slice(slashIdx + 1))
|
||||
return {mode: "invitation", scheme, server, linkId, linkKey}
|
||||
} else {
|
||||
const connType = ctTypeFromChar[typeChar]
|
||||
if (!connType) throw new Error("unknown contact type: " + typeChar)
|
||||
const linkKey = base64urlDecode(fragment)
|
||||
return {mode: "contact", scheme, connType, server, linkKey}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"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