From 81f0c2be4597842004c62040a9db296c4d5f0147 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:09:10 +0000 Subject: [PATCH] test, fix --- smp-web/src/agent/client.ts | 27 ++++++++++++++++++++------- smp-web/src/agent/smp-ops.ts | 17 ++++++++++++----- smp-web/tests/agent-repl.ts | 7 +++++-- smp-web/tests/infra-test.ts | 10 ++++++---- tests/SMPWebTests.hs | 28 ++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/smp-web/src/agent/client.ts b/smp-web/src/agent/client.ts index 9f760a400..ff495e81a 100644 --- a/smp-web/src/agent/client.ts +++ b/smp-web/src/agent/client.ts @@ -71,9 +71,13 @@ export interface AgentConfig { export const defaultAgentConfig: AgentConfig = { tbqSize: 128, connIdBytes: 12, - smpAgentVRange: [1, 8], - smpClientVRange: [7, 18], - e2eEncryptVRange: [1, 2], + // supportedSMPAgentVRange = [minSupportedSMPAgentVersion=2, currentSMPAgentVersion=7] (Agent/Protocol.hs:315-322) + smpAgentVRange: [2, 7], + // supportedSMPClientVRange = [initialSMPClientVersion=1, currentSMPClientVersion=4] (Protocol.hs:282-297) + // NOTE: this is VersionSMPC (SMP client protocol), NOT the SMP transport version (≤18) + smpClientVRange: [1, 4], + // supportedE2EEncryptVRange = [kdfX3DHE2EEncryptVersion=2, currentE2EEncryptVersion=3] (Ratchet.hs:146-155) + e2eEncryptVRange: [2, 3], messageRetryInterval: { riFast: {initialInterval: 2_000_000, increaseAfter: 10_000_000, maxInterval: 120_000_000}, riSlow: {initialInterval: 300_000_000, increaseAfter: 60_000_000, maxInterval: 6 * 3600_000_000}, @@ -141,7 +145,9 @@ export interface AgentClient { userNetworkInfo: {networkType: string, online: boolean} subscrConns: Set // hex connIds being subscribed currentSubs: TSessionSubs - workerSeq: number + // Monotonic counter shared by newWorker (workerId) and getSessVar (sessionVarId). + // Mutable ref so getSessVar (session.ts) can increment the same counter — Haskell uses one TVar. + workerSeq: {val: number} smpDeliveryWorkers: Map}> asyncCmdWorkers: Map rcvNetworkOp: AgentOpState @@ -168,7 +174,7 @@ export function newAgentClient(config: AgentConfig, store: AgentStore, smpServer userNetworkInfo: {networkType: "UNOther", online: true}, subscrConns: new Set(), currentSubs: new TSessionSubs(), - workerSeq: 0, + workerSeq: {val: 0}, smpDeliveryWorkers: new Map(), asyncCmdWorkers: new Map(), rcvNetworkOp: {opSuspended: false, opsInProgress: 0}, @@ -186,7 +192,7 @@ export function newAgentClient(config: AgentConfig, store: AgentStore, smpServer // newWorker (Client.hs:439-445) export function newWorker(c: AgentClient): Worker { - const workerId = c.workerSeq++ + const workerId = c.workerSeq.val++ return { workerId, doWork: TMVar.new(undefined), // starts with "has work" @@ -295,7 +301,10 @@ async function runWork( // checkRestarts: restart worker.restarts = rc hasWorkToDo_(worker.doWork) - worker.action.tryTake() + // Haskell: `void $ tryPutTMVar action Nothing` — a no-op here because `action` is + // full (=1) for the whole restart chain (recursion stays inside the fired work()). + // We must NOT empty it: doing so would let a concurrent getAgentWorker start a + // second worker. tryPut on a full TMVar is a no-op, matching Haskell exactly. worker.action.tryPut(null) c.subQ.enqueue(["", new Uint8Array(0), {tag: "ERR", err: {tag: "INTERNAL", msg}}]) // when restart runWork — restart the worker @@ -345,6 +354,10 @@ function agentOpState(c: AgentClient, op: AgentOperation): AgentOpState { } // beginAgentOperation (Client.hs:2223-2230) +// DEVIATION: Haskell blocks (STM `retry`) while opSuspended, resuming when the agent +// returns to foreground. Single-threaded JS can't synchronously block; the widget never +// suspends (no suspendAgent), so opSuspended stays false and this path is unreachable. +// We throw rather than silently proceed, to surface any unexpected suspend during dev. export function beginAgentOperation(c: AgentClient, op: AgentOperation): void { const s = agentOpState(c, op) if (s.opSuspended) throw new AgentError({tag: "INACTIVE"}) diff --git a/smp-web/src/agent/smp-ops.ts b/smp-web/src/agent/smp-ops.ts index e46a153db..7064cc76c 100644 --- a/smp-web/src/agent/smp-ops.ts +++ b/smp-web/src/agent/smp-ops.ts @@ -14,7 +14,7 @@ import {AgentError, type AgentClient, type AgentErrorType} from "./client.js" import {ABQueue} from "./queue.js" import type {RcvQueueSub} from "./subscriptions.js" import {cbEncrypt, cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js" -import {generateX25519KeyPair, generateEd25519KeyPair, dh, encodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js" +import {generateX25519KeyPair, generateEd25519KeyPair, dh, encodePubKeyX25519, encodePubKeyEd25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js" import {concatBytes, encodeBytes} from "@simplex-chat/xftp-web/dist/protocol/encoding.js" // -- Transport session key (simplified: one session per server, no TSMEntity) @@ -53,7 +53,7 @@ export async function getSMPServerClient( if (!c.active) throw new AgentError({tag: "INACTIVE"}) const key = tSessKey(userId, server) const clients = c.smpClients as Map> - const {isNew, v} = getSessVar({val: c.workerSeq}, key, clients) + const {isNew, v} = getSessVar(c.workerSeq, key, clients) if (isNew) { return smpConnectClient(c, userId, server, keyHash, wsUrl, key, v) } @@ -138,6 +138,9 @@ function smpClientDisconnected( // -- agentCbEncrypt (Client.hs:2074-2082) // Per-queue E2E encrypt with stored DH secret. +// e2ePubKey is the RAW 32-byte X25519 public key (or null for messages). +// Haskell smpEncode of PubHeader's `Maybe C.PublicKeyX25519` DER-encodes the key +// (Crypto.hs:568-570), so we DER-encode it before placing it in the header. // Returns encoded ClientMsgEnvelope. export function agentCbEncrypt( e2eDhSecret: Uint8Array, @@ -151,7 +154,7 @@ export function agentCbEncrypt( const paddedLen = e2ePubKey !== null ? 15904 : 16000 const cmEncBody = cbEncrypt(e2eDhSecret, cmNonce, msg, paddedLen) const env: ClientMsgEnvelope = { - cmHeader: {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey}, + cmHeader: {phVersion: smpClientVersion, phE2ePubDhKey: e2ePubKey !== null ? encodePubKeyX25519(e2ePubKey) : null}, cmNonce, cmEncBody, } @@ -205,9 +208,11 @@ export async function sendConfirmation( ): Promise { if (!sq.e2e_pub_key) throw new AgentError({tag: "INTERNAL", msg: "sendConfirmation: no e2e pub key"}) const senderCanSecure_ = sq.queue_mode === "M" + // PHConfirmation carries C.toPublic sndPrivateKey, DER-encoded by smpEncode (Crypto.hs:568-570). + // (Only used for non-messaging queues; messaging queues use PHEmpty.) const privHeader: PrivHeader = senderCanSecure_ ? {type: "PHEmpty"} - : {type: "PHConfirmation", key: toPublicEd25519(sq.snd_private_key)} + : {type: "PHConfirmation", key: encodePubKeyEd25519(toPublicEd25519(sq.snd_private_key))} const spKey: AuthKey | null = senderCanSecure_ ? {type: "ed25519", key: sq.snd_private_key} : null const clientMsg: ClientMessage = {privHeader, body: agentConfirmation} const msg = agentCbEncrypt(sq.e2e_dh_secret, sq.smp_client_version, sq.e2e_pub_key, encodeClientMessage(clientMsg)) @@ -333,7 +338,9 @@ export async function newRcvQueue( snd_id: ids.sndId, snd_key: null, status: "new", - smp_client_version: smp.smpVersion, + // Haskell newRcvQueue_: smpClientVersion = maxVersion vRange (= maxVersion smpClientVRange). + // This is VersionSMPC (used in the per-queue PubHeader), NOT the SMP transport version. + smp_client_version: c.config.smpClientVRange[1], rcv_queue_id: 0, rcv_primary: 1, replace_rcv_queue_id: null, diff --git a/smp-web/tests/agent-repl.ts b/smp-web/tests/agent-repl.ts index 7d8cab320..0e695941c 100644 --- a/smp-web/tests/agent-repl.ts +++ b/smp-web/tests/agent-repl.ts @@ -71,7 +71,7 @@ async function parseLine(line: string): Promise { if (!agentClient || !store) return "error: not initialized" const connId = fromHex(parts[1]) await store.createNewConn({ - connId, connMode: "INV", userId, smpAgentVersion: 8, + connId, connMode: "INV", userId, smpAgentVersion: 7, enableNtfs: true, duplexHandshake: true, deleted: false, ratchetSyncState: "ok", pqSupport: false, lastInternalMsgId: 0, lastInternalRcvMsgId: 0, lastInternalSndMsgId: 0, @@ -121,10 +121,13 @@ async function parseLine(line: string): Promise { } case "CB_ENCRYPT": { + // CB_ENCRYPT [e2ePubKeyHex] + // If e2ePubKeyHex is given (raw 32 bytes), it goes in the PubHeader (confirmation mode). const dhSecret = fromHex(parts[1]) const version = parseInt(parts[2], 10) const body = fromHex(parts[3]) - const envelope = agentCbEncrypt(dhSecret, version, null, body) + const e2ePubKey = parts[4] ? fromHex(parts[4]) : null + const envelope = agentCbEncrypt(dhSecret, version, e2ePubKey, body) return "ok: " + toHex(envelope) } diff --git a/smp-web/tests/infra-test.ts b/smp-web/tests/infra-test.ts index f3b29b52d..b2c8cb667 100644 --- a/smp-web/tests/infra-test.ts +++ b/smp-web/tests/infra-test.ts @@ -449,7 +449,7 @@ import {agentCbEncrypt, agentCbDecrypt, agentCbEncryptOnce} from "../dist/agent/ import {decodeClientMsgEnvelope, decodeClientMessage, type ClientMsgEnvelope} from "../dist/protocol.js" import {Decoder} from "@simplex-chat/xftp-web/dist/protocol/encoding.js" import {cbDecrypt} from "@simplex-chat/xftp-web/dist/crypto/secretbox.js" -import {generateX25519KeyPair, dh} from "@simplex-chat/xftp-web/dist/crypto/keys.js" +import {generateX25519KeyPair, dh, decodePubKeyX25519} from "@simplex-chat/xftp-web/dist/crypto/keys.js" async function testAgentCbEncrypt() { console.log(" agentCbEncrypt...") @@ -494,7 +494,9 @@ async function testAgentCbEncrypt() { const env2 = decodeClientMsgEnvelope(d2) assertEq(env2.cmHeader.phVersion, smpVersion, "confirmation envelope version") assert(env2.cmHeader.phE2ePubDhKey !== null, "confirmation has pub key") - assertEq(env2.cmHeader.phE2ePubDhKey!.length, 32, "pub key is 32 bytes") + // DER-encoded X25519 pubkey is 44 bytes (12-byte prefix + 32 raw) + assertEq(env2.cmHeader.phE2ePubDhKey!.length, 44, "pub key is DER-encoded (44 bytes)") + assertEq(hex(decodePubKeyX25519(env2.cmHeader.phE2ePubDhKey!)), hex(sndPub), "DER decodes to raw sndPub") // agentCbDecrypt const decrypted2 = agentCbDecrypt(dhSecretRcv, env2.cmNonce, env2.cmEncBody) @@ -508,8 +510,8 @@ async function testAgentCbEncrypt() { const d3 = new Decoder(envOnce) const env3 = decodeClientMsgEnvelope(d3) assert(env3.cmHeader.phE2ePubDhKey !== null, "encryptOnce has ephemeral pub key") - // Receiver can decrypt using their private key + sender's ephemeral pub key - const ephDhSecret = dh(env3.cmHeader.phE2ePubDhKey!, rcvPriv) + // Receiver can decrypt using their private key + sender's ephemeral pub key (DER-decoded) + const ephDhSecret = dh(decodePubKeyX25519(env3.cmHeader.phE2ePubDhKey!), rcvPriv) const decrypted3 = cbDecrypt(ephDhSecret, env3.cmNonce, env3.cmEncBody) assert(decrypted3 !== null, "encryptOnce: receiver can decrypt") } diff --git a/tests/SMPWebTests.hs b/tests/SMPWebTests.hs index bf6566737..1eea94f3d 100644 --- a/tests/SMPWebTests.hs +++ b/tests/SMPWebTests.hs @@ -1918,6 +1918,34 @@ smpWebTests_ = do _ <- jsCmd hIn hOut "CLOSE" terminateProcess ph + it "agentCbEncrypt cross-language: confirmation envelope with DER-encoded pubkey parses in Haskell" $ do + g <- C.newRandom + (rcvPub, _rcvPriv) <- atomically $ C.generateKeyPair @'C.X25519 g + (_sndPub, sndPriv) <- atomically $ C.generateKeyPair @'C.X25519 g + -- e2ePubKey carried in the PubHeader (confirmation mode) + (e2ePub, _e2ePriv) <- atomically $ C.generateKeyPair @'C.X25519 g + let dhSecret = C.dh' rcvPub sndPriv + C.DhSecretX25519 dhSecretRaw = dhSecret + dhSecretBytes = BA.convert dhSecretRaw :: B.ByteString + e2ePubRaw = C.pubKeyBytes e2ePub -- raw 32-byte X25519 key + plaintext = "confirmation body" + versionInt = 4 :: Int + (hIn, hOut, ph) <- spawnJsAgent + -- TS: encrypt with e2ePubKey (raw) → should DER-encode it in the PubHeader + tsResp <- jsCmd hIn hOut $ "CB_ENCRYPT " <> bsToHex dhSecretBytes <> " " <> show versionInt <> " " <> bsToHex plaintext <> " " <> bsToHex e2ePubRaw + let tsParts = words tsResp + head tsParts `shouldBe` "ok:" + let envelopeBytes = hexToBS (tsParts !! 1) + -- Haskell: decode full envelope. PubHeader's Maybe PublicKeyX25519 requires DER — + -- if TS sent a raw 32-byte key this smpDecode would fail. + ClientMsgEnvelope {cmHeader = PubHeader _ phKey, cmNonce = nonce, cmEncBody = encBody} <- either fail pure $ smpDecode envelopeBytes + -- The decoded pubkey must equal the original + phKey `shouldBe` Just e2ePub + decrypted <- either (fail . show) pure $ C.cbDecrypt dhSecret nonce encBody + B.take (B.length plaintext) decrypted `shouldBe` plaintext + _ <- jsCmd hIn hOut "CLOSE" + terminateProcess ph + it "agentCbEncrypt cross-language: Haskell encrypts, TS decrypts" $ do g <- C.newRandom -- Generate shared DH secret