Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 45 additions & 28 deletions protocol/simplex-messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ Simplex messaging router implementations MUST NOT create, store or send to any o

- Any other information that may compromise privacy or [forward secrecy][4] of communication between clients using simplex messaging routers (the routers cannot compromise forward secrecy of any application layer protocol, such as double ratchet).

Routers with the names role make outbound JSON-RPC calls to an Ethereum endpoint to read `NameRecord` data; the lookup key reaches that endpoint. Operators MUST run the endpoint themselves (loopback Reth + Nimbus, or a self-hosted central deployment) — sharing one endpoint across multiple operators collapses the two-server privacy property because the endpoint operator would see every lookup key across all of them. The names role and the SMP-proxy role MUST NOT be enabled on the same router by default; a slow `RSLV` cache miss can serialise other forwarded commands on the same proxy-relay session.
Routers with the names role make outbound HTTP calls to a backing resolver service (the reference implementation is `scripts/resolver/snrc-resolve.py`, which in turn makes JSON-RPC calls to an Ethereum endpoint) to read `NameRecord` data; the lookup key reaches that resolver and its upstream RPC endpoint. Operators MUST run both the resolver process and its upstream RPC endpoint themselves (loopback Reth + Nimbus, or a self-hosted central deployment) — sharing them across multiple operators collapses the two-server privacy property because the resolver / RPC operator would see every lookup key across all of them. The names role and the SMP-proxy role MUST NOT be enabled on the same router by default; a slow `RSLV` cache miss can serialise other forwarded commands on the same proxy-relay session.

## Message delivery notifications

Expand Down Expand Up @@ -1443,10 +1443,13 @@ session, or identity; the proxy router sees the client connection but cannot
read the encrypted lookup key inside the forwarded transmission.

**Backing store.** This protocol does not prescribe where the names router
reads `NameRecord` from. The reference implementation queries the SNRC contract
on Ethereum via a JSON-RPC endpoint; alternative backings (different chains,
DHT, etc.) are valid as long as they return a `NameRecord` matching the encoding
below.
reads `NameRecord` from. The reference implementation forwards each RSLV to a
companion REST resolver process (`scripts/resolver/snrc-resolve.py`) that
queries the SNRC contract on Ethereum; alternative backings (different chains,
DHT, etc.) are valid as long as they expose the documented HTTP shape (`GET
/resolve/<name>` returning a `NameRecord` on 200, 404 / 400 for unknown names
or TLDs, 502 for upstream RPC failures) or substitute a different transport
while still returning a `NameRecord` matching the encoding below.

#### Resolve name command

Expand All @@ -1461,25 +1464,23 @@ rslv = %s"RSLV" SP json-bytes ; json-bytes consumes the remainder of the trans
| Field | JSON type | Constraints |
|---|---|---|
| `name` | string | the canonical fully-qualified name (TLD always explicit, e.g. `"privacy.simplex"`, `"test.testing"`, `"example.com"`); UTF-8 bytes only |
| `contract` | string | `"0x"` followed by 40 lowercase hex characters (20 raw bytesthe SNRC contract address the client expects the server to query) |
| `contract` | string | `"0x"` followed by 40 lowercase hex characters (20 raw bytes); currently ignored by the server, reserved for future eth-backed implementations that may use it to constrain which on-chain registry the client expects the server to query |

**Server-side validation.** The names router parses `name` as a fully-qualified
domain (TLD required — bare labels are rejected), extracts the TLD, and looks
up the expected SNRC contract address in a whitelist hardcoded in the server
binary (TLD-specific addresses with an optional catch-all for unspecified
TLDs and web domains). If no whitelist entry matches the TLD, or if the
client-supplied `contract` differs from the configured address, the server
replies with `ERR AUTH` without contacting the chain. This lets one names
router safely host multiple TLDs (each backed by its own SNRC contract) and
reject clients pointing at a contract the operator doesn't run.
domain (TLD required — bare labels are rejected) and forwards it to the
configured backing resolver. The `contract` field is parsed for forward
compatibility but ignored by the reference implementation: the backing
resolver is the source of truth for which on-chain registry maps to each TLD.
Any failure (malformed name, resolver 404 / 400 / 5xx, transport failure,
timeout, decode error, names role disabled) collapses to `ERR AUTH`.

