Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
92fb0ea
Merge branch 'master' into stable
epoberezkin May 29, 2026
19db2c8
smp-server: namespaces resolver scaffolding
shumvgolove May 29, 2026
3999fac
smp-server: Names resolver hardening + cleanup
shumvgolove May 29, 2026
99b1330
smp-server: fuse parallel dispatchers
shumvgolove May 29, 2026
c0e14e3
smp-server: JSON wire format for NameRecord + Names.hs restructure
shumvgolove May 29, 2026
6b216ca
smp-server: redact RpcAuth in Show
shumvgolove May 28, 2026
c812725
smp-server: JSON wire fixups + spec rewrite + small cleanups
shumvgolove May 29, 2026
66bca0c
plan: prepend implementation-diverged banner
shumvgolove May 28, 2026
daac3e2
move SimplexName into shared module
shumvgolove May 28, 2026
1e2cf23
Merge origin/stable
shumvgolove May 29, 2026
c3e7b61
smp-server: name + contract whitelist on RSLV
shumvgolove May 29, 2026
3c93489
smp-server: address audit findings (canonical JSON, INI guards, SSRF,…
shumvgolove Jun 1, 2026
f686a94
smp-server: round 2 audit fixes (label case, response cap, ipv6 link-…
shumvgolove Jun 1, 2026
ba245e7
smp-server: round 3 audit fixes (SSRF coverage, drop noop closeManage…
shumvgolove Jun 1, 2026
1d394f5
smp-server: round 4 audit fixes (0X-hex host, expanded IPv6 forms, pi…
shumvgolove Jun 1, 2026
b66d973
smp-server: hardcode TldRegistries (drop registry_tld_* INI keys)
shumvgolove Jun 1, 2026
9cfdb55
smp-server: round 6 audit fixes (IPv6 SSRF, redirects, ASCII labels)
shumvgolove Jun 2, 2026
1950634
Merge remote-tracking branch 'origin/master' into sh/smp-namespace
shumvgolove Jun 3, 2026
9e4d8d9
namespace: bound parser input to 253 bytes (DoS defense)
shumvgolove Jun 3, 2026
92b3d04
namespace: switch to Python HTTP resolver + agent plumbing (#1796)
shumvgolove Jun 8, 2026
9befa48
namespace: relax resolver_endpoint validation (path prefix, http with…
shumvgolove Jun 9, 2026
f555e9a
namespace: NameRecord links as arrays (multi-link, cap 5)
shumvgolove Jun 11, 2026
df1aa24
namespace: distinct RSLV error responses
shumvgolove Jun 11, 2026
0fa0909
refactor(names): server role + one error type
shumvgolove Jun 12, 2026
ce69adf
test(server): update stats backup line count
shumvgolove Jun 13, 2026
27045c7
Merge remote-tracking branch 'origin/master' into sh/smp-namespace
shumvgolove Jun 22, 2026
5e0b757
feat(names): public-namespace resolution via RSLV/RNAME
shumvgolove Jun 22, 2026
2e2bc86
remove comments
epoberezkin Jun 22, 2026
c57cdce
simplify
evgeny-simplex Jun 22, 2026
67367c4
move tests name
epoberezkin Jun 23, 2026
6843b14
simplify: text addresses, Tail JSON, drop admitRslv
shumvgolove Jun 23, 2026
55c6717
fix
evgeny-simplex Jun 24, 2026
4b28841
remove spaghetti
evgeny-simplex Jun 24, 2026
94a0541
reduce diff
evgeny-simplex Jun 24, 2026
ae3aefd
async again, refactor
evgeny-simplex Jun 24, 2026
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
455 changes: 455 additions & 0 deletions plans/20260522_01_smp_public_namespaces.md

Large diffs are not rendered by default.

124 changes: 122 additions & 2 deletions protocol/simplex-messaging.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Version 19, 2025-01-24
Version 20, 2026-05-25

# Simplex Messaging Protocol (SMP)

Expand Down Expand Up @@ -67,6 +67,9 @@ Version 19, 2025-01-24
- [Queue deleted notification](#queue-deleted-notification)
- [Error responses](#error-responses)
- [OK response](#ok-response)
- [Resolver commands](#resolver-commands)
- [Resolve name command](#resolve-name-command)
- [Name record response](#name-record-response)
- [Transport connection with the SMP router](#transport-connection-with-the-SMP-router)
- [General transport protocol considerations](#general-transport-protocol-considerations)
- [TLS transport encryption](#tls-transport-encryption)
Expand All @@ -83,7 +86,7 @@ It's designed with the focus on communication security and integrity, under the

It is designed as a low level protocol for other application protocols to solve the problem of secure and private message transmission, making [MITM attack][1] very difficult at any part of the message transmission system.

This document describes SMP protocol version 19. Versions 1-5 are discontinued. The version history:
This document describes SMP protocol version 20. Versions 1-5 are discontinued. The version history:

- v1: binary protocol encoding
- v2: message flags (used to control notifications)
Expand All @@ -103,6 +106,7 @@ This document describes SMP protocol version 19. Versions 1-5 are discontinued.
- v17: create notification credentials with NEW command
- v18: support client notices in BLOCKED error
- v19: service subscriptions to messages (SUBS, NSUBS, SOKS, ENDS, ALLS commands)
- v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD

## Introduction

Expand Down Expand Up @@ -424,6 +428,8 @@ 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 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 client forwarding `RSLV` through a proxy that is also the names router would expose both its connection and the lookup key to one operator, collapsing the two-server privacy property. (Resolution itself runs on a forked thread, so a slow `RSLV` does not serialise other forwarded commands on the session.)

## Message delivery notifications

Supporting message delivery while the client mobile app is not running requires sending push notifications with the device token. All alternative mechanisms for background message delivery are unreliable, particularly on iOS platform.
Expand Down Expand Up @@ -1422,6 +1428,120 @@ When the command is successfully executed by the router, it should respond with
ok = %s"OK"
```

### Resolver commands

Resolver commands implement public-namespace name resolution on the names-role
router. A names router translates an opaque lookup key (such as `alice` or
`alice.simplex.eth`) into a `NameRecord` carrying the channel and contact links
the named party publishes.

**Direct or forwarded.** RSLV is an unauthenticated command accepted both
directly from a transport client and inside a `PFWD` block via the SMP proxy;
the client chooses. Forwarded delivery preserves the two-server privacy property
of the resolver design: the names router sees the lookup key but never the
client IP, session, or identity, while the proxy router sees the client
connection but cannot read the encrypted lookup key inside the forwarded
transmission. Direct delivery is simpler but exposes the client's connection to
the names router, so clients SHOULD prefer the forwarded path when proxying is
available.

**Backing store.** This protocol does not prescribe where the names router
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

The `RSLV` command carries the canonical fully-qualified name directly as the
payload (not JSON):

```abnf
rslv = %s"RSLV" SP domain ; domain = canonical name as non-space bytes, consuming the remainder of the transmission
```

`domain` is the UTF-8 canonical fully-qualified name with the TLD always
explicit (e.g. `privacy.simplex`, `test.testing`, `example.com`), bounded to
253 bytes.

**Server-side validation.** The names router parses `domain` as a
fully-qualified name (TLD required — bare labels are rejected) and forwards it
to the configured backing resolver, which is the source of truth for which
on-chain registry maps to each TLD.

The names router responds with either an `RNAME` response carrying the resolved
record, or an `ERR NAME` error whose subcode a client iterating across several
configured servers can act on distinctly:

| Response | Condition | Client action |
|---|---|---|
| `RNAME` | record resolved | use it |
| `ERR NAME NOT_FOUND` | name not registered, unknown TLD, or malformed name | authoritative "no such name" — stop |
| `ERR NAME NO_RESOLVER` | this router has no resolver (names role not enabled) | skip this server, try the next |
| `ERR NAME RESOLVER <detail>` | transient failure: backing resolver error (upstream 5xx, transport, timeout, decode) | transient — retry or surface, do not treat as "not found" |

A client SHOULD NOT broadcast a `name` to further servers after a name-capable
router has answered (`NOT_FOUND` or `RESOLVER`), since that router has already
seen the lookup key; `NO_RESOLVER` discloses nothing about the name beyond the
fact that this router cannot resolve, so iterating past it is safe.

#### Name record response

The `RNAME` response carries a JSON-encoded record as the payload:

```abnf
rname = %s"RNAME" SP json-bytes ; json-bytes consumes the remainder of the transmission
```

`json-bytes` MUST be a UTF-8 JSON object with the following schema:

| Field | JSON type | Constraints |
|---|---|---|
| `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` | array of strings | each a SimpleX contact link (primary first); empty array `[]` when unset |
| `simplexChannel` | array of strings | each a SimpleX channel link (primary first); empty array `[]` when unset |
| `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) |
| `resolver` | string | `"0x"` followed by 40 lowercase hex characters; the resolver contract address that produced the record |

Text fields (`nickname`, `website`, `location`) 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. Link fields
(`simplexContact`, `simplexChannel`) are arrays, primary link first, and use the
empty array `[]` when unset. Coin fields (`eth`, `btc`, `xmr`, `dot`) use JSON
`null` as the "unset" sentinel and MAY also be absent from the object entirely.

The backing resolver filters records that are expired or otherwise unavailable
(the names router then returns `ERR NAME NOT_FOUND` 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
SMP version bump.

**Field order is not significant.** Receivers parse JSON by key name, so object
key order, insignificant whitespace, and number formatting carry no meaning;
records are interpreted by decoded value, never compared byte-for-byte. Peers
MUST NOT rely on a byte-canonical form — a different resolver or server may emit
the same record with different key order or spacing. This order-independence is
what makes the format forward-compatible (see the unknown-field rule above).

**Wire-size budget.** The names router caps the resolver response it will
accept (`resolver_max_response_bytes`, ≤ 16000 bytes, the default) so the
re-encoded `RNAME` stays within the SMP proxied transmission budget of 16224
bytes; a response over the cap is rejected as `ERR NAME RESOLVER`. The link
arrays are bounded by this overall budget rather than a fixed per-field count.

## Transport connection with the SMP router

### General transport protocol considerations
Expand Down
14 changes: 14 additions & 0 deletions simplexmq.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ library
Simplex.Messaging.Crypto.ShortLink
Simplex.Messaging.Encoding
Simplex.Messaging.Encoding.String
Simplex.Messaging.Names.Record
Simplex.Messaging.Notifications.Client
Simplex.Messaging.Notifications.Protocol
Simplex.Messaging.Notifications.Transport
Expand All @@ -154,6 +155,7 @@ library
Simplex.Messaging.Server.QueueStore.Postgres.Config
Simplex.Messaging.Server.QueueStore.QueueInfo
Simplex.Messaging.ServiceScheme
Simplex.Messaging.SimplexName
Simplex.Messaging.Session
Simplex.Messaging.SystemTime
Simplex.Messaging.TMap
Expand Down Expand Up @@ -274,6 +276,8 @@ library
Simplex.Messaging.Server.MsgStore.Journal.SharedLock
Simplex.Messaging.Server.MsgStore.STM
Simplex.Messaging.Server.MsgStore.Types
Simplex.Messaging.Server.Names
Simplex.Messaging.Server.Names.HttpResolver
Simplex.Messaging.Server.NtfStore
Simplex.Messaging.Server.Prometheus
Simplex.Messaging.Server.QueueStore
Expand Down Expand Up @@ -390,7 +394,10 @@ library
build-depends:
case-insensitive ==1.2.*
, hashable ==1.4.*
, http-client >=0.7 && <0.8
, http-client-tls >=0.3 && <0.4
, ini ==0.4.1
, network-uri >=2.6 && <2.7
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
, temporary ==1.3.*
Expand Down Expand Up @@ -524,10 +531,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 @@ -540,9 +549,12 @@ test-suite simplexmq-test
CoreTests.VersionRangeTests
FileDescriptionTests
RemoteControl
NamesResolverServer
RSLVTests
ServerTests
SMPAgentClient
SMPClient
SMPNamesTests
SMPProxyTests
Util
XFTPAgent
Expand Down Expand Up @@ -623,6 +635,8 @@ test-suite simplexmq-test
, unliftio
, unliftio-core
, unordered-containers
, wai
, warp
, yaml
default-language: Haskell2010
if flag(server_postgres)
Expand Down
14 changes: 14 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 (PFWD RSLV). The agent owns server selection: it
-- picks a names-capable server (ServerRoles.names) from the user's nameSrvs, so
-- chat clients just pass the parsed domain.
resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexNameDomain -> AE NameRecord
resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain
{-# INLINE resolveSimplexName #-}

getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519)
getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c
{-# INLINE getConnLinkPrivKey #-}
Expand Down Expand Up @@ -1182,6 +1191,11 @@ 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 -> SimplexNameDomain -> AM NameRecord
resolveSimplexName' c nm userId domain = do
resolverSrv <- getNextNameServer c userId
resolveName c nm userId resolverSrv domain

changeConnectionUser' :: AgentClient -> UserId -> ConnId -> UserId -> AM ()
changeConnectionUser' c oldUserId connId newUserId = do
SomeConn _ conn <- withStore c (`getConn` connId)
Expand Down
25 changes: 25 additions & 0 deletions src/Simplex/Messaging/Agent/Client.hs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ module Simplex.Messaging.Agent.Client
deleteQueueLink,
secureGetQueueLink,
getQueueLink,
resolveName,
getNextNameServer,
enableQueueNotifications,
EnableQueueNtfReq (..),
enableQueuesNtfs,
Expand Down Expand Up @@ -267,6 +269,7 @@ import Simplex.Messaging.Protocol
NetworkError (..),
MsgFlags (..),
MsgId,
NameRecord,
NtfServer,
NtfServerWithAuth,
ProtoServer,
Expand Down Expand Up @@ -1990,6 +1993,28 @@ 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 -> SimplexNameDomain -> AM NameRecord
resolveName c nm userId server domain =
snd <$> sendOrProxySMPCommand c nm userId server "" "RSLV" NoEntity resolveViaProxy resolveDirectly
where
resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain
resolveDirectly smp = directResolveName smp nm domain

-- | Pick a names-capable server for the user (the agent owns server selection,
-- accounting for the names role). nameSrvs is opt-in (a plain list); empty means
-- no server resolves names - a declared agent error, never a fallback.
getNextNameServer :: AgentClient -> UserId -> AM SMPServer
getNextNameServer c userId =
liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case
Just UserServers {nameSrvs} -> case L.nonEmpty nameSrvs of
Just srvs -> protoServer <$> pickServer srvs
Nothing -> throwE NO_NAME_SERVERS
Nothing -> throwE $ INTERNAL "unknown userId - no user servers"

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
11 changes: 8 additions & 3 deletions src/Simplex/Messaging/Agent/Env/SQLite.hs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ data ServerCfg p = ServerCfg

data ServerRoles = ServerRoles
{ storage :: Bool,
proxy :: Bool
proxy :: Bool,
names :: Bool
}
deriving (Show)

allRoles :: ServerRoles
allRoles = ServerRoles True True
allRoles = ServerRoles True True True

presetServerCfg :: Bool -> ServerRoles -> Maybe OperatorId -> ProtoServerWithAuth p -> ServerCfg p
presetServerCfg enabled roles operator server =
Expand All @@ -119,16 +120,20 @@ presetServerCfg enabled roles operator server =
data UserServers p = UserServers
{ storageSrvs :: NonEmpty (Maybe OperatorId, ProtoServerWithAuth p),
proxySrvs :: NonEmpty (Maybe OperatorId, ProtoServerWithAuth p),
-- name resolution is opt-in: a plain list (NOT NonEmpty, no fallback-to-all).
-- Empty = no servers resolve names = a clean agent error, never falls back.
nameSrvs :: [(Maybe OperatorId, ProtoServerWithAuth p)],
knownHosts :: Set TransportHost
}

type OperatorId = Int64

-- This function sets all servers as enabled in case all passed servers are disabled.
mkUserServers :: NonEmpty (ServerCfg p) -> UserServers p
mkUserServers srvs = UserServers {storageSrvs = filterSrvs storage, proxySrvs = filterSrvs proxy, knownHosts}
mkUserServers srvs = UserServers {storageSrvs = filterSrvs storage, proxySrvs = filterSrvs proxy, nameSrvs, knownHosts}
where
filterSrvs role = L.map (\ServerCfg {operator, server} -> (operator, server)) $ fromMaybe srvs $ L.nonEmpty $ L.filter (\ServerCfg {enabled, roles} -> enabled && role roles) srvs
nameSrvs = map (\ServerCfg {operator, server} -> (operator, server)) $ L.filter (\ServerCfg {enabled, roles} -> enabled && names roles) srvs
knownHosts = S.unions $ L.map (\ServerCfg {server = ProtoServerWithAuth srv _} -> serverHosts srv) srvs

serverHosts :: ProtocolServer p -> Set TransportHost
Expand Down
Loading
Loading