Skip to content

Commit c09a11c

Browse files
smp client with tests
1 parent 34e3d30 commit c09a11c

6 files changed

Lines changed: 1039 additions & 53 deletions

File tree

smp-web/src/client.ts

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
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+

smp-web/src/crypto.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import {hkdf as nobleHkdf} from "@noble/hashes/hkdf"
55
import {sha512} from "@noble/hashes/sha512"
66
import {gcm} from "@noble/ciphers/aes.js"
7-
import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
7+
import {cbEncrypt, cbDecrypt, cryptoBox} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js"
8+
import {dh} from "@simplex-chat/xftp-web/dist/crypto/keys.js"
89
import {concatBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
910
import {pad, unPad} from "@simplex-chat/xftp-web/dist/crypto/padding.js"
1011

@@ -87,3 +88,18 @@ export function decryptAEAD(
8788
const padded = cipher.decrypt(encrypted)
8889
return unPad(padded)
8990
}
91+
92+
// -- SHA-512 hash (Crypto.hs:1016)
93+
94+
export function sha512Hash(msg: Uint8Array): Uint8Array {
95+
return sha512(msg)
96+
}
97+
98+
// -- Command authentication (Crypto.hs:1366-1367)
99+
100+
// cbAuthenticate (Crypto.hs:1367)
101+
// cryptoBox(dh(serverPubKey, entityPrivKey), nonce, sha512Hash(msg)) → 80 bytes (16 tag + 64 hash)
102+
export function cbAuthenticator(serverPubKey: Uint8Array, entityPrivKey: Uint8Array, nonce: Uint8Array, msg: Uint8Array): Uint8Array {
103+
const dhSecret = dh(serverPubKey, entityPrivKey)
104+
return cryptoBox(dhSecret, nonce, sha512Hash(msg))
105+
}

0 commit comments

Comments
 (0)