The names router responds with either a `NAME` response carrying the resolved
record, or `ERR AUTH` collapsing every failure mode (name not found, malformed
name, TLD not in whitelist, contract mismatch, names role disabled, RPC
unreachable, decode error, timeout). The wire code does not distinguish
between these — stats counters MAY be exposed out-of-band for operator
observability (`bad_name` is incremented for validation/whitelist failures,
distinct from `not_found` for valid lookups with no on-chain record).
name, names role disabled, resolver unreachable, decode error, timeout). The
wire code does not distinguish between these — stats counters MAY be exposed
out-of-band for operator observability (`bad_name` is incremented for
validation failures, distinct from `not_found` for valid lookups with no
backing record).

#### Name record response

Expand All @@ -1493,14 +1494,30 @@ name = %s"NAME" SP json-bytes ; json-bytes consumes the remainder of the trans

| Field | JSON type | Constraints |
|---|---|---|
| `displayName` | string | ≤ 255 bytes UTF-8 |
| `name` | string | ≤ 255 bytes UTF-8 |
| `nickname` | string | ≤ 255 bytes UTF-8; senders MUST emit the empty string `""` when unset |
| `website` | string | ≤ 255 bytes UTF-8; same empty-string-when-unset rule |
| `location` | string | ≤ 255 bytes UTF-8; same empty-string-when-unset rule |
| `simplexContact` | string | ≤ 1024 bytes UTF-8; same empty-string-when-unset rule |
| `simplexChannel` | string | ≤ 1024 bytes UTF-8; same empty-string-when-unset rule |
| `eth` | string or null | ≤ 255 bytes UTF-8; senders MUST emit `null` when unset; receivers MUST also accept absent keys as unset |
| `btc` | string or null | ≤ 255 bytes UTF-8; same null / absent rules |
| `xmr` | string or null | ≤ 255 bytes UTF-8; same null / absent rules |
| `dot` | string or null | ≤ 255 bytes UTF-8; same null / absent rules |
| `owner` | string | `"0x"` followed by 40 lowercase hex characters (20 raw bytes) |
| `channelLinks` | array of strings | each ≤ 1024 bytes UTF-8; combined count of `channelLinks + contactLinks` ≤ 8 |
| `contactLinks` | array of strings | each ≤ 1024 bytes UTF-8; combined count cap shared with `channelLinks` |
| `adminAddress` | string or null | ≤ 255 bytes UTF-8; senders MUST emit `null` when unset; receivers MUST also accept absent keys as unset |
| `adminEmail` | string or null | ≤ 255 bytes UTF-8; senders MUST emit `null` when unset; receivers MUST also accept absent keys as unset |
| `expiry` | integer | Int64 Unix seconds, MUST be ≥ 0; `0` means "never expires" |
| `isTest` | boolean | true on testnet deployments |
| `resolver` | string | `"0x"` followed by 40 lowercase hex characters; the resolver contract address that produced the record |

Text fields (`nickname`, `website`, `location`, `simplexContact`,
`simplexChannel`) use the empty string `""` as the "unset" sentinel: a
backing resolver with no value for the field MUST emit an empty string, not
JSON `null` and not an absent key. Coin fields (`eth`, `btc`, `xmr`, `dot`)
use JSON `null` as the "unset" sentinel and MAY also be absent from the
object entirely.

The server MUST filter records its backing resolver indicates are expired
or otherwise unavailable (returning `ERR AUTH` to the client), so the wire
format carries no expiry field. Testnet-vs-mainnet status is derived from
the queried TLD rather than an in-record flag.

