From f53a6f3364c1103231d737fa925fa99120ed4ecd Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 09:19:47 +0000 Subject: [PATCH] names: ROWN, the names an address owns A recovered key has to be scanned for the accounts it already uses, which needs a question the protocol could not ask: what does this address own. ROWN carries one address and a page offset and is answered with ROWND, from nameOwnedSMPVersion. The agent returns the relay it used so a scan can ask the next account elsewhere: sending every account to one relay would tell it they belong to one wallet. inUse is the answer the scan acts on. Holding a name is only one way for an account to be in use, so the resolver reports the nonce and balance it is derived from; a scan that reads names alone hands out an account its owner is already using. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WHaVjENni2mwBUL77SwFvr --- src/Simplex/Messaging/Agent.hs | 14 +++++++ src/Simplex/Messaging/Agent/Client.hs | 29 ++++++++++++++ src/Simplex/Messaging/Client.hs | 25 ++++++++++++ src/Simplex/Messaging/Eth/Address.hs | 8 ++++ src/Simplex/Messaging/Names/Record.hs | 29 ++++++++++++++ src/Simplex/Messaging/Protocol.hs | 19 ++++++++- src/Simplex/Messaging/Server.hs | 17 +++++++- src/Simplex/Messaging/Server/Names.hs | 24 +++++++++++ .../Messaging/Server/Names/HttpResolver.hs | 10 ++++- src/Simplex/Messaging/Transport.hs | 20 ++++++---- tests/NamesResolverServer.hs | 1 + tests/RSLVTests.hs | 40 +++++++++++++++++++ 12 files changed, 226 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index e9a7379b4..3e1e707ef 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -67,6 +67,7 @@ module Simplex.Messaging.Agent deleteConnShortLink, getConnShortLink, resolveSimplexName, + ownedSimplexNames, getConnLinkPrivKey, deleteLocalInvShortLink, changeConnectionUser, @@ -222,6 +223,8 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken, NtfRegCode (NtfRegCode), NtfTknStatus (..), NtfTokenId, PNMessageData (..), pnMessagesP) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, parse) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( BrokerMsg, Cmd (..), @@ -465,6 +468,12 @@ resolveSimplexName :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDoma resolveSimplexName c nm userId domain = withAgentEnv c $ resolveSimplexName' c nm userId domain {-# INLINE resolveSimplexName #-} +-- | Names an address owns. The relay used is returned so a scan can pass it +-- back as used and ask the next account elsewhere. +ownedSimplexNames :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AE (SMPServer, OwnedNames) +ownedSimplexNames c nm userId used addr offset = withAgentEnv c $ ownedSimplexNames' c nm userId used addr offset +{-# INLINE ownedSimplexNames #-} + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c {-# INLINE getConnLinkPrivKey #-} @@ -1270,6 +1279,11 @@ getConnShortLink' c nm userId = \case deleteLocalInvShortLink' :: AgentClient -> ConnShortLink 'CMInvitation -> AM () deleteLocalInvShortLink' c (CSLInvitation _ srv linkId _) = withStore' c $ \db -> deleteInvShortLink db srv linkId +ownedSimplexNames' :: AgentClient -> NetworkRequestMode -> UserId -> [SMPServer] -> Address -> Word32 -> AM (SMPServer, OwnedNames) +ownedSimplexNames' c nm userId used addr offset = do + srv <- getNextNameServerAvoiding c userId used + (srv,) <$> ownedNames c nm userId srv addr offset + resolveSimplexName' :: AgentClient -> NetworkRequestMode -> UserId -> SimplexDomain -> AM NameResponse resolveSimplexName' c nm userId domain = do resolverSrv <- getNextNameServer c userId diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 8cbb4c17b..7fa17cd08 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -70,6 +70,8 @@ module Simplex.Messaging.Agent.Client secureGetQueueLink, getQueueLink, resolveName, + ownedNames, + getNextNameServerAvoiding, getNextNameServer, enableQueueNotifications, EnableQueueNtfReq (..), @@ -263,6 +265,8 @@ import Simplex.Messaging.Notifications.Protocol import Simplex.Messaging.Notifications.Transport (NTFVersion) import Simplex.Messaging.Notifications.Types import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parse, sumTypeJSON) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol ( AProtocolType (..), BrokerMsg, @@ -2029,6 +2033,31 @@ resolveName c nm userId server domain = resolveViaProxy smp proxySess = proxyResolveName smp nm proxySess domain resolveDirectly smp = directResolveName smp nm domain +-- | Names an address owns, from one names-capable relay. Mirrors resolveName: +-- proxied where the network config allows it, direct otherwise. +ownedNames :: AgentClient -> NetworkRequestMode -> UserId -> SMPServer -> Address -> Word32 -> AM OwnedNames +ownedNames c nm userId server addr offset = + snd <$> sendOrProxySMPCommand c nm userId server "" "ROWN" NoEntity ownedViaProxy ownedDirectly + where + ownedViaProxy smp proxySess = proxyOwnedNames smp nm proxySess addr offset + ownedDirectly smp = directOwnedNames smp nm addr offset + +-- | A names-capable server the caller has not used yet. A scan asks about one +-- account per relay, because sending the whole list to one relay would tell it +-- those accounts belong to one wallet. With fewer relays than accounts the +-- choice wraps onto used ones, which is the best the configured set allows. +getNextNameServerAvoiding :: AgentClient -> UserId -> [SMPServer] -> AM SMPServer +getNextNameServerAvoiding c userId used = + liftIO (TM.lookupIO userId (userServers c :: TMap UserId (UserServers 'PSMP))) >>= \case + Just UserServers {nameSrvs} -> + let unused = filter ((`notElem` used) . protoServer . snd) nameSrvs + in case L.nonEmpty unused of + Just srvs -> protoServer <$> pickServer srvs + Nothing -> case L.nonEmpty nameSrvs of + Just srvs -> protoServer <$> pickServer srvs + Nothing -> throwE NO_NAME_SERVERS + Nothing -> throwE $ INTERNAL "unknown userId - no user servers" + -- | 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. diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f8f1a4cb9..fd9e72434 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -75,6 +75,8 @@ module Simplex.Messaging.Client proxySMPMessage, proxyResolveName, directResolveName, + proxyOwnedNames, + directOwnedNames, forwardSMPTransmission, getSMPQueueInfo, sendProtocolCommand, @@ -163,6 +165,9 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON) +import Data.Word (Word32) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Protocol import Simplex.Messaging.Protocol.Types import Simplex.Messaging.Server.QueueStore.QueueInfo @@ -1076,6 +1081,26 @@ directResolveName c nm name r -> throwE $ unexpectedResponse r | otherwise = throwE $ PCETransportError TEVersion +-- | Names an address owns, via PFWD. A scan reveals which accounts belong to +-- one wallet, so hiding the client IP matters more here than for one name. +proxyOwnedNames :: SMPClient -> NetworkRequestMode -> ProxiedRelay -> Address -> Word32 -> ExceptT SMPClientError IO (Either ProxyClientError OwnedNames) +proxyOwnedNames c nm proxiedRelay addr offset + | prVersion proxiedRelay >= nameOwnedSMPVersion = + proxySMPCommand c nm proxiedRelay Nothing NoEntity (ROWN addr offset) >>= \case + Right (ROWND owned) -> pure $ Right owned + Right r -> throwE $ unexpectedResponse r + Left e -> pure $ Left e + | otherwise = throwE $ PCETransportError TEVersion + +-- | Direct (non-PFWD) owned-names lookup, exposing the client IP. +directOwnedNames :: SMPClient -> NetworkRequestMode -> Address -> Word32 -> ExceptT SMPClientError IO OwnedNames +directOwnedNames c nm addr offset + | thVersion (thParams c) >= nameOwnedSMPVersion = + sendProtocolCommand c nm Nothing NoEntity (Cmd SResolver (ROWN addr offset)) >>= \case + ROWND owned -> pure owned + r -> throwE $ unexpectedResponse r + | otherwise = throwE $ PCETransportError TEVersion + resolvedNameOrNotFound :: SimplexDomain -> NameResponse -> Bool resolvedNameOrNotFound d NameResponse {registration} = case registration of NRRegistered {nameRecord} -> T.toLower (nrName nameRecord) == fullDomainName d diff --git a/src/Simplex/Messaging/Eth/Address.hs b/src/Simplex/Messaging/Eth/Address.hs index f0d9a5ef9..699938fb4 100644 --- a/src/Simplex/Messaging/Eth/Address.hs +++ b/src/Simplex/Messaging/Eth/Address.hs @@ -19,8 +19,10 @@ import Data.Char (isDigit, isHexDigit, isLower, isUpper, toLower) import Data.Word (Word32, Word8) import Simplex.Messaging.Crypto.BIP32 (hardened) import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Eth.Keccak (keccak256) +import Simplex.Messaging.Util ((<$?>)) -- | A 20-byte Ethereum address. 'Show' renders the EIP-55 checksummed form, as pasted into a block explorer. newtype Address = Address ByteString @@ -30,6 +32,12 @@ instance Show Address where show = BC.unpack . checksumAddress -- | EIP-55 checksummed hex. Parsing accepts bare or @0x@-prefixed hex and verifies a mixed-case checksum. +-- | On the wire the address is its EIP-55 hex, length-prefixed, so a field +-- following it is not swallowed by the hex parser. +instance Encoding Address where + smpEncode = smpEncode . strEncode + smpP = strDecode <$?> smpP + instance StrEncoding Address where strEncode = checksumAddress strP = do diff --git a/src/Simplex/Messaging/Names/Record.hs b/src/Simplex/Messaging/Names/Record.hs index 461bec16e..5f8feecd8 100644 --- a/src/Simplex/Messaging/Names/Record.hs +++ b/src/Simplex/Messaging/Names/Record.hs @@ -13,6 +13,8 @@ module Simplex.Messaging.Names.Record NamePricing (..), USDCents (..), NameReservedReason (..), + OwnedNames (..), + OwnedName (..), ) where @@ -90,6 +92,29 @@ data NameRegistration NRReserved {reservedReason :: NameReservedReason} deriving (Eq, Show) +-- | What the registry holds for an address: the names it owns, and whether +-- the account has been used at all. Holding a name is only one way to be in +-- use, so a recovery scan that reads only `ownNames` hands out an account its +-- owner is already using. +data OwnedNames = OwnedNames + { ownNames :: [OwnedName], + ownInUse :: Bool, + -- | the cursor to resume from, absent when the listing is complete + ownNextOffset :: Maybe Int + } + deriving (Eq, Show) + +-- | One name an address holds. Enumeration is not maintained on expiry, so a +-- lapsed name stays listed; `onStatus` is how a caller tells it apart. +data OwnedName = OwnedName + { -- | absent when the registrar never recorded the label + onName :: Maybe Text, + onLabelhash :: Text, + onExpires :: SystemSeconds, + onStatus :: Text + } + deriving (Eq, Show) + -- | Enough to price the name locally, which the router cannot do behind a hash. data NamePricing = NamePricing { -- | US cents per year, for the lengths the registry prices specially @@ -135,6 +160,10 @@ instance ToJSON NameReservedReason where instance FromJSON NameReservedReason where parseJSON = textParseJSON "NameReservedReason" +$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "on"} ''OwnedName) + +$(JQ.deriveJSON defaultJSON {J.fieldLabelModifier = dropPrefix "own"} ''OwnedNames) + $(JQ.deriveJSON defaultJSON ''NamePricing) -- taggedObjectJSON, not sumTypeJSON: this JSON is the RNAME payload and the diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index e2cbef3a4..854bd7fae 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -261,7 +261,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Type.Equality -import Data.Word (Word8, Word16) +import Data.Word (Word8, Word16, Word32) import GHC.TypeLits (ErrorMessage (..), TypeError, type (+)) import qualified GHC.TypeLits as TE import qualified GHC.TypeLits as Type @@ -271,6 +271,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), FromField (..), ToField (. import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Eth.Address (Address) import Simplex.Messaging.Names.Record import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol.Types @@ -610,6 +611,8 @@ data Command (p :: Party) where RFWD :: EncFwdTransmission -> Command ProxyService -- use CorrId as CbNonce, proxy to relay -- Resolve SimpleX name. RSLV :: NameQuery -> Command Resolver + -- Names an address owns, and whether the account has been used at all. + ROWN :: Address -> Word32 -> Command Resolver deriving instance Show (Command p) @@ -747,6 +750,8 @@ data BrokerMsg where PONG :: BrokerMsg -- What the router knows about a SimpleX name. RNAME :: NameResponse -> BrokerMsg + -- What the router knows about the names an address owns. + ROWND :: OwnedNames -> BrokerMsg deriving (Eq, Show) data RcvMessage = RcvMessage @@ -958,6 +963,7 @@ data CommandTag (p :: Party) where NSUB_ :: CommandTag Notifier NSUBS_ :: CommandTag NotifierService RSLV_ :: CommandTag Resolver + ROWN_ :: CommandTag Resolver data CmdTag = forall p. PartyI p => CT (SParty p) (CommandTag p) @@ -985,6 +991,7 @@ data BrokerMsgTag | ERR_ | PONG_ | RNAME_ + | ROWND_ deriving (Show) class ProtocolMsgTag t where @@ -1022,6 +1029,7 @@ instance PartyI p => Encoding (CommandTag p) where NSUB_ -> "NSUB" NSUBS_ -> "NSUBS" RSLV_ -> "RSLV" + ROWN_ -> "ROWN" smpP = messageTagP instance ProtocolMsgTag CmdTag where @@ -1051,6 +1059,7 @@ instance ProtocolMsgTag CmdTag where "NSUB" -> Just $ CT SNotifier NSUB_ "NSUBS" -> Just $ CT SNotifierService NSUBS_ "RSLV" -> Just $ CT SResolver RSLV_ + "ROWN" -> Just $ CT SResolver ROWN_ _ -> Nothing instance Encoding CmdTag where @@ -1081,6 +1090,7 @@ instance Encoding BrokerMsgTag where ERR_ -> "ERR" PONG_ -> "PONG" RNAME_ -> "RNAME" + ROWND_ -> "ROWND" smpP = messageTagP instance ProtocolMsgTag BrokerMsgTag where @@ -1104,6 +1114,7 @@ instance ProtocolMsgTag BrokerMsgTag where "ERR" -> Just ERR_ "PONG" -> Just PONG_ "RNAME" -> Just RNAME_ + "ROWND" -> Just ROWND_ _ -> Nothing -- | SMP message body format @@ -1846,6 +1857,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD fwdV pubKey (EncTransmission s) -> e (PFWD_, ' ', fwdV, pubKey, Tail s) RFWD (EncFwdTransmission s) -> e (RFWD_, ' ', Tail s) RSLV q -> e (RSLV_, ' ', if v >= nameAvailSMPVersion then hashedQuery q else q) + ROWN addr offset -> e (ROWN_, ' ', addr, offset) where e :: Encoding a => a -> ByteString e = smpEncode @@ -1871,6 +1883,7 @@ instance PartyI p => ProtocolEncoding SMPVersion ErrorType (Command p) where PFWD {} -> entityCmd RFWD _ -> noAuthCmd RSLV _ -> noAuthCmd + ROWN _ _ -> noAuthCmd SUB -> serviceCmd NSUB -> serviceCmd -- other client commands must have both signature and queue ID @@ -1953,6 +1966,7 @@ instance ProtocolEncoding SMPVersion ErrorType Cmd where | v >= rcvServiceSMPVersion -> Cmd SNotifierService <$> (NSUBS <$> _smpP <*> smpP) | otherwise -> pure $ Cmd SNotifierService $ NSUBS (-1) mempty CT SResolver RSLV_ -> Cmd SResolver . RSLV <$> _smpP + CT SResolver ROWN_ -> Cmd SResolver <$> (ROWN <$> _smpP <*> smpP) fromProtocolError = fromProtocolError @SMPVersion @ErrorType @BrokerMsg {-# INLINE fromProtocolError #-} @@ -1995,6 +2009,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where | v < clientNoticesSMPVersion -> BLOCKED info {notice = Nothing} _ -> err PONG -> e PONG_ + ROWND owned -> e (ROWND_, ' ', Tail $ LB.toStrict $ J.encode owned) RNAME res | v >= nameAvailSMPVersion -> e (RNAME_, ' ', Tail $ LB.toStrict $ J.encode res) | otherwise -> case registration res of @@ -2046,6 +2061,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where OK_ -> pure OK ERR_ -> ERR <$> _smpP PONG_ -> pure PONG + ROWND_ -> fmap ROWND . J.eitherDecodeStrict . unTail <$?> _smpP RNAME_ | v >= nameAvailSMPVersion -> fmap RNAME . J.eitherDecodeStrict . unTail <$?> _smpP | otherwise -> fmap (RNAME . oldResponse) . J.eitherDecodeStrict . unTail <$?> _smpP @@ -2074,6 +2090,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where RRES _ -> noEntityMsg ALLS -> noEntityMsg RNAME {} -> noEntityMsg + ROWND {} -> noEntityMsg -- other broker responses must have queue ID _ | B.null entId -> Left $ CMD NO_ENTITY diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 097cabbbe..051c4bac8 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -109,7 +109,9 @@ import Simplex.Messaging.Server.Env.STM as Env import Simplex.Messaging.Server.Expiration import Simplex.Messaging.Server.MsgStore import Simplex.Messaging.Server.MsgStore.Journal (JournalMsgStore, JournalQueue (..), getJournalQueueMessages) -import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, resolveName) +import Data.Word (Word32) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Server.Names (NamesEnv, closeNamesEnv, ownedNames, resolveName) import Simplex.Messaging.Server.MsgStore.STM import Simplex.Messaging.Server.MsgStore.Types import Simplex.Messaging.Server.NtfStore @@ -1276,6 +1278,7 @@ verifyQueueTransmission service thAuth (tAuth, authorized, (corrId, entId, comma vc SProxiedClient _ = VRVerified Nothing vc SProxyService (RFWD _) = VRVerified Nothing vc SResolver (RSLV _) = VRVerified Nothing + vc SResolver (ROWN _ _) = VRVerified Nothing checkRole = case (service, partyClientRole p) of (Just THClientService {serviceRole}, Just role) -> serviceRole == role _ -> True @@ -1506,6 +1509,15 @@ client answered = \case NRRegistered {} -> True _ -> v >= nameAvailSMPVersion + -- Forked for the same reason as RSLV: one owned-by is many eth_calls. + ownedNamesMsg :: NamesEnv -> Address -> Word32 -> M s BrokerMsg + ownedNamesMsg nenv addr offset = do + st <- asks (rslvStats . serverStats) + (selector, msg) <- + liftIO (ownedNames nenv addr offset) <&> \case + Right owned -> (rslvSucc, ROWND owned) + Left e -> (rslvResolverErrs, ERR $ NAME e) + incStat (selector st) $> msg transportErr :: TransportError -> ErrorType transportErr = PROXY . BROKER . TRANSPORT mkIncProxyStats :: MonadIO m => ProxyStats -> ProxyStats -> OwnServer -> (ProxyStats -> IORef Int) -> m () @@ -1523,6 +1535,9 @@ client Cmd SResolver (RSLV d) -> rslvNamesEnv >>= \case Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (resolveNameMsg (thVersion thParams') nenv d) + Cmd SResolver (ROWN addr offset) -> rslvNamesEnv >>= \case + Nothing -> pure $ response (corrId, NoEntity, ERR (NAME NO_RESOLVER)) + Just nenv -> forkCmd serverResolverConcurrency corrId NoEntity (ownedNamesMsg nenv addr offset) Cmd SSenderLink command -> case command of LKEY k -> withQueue $ \q qr -> checkMode QMMessaging qr $ secureQueue_ q k $>> getQueueLink_ q qr LGET -> withQueue $ \q qr -> checkContact qr $ getQueueLink_ q qr diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 601519c56..2633ed0ee 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -12,6 +12,7 @@ module Simplex.Messaging.Server.Names closeNamesEnv, pingEndpoint, resolveName, + ownedNames, ) where @@ -19,10 +20,14 @@ import qualified Control.Exception as E import Control.Logger.Simple (logError) import Data.Bifunctor (first) import Data.Maybe (fromMaybe) +import Data.Word (Word32) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Simplex.Messaging.Encoding +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Protocol (NameErrorType (..), NameQuery, NameResponse) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedNames) import Simplex.Messaging.Server.Names.HttpResolver ( ResolverEnv, ResolverError (..), @@ -30,6 +35,7 @@ import Simplex.Messaging.Server.Names.HttpResolver closeResolverEnv, healthHttp, newResolverEnv, + ownedByHttp, resolveHttp, ) import System.Timeout (timeout) @@ -74,6 +80,24 @@ fetch :: NamesEnv -> NameQuery -> IO (Either NameErrorType NameResponse) fetch NamesEnv {resolverEnv} q = first mapResolverError <$> resolveHttp resolverEnv (decodeLatin1 $ smpEncode q) +-- | The names an address owns. A resolver without the endpoint answers 404, +-- which mapResolverError reports as RESOLVER, so a scan cannot read "this +-- resolver cannot say" as "this address owns nothing". +ownedNames :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) +ownedNames env addr offset = do + r <- E.try (timeout (resolverTimeoutMs (config env) * 1000) (fetchOwned env addr offset)) + case r of + Right result -> pure (fromMaybe (Left (RESOLVER "timeout")) result) + Left e + | Just (_ :: E.SomeAsyncException) <- E.fromException e -> E.throwIO e + | otherwise -> do + logError $ "[NAMES] resolver fetch raised " <> T.pack (E.displayException e) + pure (Left (RESOLVER "resolver error")) + +fetchOwned :: NamesEnv -> Address -> Word32 -> IO (Either NameErrorType OwnedNames) +fetchOwned NamesEnv {resolverEnv} addr offset = + first mapResolverError <$> ownedByHttp resolverEnv (decodeLatin1 $ strEncode addr) (fromIntegral offset) + mapResolverError :: ResolverError -> NameErrorType mapResolverError = \case HttpStatusErr code -> RESOLVER ("HTTP " <> T.pack (show code)) diff --git a/src/Simplex/Messaging/Server/Names/HttpResolver.hs b/src/Simplex/Messaging/Server/Names/HttpResolver.hs index 0f272bcf4..c9355459b 100644 --- a/src/Simplex/Messaging/Server/Names/HttpResolver.hs +++ b/src/Simplex/Messaging/Server/Names/HttpResolver.hs @@ -29,6 +29,7 @@ module Simplex.Messaging.Server.Names.HttpResolver newResolverEnv, closeResolverEnv, resolveHttp, + ownedByHttp, healthHttp, ) where @@ -59,7 +60,7 @@ import qualified Network.HTTP.Client as HC import Network.HTTP.Client.TLS (tlsManagerSettings) import qualified Network.HTTP.Types as HT import Network.HTTP.Types.URI (urlEncode) -import Simplex.Messaging.Names.Record (NameResponse) +import Simplex.Messaging.Names.Record (NameResponse, OwnedNames) data RpcAuth = AuthBearer Text | AuthBasic Text Text @@ -117,6 +118,13 @@ resolveHttp env q = (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) <$> httpGet env ("/v2/resolve/" <> B.unpack (urlEncode True (encodeUtf8 q))) +-- | The address is EIP-55 hex, which is already URL-safe, but it is encoded +-- for the same reason the name is: nothing from a client reaches the path raw. +ownedByHttp :: ResolverEnv -> Text -> Int -> IO (Either ResolverError OwnedNames) +ownedByHttp env addr offset = + (>>= first InvalidJson . J.eitherDecodeStrict . BL.toStrict) + <$> httpGet env ("/v2/owned-by/" <> B.unpack (urlEncode True (encodeUtf8 addr)) <> "?offset=" <> show offset) + -- | GET /health; success = reachable with status < 400. The body is -- size-capped but NOT decoded — the probe only checks reachability. healthHttp :: ResolverEnv -> IO (Either ResolverError ()) diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index d2c30d25a..6d4f9951f 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -54,6 +54,7 @@ module Simplex.Messaging.Transport namesSMPVersion, serverInfoSMPVersion, nameAvailSMPVersion, + nameOwnedSMPVersion, simplexMQVersion, smpBlockSize, TransportConfig (..), @@ -218,6 +219,11 @@ serverInfoSMPVersion = VersionSMP 21 nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 +-- | ROWN lists the names an address owns. A server below this does not answer +-- it, which a recovery scan must not read as the address owning nothing. +nameOwnedSMPVersion :: VersionSMP +nameOwnedSMPVersion = VersionSMP 23 + minClientSMPRelayVersion :: VersionSMP minClientSMPRelayVersion = VersionSMP 14 @@ -225,20 +231,20 @@ minServerSMPRelayVersion :: VersionSMP minServerSMPRelayVersion = VersionSMP 14 currentClientSMPRelayVersion :: VersionSMP -currentClientSMPRelayVersion = VersionSMP 22 +currentClientSMPRelayVersion = VersionSMP 23 currentServerSMPRelayVersion :: VersionSMP -currentServerSMPRelayVersion = VersionSMP 22 +currentServerSMPRelayVersion = VersionSMP 23 -- Max SMP protocol version to be used in e2e encrypted connection between -- client and server, as defined by SMP proxy. Normally set below the current -- version to prevent client version fingerprinting by the destination relays --- when clients upgrade at different times. Pinned to the current version (22) --- for this release because a proxied RSLV only carries availability from --- nameAvailSMPVersion (22), so the one-version anti-fingerprinting buffer does --- not apply yet; it reappears once the current version advances past 22. +-- when clients upgrade at different times. Pinned to the current version (23) +-- for this release because a proxied ROWN is only answered from +-- nameOwnedSMPVersion (23), and a scan that cannot proxy exposes its address +-- to the relay; the buffer reappears once the current version advances past 23. proxiedSMPRelayVersion :: VersionSMP -proxiedSMPRelayVersion = VersionSMP 22 +proxiedSMPRelayVersion = VersionSMP 23 -- minimal supported protocol version is 14 supportedClientSMPRelayVRange :: VersionRangeSMP diff --git a/tests/NamesResolverServer.hs b/tests/NamesResolverServer.hs index 054d55e40..14b6bc551 100644 --- a/tests/NamesResolverServer.hs +++ b/tests/NamesResolverServer.hs @@ -53,6 +53,7 @@ resolveResp :: Status -> LB.ByteString -> [Text] -> (Status, LB.ByteString) resolveResp st body = \case ["health"] -> (ok200, "{}") ("v2" : "resolve" : _) -> (st, body) + ("v2" : "owned-by" : _) -> (st, body) _ -> (notFound404, "{}") testNamesConfig :: Int -> NamesConfig diff --git a/tests/RSLVTests.hs b/tests/RSLVTests.hs index 9263b6953..e67aa6b33 100644 --- a/tests/RSLVTests.hs +++ b/tests/RSLVTests.hs @@ -27,6 +27,8 @@ import Simplex.Messaging.Client import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strDecode) import SMPNamesTests (availableBody, registeredBody, reservedBody, resolved, testNameRecord, testPricing) +import Simplex.Messaging.Eth.Address (Address) +import Simplex.Messaging.Names.Record (OwnedName (..), OwnedNames (..)) import Simplex.Messaging.Protocol ( BrokerMsg (..), Cmd (..), @@ -72,6 +74,16 @@ withProxyAndResolver (st, body) runTest = withSmpServerConfigOn (transport @TLS) memProxyCfg testPort $ \_ -> withSmpServerConfigOn (transport @TLS) (withNames port memCfg2) testPort2 (const runTest) +sendRown :: Transport c => THandleSMP c 'TClient -> B.ByteString -> Address -> IO (Transmission (Either ErrorType BrokerMsg)) +sendRown h@THandle {params} corrId addr = do + let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (ROWN addr 0)) + [Right ()] <- tPut h (Right (Nothing, tToSend) :| []) + r :| _ <- tGetClient h + pure r + +testAddr :: Address +testAddr = either error id $ strDecode "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + sendRslv :: Transport c => THandleSMP c 'TClient -> B.ByteString -> SimplexDomain -> IO (Transmission (Either ErrorType BrokerMsg)) sendRslv h@THandle {params} corrId d = do let TransmissionForAuth {tToSend} = encodeTransmissionForAuth params (CorrId corrId, NoEntity, Cmd SResolver (RSLV (NQDomain d))) @@ -84,6 +96,8 @@ rslvTests = do describe "RSLV direct (non-forwarded)" $ do it "resolver without the v2 route (404) -> NAME RESOLVER, not NOT_FOUND" testRslvBackendNotFound it "resolver replies 502 -> NAME (RESOLVER ..)" testRslvBackendHttpErr + it "ROWN returns the names an address owns" testRownOwned + it "ROWN on a resolver without the endpoint is a resolver error, not an empty answer" testRownUnsupported it "no names config -> NAME NO_RESOLVER" testRslvDisabled it "refuses to send RSLV on a session below namesSMPVersion" testRslvVersion describe "RSLV forwarded (PFWD)" $ do @@ -105,6 +119,32 @@ rslvTests = do -- | /v2/resolve answers 200, 400 or 502, so a 404 is a resolver that predates -- the route, not a name that does not exist. +-- The scan reads inUse, so an account in use with no names must not arrive +-- looking the same as one that owns nothing. +testRownOwned :: IO () +testRownOwned = + withResolverServer (status200, ownedBody) $ + testSMPClient @TLS $ \h -> do + (corrId, _entId, resp) <- sendRown h "ro01" testAddr + corrId `shouldBe` CorrId "ro01" + case resp of + Right (ROWND owned) -> do + map onName (ownNames owned) `shouldBe` [Just "alice.testing"] + ownInUse owned `shouldBe` True + r -> expectationFailure $ "unexpected " <> show r + +-- A resolver that does not serve owned-by answers 404, which must reach the +-- client as a resolver error: read as "owns nothing" it would end a scan early. +testRownUnsupported :: IO () +testRownUnsupported = + withResolverServer (status404, "{}") $ + testSMPClient @TLS $ \h -> do + (_, _, resp) <- sendRown h "ro02" testAddr + resp `shouldBe` Right (ERR (NAME (RESOLVER "HTTP 404"))) + +ownedBody :: LB.ByteString +ownedBody = "{\"address\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"names\":[{\"name\":\"alice.testing\",\"labelhash\":\"0x9c02\",\"expires\":1821603121,\"status\":\"registered\"}],\"inUse\":true,\"nextOffset\":null}" + testRslvBackendNotFound :: IO () testRslvBackendNotFound = withResolverServer (status404, "{}") $