diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index aa01974d8..3f3524d0d 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -1467,28 +1467,37 @@ out-of-band for operator observability. #### Name record response -```abnf -name = %s"NAME" SP nameRecord +The `NAME` response carries a JSON-encoded record as the payload: -nameRecord = displayName owner channelLinks contactLinks adminAddr adminEmail expiry isTest -displayName = length *OCTET ; 1-byte length prefix, up to 255 bytes UTF-8 -owner = 20OCTET ; raw 20-byte Ethereum-style address -channelLinks = count *nameLink ; count is a 1-byte unsigned integer -contactLinks = count *nameLink ; combined count of channelLinks + contactLinks ≤ 8 -nameLink = length16 *OCTET ; 2-byte big-endian length, up to 1024 bytes UTF-8 -adminAddr = optionalText ; "0" absent or "1" + 1-byte length + UTF-8 up to 255 bytes -adminEmail = optionalText ; same encoding as adminAddr -expiry = 8OCTET ; Int64 big-endian, Unix seconds, MUST be ≥ 0 -isTest = "T" / "F" +```abnf +name = %s"NAME" SP json-bytes ; json-bytes consumes the remainder of the transmission ``` -The encoding is canonical: every primitive has exactly one valid byte form, so -two names routers reading the same backing state produce byte-identical -responses. +`json-bytes` MUST be a UTF-8 JSON object with the following schema: -**Wire-size budget.** A maximal `nameRecord` (8 links × 1024 bytes + maximal -admin / display strings) fits comfortably within the SMP proxied transmission -budget of 16224 bytes. +| Field | JSON type | Constraints | +|---|---|---| +| `displayName` | string | ≤ 255 bytes UTF-8 | +| `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 | + +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. + +**Canonical encoding.** Two names routers reading the same backing state and +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 +SMP proxied transmission budget of 16224 bytes. ## Transport connection with the SMP router diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 8da8e66b0..8cac20972 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -256,7 +256,7 @@ import Data.Kind import Data.List (foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (isJust, isNothing) +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -750,11 +750,11 @@ unNameOwner (NameOwner bs) = bs instance J.ToJSON NameOwner where toJSON (NameOwner bs) = J.String $ "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 bs) - toEncoding (NameOwner bs) = J.toEncoding $ "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 bs) instance J.FromJSON NameOwner where parseJSON = J.withText "NameOwner" $ \t -> do - let hex = maybe t id (T.stripPrefix "0x" t) + -- Accept "0x" and "0X" prefixes (matches Server/Main.hs:parseEthAddr via fromHex). + let hex = fromMaybe t (T.stripPrefix "0x" t <|> T.stripPrefix "0X" t) case BAE.convertFromBase BAE.Base16 (encodeUtf8 hex) of Left e -> fail e Right bs -> either fail pure (mkNameOwner bs) @@ -775,7 +775,6 @@ unNameLink (NameLink t) = t instance J.ToJSON NameLink where toJSON (NameLink t) = J.toJSON t - toEncoding (NameLink t) = J.toEncoding t instance J.FromJSON NameLink where parseJSON = J.withText "NameLink" (either fail pure . mkNameLink) @@ -809,18 +808,22 @@ instance J.ToJSON NameRecord where instance J.FromJSON NameRecord where parseJSON = J.withObject "NameRecord" $ \o -> do - nrDisplayName <- o J..: "displayName" + nrDisplayName <- o J..: "displayName" >>= capUtf8 "displayName" 255 nrOwner <- o J..: "owner" nrChannelLinks <- o J..: "channelLinks" nrContactLinks <- o J..: "contactLinks" when (length nrChannelLinks + length nrContactLinks > 8) $ fail "combined channelLinks + contactLinks > 8" - nrAdminAddress <- o J..:? "adminAddress" - nrAdminEmail <- o J..:? "adminEmail" + nrAdminAddress <- o J..:? "adminAddress" >>= traverse (capUtf8 "adminAddress" 255) + nrAdminEmail <- o J..:? "adminEmail" >>= traverse (capUtf8 "adminEmail" 255) nrExpiry <- o J..: "expiry" when (nrExpiry < 0) $ fail "expiry must be non-negative" nrIsTest <- o J..: "isTest" pure NameRecord {nrDisplayName, nrOwner, nrChannelLinks, nrContactLinks, nrAdminAddress, nrAdminEmail, nrExpiry, nrIsTest} + where + capUtf8 fld lim t + | B.length (encodeUtf8 t) <= lim = pure t + | otherwise = fail $ fld <> " exceeds " <> show lim <> " bytes UTF-8" data BrokerMsg where -- SMP broker messages (responses, client messages, notifications) diff --git a/src/Simplex/Messaging/Server/Main.hs b/src/Simplex/Messaging/Server/Main.hs index 80f09e0da..a240060a1 100644 --- a/src/Simplex/Messaging/Server/Main.hs +++ b/src/Simplex/Messaging/Server/Main.hs @@ -841,7 +841,12 @@ validateUrl url auth_ = do ua <- maybe (Left "missing authority (host)") Right (uriAuthority uri) when (null (uriRegName ua)) $ Left "empty host" unless (null (uriUserInfo ua)) $ Left "userinfo (user:pass@) not allowed; use rpc_auth instead" - when (null (uriPort ua)) $ Left "explicit port required (e.g. http://host:8545)" + case uriPort ua of + "" -> Left "explicit port required (e.g. http://host:8545)" + ':' : portStr -> case readMaybe portStr of + Just n | n >= 1 && n <= 65535 -> Right () + _ -> Left $ "port " <> portStr <> " out of range (must be 1..65535)" + other -> Left $ "unexpected port syntax: " <> other unless (null (uriQuery uri)) $ Left "query string not allowed" unless (null (uriFragment uri)) $ Left "fragment not allowed" let path = uriPath uri diff --git a/src/Simplex/Messaging/Server/Main/Init.hs b/src/Simplex/Messaging/Server/Main/Init.hs index 659845f99..176727b08 100644 --- a/src/Simplex/Messaging/Server/Main/Init.hs +++ b/src/Simplex/Messaging/Server/Main/Init.hs @@ -158,8 +158,9 @@ iniFileContent cfgPath logPath opts host basicAuth controlPortPwds = \[NAMES]\n\ \# Public-namespace resolution (SNRC on Ethereum).\n\ \# Requires an Ethereum JSON-RPC endpoint (Reth+Nimbus). See deployment guide.\n\ - \# Co-locating with the proxy role logs a warning at startup - slow RSLV cache misses\n\ - \# can serialise other forwarded commands. For high-volume deployments, run on a separate host.\n\ + \# Co-locating with the proxy role logs a startup advisory: slow RSLV calls can\n\ + \# serialise other forwarded commands on the same proxy-relay session.\n\ + \# For high-volume deployments, run [NAMES] on a separate host.\n\ \# Restart required to change settings.\n\ \enable: off\n\ \# Same-host:\n\ diff --git a/src/Simplex/Messaging/Server/Names.hs b/src/Simplex/Messaging/Server/Names.hs index 640618317..fb07c2c34 100644 --- a/src/Simplex/Messaging/Server/Names.hs +++ b/src/Simplex/Messaging/Server/Names.hs @@ -24,16 +24,18 @@ module Simplex.Messaging.Server.Names ) where +import Control.Monad (when, unless) import qualified Control.Exception as E import Control.Logger.Simple (logError) import Data.ByteString.Char8 (ByteString) +import Data.IORef (IORef, atomicModifyIORef', newIORef) import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock.POSIX (getPOSIXTime) import Simplex.Messaging.Protocol (NameOwner, NameRecord (..), unNameOwner) import Simplex.Messaging.Server.Names.Eth.RPC (EthRpcEnv, EthRpcError (..), RpcAuth (..), closeEthRpcEnv, ethCallReal, newEthRpcEnv) -import Simplex.Messaging.Server.Names.Eth.SNRC (decodeGetRecord, encodeGetRecord, namehash) +import Simplex.Messaging.Server.Names.Eth.SNRC (decodeAddress, decodeGetRecord, encodeGetRecord, isZeroOwner, namehash) import System.Timeout (timeout) data NamesConfig = NamesConfig @@ -61,7 +63,10 @@ type EthCall = ByteString -> ByteString -> IO (Either EthRpcError ByteString) data NamesEnv = NamesEnv { config :: NamesConfig, ethCall :: EthCall, - rpcEnv :: Maybe EthRpcEnv -- Nothing for test stubs + rpcEnv :: Maybe EthRpcEnv, -- Nothing for test stubs + -- One-shot guard so the placeholder-decoder warning logs once per process, + -- not once per RSLV. + placeholderWarned :: IORef Bool } newNamesEnv :: NamesConfig -> IO NamesEnv @@ -71,7 +76,9 @@ newNamesEnv cfg = do -- | Allocate resolver with an injected ethCall (test seam). newNamesEnvWith :: NamesConfig -> EthCall -> Maybe EthRpcEnv -> IO NamesEnv -newNamesEnvWith config ethCall rpcEnv = pure NamesEnv {config, ethCall, rpcEnv} +newNamesEnvWith config ethCall rpcEnv = do + placeholderWarned <- newIORef False + pure NamesEnv {config, ethCall, rpcEnv, placeholderWarned} closeNamesEnv :: NamesEnv -> IO () closeNamesEnv NamesEnv {rpcEnv} = mapM_ closeEthRpcEnv rpcEnv @@ -101,14 +108,25 @@ resolveName env key = do pure (Left EthHttpErr) fetch :: NamesEnv -> ByteString -> IO (Either ResolveError NameRecord) -fetch NamesEnv {ethCall, config} key = +fetch env@NamesEnv {ethCall, config} key = ethCall (unNameOwner (snrcAddress config)) (encodeGetRecord (namehash key)) >>= \case Left e -> pure (Left (mapEthRpcError e)) Right ret -> case decodeGetRecord ret of - Right Nothing -> pure (Left NotFound) + Right Nothing -> notFoundWithPlaceholderWarn ret Right (Just rec) -> checkExpiry rec Left _ -> pure (Left EthDecodeErr) where + -- decodeGetRecord is currently a placeholder: it returns Right Nothing + -- for BOTH "zero-owner sentinel" (real NotFound) and "non-zero owner + -- with real data but no ABI decoder yet". Inspect the owner slot + -- directly to distinguish, and surface the latter once per process so + -- an operator who enables [NAMES] against a working SNRC contract sees + -- the resolver is functionally stubbed. + notFoundWithPlaceholderWarn ret = do + case decodeAddress 32 ret of + Right owner -> unless (isZeroOwner owner) (warnPlaceholderOnce env) + Left _ -> pure () + pure (Left NotFound) -- Defense in depth: the SNRC contract should already return the -- zero-owner sentinel for expired records, but a buggy / pre-upgrade -- contract might not. nrExpiry == 0 means "never expires" (reserved @@ -119,6 +137,14 @@ fetch NamesEnv {ethCall, config} key = then Left NotFound else Right rec +warnPlaceholderOnce :: NamesEnv -> IO () +warnPlaceholderOnce NamesEnv {placeholderWarned} = do + first <- atomicModifyIORef' placeholderWarned (\w -> (True, not w)) + when first $ + logError + "[NAMES] decodeGetRecord placeholder hit — SNRC ABI codec not finalised; \ + \every non-zero-owner record returns NotFound until the decoder ships" + -- | Collapse the JSON-RPC transport-layer error space into the resolver's -- public error space. mapEthRpcError :: EthRpcError -> ResolveError diff --git a/src/Simplex/Messaging/Server/Names/Eth/SNRC.hs b/src/Simplex/Messaging/Server/Names/Eth/SNRC.hs index 80b11a255..adf3d2d5e 100644 --- a/src/Simplex/Messaging/Server/Names/Eth/SNRC.hs +++ b/src/Simplex/Messaging/Server/Names/Eth/SNRC.hs @@ -35,6 +35,7 @@ module Simplex.Messaging.Server.Names.Eth.SNRC decodeString, decodeUtf8Text, decodeStringArray, + isZeroOwner, ) where diff --git a/tests/SMPNamesTests.hs b/tests/SMPNamesTests.hs index 102cf1f73..8eea37791 100644 --- a/tests/SMPNamesTests.hs +++ b/tests/SMPNamesTests.hs @@ -17,6 +17,7 @@ import qualified Data.Aeson as J import qualified Data.ByteString.Lazy as LB import Simplex.Messaging.Protocol ( LookupKey (..), + NameOwner, NameRecord (..), mkNameLink, mkNameOwner, @@ -41,8 +42,6 @@ import Simplex.Messaging.Server.Names newNamesEnvWith, resolveName, ) -import Simplex.Messaging.Transport (VersionSMP) -import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec -- Reference vectors: @@ -63,9 +62,6 @@ sha3_256Abc = "\x3a\x98\x5d\xa7\x4f\xe2\x25\xb2\x04\x5c\x17\x2d\x6b\xd3\x90\xbd\ namehashEth :: ByteString namehashEth = "\x93\xcd\xeb\x70\x8b\x75\x45\xdc\x66\x8e\xb9\x28\x01\x76\x16\x9d\x1c\x33\xcf\xd8\xed\x6f\x04\x69\x0a\x0b\xcc\x88\xa9\x3f\xc4\xae" -v20 :: VersionSMP -v20 = Version 20 - twentyOnes :: ByteString twentyOnes = B.replicate 20 '\x01' @@ -109,6 +105,16 @@ nameRecordEncodingSpec = do bytes = LB.toStrict (J.encode overflow) (J.eitherDecodeStrict bytes :: Either String NameRecord) `shouldSatisfy` isLeft + it "rejects nrDisplayName > 255 bytes UTF-8" $ do + let oversize = sampleRecord {nrDisplayName = T.replicate 256 "x"} + bytes = LB.toStrict (J.encode oversize) + (J.eitherDecodeStrict bytes :: Either String NameRecord) `shouldSatisfy` isLeft + + it "FromJSON NameOwner accepts both 0x and 0X prefixes" $ do + let json p = "\"" <> p <> "0101010101010101010101010101010101010101\"" + (J.eitherDecodeStrict (json "0x") :: Either String NameOwner) `shouldSatisfy` isRight + (J.eitherDecodeStrict (json "0X") :: Either String NameOwner) `shouldSatisfy` isRight + it "encodes within the proxied transmission budget" $ do let huge = either error id (mkNameLink (T.replicate 1024 "x")) wide =