Receivers MUST tolerate extra unknown fields (forward-compatibility for future
field additions). Adding a required field is a breaking change requiring an
Expand All @@ -1511,8 +1528,8 @@ producing the same `NameRecord` MUST emit byte-identical JSON: emit object
keys in the order listed above, integers without decimal points, no
insignificant whitespace.

**Wire-size budget.** A maximal `nameRecord` (8 × 1024-byte links plus
maximal admin / display strings) JSON-encodes to roughly 9 KB, well under the
**Wire-size budget.** A maximal `nameRecord` (two 1024-byte SimpleX links
plus the other capped strings) JSON-encodes to roughly 4 KB, well under the
SMP proxied transmission budget of 16224 bytes.

## Transport connection with the SMP router
Expand Down
8 changes: 6 additions & 2 deletions simplexmq.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ library
Simplex.Messaging.Crypto.ShortLink
Simplex.Messaging.Encoding
Simplex.Messaging.Encoding.String
Simplex.Messaging.Names.Owner
Simplex.Messaging.Names.Record
Simplex.Messaging.Notifications.Client
Simplex.Messaging.Notifications.Protocol
Simplex.Messaging.Notifications.Transport
Expand Down Expand Up @@ -263,8 +265,7 @@ library
Simplex.Messaging.Server.MsgStore.STM
Simplex.Messaging.Server.MsgStore.Types
Simplex.Messaging.Server.Names
Simplex.Messaging.Server.Names.Eth.RPC
Simplex.Messaging.Server.Names.Eth.SNRC
Simplex.Messaging.Server.Names.HttpResolver
Simplex.Messaging.Server.NtfStore
Simplex.Messaging.Server.Prometheus
Simplex.Messaging.Server.QueueStore
Expand Down Expand Up @@ -496,10 +497,12 @@ test-suite simplexmq-test
AgentTests.EqInstances
AgentTests.FunctionalAPITests
AgentTests.MigrationTests
AgentTests.ResolveNameTests
AgentTests.ServerChoice
AgentTests.ShortLinkTests
CLITests
CoreTests.BatchingTests
CoreTests.ConnectTargetTests
CoreTests.CryptoFileTests
CoreTests.CryptoTests
CoreTests.EncodingTests
Expand All @@ -512,6 +515,7 @@ test-suite simplexmq-test
CoreTests.VersionRangeTests
FileDescriptionTests
RemoteControl
RSLVTests
ServerTests
SMPAgentClient
SMPClient
Expand Down
19 changes: 19 additions & 0 deletions src/Simplex/Messaging/Agent.hs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ module Simplex.Messaging.Agent
setConnShortLink,
deleteConnShortLink,
getConnShortLink,
resolveSimplexName,
getConnLinkPrivKey,
deleteLocalInvShortLink,
changeConnectionUser,
Expand Down Expand Up @@ -216,6 +217,7 @@ import Simplex.Messaging.Protocol
ErrorType (AUTH),
MsgBody,
MsgFlags (..),
NameRecord,
NtfServer,
ProtoServerWithAuth (..),
ProtocolServer (..),
Expand Down Expand Up @@ -440,6 +442,13 @@ getConnShortLink :: AgentClient -> NetworkRequestMode -> UserId -> ConnShortLink
getConnShortLink c = withAgentEnv c .:. getConnShortLink' c
{-# INLINE getConnShortLink #-}

-- | Resolve a SimpleX name via the configured resolver SMP server (PFWD RSLV).
-- The TLD->contract whitelist lives in the agent so chat clients only need to
-- pass the resolver address and the parsed domain.
resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexNameDomain -> AE NameRecord
resolveSimplexName c = withAgentEnv c .:: resolveSimplexName' c
{-# INLINE resolveSimplexName #-}

getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519)
getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c
{-# INLINE getConnLinkPrivKey #-}
Expand Down Expand Up @@ -1182,6 +1191,16 @@ getConnShortLink' c nm userId = \case
deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM ()
deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId

resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> SimplexNameDomain -> AM NameRecord
resolveSimplexName' c nm userId resolverSrv domain =
resolveName c nm userId resolverSrv placeholderContract (fullDomainName domain)
where
-- The wire format still carries a 20-byte `contract` field on RslvRequest
-- (no SMP version bump), but the server-side resolver ignores it: the
-- backing Python REST resolver is the source of truth for which on-chain
-- registry maps to each TLD. The agent sends the all-zero placeholder.
placeholderContract = either error id (SMP.mkNameOwner (B.replicate 20 '\NUL'))

changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM ()
changeConnectionUser' c oldUserId connId newUserId = do
SomeConn _ conn <- withStore c (`getConn` connId)
Expand Down
14 changes: 14 additions & 0 deletions src/Simplex/Messaging/Agent/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ module Simplex.Messaging.Agent.Client
deleteQueueLink,
secureGetQueueLink,
getQueueLink,
resolveName,
enableQueueNotifications,
EnableQueueNtfReq (..),
enableQueuesNtfs,
Expand Down Expand Up @@ -267,6 +268,8 @@ import Simplex.Messaging.Protocol
NetworkError (..),
MsgFlags (..),
MsgId,
NameOwner,
NameRecord,
NtfServer,
NtfServerWithAuth,
ProtoServer,
Expand Down Expand Up @@ -1990,6 +1993,17 @@ getQueueLink c nm userId server lnkId =
getViaProxy smp proxySess = proxyGetSMPQueueLink smp nm proxySess lnkId
getDirectly smp = getSMPQueueLink smp nm lnkId

-- | Resolve a public-namespace name. Prefers PFWD (hides client IP from the
-- resolver) and falls back to a direct send when the proxy is unavailable
-- (faster but exposes the client IP). Mode selection is delegated to
-- `sendOrProxySMPCommand`, which honours the network config (SPMNever etc.).
resolveName :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> NameOwner -> Text -> AM NameRecord
resolveName c nm userId server contract name =
snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly
where
resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess contract name
resolveDirectly smp = directResolveName smp nm contract name

enableQueueNotifications :: AgentClient -> RcvQueue -> SMP.NtfPublicAuthKey -> SMP.RcvNtfPublicDhKey -> AM (SMP.NotifierId, SMP.RcvNtfPublicDhKey)
enableQueueNotifications c rq@RcvQueue {rcvId, rcvPrivateKey} notifierKey rcvNtfPublicDhKey =
withSMPClient c NRMBackground rq "NKEY <nkey>" $ \smp ->
Expand Down
20 changes: 20 additions & 0 deletions src/Simplex/Messaging/Agent/Protocol.hs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ module Simplex.Messaging.Agent.Protocol
OwnerId,
ConnectionLink (..),
AConnectionLink (..),
ConnectTarget (..),
SimplexNameInfo (..),
SimplexNameDomain (..),
SimplexTLD (..),
Expand Down Expand Up @@ -195,6 +196,7 @@ import qualified Data.Aeson.TH as J
import qualified Data.Aeson.Types as JT
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Attoparsec.Combinator (lookAhead)
import qualified Data.ByteString.Base64.URL as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
Expand Down Expand Up @@ -1596,6 +1598,24 @@ instance ToJSON AConnectionLink where
instance FromJSON AConnectionLink where
parseJSON = strParseJSON "AConnectionLink"

data ConnectTarget = CTLink AConnectionLink | CTName SimplexNameInfo
deriving (Eq, Show)

instance StrEncoding ConnectTarget where
strEncode = \case
CTLink l -> strEncode l
CTName n -> strEncode n
strP = CTName <$> (lookAhead nameStart *> strP) <|> CTLink <$> strP
where
nameStart = "@" <|> "#" <|> "simplex:/name"

instance ToJSON ConnectTarget where
toEncoding = strToJEncoding
toJSON = strToJSON

instance FromJSON ConnectTarget where
parseJSON = strParseJSON "ConnectTarget"

instance ConnectionModeI m => StrEncoding (ConnShortLink m) where
strEncode = \case
CSLInvitation sch srv (SMP.EntityId lnkId) (LinkKey k) -> slEncode sch srv 'i' lnkId k
Expand Down
22 changes: 22 additions & 0 deletions src/Simplex/Messaging/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ module Simplex.Messaging.Client
deleteSMPQueues,
connectSMPProxiedRelay,
proxySMPMessage,
proxyResolveName,
directResolveName,
forwardSMPTransmission,
getSMPQueueInfo,
sendProtocolCommand,
Expand Down Expand Up @@ -1046,6 +1048,26 @@ sendSMPMessage c nm spKey sId flags msg =
proxySMPMessage :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> Maybe SndPrivateAuthKey -> SenderId -> MsgFlags -> MsgBody -> ExceptT SMPClientError IO (Either ProxyClientError ())
proxySMPMessage c nm proxiedRelay spKey sId flags msg = proxyOKSMPCommand c nm proxiedRelay spKey sId (SEND flags msg)

-- | Resolve a public-namespace name via PFWD. Preferred path - hides the
-- client IP from the resolver. Mirrors `proxySMPMessage`'s shape; routes
-- through `proxySMPCommand` and pattern-matches the expected NAME response.
proxyResolveName :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> NameOwner -> Text -> ExceptT SMPClientError IO (Either ProxyClientError NameRecord)
proxyResolveName c nm proxiedRelay contract name =
proxySMPCommand c nm proxiedRelay Nothing NoEntity (RSLV RslvRequest {name, contract}) >>= \case
Right (NAME nr) -> pure $ Right nr
Right r -> throwE $ unexpectedResponse r
Left e -> pure $ Left e

-- | Direct (non-PFWD) name resolution. Exposes the client IP to the resolver;
-- callers that want anonymity should use `proxyResolveName` via the standard
-- proxy fallback in the agent. RSLV requires no entity ID or authorization
-- (see `noAuthCmd` in Protocol.hs).
directResolveName :: SMPClient -> NetworkRequestMode -> NameOwner -> Text -> ExceptT SMPClientError IO NameRecord
directResolveName c nm contract name =
sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (RSLV RslvRequest {name, contract})) >>= \case
NAME nr -> pure nr
r -> throwE $ unexpectedResponse r

-- | Acknowledge message delivery (server deletes the message).
--
-- https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#acknowledge-message-delivery
Expand Down
46 changes: 46 additions & 0 deletions src/Simplex/Messaging/Names/Owner.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

module Simplex.Messaging.Names.Owner
( NameOwner,
mkNameOwner,
unNameOwner,
)
where

import Control.Applicative ((<|>))
import qualified Data.Aeson as J
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)

