test, fix

This commit is contained in:
Evgeny @ SimpleX Chat
2026-06-11 15:09:17 +00:00
parent 8b7fc88cb8
commit 81f0c2be45
5 changed files with 71 additions and 18 deletions
+20 -7
View File
@@ -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<string> // 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<string, {worker: Worker, retryLock: TMVar<void>}>
asyncCmdWorkers: Map<string, Worker>
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<void>(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"})
+12 -5
View File
@@ -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<string, SessionVar<SMPConnectedClient>>
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<void> {
if (!sq.e2e_pub_key) throw new AgentError({tag: "INTERNAL", msg: "sendConfirmation: no e2e pub key"})
const senderCanSecure_ = sq.queue_mode === "M"
// PHConfirmation carries C.toPublic sndPrivateKey, DER-encoded by smpEncode (Crypto.hs:568-570).
// (Only used for non-messaging queues; messaging queues use PHEmpty.)
const privHeader: PrivHeader = senderCanSecure_
? {type: "PHEmpty"}
: {type: "PHConfirmation", key: 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,
+5 -2
View File
@@ -71,7 +71,7 @@ async function parseLine(line: string): Promise<string> {
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<string> {
}
case "CB_ENCRYPT": {
// CB_ENCRYPT <dhSecretHex> <version> <bodyHex> [e2ePubKeyHex]
// If e2ePubKeyHex is given (raw 32 bytes), it goes in the PubHeader (confirmation mode).
const dhSecret = fromHex(parts[1])
const version = parseInt(parts[2], 10)
const body = fromHex(parts[3])
const 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)
}
+6 -4
View File
@@ -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")
}
+28
View File
@@ -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