|
| 1 | +// SMP client: command/response correlation, authentication, typed async API. |
| 2 | +// Mirrors: Simplex.Messaging.Client |
| 3 | + |
| 4 | +import { |
| 5 | + encodeTransmission, encodeTransmissionForAuth, authTransmission, |
| 6 | + tEncodeBatch1, tParse, tDecodeClient, protocolError, encodePING, |
| 7 | + decodeResponse, |
| 8 | + type AuthKey, type SMPResponse, type RawTransmission, |
| 9 | + encodeNEW, encodeKEY, encodeSKEY, encodeSUB, encodeACK, |
| 10 | + encodeSEND, encodeOFF, encodeDEL, encodeGET, encodeQUE, encodeLGET, |
| 11 | + type IDSResponse, type MSGResponse, |
| 12 | +} from "./protocol.js" |
| 13 | +import { |
| 14 | + connectSMP, |
| 15 | + type SMPConnection, |
| 16 | +} from "./transport/websockets.js" |
| 17 | +import {SMP_BLOCK_SIZE} from "./transport.js" |
| 18 | +import {sbEncryptBlock, sbDecryptBlock} from "./crypto.js" |
| 19 | +import {blockPad, blockUnpad} from "@simplex-chat/xftp-web/dist/protocol/transmission.js" |
| 20 | +import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js" |
| 21 | +import {x25519KeyPairFromPrivate, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js" |
| 22 | + |
| 23 | +// -- Error types (Client.hs:741-770) |
| 24 | + |
| 25 | +export type SMPClientError = |
| 26 | + | {type: "PROTOCOL", error: string} // ERR response from server |
| 27 | + | {type: "RESPONSE", error: string} // failed to parse response |
| 28 | + | {type: "UNEXPECTED", raw: string} // wrong response type for command |
| 29 | + | {type: "TIMEOUT"} // response timeout |
| 30 | + | {type: "NETWORK", error: string} // connection failure |
| 31 | + | {type: "TRANSPORT", error: string} // handshake/transport error |
| 32 | + |
| 33 | +// -- SMPClient |
| 34 | + |
| 35 | +export interface SMPClient { |
| 36 | + readonly sessionId: Uint8Array |
| 37 | + readonly smpVersion: number |
| 38 | + readonly serverPubKey: Uint8Array |
| 39 | + |
| 40 | + // Core: send pre-encoded command, await correlated response |
| 41 | + sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse> |
| 42 | + |
| 43 | + // High-level commands |
| 44 | + createQueue(authKeyPair: {publicKey: Uint8Array, privateKey: Uint8Array}, dhKey: Uint8Array, subscribe: boolean): Promise<IDSResponse> |
| 45 | + subscribeQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void> |
| 46 | + getMessage(privKey: AuthKey, rcvId: Uint8Array): Promise<MSGResponse | null> |
| 47 | + sendMessage(privKey: AuthKey | null, sndId: Uint8Array, notification: boolean, msg: Uint8Array): Promise<void> |
| 48 | + ackMessage(privKey: AuthKey, rcvId: Uint8Array, msgId: Uint8Array): Promise<void> |
| 49 | + secureQueue(privKey: AuthKey, rcvId: Uint8Array, senderKey: Uint8Array): Promise<void> |
| 50 | + secureSndQueue(privKey: AuthKey, sndId: Uint8Array): Promise<void> |
| 51 | + getQueueLink(linkId: Uint8Array): Promise<SMPResponse> |
| 52 | + deleteQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void> |
| 53 | + suspendQueue(privKey: AuthKey, rcvId: Uint8Array): Promise<void> |
| 54 | + |
| 55 | + close(): void |
| 56 | +} |
| 57 | + |
| 58 | +interface PendingRequest { |
| 59 | + resolve: (resp: SMPResponse) => void |
| 60 | + reject: (err: SMPClientError) => void |
| 61 | + timer: ReturnType<typeof setTimeout> |
| 62 | +} |
| 63 | + |
| 64 | +function toHex(bytes: Uint8Array): string { |
| 65 | + return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("") |
| 66 | +} |
| 67 | + |
| 68 | +export async function createSMPClient( |
| 69 | + url: string, |
| 70 | + keyHash: Uint8Array, |
| 71 | + onMessage: (entityId: Uint8Array, msg: SMPResponse) => void, |
| 72 | + onDisconnected: () => void, |
| 73 | + config?: {timeout?: number, pingInterval?: number, pingMaxCount?: number, wsOptions?: object}, |
| 74 | +): Promise<SMPClient> { |
| 75 | + const timeout_ = config?.timeout ?? 10_000 |
| 76 | + const pingInterval = config?.pingInterval ?? 600_000 |
| 77 | + const pingMaxCount = config?.pingMaxCount ?? 3 |
| 78 | + |
| 79 | + const conn = await connectSMP(url, keyHash, config?.wsOptions) |
| 80 | + if (!conn.serverPubKey) throw new Error("createSMPClient: server has no auth key") |
| 81 | + |
| 82 | + const serverPubKey = conn.serverPubKey |
| 83 | + const pending = new Map<string, PendingRequest>() |
| 84 | + let closed = false |
| 85 | + let pingTimer: ReturnType<typeof setInterval> | null = null |
| 86 | + let timeoutCount = 0 |
| 87 | + |
| 88 | + // -- Receive loop |
| 89 | + |
| 90 | + function onBlock(data: ArrayBuffer | Buffer) { |
| 91 | + if (closed) return |
| 92 | + timeoutCount = 0 |
| 93 | + try { |
| 94 | + const raw = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) |
| 95 | + const block = new Uint8Array(raw) |
| 96 | + // Decrypt block |
| 97 | + const decrypted = decryptBlock(block) |
| 98 | + // Parse batch |
| 99 | + const transmissions = tParse(decrypted) |
| 100 | + for (const raw of transmissions) { |
| 101 | + dispatch(raw) |
| 102 | + } |
| 103 | + } catch (e: any) { |
| 104 | + // Parse error — log to stderr |
| 105 | + process.stderr.write("SMP client receive error: " + e.message + "\n") |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + function decryptBlock(block: Uint8Array): Uint8Array { |
| 110 | + if (conn.rcvKey) { |
| 111 | + const {decrypted, nextChainKey} = sbDecryptBlock(conn.rcvKey, block) |
| 112 | + conn.rcvKey = nextChainKey |
| 113 | + return decrypted |
| 114 | + } |
| 115 | + // No block encryption — strip padding |
| 116 | + return blockUnpad(block) |
| 117 | + } |
| 118 | + |
| 119 | + function dispatch(raw: RawTransmission) { |
| 120 | + // Parse response |
| 121 | + let response: SMPResponse |
| 122 | + try { |
| 123 | + response = decodeResponse(new Decoder(raw.command)) |
| 124 | + } catch (e: any) { |
| 125 | + process.stderr.write("dispatch parse error: " + e.message + " command=" + toHex(raw.command) + "\n") |
| 126 | + // If we can correlate, reject the pending request |
| 127 | + const key = toHex(raw.corrId) |
| 128 | + const req = pending.get(key) |
| 129 | + if (req) { |
| 130 | + pending.delete(key) |
| 131 | + clearTimeout(req.timer) |
| 132 | + req.reject({type: "RESPONSE", error: e.message}) |
| 133 | + } |
| 134 | + return |
| 135 | + } |
| 136 | + |
| 137 | + // Classify: ERR → PCEProtocolError |
| 138 | + const err = protocolError(response) |
| 139 | + |
| 140 | + // Correlate by corrId |
| 141 | + const corrIdBytes = raw.corrId |
| 142 | + if (corrIdBytes.length === 0) { |
| 143 | + // Server push (no corrId) — deliver to event callback |
| 144 | + onMessage(raw.entityId, response) |
| 145 | + return |
| 146 | + } |
| 147 | + |
| 148 | + const key = toHex(corrIdBytes) |
| 149 | + const req = pending.get(key) |
| 150 | + if (req) { |
| 151 | + pending.delete(key) |
| 152 | + clearTimeout(req.timer) |
| 153 | + if (err) { |
| 154 | + req.reject({type: "PROTOCOL", error: err}) |
| 155 | + } else { |
| 156 | + req.resolve(response) |
| 157 | + } |
| 158 | + } else { |
| 159 | + // No pending request — might be a late response or server push with corrId |
| 160 | + // Deliver as event |
| 161 | + if (!err) onMessage(raw.entityId, response) |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + // Wire up WebSocket receive |
| 166 | + conn.ws.onmessage = (event) => onBlock(event.data as ArrayBuffer) |
| 167 | + conn.ws.onclose = () => { |
| 168 | + process.stderr.write("WebSocket closed\n") |
| 169 | + if (!closed) { |
| 170 | + closed = true |
| 171 | + cleanup() |
| 172 | + onDisconnected() |
| 173 | + } |
| 174 | + } |
| 175 | + conn.ws.onerror = (e) => { |
| 176 | + process.stderr.write("WebSocket error: " + String(e) + "\n") |
| 177 | + } |
| 178 | + |
| 179 | + // -- Ping |
| 180 | + |
| 181 | + function startPing() { |
| 182 | + if (pingInterval <= 0) return |
| 183 | + pingTimer = setInterval(async () => { |
| 184 | + try { |
| 185 | + await client.sendCommand(null, new Uint8Array(0), encodePING()) |
| 186 | + } catch { |
| 187 | + timeoutCount++ |
| 188 | + if (pingMaxCount > 0 && timeoutCount >= pingMaxCount) { |
| 189 | + client.close() |
| 190 | + } |
| 191 | + } |
| 192 | + }, pingInterval) |
| 193 | + } |
| 194 | + |
| 195 | + function cleanup() { |
| 196 | + if (pingTimer) { |
| 197 | + clearInterval(pingTimer) |
| 198 | + pingTimer = null |
| 199 | + } |
| 200 | + // Reject all pending requests |
| 201 | + for (const [, req] of pending) { |
| 202 | + clearTimeout(req.timer) |
| 203 | + req.reject({type: "NETWORK", error: "disconnected"}) |
| 204 | + } |
| 205 | + pending.clear() |
| 206 | + } |
| 207 | + |
| 208 | + // -- Send |
| 209 | + |
| 210 | + function sendCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<SMPResponse> { |
| 211 | + if (closed) return Promise.reject({type: "NETWORK", error: "closed"} as SMPClientError) |
| 212 | + |
| 213 | + // Generate random corrId/nonce (24 bytes) |
| 214 | + const nonce = crypto.getRandomValues(new Uint8Array(24)) |
| 215 | + |
| 216 | + // Encode transmission |
| 217 | + const {tForAuth, tToSend} = encodeTransmissionForAuth(conn.sessionId, nonce, entityId, command) |
| 218 | + |
| 219 | + // Authenticate |
| 220 | + const auth = authTransmission(serverPubKey, privKey, nonce, tForAuth) |
| 221 | + |
| 222 | + // Encode as single-command batch |
| 223 | + const block = tEncodeBatch1(auth, tToSend) |
| 224 | + |
| 225 | + // Send encrypted block |
| 226 | + if (conn.sndKey) { |
| 227 | + const {encrypted, nextChainKey} = sbEncryptBlock(conn.sndKey, block, SMP_BLOCK_SIZE - 16) |
| 228 | + conn.sndKey = nextChainKey |
| 229 | + conn.ws.send(encrypted) |
| 230 | + } else { |
| 231 | + conn.ws.send(blockPad(block, SMP_BLOCK_SIZE)) |
| 232 | + } |
| 233 | + |
| 234 | + |
| 235 | + // Create pending request with timeout |
| 236 | + return new Promise<SMPResponse>((resolve, reject) => { |
| 237 | + const key = toHex(nonce) |
| 238 | + const timer = setTimeout(() => { |
| 239 | + pending.delete(key) |
| 240 | + timeoutCount++ |
| 241 | + reject({type: "TIMEOUT"} as SMPClientError) |
| 242 | + }, timeout_) |
| 243 | + pending.set(key, {resolve, reject, timer}) |
| 244 | + }) |
| 245 | + } |
| 246 | + |
| 247 | + // -- High-level commands |
| 248 | + |
| 249 | + async function okCommand(privKey: AuthKey | null, entityId: Uint8Array, command: Uint8Array): Promise<void> { |
| 250 | + const resp = await sendCommand(privKey, entityId, command) |
| 251 | + if (resp.type !== "OK" && resp.type !== "SOK") { |
| 252 | + throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + const client: SMPClient = { |
| 257 | + sessionId: conn.sessionId, |
| 258 | + smpVersion: conn.smpVersion, |
| 259 | + serverPubKey, |
| 260 | + sendCommand, |
| 261 | + |
| 262 | + // createQueue (Client.hs:813-827) |
| 263 | + async createQueue(authKeyPair, dhKey, subscribe) { |
| 264 | + const command = encodeNEW(authKeyPair.publicKey, dhKey, null, subscribe) |
| 265 | + // Auth with the X25519 private key from the keypair |
| 266 | + const privKey: AuthKey = {type: "x25519", key: authKeyPair.privateKey} |
| 267 | + const resp = await sendCommand(privKey, new Uint8Array(0), command) |
| 268 | + if (resp.type !== "IDS") throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError |
| 269 | + return resp.response |
| 270 | + }, |
| 271 | + |
| 272 | + // subscribeSMPQueue (Client.hs:833-836) |
| 273 | + async subscribeQueue(privKey, rcvId) { |
| 274 | + const resp = await sendCommand(privKey, rcvId, encodeSUB()) |
| 275 | + // SUB can return MSG (queued message) — push to onMessage |
| 276 | + if (resp.type === "MSG") { |
| 277 | + onMessage(rcvId, resp) |
| 278 | + return |
| 279 | + } |
| 280 | + if (resp.type !== "OK" && resp.type !== "SOK") { |
| 281 | + throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError |
| 282 | + } |
| 283 | + }, |
| 284 | + |
| 285 | + // getSMPMessage (Client.hs:875-880) |
| 286 | + async getMessage(privKey, rcvId) { |
| 287 | + const resp = await sendCommand(privKey, rcvId, encodeGET()) |
| 288 | + if (resp.type === "OK") return null |
| 289 | + if (resp.type === "MSG") { |
| 290 | + onMessage(rcvId, resp) |
| 291 | + return resp.response |
| 292 | + } |
| 293 | + throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError |
| 294 | + }, |
| 295 | + |
| 296 | + // sendSMPMessage (Client.hs:1027-1031) |
| 297 | + async sendMessage(privKey, sndId, notification, msg) { |
| 298 | + await okCommand(privKey, sndId, encodeSEND(notification, msg)) |
| 299 | + }, |
| 300 | + |
| 301 | + // ackSMPMessage (Client.hs:1040-1045) |
| 302 | + async ackMessage(privKey, rcvId, msgId) { |
| 303 | + const resp = await sendCommand(privKey, rcvId, encodeACK(msgId)) |
| 304 | + // ACK can return MSG — push to onMessage |
| 305 | + if (resp.type === "MSG") { |
| 306 | + onMessage(rcvId, resp) |
| 307 | + return |
| 308 | + } |
| 309 | + if (resp.type !== "OK") { |
| 310 | + throw {type: "UNEXPECTED", raw: resp.type} as SMPClientError |
| 311 | + } |
| 312 | + }, |
| 313 | + |
| 314 | + // secureSMPQueue (Client.hs:938-939) |
| 315 | + async secureQueue(privKey, rcvId, senderKey) { |
| 316 | + await okCommand(privKey, rcvId, encodeKEY(senderKey)) |
| 317 | + }, |
| 318 | + |
| 319 | + // secureSndSMPQueue (Client.hs:943-944) |
| 320 | + // SKEY sends the public key derived from the private key |
| 321 | + async secureSndQueue(privKey, sndId) { |
| 322 | + // x25519KeyPairFromPrivate derives public from private |
| 323 | + const pubKey = x25519KeyPairFromPrivate(privKey.key).publicKey |
| 324 | + await okCommand(privKey, sndId, encodeSKEY(encodePubKeyX25519(pubKey))) |
| 325 | + }, |
| 326 | + |
| 327 | + // getSMPQueueLink (Client.hs:976-980) |
| 328 | + async getQueueLink(linkId) { |
| 329 | + return sendCommand(null, linkId, encodeLGET()) |
| 330 | + }, |
| 331 | + |
| 332 | + // deleteSMPQueue (Client.hs:1058-1059) |
| 333 | + async deleteQueue(privKey, rcvId) { |
| 334 | + await okCommand(privKey, rcvId, encodeDEL()) |
| 335 | + }, |
| 336 | + |
| 337 | + // suspendSMPQueue (Client.hs:1051-1052) |
| 338 | + async suspendQueue(privKey, rcvId) { |
| 339 | + await okCommand(privKey, rcvId, encodeOFF()) |
| 340 | + }, |
| 341 | + |
| 342 | + close() { |
| 343 | + if (closed) return |
| 344 | + closed = true |
| 345 | + cleanup() |
| 346 | + conn.ws.close() |
| 347 | + }, |
| 348 | + } |
| 349 | + |
| 350 | + startPing() |
| 351 | + return client |
| 352 | +} |
| 353 | + |
0 commit comments