-- | 20-byte Ethereum address (NameRecord owner). Bare constructor not exported;
-- use `mkNameOwner` to enforce the 20-byte invariant.
newtype NameOwner = NameOwner ByteString
deriving (Eq)

-- Render the 20 raw bytes as "0x"-prefixed lowercase hex so log lines /
-- traceShow output match the on-the-wire JSON form instead of Latin-1 garbage.
instance Show NameOwner where
show (NameOwner bs) = "NameOwner 0x" <> B.unpack (BAE.convertToBase BAE.Base16 bs)

mkNameOwner :: ByteString -> Either String NameOwner
mkNameOwner bs
| B.length bs == 20 = Right (NameOwner bs)
| otherwise = Left "NameOwner must be 20 bytes"

unNameOwner :: NameOwner -> ByteString
unNameOwner (NameOwner bs) = bs
{-# INLINE unNameOwner #-}

instance J.ToJSON NameOwner where
toJSON (NameOwner bs) = J.String $ "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 bs)

instance J.FromJSON NameOwner where
parseJSON = J.withText "NameOwner" $ \t -> do
-- Accept "0x" and "0X" prefixes (matches the Server-side hex decoder).
let hex = fromMaybe t (T.stripPrefix "0x" t <|> T.stripPrefix "0X" t)
either fail pure $ BAE.convertFromBase BAE.Base16 (encodeUtf8 hex) >>= mkNameOwner
Loading
Loading