From bda906c8fbcd2ab05382e4e02a3afadc0173c6ee Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:52:20 +0000 Subject: [PATCH] parse short connection links --- .../2026-03-20-smp-agent-web-spike.md | 6 +- smp-web/src/agent/protocol.ts | 87 +++++++++++++++++++ tests/SMPWebTests.hs | 79 +++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 smp-web/src/agent/protocol.ts diff --git a/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md b/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md index 307c1550b..09fc76a3c 100644 --- a/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md +++ b/rfcs/2026-03-20-smp-agent-web/2026-03-20-smp-agent-web-spike.md @@ -214,7 +214,11 @@ Each step produces working, tested code. Steps 1-11 work without block encryptio - `base64UrlDecode(s)`: pad to multiple of 4, replace `-`→`+`, `_`→`/`, decode - Returns `{scheme, connType, server: {hosts, port, keyHash}, linkKey}` -**Tests**: Haskell `strEncode` a `ConnShortLink` → TypeScript `parseShortLink` parses. All fields match. Test multiple formats: with/without query params, different type chars. +**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. ### Step 7: HKDF Key Derivation diff --git a/smp-web/src/agent/protocol.ts b/smp-web/src/agent/protocol.ts new file mode 100644 index 000000000..a875a6090 --- /dev/null +++ b/smp-web/src/agent/protocol.ts @@ -0,0 +1,87 @@ +// Agent protocol types and short link parsing. +// Mirrors: Simplex.Messaging.Agent.Protocol + +import {base64urlDecode} from "@simplex-chat/xftp-web/dist/protocol/description.js" + +// -- Short link types (Agent/Protocol.hs:1462-1470) + +export type ShortLinkScheme = "simplex" | "https" + +export type ContactConnType = "contact" | "channel" | "group" | "relay" + +export interface ShortLinkServer { + hosts: string[] + port: string + keyHash: Uint8Array +} + +export type ConnShortLink = + | {mode: "invitation", scheme: ShortLinkScheme, server: ShortLinkServer, linkId: Uint8Array, linkKey: Uint8Array} + | {mode: "contact", scheme: ShortLinkScheme, connType: ContactConnType, server: ShortLinkServer, linkKey: Uint8Array} + +// -- Contact type char mapping (Agent/Protocol.hs:1651-1666) + +const ctTypeFromChar: Record = { + a: "contact", + c: "channel", + g: "group", + r: "relay", +} + +// -- Short link parser (Agent/Protocol.hs:1596-1629) +// Mirrors strP for AConnShortLink + +export function connShortLinkStrP(uri: string): ConnShortLink { + 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") + } + + // rest: /typeChar#fragment?query + 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) + + // Build server + 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} + } +} diff --git a/tests/SMPWebTests.hs b/tests/SMPWebTests.hs index 9ae519c66..e16655a0f 100644 --- a/tests/SMPWebTests.hs +++ b/tests/SMPWebTests.hs @@ -1,4 +1,7 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} -- | Per-function tests for the smp-web TypeScript SMP client library. @@ -10,13 +13,19 @@ module SMPWebTests (smpWebTests) where import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as BC +import Data.List.NonEmpty (NonEmpty (..)) import Data.Word (Word16) +import qualified Simplex.Messaging.Agent.Protocol as AP import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Protocol (SMPServer, pattern SMPServer) import Simplex.Messaging.Server.Env.STM (AStoreType (..)) import Simplex.Messaging.Server.MsgStore.Types (SMSType (..), SQSType (..)) import Simplex.Messaging.Server.Web (attachStaticAndWS) import Simplex.Messaging.Transport (TLS) +import Simplex.Messaging.Transport.Client (TransportHost (..)) import SMPClient (cfgWebOn, testKeyHash, testPort, withSmpServerConfig) import Test.Hspec hiding (it) import Util @@ -40,6 +49,12 @@ impWS :: String impWS = "import { connectSMP, sendBlock, receiveBlock } from './dist/transport/websockets.js';" <> "import { blockPad, blockUnpad } from '@simplex-chat/xftp-web/dist/protocol/transmission.js';" +impAgentProto :: String +impAgentProto = "import { connShortLinkStrP } from './dist/agent/protocol.js';" + +jsStr :: B.ByteString -> String +jsStr bs = "'" <> BC.unpack bs <> "'" + smpWebTests :: SpecWith () smpWebTests = describe "SMP Web Client" $ do describe "protocol" $ do @@ -139,6 +154,70 @@ smpWebTests = describe "SMP Web Client" $ do <> "})") tsEncoded `shouldBe` hsEncoded + describe "agent/protocol" $ do + describe "ConnShortLink" $ do + it "parses simplex: contact link" $ do + let srv = SMPServer ("smp1.example.com" :| []) "" (C.KeyHash $ B.pack [1..32]) + linkKey = AP.LinkKey $ B.pack [100..131] + link = AP.CSLContact AP.SLSSimplex AP.CCTContact srv linkKey + uri = strEncode link + tsResult <- callNode $ impAgentProto + <> "const r = connShortLinkStrP(" <> jsStr uri <> ");" + <> jsOut ("new Uint8Array([...r.linkKey, ...r.server.keyHash])") + tsResult `shouldBe` (B.pack [100..131] <> B.pack [1..32]) + + it "parses https: contact link with port" $ do + let srv = SMPServer ("smp2.example.com" :| []) "5223" (C.KeyHash $ B.pack [50..81]) + linkKey = AP.LinkKey $ B.pack [200..231] + link = AP.CSLContact AP.SLSServer AP.CCTContact srv linkKey + uri = strEncode link + tsResult <- callNode $ impAgentProto + <> "const r = connShortLinkStrP(" <> jsStr uri <> ");" + <> "const enc = new TextEncoder();" + <> jsOut ("new Uint8Array([...r.linkKey, ...r.server.keyHash, ...enc.encode(r.server.port), 0, ...enc.encode(r.server.hosts.join(','))])") + let expected = B.pack [200..231] <> B.pack [50..81] <> "5223" <> B.singleton 0 <> "smp2.example.com" + tsResult `shouldBe` expected + + it "parses simplex: contact link with multiple hosts" $ do + let srv = SMPServer ("host1.example.com" :| ["host2.example.com"]) "" (C.KeyHash $ B.pack [1..32]) + linkKey = AP.LinkKey $ B.pack [10..41] + link = AP.CSLContact AP.SLSSimplex AP.CCTContact srv linkKey + uri = strEncode link + tsResult <- callNode $ impAgentProto + <> "const r = connShortLinkStrP(" <> jsStr uri <> ");" + <> "const enc = new TextEncoder();" + <> jsOut ("new Uint8Array([...r.linkKey, ...enc.encode(r.server.hosts.join(','))])") + tsResult `shouldBe` (B.pack [10..41] <> "host1.example.com,host2.example.com") + + it "parses group link type" $ do + let srv = SMPServer ("smp.example.com" :| []) "" (C.KeyHash $ B.pack [1..32]) + linkKey = AP.LinkKey $ B.pack [10..41] + link = AP.CSLContact AP.SLSSimplex AP.CCTGroup srv linkKey + uri = strEncode link + tsResult <- callNode $ impAgentProto + <> "const r = connShortLinkStrP(" <> jsStr uri <> ");" + <> "const enc = new TextEncoder();" + <> jsOut ("enc.encode(r.connType)") + tsResult `shouldBe` "group" + + it "round-trips: Haskell encode -> TypeScript parse -> fields match" $ do + let srv = SMPServer ("server1.simplex.im" :| ["server2.simplex.im"]) "443" (C.KeyHash $ B.pack [1..32]) + linkKey = AP.LinkKey $ B.pack [200..231] + link = AP.CSLContact AP.SLSServer AP.CCTContact srv linkKey + uri = strEncode link + -- TypeScript returns: mode, scheme, connType, host count, port, linkKey + tsResult <- callNode $ impAgentProto + <> "const r = connShortLinkStrP(" <> jsStr uri <> ");" + <> "const enc = new TextEncoder();" + <> jsOut ("new Uint8Array([" + <> "r.mode === 'contact' ? 1 : 0," + <> "r.scheme === 'https' ? 1 : 0," + <> "r.connType === 'contact' ? 1 : 0," + <> "r.server.hosts.length," + <> "...r.linkKey" + <> "])") + tsResult `shouldBe` B.pack ([1, 1, 1, 2] ++ [200..231]) + describe "WebSocket handshake" $ do it "TypeScript connects and completes SMP handshake" $ do let msType = ASType SQSMemory SMSJournal