diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index c5e5d8489..5c421b6d9 100644 --- a/protocol/simplex-messaging.md +++ b/protocol/simplex-messaging.md @@ -86,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 22. Versions 1-5 are discontinued. The version history: +This document describes SMP protocol version 23. Versions 1-5 are discontinued. The version history: - v1: binary protocol encoding - v2: message flags (used to control notifications) @@ -109,6 +109,7 @@ This document describes SMP protocol version 22. Versions 1-5 are discontinued. - v20: public namespaces resolver (RSLV command, RNAME response) — direct or forwarded via PFWD - v21: server public information in handshake - v22: `RNAME` says whether a name can be registered, not only what it resolves to +- v23: version in nonces of forwarded commands, random nonces for forwarded responses ## Introduction @@ -1128,7 +1129,7 @@ The proxy router may respond with error response in case the destination router Sender can send `SKEY` and `SEND` commands via proxy after obtaining the session ID with `PRXY` command (see [Request proxied session](#request-proxied-session)). -Transmission sent to proxy router should use session ID as entity ID and use a random correlation ID of 24 bytes as a nonce for crypto_box encryption of transmission to the destination router. The random ephemeral X25519 key to encrypt transmission should be unique per command, and it should be combined with the key sent by the router in the handshake header to proxy and to the client in `PKEY` command. +Transmission sent to proxy router should use session ID as entity ID and use a random correlation ID of 24 bytes as a nonce for crypto_box encryption of transmission to the destination router. When `smpVersion` in `PFWD` is 23 or higher, the first 2 bytes of this nonce are XOR-ed with `smpVersion`. The random ephemeral X25519 key to encrypt transmission should be unique per command, and it should be combined with the key sent by the router in the handshake header to proxy and to the client in `PKEY` command. Encrypted transmission should use the received session ID from the connection between proxy router and destination router in the authorized body. @@ -1140,10 +1141,11 @@ commandKey = length x509encoded The proxy router will forward the encrypted transmission in `RFWD` command (see below). -Having received the `RRES` response from the destination router, proxy router will forward `PRES` response to the client. `PRES` response should use the same correlation ID as `PFWD` command. The destination router will use this correlation ID increased by 1 as a nonce for encryption of the response. +Having received the `RRES` response from the destination router, proxy router will forward `PRES` response to the client. `PRES` response should use the same correlation ID as `PFWD` command. The destination router will use this correlation ID increased by 1 as a nonce for encryption of the response. When `smpVersion` in `PFWD` is 23 or higher, the destination router uses a random nonce instead, and sends it before the encrypted response. ```abnf -proxyResponse = %s"PRES" SP +proxyResponse = %s"PRES" SP [responseNonce] +responseNonce = 24*24 OCTET ; from v23 forwardedResponse = *OCTET ; client-encrypted SMP response, decrypted by client using per-command DH secret ``` diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f8f1a4cb9..551bddfb7 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -142,6 +142,7 @@ import Control.Monad.Trans.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as J import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Base64 as B64 @@ -1228,14 +1229,17 @@ proxySMPCommand c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c TBError e _ : _ -> throwE $ PCETransportError e TBTransmission s _ : _ -> pure s TBTransmissions s _ _ : _ -> pure s - et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret nonce b paddedProxiedTLength + et <- liftEitherWith PCECryptoError $ EncTransmission <$> C.cbEncrypt cmdSecret (encTransmissionNonce v nonce) b paddedProxiedTLength -- proxy interaction errors are wrapped let tOut = Just $ 2 * netTimeoutInt tcpTimeout nm tryE (sendProtocolCommand_ c nm (Just nonce) tOut Nothing (EntityId sessionId) (Cmd SProxiedClient (PFWD v cmdPubKey et))) >>= \case Right r -> case r of PRES (EncResponse er) -> do -- server interaction errors are thrown directly - t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er + let (respNonce, er') + | v >= fwdNoncesSMPVersion = first C.cbNonce $ B.splitAt 24 er + | otherwise = (C.reverseNonce nonce, er) + t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret respNonce er' case tParse serverThParams t' of t'' :| [] -> case tDecodeClient serverThParams t'' of (_, _, cmd) -> case cmd of diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 14f2a967f..348d15a9a 100644 --- a/src/Simplex/Messaging/Protocol.hs +++ b/src/Simplex/Messaging/Protocol.hs @@ -168,6 +168,7 @@ module Simplex.Messaging.Protocol EncFwdTransmission (..), EncResponse (..), EncTransmission (..), + encTransmissionNonce, FwdResponse (..), FwdTransmission (..), NameRecord (..), @@ -702,6 +703,11 @@ instance Encoding NewNtfCreds where newtype EncTransmission = EncTransmission ByteString deriving (Show) +encTransmissionNonce :: VersionSMP -> C.CbNonce -> C.CbNonce +encTransmissionNonce v nonce@(C.CbNonce s) + | v >= fwdNoncesSMPVersion = C.cbNonce $ BS.pack (BS.zipWith xor (smpEncode v) s) <> BS.drop 2 s + | otherwise = nonce + data FwdTransmission = FwdTransmission { fwdCorrId :: CorrId, fwdVersion :: VersionSMP, diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 405e9b5f0..f39e69c0b 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -2131,7 +2131,7 @@ client unless (fwdVersion `isCompatible` thServerVRange thParams') $ throwE $ transportErr TEVersion let clientSecret = C.dh' fwdKey serverPrivKey clientNonce = C.cbNonce $ bs fwdCorrId - b <- liftEitherWith (const CRYPTO) $ C.cbDecrypt clientSecret clientNonce et + b <- liftEitherWith (const CRYPTO) $ C.cbDecrypt clientSecret (encTransmissionNonce fwdVersion clientNonce) et let clntTHParams = smpTHParamsSetVersion fwdVersion thParams' -- only allowing single forwarded transactions t' <- case tParse clntTHParams b of @@ -2144,7 +2144,13 @@ client TBError _ _ : _ -> throwE BLOCK TBTransmission b' _ : _ -> pure b' TBTransmissions b' _ _ : _ -> pure b' - r2 <- liftEitherWith (const BLOCK) $ EncResponse <$> C.cbEncrypt clientSecret (C.reverseNonce clientNonce) r' paddedProxiedTLength + let encrypt nonce = liftEitherWith (const BLOCK) $ C.cbEncrypt clientSecret nonce r' paddedProxiedTLength + r2 <- + if fwdVersion >= fwdNoncesSMPVersion + then do + nonce <- atomically . C.randomCbNonce =<< asks random + EncResponse . (smpEncode nonce <>) <$> encrypt nonce + else EncResponse <$> encrypt (C.reverseNonce clientNonce) let fr = FwdResponse {fwdCorrId, fwdResponse = r2} pure $ RRES $ EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) -- the inner response, or Nothing if forked (RSLV). diff --git a/src/Simplex/Messaging/Transport.hs b/src/Simplex/Messaging/Transport.hs index 6fa307fd1..0a082a388 100644 --- a/src/Simplex/Messaging/Transport.hs +++ b/src/Simplex/Messaging/Transport.hs @@ -54,6 +54,7 @@ module Simplex.Messaging.Transport namesSMPVersion, serverInfoSMPVersion, nameAvailSMPVersion, + fwdNoncesSMPVersion, simplexMQVersion, smpBlockSize, TransportConfig (..), @@ -177,6 +178,7 @@ smpBlockSize = 16384 -- 20 - public namespaces resolver, RSLV command (6/20/2026) -- 21 - server public information in handshake (7/5/2026) -- 22 - RNAME answers name availability as well as the record (7/25/2026) +-- 23 - version in forwarded command nonce, random nonce in forwarded responses (10/2/2026) data SMPVersion @@ -218,6 +220,9 @@ serverInfoSMPVersion = VersionSMP 21 nameAvailSMPVersion :: VersionSMP nameAvailSMPVersion = VersionSMP 22 +fwdNoncesSMPVersion :: VersionSMP +fwdNoncesSMPVersion = VersionSMP 23 + minClientSMPRelayVersion :: VersionSMP minClientSMPRelayVersion = VersionSMP 14 @@ -225,20 +230,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 forwarded commands use the nonces from +-- fwdNoncesSMPVersion (23), so the one-version anti-fingerprinting buffer does +-- not apply yet; it 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/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 430d52304..eca9fc5eb 100644 --- a/tests/SMPProxyTests.hs +++ b/tests/SMPProxyTests.hs @@ -73,6 +73,8 @@ smpProxyTests = do xit "no SMP service at host/port" todo xit "bad SMP fingerprint" todo xit "batching proxy requests" todo + it "relay rejects forwarded command with changed version" $ \_ -> + testChangedFwdVersion describe "deliver message via SMP proxy" $ do let srv1 = SMPServer testHost testPort testKeyHash srv2 = SMPServer testHost2 testPort2 testKeyHash @@ -95,6 +97,11 @@ smpProxyTests = do deliverMessageViaProxy proxyServ relayServ C.SEd25519 msg1 msg2 it "max message size, X25519 keys" . twoServersFirstProxy $ deliverMessageViaProxy proxyServ relayServ C.SX25519 msg1 msg2 + describe "version compatibility" $ do + let deliver clientVR = deliverMessagesViaProxyVR clientVR srv1 srv2 C.SEd448 ["hello 1"] ["hello 2"] + it "prev client" . twoServersFirstProxy $ deliver (prevRange supportedClientSMPRelayVRange) + it "prev proxy" . twoServersPrevProxy $ deliver supportedClientSMPRelayVRange + it "prev relay" . twoServersPrevRelay $ deliver supportedClientSMPRelayVRange describe "stress test 1k" $ do let deliver n = deliverMessagesViaProxy srv1 srv2 C.SEd448 [] (map bshow [1 :: Int .. n]) it "1x1000" . twoServersFirstProxy $ deliver 1000 @@ -155,6 +162,9 @@ smpProxyTests = do twoServersFirstProxy test msType = twoServers_ (proxyCfgMS msType) (updateCfg (cfgMS msType) $ \cfg_ -> cfg_ {msgQueueQuota = 128, maxJournalMsgCount = 256}) test msType twoServersMoreConc test msType = twoServers_ (updateCfg (proxyCfgMS msType) $ \cfg_ -> cfg_ {serverClientConcurrency = 128}) (updateCfg (cfgMS msType) $ \cfg_ -> cfg_ {msgQueueQuota = 128, maxJournalMsgCount = 256}) test msType twoServersNoConc test msType = twoServers_ (updateCfg (proxyCfgMS msType) $ \cfg_ -> cfg_ {serverClientConcurrency = 1}) (updateCfg (cfgMS msType) $ \cfg_ -> cfg_ {msgQueueQuota = 128, maxJournalMsgCount = 256}) test msType + twoServersPrevProxy test msType = twoServers_ (prevServerVRange $ proxyCfgMS msType) (cfgMS msType) test msType + twoServersPrevRelay test msType = twoServers_ (proxyCfgMS msType) (prevServerVRange $ cfgMS msType) test msType + prevServerVRange cfg' = updateCfg cfg' $ \cfg_ -> cfg_ {smpServerVRange = prevRange $ smpServerVRange cfg_} twoServers_ :: AServerConfig -> AServerConfig -> IO () -> AStoreType -> IO () twoServers_ cfg1 cfg2 runTest (ASType qsType _) = withSmpServerConfigOn (transport @TLS) cfg1 testPort $ \_ -> @@ -167,11 +177,14 @@ deliverMessageViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SM deliverMessageViaProxy proxyServ relayServ alg msg msg' = deliverMessagesViaProxy proxyServ relayServ alg [msg] [msg'] deliverMessagesViaProxy :: (C.AlgorithmI a, C.AuthAlgorithm a) => SMPServer -> SMPServer -> C.SAlgorithm a -> [ByteString] -> [ByteString] -> IO () -deliverMessagesViaProxy proxyServ relayServ alg unsecuredMsgs securedMsgs = do +deliverMessagesViaProxy = deliverMessagesViaProxyVR $ mkVersionRange minServerSMPRelayVersion currentClientSMPRelayVersion + +deliverMessagesViaProxyVR :: (C.AlgorithmI a, C.AuthAlgorithm a) => VersionRangeSMP -> SMPServer -> SMPServer -> C.SAlgorithm a -> [ByteString] -> [ByteString] -> IO () +deliverMessagesViaProxyVR clientVR proxyServ relayServ alg unsecuredMsgs securedMsgs = do g <- C.newRandom -- set up proxy ts <- getCurrentTime - pc' <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) defaultSMPClientConfig {serverVRange = mkVersionRange minServerSMPRelayVersion currentClientSMPRelayVersion} [] Nothing ts (\_ -> pure ()) + pc' <- getProtocolClient g NRMInteractive (1, proxyServ, Nothing) defaultSMPClientConfig {serverVRange = clientVR} [] Nothing ts (\_ -> pure ()) pc <- either (fail . show) pure pc' THAuthClient {} <- maybe (fail "getProtocolClient returned no thAuth") pure $ thAuth $ thParams pc -- set up relay @@ -448,6 +461,21 @@ requestRelaySession = testSMPClient_ "localhost" testPort supportedServerSMPRelayVRange Nothing $ \(th :: THandleSMP TLS 'TClient) -> (\(_, _, reply) -> reply) <$> sendRecv th (Nothing, "1", NoEntity, SMP.PRXY testSMPServer2 Nothing) +testChangedFwdVersion :: IO () +testChangedFwdVersion = + withSmpServerConfigOn (transport @TLS) cfg testPort $ \_ -> do + g <- C.newRandom + ts <- getCurrentTime + rc <- either (fail . show) pure =<< getProtocolClient g NRMInteractive (1, testSMPServer, Nothing) defaultSMPClientConfig [] Nothing ts (\_ -> pure ()) + THAuthClient {peerServerPubKey} <- maybe (fail "getProtocolClient returned no thAuth") pure $ thAuth $ thParams rc + (cmdPubKey, cmdPrivKey) <- atomically $ C.generateKeyPair g + nonce@(C.CbNonce corrId) <- atomically $ C.randomCbNonce g + let v = currentClientSMPRelayVersion + et <- either (fail . show) (pure . SMP.EncTransmission) $ C.cbEncrypt (C.dh' peerServerPubKey cmdPrivKey) (SMP.encTransmissionNonce v nonce) "" SMP.paddedProxiedTLength + let forward fwdVersion = forwardSMPTransmission rc (SMP.CorrId corrId) fwdVersion cmdPubKey et + _ <- runExceptT' $ forward v + runExceptT (forward $ prevVersion v) `shouldReturn` Left (PCEProtocolError SMP.CRYPTO) + -- Shared "phase 2" of the reconnection tests: start a healthy relay, confirm it is reachable -- directly (PING, not via the proxy) so a proxy failure can only mean the proxy didn't reconnect, -- let any stored connection error expire, then require the proxy to establish the session (PKEY).