From c90561b7d1194c0ab73c47dce1947cc1cbeea824 Mon Sep 17 00:00:00 2001 From: Ed Asriyan Date: Sat, 1 Aug 2026 04:47:34 +0000 Subject: [PATCH] feat: add server public information handling in XFTP protocol --- protocol/xftp.md | 5 +++-- src/Simplex/FileTransfer/Client.hs | 6 +++-- src/Simplex/FileTransfer/Server.hs | 7 ++++-- src/Simplex/FileTransfer/Server/Env.hs | 3 +++ src/Simplex/FileTransfer/Server/Main.hs | 1 + src/Simplex/FileTransfer/Transport.hs | 29 ++++++++++++++++++------- src/Simplex/Messaging/Agent.hs | 2 +- src/Simplex/Messaging/Agent/Client.hs | 6 ++--- tests/XFTPClient.hs | 1 + 9 files changed, 42 insertions(+), 18 deletions(-) diff --git a/protocol/xftp.md b/protocol/xftp.md index 4180e652b6..4fcebea90c 100644 --- a/protocol/xftp.md +++ b/protocol/xftp.md @@ -1,4 +1,4 @@ -Version 3, 2025-01-24 +Version 4, 2026-08-08 # SimpleX File Transfer Protocol @@ -50,11 +50,12 @@ The objective of SimpleX File Transfer Protocol (XFTP) is to facilitate the secu XFTP is implemented as an application level protocol on top of HTTP2 and TLS. -This document describes XFTP protocol version 3. The version history: +This document describes XFTP protocol version 4. The version history: - v1: initial version - v2: authenticated commands - added basic auth support for commands - v3: blocked files - added BLOCKED error type for policy violations +- v4: server public information in handshake The protocol describes the set of commands that senders and recipients can send to XFTP routers to create, upload, download and delete data packets of several pre-defined sizes. XFTP routers SHOULD support packets of 4 sizes: 64KB, 256KB, 1MB and 4MB (1KB = 1024 bytes, 1MB = 1024KB). diff --git a/src/Simplex/FileTransfer/Client.hs b/src/Simplex/FileTransfer/Client.hs index a5cd4acfe1..785d3040bc 100644 --- a/src/Simplex/FileTransfer/Client.hs +++ b/src/Simplex/FileTransfer/Client.hs @@ -38,6 +38,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson as J import Data.Bifunctor (first) import Data.ByteString.Builder (Builder, byteString) import Data.ByteString.Char8 (ByteString) @@ -155,12 +156,13 @@ getXFTPClient transportSession@(_, srv, _) config@XFTPClientConfig {clientALPN, xftpClientHandshakeV1 :: VersionRangeXFTP -> C.KeyHash -> HTTP2Client -> THandleParamsXFTP 'TClient -> ExceptT XFTPClientError IO (THandleParamsXFTP 'TClient) xftpClientHandshakeV1 serverVRange keyHash@(C.KeyHash kh) c@HTTP2Client {sessionId, serverKey} thParams0 = do - shs@XFTPServerHandshake {authPubKey = ck} <- getServerHandshake + shs@XFTPServerHandshake {authPubKey = ck, serverInfoBytes} <- getServerHandshake (vr, sk) <- processServerHandshake shs let v = maxVersion vr sendClientHandshake XFTPClientHandshake {xftpVersion = v, keyHash} let thAuth = Just THAuthClient {peerServerPubKey = sk, peerServerCertKey = ck, clientService = Nothing, sessSecret = Nothing} - pure thParams0 {thAuth, thVersion = v, thServerVRange = vr} + serverInfo = J.eitherDecodeStrict' <$> serverInfoBytes + pure thParams0 {thAuth, thVersion = v, thServerVRange = vr, serverInfo} where getServerHandshake :: ExceptT XFTPClientError IO XFTPServerHandshake getServerHandshake = do diff --git a/src/Simplex/FileTransfer/Server.hs b/src/Simplex/FileTransfer/Server.hs index 9f74997823..b4dea099e0 100644 --- a/src/Simplex/FileTransfer/Server.hs +++ b/src/Simplex/FileTransfer/Server.hs @@ -23,11 +23,13 @@ import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Control.Monad.Trans.Except +import qualified Data.Aeson as J import Data.Bifunctor (first) import qualified Data.ByteString.Base64.URL as B64 import Data.ByteString.Builder (Builder, byteString) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L @@ -124,7 +126,7 @@ data Handshake | HandshakeAccepted (THandleParams XFTPVersion 'TServer) xftpServer :: forall s. FileStoreClass s => XFTPServerConfig s -> TMVar Bool -> M s () -xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration, xftpServerVRange} started = do +xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpiration, fileExpiration, xftpServerVRange, information} started = do mapM_ (expireServerFiles Nothing) fileExpiration restoreServerStats raceAny_ @@ -202,7 +204,8 @@ xftpServer cfg@XFTPServerConfig {xftpPort, transportConfig, inactiveClientExpira fst kp <$ TM.insert sessionId (HandshakeSent $ snd kp) sessions let authPubKey = CertChainPubKey chain (C.signX509 serverSignKey $ C.publicToX509 k) webIdentityProof = C.sign serverSignKey . (<> sessionId) <$> challenge_ - let hs = XFTPServerHandshake {xftpVersionRange = xftpServerVRange, sessionId, authPubKey, webIdentityProof} + serverInfoBytes = LB.toStrict . J.encode <$> information + let hs = XFTPServerHandshake {xftpVersionRange = xftpServerVRange, sessionId, authPubKey, webIdentityProof, serverInfoBytes} shs <- encodeXftp hs #ifdef slow_servers lift randomDelay diff --git a/src/Simplex/FileTransfer/Server/Env.hs b/src/Simplex/FileTransfer/Server/Env.hs index b816eb36b3..930c151f5f 100644 --- a/src/Simplex/FileTransfer/Server/Env.hs +++ b/src/Simplex/FileTransfer/Server/Env.hs @@ -64,6 +64,7 @@ import Simplex.FileTransfer.Transport (VersionRangeXFTP) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (BasicAuth, RcvPublicAuthKey) import Simplex.Messaging.Server.Expiration +import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Transport.Server (ServerCredentials (..), TransportServerConfig (..), loadFingerprint, loadServerCredential) import Simplex.Messaging.Util (tshow) import System.IO (IOMode (..)) @@ -97,6 +98,8 @@ data XFTPServerConfig s = XFTPServerConfig httpCredentials :: Maybe ServerCredentials, -- | XFTP client-server protocol version range xftpServerVRange :: VersionRangeXFTP, + -- | server public information sent in handshake and used to generate static mini-site + information :: Maybe ServerPublicInfo, -- stats config - see SMP server config logStatsInterval :: Maybe Int64, logStatsStartTime :: Int64, diff --git a/src/Simplex/FileTransfer/Server/Main.hs b/src/Simplex/FileTransfer/Server/Main.hs index 070dc546fe..da439edf4e 100644 --- a/src/Simplex/FileTransfer/Server/Main.hs +++ b/src/Simplex/FileTransfer/Server/Main.hs @@ -306,6 +306,7 @@ xftpServerCLI_ generateSite serveStaticFiles cfgPath logPath = do }, httpCredentials = httpCredentials_, xftpServerVRange = supportedFileServerVRange, + information = serverPublicInfo ini, logStatsInterval = logStats $> 86400, -- seconds logStatsStartTime = 0, -- seconds from 00:00 UTC serverStatsLogFile = combine logPath "file-server-stats.daily.log", diff --git a/src/Simplex/FileTransfer/Transport.hs b/src/Simplex/FileTransfer/Transport.hs index d55b251483..4c3e27f553 100644 --- a/src/Simplex/FileTransfer/Transport.hs +++ b/src/Simplex/FileTransfer/Transport.hs @@ -12,6 +12,7 @@ module Simplex.FileTransfer.Transport ( supportedFileServerVRange, authCmdsXFTPVersion, blockedFilesXFTPVersion, + serverInfoXFTPVersion, xftpClientHandshakeStub, alpnSupportedXFTPhandshakes, xftpALPNv1, @@ -36,7 +37,6 @@ module Simplex.FileTransfer.Transport ) where -import Control.Applicative (optional) import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad @@ -62,7 +62,7 @@ import Simplex.Messaging.Parsers import Simplex.Messaging.Protocol (BlockingInfo, CommandError) import Simplex.Messaging.Transport (ALPN, CertChainPubKey, ServiceCredentials, SessionId, THandle (..), THandleParams (..), TransportError (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.File -import Simplex.Messaging.Util (bshow, tshow, (<$?>)) +import Simplex.Messaging.Util (bshow, tshow, (<$?>), (<$$>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import System.IO (Handle, IOMode (..), withFile) @@ -97,8 +97,11 @@ authCmdsXFTPVersion = VersionXFTP 2 blockedFilesXFTPVersion :: VersionXFTP blockedFilesXFTPVersion = VersionXFTP 3 +serverInfoXFTPVersion :: VersionXFTP +serverInfoXFTPVersion = VersionXFTP 4 + currentXFTPVersion :: VersionXFTP -currentXFTPVersion = VersionXFTP 3 +currentXFTPVersion = VersionXFTP 4 supportedFileServerVRange :: VersionRangeXFTP supportedFileServerVRange = mkVersionRange initialXFTPVersion currentXFTPVersion @@ -124,7 +127,9 @@ data XFTPServerHandshake = XFTPServerHandshake -- | pub key to agree shared secrets for command authorization and entity ID encryption. authPubKey :: CertChainPubKey, -- | signed identity challenge from XFTPClientHello - webIdentityProof :: Maybe C.ASignature + webIdentityProof :: Maybe C.ASignature, + -- | optional server public information (JSON-encoded ServerPublicInfo), sent when version >= serverInfoXFTPVersion + serverInfoBytes :: Maybe ByteString } data XFTPClientHandshake = XFTPClientHandshake @@ -151,13 +156,21 @@ instance Encoding XFTPClientHandshake where pure XFTPClientHandshake {xftpVersion, keyHash} instance Encoding XFTPServerHandshake where - smpEncode XFTPServerHandshake {xftpVersionRange, sessionId, authPubKey, webIdentityProof} = - smpEncode (xftpVersionRange, sessionId, authPubKey, C.signatureBytes webIdentityProof) + smpEncode XFTPServerHandshake {xftpVersionRange, sessionId, authPubKey, webIdentityProof, serverInfoBytes} = + smpEncode (xftpVersionRange, sessionId, authPubKey, C.signatureBytes webIdentityProof) <> info + where + info = ifHasServerInfo (maxVersion xftpVersionRange) (smpEncode (Large <$> serverInfoBytes)) "" smpP = do (xftpVersionRange, sessionId, authPubKey) <- smpP - webIdentityProof <- optional $ C.decodeSignature <$?> smpP + -- decode the (length-prefixed) signature bytes deterministically: empty bytes decode to Nothing. + -- (Must not use `optional`, which would backtrack and leave the bytes for the parsers that follow.) + webIdentityProof <- C.decodeSignature <$?> smpP + serverInfoBytes <- ifHasServerInfo (maxVersion xftpVersionRange) (unLarge <$$> smpP) (pure Nothing) Tail _compat <- smpP - pure XFTPServerHandshake {xftpVersionRange, sessionId, authPubKey, webIdentityProof} + pure XFTPServerHandshake {xftpVersionRange, sessionId, authPubKey, webIdentityProof, serverInfoBytes} + +ifHasServerInfo :: VersionXFTP -> a -> a -> a +ifHasServerInfo v a b = if v >= serverInfoXFTPVersion then a else b sendEncFile :: Handle -> (Builder -> IO ()) -> LC.SbState -> Word32 -> IO () sendEncFile h send = go diff --git a/src/Simplex/Messaging/Agent.hs b/src/Simplex/Messaging/Agent.hs index 18a7ac9809..56f474a0f7 100644 --- a/src/Simplex/Messaging/Agent.hs +++ b/src/Simplex/Messaging/Agent.hs @@ -679,7 +679,7 @@ getConnectionRatchetAdHash c = withAgentEnv c . getConnectionRatchetAdHash' c testProtocolServer :: forall p. ProtocolTypeI p => AgentClient -> NetworkRequestMode -> UserId -> ProtoServerWithAuth p -> IO (Either ProtocolTestFailure (Maybe (Either String ServerPublicInfo))) testProtocolServer c nm userId srv = withAgentEnv' c $ case protocolTypeI @p of SPSMP -> runSMPServerTest c nm userId srv - SPXFTP -> maybe (Right Nothing) Left <$> runXFTPServerTest c nm userId srv + SPXFTP -> runXFTPServerTest c nm userId srv SPNTF -> maybe (Right Nothing) Left <$> runNTFServerTest c nm userId srv -- | set SOCKS5 proxy on/off and optionally set TCP timeouts for fast network diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index b2df59b962..43cf190397 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -1326,7 +1326,7 @@ runSMPServerTest c@AgentClient {presetDomains} nm userId (ProtoServerWithAuth sr testErr :: ProtocolTestStep -> SMPClientError -> ProtocolTestFailure testErr step = ProtocolTestFailure step . protocolClientError SMP addr -runXFTPServerTest :: AgentClient -> NetworkRequestMode -> UserId -> XFTPServerWithAuth -> AM' (Maybe ProtocolTestFailure) +runXFTPServerTest :: AgentClient -> NetworkRequestMode -> UserId -> XFTPServerWithAuth -> AM' (Either ProtocolTestFailure (Maybe (Either String ServerPublicInfo))) runXFTPServerTest c@AgentClient {presetDomains} nm userId (ProtoServerWithAuth srv auth) = do cfg <- asks $ xftpCfg . config g <- asks random @@ -1352,8 +1352,8 @@ runXFTPServerTest c@AgentClient {presetDomains} nm userId (ProtoServerWithAuth s unless (digest == rcvDigest) $ throwE $ ProtocolTestFailure TSCompareFile $ XFTP (B.unpack $ strEncode srv) DIGEST liftError (testErr TSDeleteFile) $ X.deleteXFTPChunk xftp spKey sId ok <- netTimeoutInt (tcpTimeout xftpNetworkConfig) nm `timeout` X.closeXFTPClient xftp - pure $ either Just (const Nothing) r <|> maybe (Just (ProtocolTestFailure TSDisconnect $ BROKER addr TIMEOUT)) (const Nothing) ok - Left e -> pure (Just $ testErr TSConnect e) + pure $ r >> maybe (Left (ProtocolTestFailure TSDisconnect $ BROKER addr TIMEOUT)) (const $ Right $ serverInfo (X.thParams xftp)) ok + Left e -> pure $ Left (testErr TSConnect e) where addr = B.unpack $ strEncode srv testErr :: ProtocolTestStep -> XFTPClientError -> ProtocolTestFailure diff --git a/tests/XFTPClient.hs b/tests/XFTPClient.hs index b306ae39ca..efdb7f21c7 100644 --- a/tests/XFTPClient.hs +++ b/tests/XFTPClient.hs @@ -192,6 +192,7 @@ testXFTPServerConfig = }, httpCredentials = Nothing, xftpServerVRange = supportedFileServerVRange, + information = Nothing, logStatsInterval = Nothing, logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log",