From ead8f10f26503a43fadcb98fc31f0e906914364c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 08:56:19 +0100 Subject: [PATCH 1/8] smp protocol: bind agreed version of forwarded command (#1915) * smp protocol: bind agreed version of forwarded command * refactor * pass proxied relay version range via config * simplify * refactor * packZipWith --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- protocol/simplex-messaging.md | 12 ++++++---- src/Simplex/Messaging/Client.hs | 28 +++++++++++++--------- src/Simplex/Messaging/Protocol.hs | 28 +++++++++++++++------- src/Simplex/Messaging/Server.hs | 12 ++++++---- src/Simplex/Messaging/Transport.hs | 19 +++++++++------ tests/AgentTests/FunctionalAPITests.hs | 6 ++++- tests/SMPClient.hs | 10 ++++++++ tests/SMPProxyTests.hs | 32 ++++++++++++++++++++++++-- 8 files changed, 109 insertions(+), 38 deletions(-) diff --git a/protocol/simplex-messaging.md b/protocol/simplex-messaging.md index c5e5d8489..684874ee5 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 = %s"0" / (%s"1" 24*24 OCTET) ; from v23 forwardedResponse = *OCTET ; client-encrypted SMP response, decrypted by client using per-command DH secret ``` @@ -1170,7 +1172,7 @@ The shared secret for encrypting transmission bodies between proxy router and de ```abnf -relayResponse = %s"RRES" SP +relayResponse = %s"RRES" SP [responseNonce] responseTransmission = fwdCorrId forwardedResponse ; fwdCorrId and forwardedResponse defined above in RFWD section ``` diff --git a/src/Simplex/Messaging/Client.hs b/src/Simplex/Messaging/Client.hs index f8f1a4cb9..7a2eedeb9 100644 --- a/src/Simplex/Messaging/Client.hs +++ b/src/Simplex/Messaging/Client.hs @@ -195,6 +195,7 @@ data PClient v err msg = PClient transportHost :: TransportHost, tcpConnectTimeout :: NetworkTimeout, tcpTimeout :: NetworkTimeout, + proxiedRelayVRange :: VersionRange v, sendPings :: TVar Bool, lastReceived :: TVar UTCTime, timeoutErrorCount :: TVar Int, @@ -240,6 +241,7 @@ smpClientStub g sessionId thVersion thAuth = do transportHost = "localhost", tcpConnectTimeout, tcpTimeout, + proxiedRelayVRange = supportedClientSMPRelayVRange, sendPings, lastReceived, timeoutErrorCount, @@ -477,6 +479,7 @@ data ProtocolClientConfig v = ProtocolClientConfig serviceCredentials :: Maybe ServiceCredentials, -- | client-server protocol version range serverVRange :: VersionRange v, + proxiedRelayVRange :: VersionRange v, -- | agree shared session secret (used in SMP proxy for additional encryption layer) agreeSecret :: Bool, -- | Whether connecting client is a proxy server. See comment in ClientHandshake @@ -495,6 +498,7 @@ defaultClientConfig clientALPN useSNI serverVRange = clientALPN, serviceCredentials = Nothing, serverVRange, + proxiedRelayVRange = serverVRange, agreeSecret = False, proxyServer = False, useSNI @@ -505,6 +509,7 @@ defaultSMPClientConfig :: ProtocolClientConfig SMPVersion defaultSMPClientConfig = (defaultClientConfig (Just alpnSupportedSMPHandshakes) False supportedClientSMPRelayVRange) { defaultTransport = (show defaultSMPPort, transport @TLS), + proxiedRelayVRange = supportedClientSMPRelayVRange, agreeSecret = True } {-# INLINE defaultSMPClientConfig #-} @@ -568,7 +573,7 @@ type SMPTransportSession = TransportSession BrokerMsg -- A single queue can be used for multiple 'SMPClient' instances, -- as 'SMPServerTransmission' includes server information. getProtocolClient :: forall v err msg. Protocol v err msg => TVar ChaChaDRG -> NetworkRequestMode -> TransportSession msg -> ProtocolClientConfig v -> [HostName] -> Maybe (TBQueue (ServerTransmissionBatch v err msg)) -> UTCTime -> (ProtocolClient v err msg -> IO ()) -> IO (Either (ProtocolClientError err) (ProtocolClient v err msg)) -getProtocolClient g nm transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, clientALPN, serviceCredentials, serverVRange, agreeSecret, proxyServer, useSNI} presetDomains msgQ proxySessTs disconnected = do +getProtocolClient g nm transportSession@(_, srv, _) cfg@ProtocolClientConfig {qSize, networkConfig, clientALPN, serviceCredentials, serverVRange, proxiedRelayVRange, agreeSecret, proxyServer, useSNI} presetDomains msgQ proxySessTs disconnected = do case chooseTransportHost networkConfig (host srv) of Right useHost -> (getCurrentTime >>= mkProtocolClient useHost >>= runClient useTransport useHost) @@ -593,6 +598,7 @@ getProtocolClient g nm transportSession@(_, srv, _) cfg@ProtocolClientConfig {qS transportHost, tcpConnectTimeout, tcpTimeout, + proxiedRelayVRange, sendPings, lastReceived, timeoutErrorCount, @@ -1115,10 +1121,10 @@ deleteSMPQueues = okSMPCommands DEL -- send PRXY :: SMPServer -> Maybe BasicAuth -> Command Sender -- receives PKEY :: SessionId -> X.CertificateChain -> X.SignedExact X.PubKey -> BrokerMsg connectSMPProxiedRelay :: SMPClient -> NetworkRequestMode -> SMPServer -> Maybe BasicAuth -> ExceptT SMPClientError IO ProxiedRelay -connectSMPProxiedRelay c@ProtocolClient {client_ = PClient {tcpConnectTimeout, tcpTimeout}} nm relayServ@ProtocolServer {port = relayPort, keyHash = C.KeyHash kh} proxyAuth = +connectSMPProxiedRelay c@ProtocolClient {client_ = PClient {tcpConnectTimeout, tcpTimeout, proxiedRelayVRange}} nm relayServ@ProtocolServer {port = relayPort, keyHash = C.KeyHash kh} proxyAuth = sendProtocolCommand_ c nm Nothing tOut Nothing NoEntity (Cmd SProxiedClient (PRXY relayServ proxyAuth)) >>= \case PKEY sId vr (CertChainPubKey chain key) -> - case supportedClientSMPRelayVRange `compatibleVersion` vr of + case proxiedRelayVRange `compatibleVersion` vr of Nothing -> throwE $ transportErr TEVersion Just (Compatible v) -> do relayKey <- liftEitherWith (const $ transportErr $ TEHandshake IDENTITY) =<< liftIO (runExceptT $ validateRelay chain key) @@ -1169,7 +1175,7 @@ instance StrEncoding ProxyClientError where -- consider how to process slow responses - is it handled somehow locally or delegated to the caller -- this method is used in the client -- sends PFWD :: C.PublicKeyX25519 -> EncTransmission -> Command Sender --- receives PRES :: EncResponse -> BrokerMsg -- proxy to client +-- receives PRES :: Maybe C.CbNonce -> EncResponse -> BrokerMsg -- proxy to client -- When client sends message via proxy, there may be one successful scenario and 9 error scenarios -- as shown below (WTF stands for unexpected response, ??? for response that failed to parse). @@ -1228,14 +1234,14 @@ 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 + PRES nonce_ (EncResponse er) -> do -- server interaction errors are thrown directly - t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (C.reverseNonce nonce) er + t' <- liftEitherWith PCECryptoError $ C.cbDecrypt cmdSecret (fromMaybe (C.reverseNonce nonce) nonce_) er case tParse serverThParams t' of t'' :| [] -> case tDecodeClient serverThParams t'' of (_, _, cmd) -> case cmd of @@ -1253,10 +1259,10 @@ proxySMPCommand c@ProtocolClient {thParams = proxyThParams, client_ = PClient {c -- this method is used in the proxy -- sends RFWD :: EncFwdTransmission -> Command Sender --- receives RRES :: EncFwdResponse -> BrokerMsg +-- receives RRES :: Maybe C.CbNonce -> EncFwdResponse -> BrokerMsg -- proxy should send PRES to the client with EncResponse -- Always uses background timeout mode -forwardSMPTransmission :: SMPClient -> CorrId -> VersionSMP -> C.PublicKeyX25519 -> EncTransmission -> ExceptT SMPClientError IO EncResponse +forwardSMPTransmission :: SMPClient -> CorrId -> VersionSMP -> C.PublicKeyX25519 -> EncTransmission -> ExceptT SMPClientError IO (Maybe C.CbNonce, EncResponse) forwardSMPTransmission c@ProtocolClient {thParams, client_ = PClient {clientCorrId = g}} fwdCorrId fwdVersion fwdKey fwdTransmission = do -- prepare params sessSecret <- case thAuth thParams of @@ -1268,11 +1274,11 @@ forwardSMPTransmission c@ProtocolClient {thParams, client_ = PClient {clientCorr eft = EncFwdTransmission $ C.cbEncryptNoPad sessSecret nonce (smpEncode fwdT) -- send sendProtocolCommand_ c NRMBackground (Just nonce) Nothing Nothing NoEntity (Cmd SProxyService (RFWD eft)) >>= \case - RRES (EncFwdResponse efr) -> do + RRES nonce_ (EncFwdResponse efr) -> do -- unwrap r' <- liftEitherWith PCECryptoError $ C.cbDecryptNoPad sessSecret (C.reverseNonce nonce) efr FwdResponse {fwdCorrId = _, fwdResponse} <- liftEitherWith (const $ PCEResponseError BLOCK) $ smpDecode r' - pure fwdResponse + pure (nonce_, fwdResponse) r -> throwE $ unexpectedResponse r -- get queue information - always sent interactively diff --git a/src/Simplex/Messaging/Protocol.hs b/src/Simplex/Messaging/Protocol.hs index 14f2a967f..a62e84224 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 (..), @@ -280,7 +281,7 @@ import Simplex.Messaging.ServiceScheme import Simplex.Messaging.SimplexName (LabelHash, SimplexDomain (..), SimplexTLD (..), fullDomainName, labelHash) import Simplex.Messaging.Transport import Simplex.Messaging.Transport.Client (TransportHost, TransportHosts (..)) -import Simplex.Messaging.Util (bshow, eitherToMaybe, safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Util (bshow, eitherToMaybe, packZipWith, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal @@ -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 $ packZipWith xor (smpEncode v) s <> BS.drop 2 s + | otherwise = nonce + data FwdTransmission = FwdTransmission { fwdCorrId :: CorrId, fwdVersion :: VersionSMP, @@ -737,8 +743,8 @@ data BrokerMsg where NMSG :: C.CbNonce -> EncNMsgMeta -> BrokerMsg -- Should include certificate chain PKEY :: SessionId -> VersionRangeSMP -> CertChainPubKey -> BrokerMsg -- TLS-signed server key for proxy shared secret and initial sender key - RRES :: EncFwdResponse -> BrokerMsg -- relay to proxy - PRES :: EncResponse -> BrokerMsg -- proxy to client + RRES :: Maybe C.CbNonce -> EncFwdResponse -> BrokerMsg -- relay to proxy + PRES :: Maybe C.CbNonce -> EncResponse -> BrokerMsg -- proxy to client END :: BrokerMsg ENDS :: Int64 -> IdsHash -> BrokerMsg DELD :: BrokerMsg @@ -1985,8 +1991,8 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where NID nId srvNtfDh -> e (NID_, ' ', nId, srvNtfDh) NMSG nmsgNonce encNMsgMeta -> e (NMSG_, ' ', nmsgNonce, encNMsgMeta) PKEY sid vr certKey -> e (PKEY_, ' ', sid, vr, certKey) - RRES (EncFwdResponse encBlock) -> e (RRES_, ' ', Tail encBlock) - PRES (EncResponse encBlock) -> e (PRES_, ' ', Tail encBlock) + RRES nonce_ (EncFwdResponse encBlock) -> fwdResp RRES_ nonce_ encBlock + PRES nonce_ (EncResponse encBlock) -> fwdResp PRES_ nonce_ encBlock END -> e END_ ENDS n idsHash -> serviceResp ENDS_ n idsHash DELD -> e DELD_ @@ -2010,6 +2016,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where serviceResp tag n idsHash | v >= rcvServiceSMPVersion = e (tag, ' ', n, idsHash) | otherwise = e (tag, ' ', n) + fwdResp tag nonce_ encBlock + | v >= fwdNoncesSMPVersion = e (tag, ' ', nonce_, Tail encBlock) + | otherwise = e (tag, ' ', Tail encBlock) protocolP v = \case MSG_ -> do @@ -2041,8 +2050,8 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where NID_ -> NID <$> _smpP <*> smpP NMSG_ -> NMSG <$> _smpP <*> smpP PKEY_ -> PKEY <$> _smpP <*> smpP <*> smpP - RRES_ -> RRES <$> (EncFwdResponse . unTail <$> _smpP) - PRES_ -> PRES <$> (EncResponse . unTail <$> _smpP) + RRES_ -> fwdRespP RRES EncFwdResponse + PRES_ -> fwdRespP PRES EncResponse END_ -> pure END ENDS_ -> serviceRespP ENDS DELD_ -> pure DELD @@ -2059,6 +2068,9 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where serviceRespP resp | v >= rcvServiceSMPVersion = resp <$> _smpP <*> smpP | otherwise = resp <$> _smpP <*> pure mempty + fwdRespP :: (Maybe C.CbNonce -> a -> BrokerMsg) -> (ByteString -> a) -> Parser BrokerMsg + fwdRespP resp enc = resp <$> (A.space *> nonceP) <*> (enc <$> A.takeByteString) + nonceP = if v >= fwdNoncesSMPVersion then smpP else pure Nothing fromProtocolError = \case PECmdSyntax -> CMD SYNTAX @@ -2075,7 +2087,7 @@ instance ProtocolEncoding SMPVersion ErrorType BrokerMsg where -- PONG response must not have queue ID PONG -> noEntityMsg PKEY {} -> noEntityMsg - RRES _ -> noEntityMsg + RRES {} -> noEntityMsg ALLS -> noEntityMsg RNAME {} -> noEntityMsg -- other broker responses must have queue ID diff --git a/src/Simplex/Messaging/Server.hs b/src/Simplex/Messaging/Server.hs index 405e9b5f0..7894a4eab 100644 --- a/src/Simplex/Messaging/Server.hs +++ b/src/Simplex/Messaging/Server.hs @@ -1462,7 +1462,7 @@ client inc own pRequests forkProxiedCmd $ do liftIO (runExceptT (forwardSMPTransmission smp corrId fwdV pubKey encBlock) `E.catches` clientHandlers) >>= \case - Right r -> PRES r <$ inc own pSuccesses + Right (nonce_, r) -> PRES nonce_ r <$ inc own pSuccesses Left e -> ERR (smpProxyError e) <$ case e of PCEProtocolError {} -> inc own pSuccesses _ -> inc own pErrorsOther @@ -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,9 +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 + nonce_ <- + if fwdVersion >= fwdNoncesSMPVersion + then Just <$> (atomically . C.randomCbNonce =<< asks random) + else pure Nothing + r2 <- liftEitherWith (const BLOCK) $ EncResponse <$> C.cbEncrypt clientSecret (fromMaybe (C.reverseNonce clientNonce) nonce_) r' paddedProxiedTLength let fr = FwdResponse {fwdCorrId, fwdResponse = r2} - pure $ RRES $ EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) + pure $ RRES nonce_ $ EncFwdResponse $ C.cbEncryptNoPad sessSecret (C.reverseNonce proxyNonce) (smpEncode fr) -- the inner response, or Nothing if forked (RSLV). r_ <- lift (rejectOrVerify clntThAuth t') >>= \case -- rejectOrVerify filters allowed commands, no need to repeat it here. 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/AgentTests/FunctionalAPITests.hs b/tests/AgentTests/FunctionalAPITests.hs index 319f885ea..8194e9ccd 100644 --- a/tests/AgentTests/FunctionalAPITests.hs +++ b/tests/AgentTests/FunctionalAPITests.hs @@ -231,7 +231,11 @@ pattern Rcvd' :: AgentMsgId -> AgentMsgId -> AEvent 'AEConn pattern Rcvd' aMsgId rcvdMsgId <- RCVD MsgMeta {integrity = MsgOk, recipient = (aMsgId, _)} [MsgReceipt {agentMsgId = rcvdMsgId, msgRcptStatus = MROk}] smpCfgVPrev :: ProtocolClientConfig SMPVersion -smpCfgVPrev = (smpCfg agentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg agentCfg} +smpCfgVPrev = + (smpCfg agentCfg) + { serverVRange = prevRange $ serverVRange $ smpCfg agentCfg, + proxiedRelayVRange = prevRange $ proxiedRelayVRange $ smpCfg agentCfg + } -- ntfCfgVPrev :: ProtocolClientConfig NTFVersion -- ntfCfgVPrev = (ntfCfg agentCfg) {clientALPN = Nothing, serverVRange = V.mkVersionRange (VersionNTF 1) (VersionNTF 1)} diff --git a/tests/SMPClient.hs b/tests/SMPClient.hs index 43aa22a76..f1d5af1db 100644 --- a/tests/SMPClient.hs +++ b/tests/SMPClient.hs @@ -349,6 +349,16 @@ proxyCfgShortTimeout = nt = NetworkTimeout {backgroundTimeout = 4_000000, interactiveTimeout = 4_000000} in cfg' {smpAgentCfg = aCfg {smpCfg = cCfg {networkConfig = (networkConfig cCfg) {tcpConnectTimeout = nt}}}} +proxyCfgVPrev :: AStoreType -> AServerConfig +proxyCfgVPrev msType = + updateCfg (proxyCfgMS msType) $ \cfg' -> + let aCfg = smpAgentCfg cfg' + cCfg = smpCfg aCfg + in cfg' + { smpServerVRange = prevRange $ smpServerVRange cfg', + smpAgentCfg = aCfg {smpCfg = cCfg {serverVRange = prevRange $ serverVRange cCfg}} + } + withSmpServerStoreMsgLogOn :: HasCallStack => (ASrvTransport, AStoreType) -> ServiceName -> (HasCallStack => ThreadId -> IO a) -> IO a withSmpServerStoreMsgLogOn (t, msType) = withSmpServerConfigOn t $ updateCfg (cfgMS msType) $ \cfg' -> cfg' {storeNtfsFile = Just testStoreNtfsFile, serverStatsBackupFile = Just testServerStatsBackupFile} diff --git a/tests/SMPProxyTests.hs b/tests/SMPProxyTests.hs index 430d52304..91bae09a6 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_ (proxyCfgVPrev 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, proxiedRelayVRange = 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). From acd5c0f530a9b3bf04ab4398edaef7161c7dbfb6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:21:21 +0400 Subject: [PATCH 2/8] docs: add cryptographic primitive registry (#1913) --- contributing/CODE.md | 1 + protocol/crypto-registry.md | 189 ++++++++++++++++++++++++++++++++++++ protocol/security.md | 2 + 3 files changed, 192 insertions(+) create mode 100644 protocol/crypto-registry.md diff --git a/contributing/CODE.md b/contributing/CODE.md index ab5d7efcc..b6ced3089 100644 --- a/contributing/CODE.md +++ b/contributing/CODE.md @@ -7,6 +7,7 @@ This file provides guidance on coding style and approaches and on building the c When designing code and planning implementations: - Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. - Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances. +- Use the default cryptographic primitive for each purpose from the [primitive registry](../protocol/crypto-registry.md); any exception requires review, and the registry must be updated with every new primitive use or domain-separation string. ## Code Quality Standards diff --git a/protocol/crypto-registry.md b/protocol/crypto-registry.md new file mode 100644 index 000000000..2154e65a2 --- /dev/null +++ b/protocol/crypto-registry.md @@ -0,0 +1,189 @@ +Revision 1, 2026-10-01 + +# SimpleX Network: cryptographic primitive registry + +The cryptographic primitives, constructions and domain-separation strings used by this repository, and which of them new code may use. For the threat model see [Security](./security.md). + +Module aliases follow the codebase: `C` = [Simplex.Messaging.Crypto](../src/Simplex/Messaging/Crypto.hs), `LC` = [Crypto.Lazy](../src/Simplex/Messaging/Crypto/Lazy.hs), `CR` = [Crypto.Ratchet](../src/Simplex/Messaging/Crypto/Ratchet.hs), `SL` = [Crypto.ShortLink](../src/Simplex/Messaging/Crypto/ShortLink.hs), `KEM` = [Crypto.SNTRUP761](../src/Simplex/Messaging/Crypto/SNTRUP761.hs) and [its bindings](../src/Simplex/Messaging/Crypto/SNTRUP761/Bindings.hs), `BBS` = [Crypto.BBS](../src/Simplex/Messaging/Crypto/BBS.hs). Lengths are in bytes. + +## Table of contents + +- [Policy](#policy) +- [Defaults for new code](#defaults-for-new-code) +- [Primitives](#primitives) +- [SimpleX box constructions](#simplex-box-constructions) +- [Domain separation and KDF inputs](#domain-separation-and-kdf-inputs) +- [Nonces and IVs](#nonces-and-ivs) +- [Implementation sources](#implementation-sources) +- [xftp-web (TypeScript)](#xftp-web-typescript) +- [Tests](#tests) + +## Policy + +- New code uses the default primitive for its purpose from [Defaults for new code](#defaults-for-new-code). +- Using any other primitive, or a primitive marked *restricted* outside its listed purpose, requires a written justification in the pull request and review by a maintainer responsible for cryptography. +- *Legacy-only* primitives stay only for the existing wire formats and stored data listed below. Do not add call sites. +- Every new HKDF info string or hash prefix must be unique, start with `SimpleX`, and be added to [Domain separation and KDF inputs](#domain-separation-and-kdf-inputs). +- Changing a primitive, key length, KDF input, info string or nonce construction changes the wire format. It requires a protocol version and an update of this registry and of the affected `protocol/` specification. + +Status values used below: + +| Status | Meaning | +|---|---| +| Default | Approved and preferred for new code for this purpose | +| Approved | Approved for new code for this purpose when the default does not fit | +| Restricted | Approved only for the listed purpose (external interoperability or a single protocol) | +| Legacy-only | Kept for existing wire formats or stored data; no new call sites | + +## Defaults for new code + +| Purpose | Default | Functions | +|---|---|---| +| Signature | Ed25519 | `C.sign'`, `C.verify'` with `C.SEd25519` | +| Key agreement | X25519 | `C.generateKeyPair`, `C.dh'` | +| Post-quantum KEM | sntrup761 | `KEM.sntrup761Keypair`, `sntrup761Enc`, `sntrup761Dec` | +| Hybrid DH + KEM secret | HKDF-SHA512 over `dh \|\| kemSecret` with a new info string, as in `CR.rootKdf` | `C.hkdf` | +| Authenticated encryption | XSalsa20-Poly1305 [SimpleX secretbox](#simplex-box-constructions) | `C.sbEncrypt`/`C.sbDecrypt`, `LC.sbEncryptTailTag` for streams | +| Authenticated encryption with a DH secret | XSalsa20-Poly1305 [SimpleX crypto_box](#simplex-box-constructions) | `C.cbEncrypt`/`C.cbDecrypt` | +| Forward-secret symmetric session | HKDF-SHA512 key chain | `C.sbcInit`, `C.sbcHkdf` | +| KDF | HKDF-SHA512 | `C.hkdf` | +| Hash commitment, derived identifier | SHA3-256 | `C.sha3_256` | +| Certificate fingerprint | SHA-256 X.509 fingerprint | `C.signedFingerprint`, `C.certificateFingerprint` | +| Randomness | ChaChaDRG seeded from system entropy | `C.newRandom`, `C.randomBytes` and the `random*` helpers | + +## Primitives + +| Primitive | Status | Purpose and protocol | Implementation | Lengths | +|---|---|---|---|---| +| Ed25519 | Default | SMP/NTF/XFTP queue and file keys (agent default `rcvAuthAlg`, `sndAuthAlg` in [Agent/Env/SQLite.hs](../src/Simplex/Messaging/Agent/Env/SQLite.hs)), short-link signatures and owner auth (`SL.encodeSign`, `SL.newOwnerAuth`), XRCP identity and session keys ([RemoteControl/Invitation.hs](../src/Simplex/RemoteControl/Invitation.hs)), agent service request signatures, client/service TLS certificates ([Transport/Credentials.hs](../src/Simplex/Messaging/Transport/Credentials.hs)) | crypton `Crypto.PubKey.Ed25519` via `C.sign'`/`C.verify'` | pub 32, priv 32, sig 64; X.509 SPKI 44 | +| Ed448 | Approved | Server CA and online certificates (default `signAlgorithm = ED448` in [Server/CLI.hs](../src/Simplex/Messaging/Server/CLI.hs)); accepted for SMP command signatures and TLS | crypton `Crypto.PubKey.Ed448`; server key generation by the `openssl genpkey` CLI | pub 57, priv 57, sig 114; SPKI 69 | +| X25519 | Default | Per-queue e2e and server-to-recipient `crypto_box` keys, SMP session DH, command authenticator (SMP v7+), proxy (PRXY/PFWD), notification tokens, XFTP chunk transport, XRCP hello and announcements | crypton `Crypto.PubKey.Curve25519` via `C.dh'` | pub 32, priv 32, secret 32; SPKI 44 | +| X448 | Restricted | E2E double ratchet only: X3DH and DH ratchet (`CR.RatchetX448`, E2E v3) | crypton `Crypto.PubKey.Curve448` | pub 56, priv 56, secret 56 | +| sntrup761 | Default | PQ KEM in double ratchet (E2E v3, agent v5+) and XRCP v1 hello | Vendored C [cbits/sntrup761.c](../cbits/sntrup761.c) via FFI; randomness from ChaChaDRG through `haskell_rng_func` ([Bindings/RNG.hs](../src/Simplex/Messaging/Crypto/SNTRUP761/Bindings/RNG.hs)) | pk 1158, sk 1763, ct 1039, shared 32 | +| XSalsa20-Poly1305, SimpleX crypto_box | Default | SMP message bodies (server to recipient), per-queue e2e envelope, confirmations, proxy forwarding, notifications, XRCP hello, XFTP chunk transport | crypton `Crypto.Cipher.XSalsa`, `Crypto.MAC.Poly1305`; `C.cryptoBox`, `LC.cbInit` | key 32 (DH secret), nonce 24, tag 16 | +| XSalsa20-Poly1305, SimpleX secretbox | Default | SMP transport block encryption (SMP v11+), short-link data (SMP v15+), XFTP file encryption, local file encryption ([Crypto/File.hs](../src/Simplex/Messaging/Crypto/File.hs)), XRCP session | same; `C.sbEncrypt`, `LC.sbEncryptTailTag`, `LC.sbInit` | key 32, nonce 24, tag 16 | +| crypto_box authenticator | Approved | Deniable sender command authorization with X25519 queue keys (SMP v7+); agent currently creates Ed25519 keys | `C.cbAuthenticate`, `C.cbVerify`: `crypto_box(sha512(msg))` | 80 = 64 + 16 | +| AES-256-GCM, 16-byte IV | Legacy-only | Double ratchet header and body (E2E v3, [pqdr.md](./pqdr.md)) | crypton `Crypto.Cipher.AES`, `Crypto.Cipher.Types`; `C.encryptAEAD`, `C.decryptAEAD`, `C.initAEAD`; J0 = GHASH(IV) as NIST SP 800-38D defines for non-96-bit IVs | key 32, IV 16, tag 16; padded header 88 (PQ off) or 2310 (PQ on), `CR.paddedHeaderLen` | +| AES-256-GCM, 12-byte IV | Restricted | WebRTC frame encryption in simplex-chat (`Simplex.Chat.Mobile.WebRTC`), for WebCrypto interoperability | `C.encryptAESNoPad`, `C.decryptAESNoPad`, `C.initAEADGCM`, `C.GCMIV` | key 32, IV 12, tag 16 | +| HKDF-SHA512 | Default | All KDFs listed in [Domain separation](#domain-separation-and-kdf-inputs) | crypton `Crypto.KDF.HKDF` with `SHA512`; `C.hkdf` (extract + expand) | output per use, at most 255 * 64 | +| SHA-256 | Default for X.509 fingerprints, otherwise Restricted | X.509 fingerprints (`C.KeyHash`, server identity, XRCP CA, service certificate hash, SMP v16+), XFTP chunk digests, agent message hashes, `requestCode`, ratchet key dedup hash, security code `codeAD = sha256(rcAD)` | crypton; `C.sha256Hash`, `LC.sha256Hash`, `getFingerprint ... HashSHA256` | 32 | +| SHA-512 | Restricted | XFTP file digests and file description hash, input of the crypto_box authenticator | crypton; `C.sha512Hash`, `LC.sha512Hash` | 64 | +| SHA-512 (inside sntrup761) | Restricted | sntrup761 internal hash | [cbits/sha512.c](../cbits/sha512.c) calls OpenSSL `SHA512()` from the system `libcrypto` (`extra-libraries: crypto`) | 64 | +| SHA3-256 | Default | Short-link key `sha3_256(fixedData)` (SMP v15+), XRCP hybrid secret, service request binding (agent v8) | crypton; `C.sha3_256`, `KEM.kemHybridSecret` | 32 | +| SHA3-384 | Restricted | Client-supplied sender ID: first 24 bytes of `sha3_384(corrId)`, computed by the agent (`prepareConnectionLink'`, `newRcvConnSrv`) and the server (`createQueue`) | crypton; `C.sha3_384` | 48, truncated to 24 | +| Keccak-256 | Restricted | Label hash for SMP name queries (`NameQuery NQHash`), must match the on-chain registry | crypton `Keccak_256`; `labelHash` in [SimplexName.hs](../src/Simplex/Messaging/SimplexName.hs) | 32 | +| MD5 | Legacy-only, non-security | XOR-aggregated queue ID hash `IdsHash` for service subscription reconciliation (SUBS, NSUBS, SOKS, ENDS) | crypton (`C.md5Hash`, `Protocol.queueIdHash`); SQLite UDF `simplex_xor_md5_combine` ([Agent/Store/SQLite.hs](../src/Simplex/Messaging/Agent/Store/SQLite.hs)); PostgreSQL pgcrypto `digest(..., 'md5')` in server and NTF schemas | 16 | +| ChaChaDRG | Default | Keys, nonces, correlation IDs, random IDs, sntrup761 randomness | crypton `Crypto.Random`; `C.newRandom` seeds with `drgNew` from system entropy | n/a | +| BBS+ over BLS12-381, SHA-256 suite | Restricted | Badge entitlement credentials and proofs ([Crypto/Entitlement.hs](../src/Simplex/Messaging/Crypto/Entitlement.hs)), XFTP storage-time extension | Vendored submodules libbbs and blst (C and assembly) via FFI; randomness from libbbs `getentropy`, `SecRandomCopyBytes` with the `commoncrypto` flag, `BCryptGenRandom` on Windows ([cbits/getentropy_win.c](../cbits/getentropy_win.c)) | sk 32, pk 96, sig 80, proof 272 + 32 per undisclosed message | +| ECDSA with SHA-256 (ES256) | Restricted | APNS provider JWT ([Push/APNS.hs](../src/Simplex/Messaging/Notifications/Server/Push/APNS.hs)) | crypton `Crypto.PubKey.ECC.ECDSA.sign`; key read by cryptostore `Crypto.Store.PKCS8`; curve taken from the key file | signature DER `SEQUENCE {r, s}`, base64url | +| TLS 1.3 / 1.2 | Restricted | SMP, XFTP, NTF, XRCP transport: `TLS_CHACHA20_POLY1305_SHA256` (1.3), `ECDHE_ECDSA_CHACHA20POLY1305_SHA256` (1.2), groups X448 and X25519, Ed448 and Ed25519 signatures (`defaultSupportedParams`) | `tls` 1.9 | n/a | +| TLS for browsers | Restricted | XFTP server with HTTPS credentials (`defaultSupportedParamsHTTPS`: `ciphersuite_strong`, FFDHE, P-521, ECDSA and RSA signatures); SMP server HTTPS requires RSA-4096 (`checkHTTPSCredentials`) | `tls`, `warp-tls` | n/a | +| X.509 | Restricted | Certificate chains, fingerprints, signed session keys (`C.signX509`, `C.verifyX509`) | `crypton-x509`, `crypton-x509-validation`, `cryptostore` | n/a | +| SQLCipher | Restricted | Agent SQLite database at rest (`PRAGMA key`); cipher parameters are SQLCipher defaults, not set in this repository | `direct-sqlcipher` (git dependency, [cabal.project](../cabal.project)) | n/a | + +## SimpleX box constructions + +`C.cryptoBox` and `LC.sbInit_` compute, for a 32-byte key `k` and 24-byte nonce `n`: + +``` +k1 = HSalsa20(k, 0^16) +subkey = HSalsa20(k1, n[0..16]) +stream = Salsa20(subkey, n[16..24]) +polyKey = stream[0..32] +ct = msg XOR stream[32..] +tag = Poly1305(polyKey, ct) +``` + +| Construction | Key `k` | Equivalent libsodium call | Layout | +|---|---|---|---| +| SimpleX crypto_box (`C.cbEncrypt`, `LC.cbInit`) | X25519 shared secret | `crypto_box_easy` (`crypto_box_beforenm` is `HSalsa20(dh, 0^16)`) | strict: `tag \|\| ct`; lazy tail-tag: `ct \|\| tag` | +| SimpleX secretbox (`C.sbEncrypt`, `LC.sbInit`, `KEM.kcb*`) | 32-byte symmetric key | `crypto_secretbox_easy` with key `crypto_core_hsalsa20(0^16, k)`, not with `k` | same | + +Padding before encryption: `C.pad` prefixes a 2-byte big-endian length and fills with `#` (message at most 65533 bytes); `LC.pad` prefixes an 8-byte length. `*NoPad` variants skip padding. + +## Domain separation and KDF inputs + +HKDF is `C.hkdf salt ikm info len` (HKDF-SHA512). + +| Info string | Salt | IKM | Output (split) | Function | Use | +|---|---|---|---|---|---| +| `"SimpleXX3DH"` | 64 zero bytes | `dh1 \|\| dh2 \|\| dh3 [\|\| kemShared]` | 96: `hk`, `nhk`, root key (32 each) | `CR.pqX3dh` | Ratchet initialization, added in E2E v2; KEM secret from E2E v3 (current minimum) | +| `"SimpleXVerifyCode"` | 64 zero bytes | same as `SimpleXX3DH` | 32: `rcVCPQ` | `CR.pqX3dh` | Verification code covering all handshake keys, new ratchets | +| `"SimpleXRootRatchet"` | root key | `dh [\|\| kemShared]` | 96: root key, chain key, next header key | `CR.rootKdf` | DH/PQ ratchet step | +| `"SimpleXChainRatchet"` | empty | chain key | 96: chain key, message key (32 each), message IV (16), header IV (16) | `CR.chainKdf` | Per-message keys | +| `"SimpleXSbChainInit"` | SMP session ID | X25519 session secret | 64: two 32-byte chain keys | `C.sbcInit` from `Transport.blockEncryption` | SMP transport block encryption, SMP v11+ | +| `"SimpleXSbChainInit"` | empty | XRCP hybrid secret (below) | 64: two 32-byte chain keys | `C.sbcInit` in [RemoteControl/Client.hs](../src/Simplex/RemoteControl/Client.hs) | XRCP v1 session | +| `"SimpleXSbChain"` | empty | chain key | 88: chain key (32), secretbox key (32), nonce (24) | `C.sbcHkdf` | Each SMP block and XRCP message | +| `"SimpleXContactLink"` | empty | link key (32) | 56: link ID (24), secretbox key (32) | `SL.contactShortLinkKdf` | Contact short links, SMP v15+ | +| `"SimpleXInvLink"` | empty | link key (32) | 32: secretbox key | `SL.invShortLinkKdf` | Invitation short links, SMP v15+ | + +Other derivations: + +| Value | Construction | Function | Use | +|---|---|---|---| +| Service request binding | `sha3_256("SimpleXService" \|\| rcAD)` | `serviceReqBinding` in [Agent.hs](../src/Simplex/Messaging/Agent.hs) | Agent RPC, agent v8 | +| BBS header | `"SimpleX badges v1"` | `entitlementBBSHeader` | Badge credentials | +| XRCP hybrid secret | `sha3_256(x25519Dh \|\| kemShared)` | `KEM.kemHybridSecret` | XRCP v1 | +| Short-link key | `sha3_256(smpEncode FixedLinkData)` | `SL.encodeSignFixedData`, checked in `SL.decryptLinkData` | SMP v15+ | +| Sender ID | `take 24 (sha3_384 corrId)` | `prepareConnectionLink'`, `newRcvConnSrv` in Agent.hs; `createQueue` in [Server.hs](../src/Simplex/Messaging/Server.hs) | Client-supplied sender ID with link data, SMP v15+; the server rejects a mismatch to prevent an ID oracle | +| Ratchet associated data | `pubKeyBytes sk1 \|\| pubKeyBytes rk1` (raw X448, 112) | `CR.pqX3dh` | AD of every ratchet AEAD | +| Security code | `sha256(rcAD)` | `ratchetVerifyCodes` in [AgentStore.hs](../src/Simplex/Messaging/Agent/Store/AgentStore.hs) | Connection verification | +| Contact request code | `sha256(smpEncode (k1, k2, kem, sndId))` | `requestCode` in Agent.hs | Contact request binding | +| Command authenticator | `crypto_box(sha512(authorized))` | `C.cbAuthenticate` | SMP v7+ | +| Queue IDs hash | `xor` of `md5(queueId)` | `Protocol.queueIdsHash` | Service subscriptions | + +`"SimpleXSbChainInit"` and `"SimpleXSbChain"` are shared by SMP block encryption and XRCP. Their outputs are separated only by the IKM (and the salt for `sbcInit`). + +## Nonces and IVs + +| Use | Construction | +|---|---| +| SMP command authenticator, proxied command | `C.cbNonce corrId`, where `corrId` is a random 24-byte nonce (`C.randomCbNonce`) | +| SMP proxied responses | `C.reverseNonce` of the request nonce | +| Server-to-recipient message body | `C.cbNonce msgId`; `msgIdBytes = 24` in [Server/Main.hs](../src/Simplex/Messaging/Server/Main.hs) | +| Per-queue e2e envelope, short-link data, notifications, XRCP hello, XFTP chunk download | Random 24-byte nonce, sent with the ciphertext | +| XFTP file, local encrypted file | Random key and nonce per file (`C.randomSbKey`, `C.randomCbNonce`) | +| SMP block, XRCP session messages | Key and nonce from `C.sbcHkdf`, one per block | +| Ratchet header, body | 16-byte IVs from `CR.chainKdf`; header IV is sent, body IV is not | +| WebRTC frames | 12-byte `C.GCMIV` supplied by the caller in simplex-chat | + +## Implementation sources + +| Source | Version or pin | Provides | +|---|---|---| +| crypton | 0.34 | Ed25519, Ed448, X25519, X448, XSalsa20, Poly1305, AES-GCM, HKDF, SHA-2, SHA-3, Keccak, MD5, ChaChaDRG, ECDSA | +| tls, crypton-x509, crypton-x509-validation, cryptostore | 1.9.0, 1.7.6, 1.6.12, 0.3.0.1 | TLS, X.509, PKCS#8 | +| [cbits/sntrup761.c](../cbits/sntrup761.c) | Copy of draft-josefsson-ntruprime-streamlined-00 ([cbits/README.md](../cbits/README.md)) | sntrup761 | +| [cbits/sha512.c](../cbits/sha512.c) and system OpenSSL `libcrypto` | system | SHA-512 for sntrup761 | +| `cbits/libbbs` submodule ([simplex-chat/libbbs](https://github.com/simplex-chat/libbbs)) | `59a0f4bf` | BBS+ (`bbs_sha256_ciphersuite`), SHA-256, SHAKE256 | +| `cbits/blst` submodule ([supranational/blst](https://github.com/supranational/blst)) | `db3defd0` | BLS12-381 (C and `build/assembly.S`, `-D__BLST_PORTABLE__`) | +| direct-sqlcipher | git `f814ee68` | SQLCipher | +| `openssl` CLI | system | Server CA and certificate key generation (`createServerX509_`) | + +## xftp-web (TypeScript) + +[xftp-web](../xftp-web/) reimplements a subset for the browser XFTP client. It has no AES-GCM, HKDF, SHA-3, MD5, X448 or sntrup761 code. + +| Primitive | Implementation | Haskell counterpart | +|---|---|---| +| Ed25519 sign, verify, keys | libsodium-wrappers-sumo (`crypto_sign_*`), [src/crypto/keys.ts](../xftp-web/src/crypto/keys.ts) | `C.sign'`, `C.verify'` | +| Ed448 verify | `@noble/curves/ed448`, keys.ts `verifyEd448` | `C.verify'` | +| X25519 | libsodium `crypto_scalarmult`, `crypto_box_keypair` | `C.dh'` | +| SimpleX crypto_box, secretbox, tail-tag streaming | Salsa20 block function written in TypeScript, libsodium `crypto_core_hsalsa20` and `crypto_onetimeauth_*`, [src/crypto/secretbox.ts](../xftp-web/src/crypto/secretbox.ts) | `C.cryptoBox`, `LC.sbInit_` | +| crypto_box authenticator | [src/protocol/client.ts](../xftp-web/src/protocol/client.ts) `cbAuthenticate`, `cbVerify` | `C.cbAuthenticate`, `C.cbVerify` | +| SHA-256, SHA-512 | libsodium `crypto_hash_sha256`, `crypto_hash_sha512*`, [src/crypto/digest.ts](../xftp-web/src/crypto/digest.ts) | `C.sha256Hash`, `C.sha512Hash` | +| Random | WebCrypto `crypto.getRandomValues`; libsodium for key generation | `C.randomBytes` | + +## Tests + +None of the tests below compares against published reference vectors (NIST, RFC 8032, RFC 7748, RFC 5869, NaCl, the sntrup761 draft or the BBS draft). Interoperability is tested between this repository's own implementations. + +| Area | Test module (hspec group) | Kind | +|---|---|---| +| Ed25519, Ed448, X25519 crypto_box, secretbox, lazy and tail-tag secretbox, AES-GCM 12-byte IV, X.509 key encoding, X.509 chains, sntrup761, BBS+, entitlements, padding | [tests/CoreTests/CryptoTests.hs](../tests/CoreTests/CryptoTests.hs) | Round-trip; fixed lengths for BBS+; fixed bytes for padding | +| Double ratchet (X25519 and X448), PQ KEM agreement, AES-GCM 16-byte IV | [tests/AgentTests/DoubleRatchetTests.hs](../tests/AgentTests/DoubleRatchetTests.hs) | Round-trip; decoding of a stored v2 ratchet JSON | +| Short links (HKDF info strings, SHA3-256 link key) | [tests/AgentTests/ShortLinkTests.hs](../tests/AgentTests/ShortLinkTests.hs) | Round-trip, tamper rejection | +| File encryption | [tests/CoreTests/CryptoFileTests.hs](../tests/CoreTests/CryptoFileTests.hs) | Round-trip | +| XRCP session (SHA3-256 hybrid, sb chain) | [tests/RemoteControl.hs](../tests/RemoteControl.hs) | End-to-end | +| SMP authenticators, block encryption, `IdsHash` (MD5) | [tests/ServerTests.hs](../tests/ServerTests.hs) | End-to-end; with the PostgreSQL queue store this checks Haskell MD5 against pgcrypto | +| Haskell and xftp-web: SHA-256/512, Ed25519, Ed448 identity proof, X25519, DER keys, crypto_box, secretbox, tail-tag, authenticator, file encryption | [tests/XFTPWebTests.hs](../tests/XFTPWebTests.hs) (`XFTP Web Client`) | Byte-identical cross-language output | diff --git a/protocol/security.md b/protocol/security.md index dabe78139..996b82f78 100644 --- a/protocol/security.md +++ b/protocol/security.md @@ -37,6 +37,8 @@ This document describes the cryptographic primitives and threat model for the Si - AES-GCM AEAD cipher, - SHA512-based HKDF for key derivation. +All primitives in use, their lengths, domain-separation strings and the policy for new code are listed in the [cryptographic primitive registry](./crypto-registry.md). + ## Threat Model From 24036368f8880de60bdb2959a7b40f5318ddde2b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 14:51:48 +0100 Subject: [PATCH 3/8] xrcp: limit multicast validity time window (#1899) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- protocol/xrcp.md | 2 +- src/Simplex/RemoteControl/Client.hs | 7 +++++-- src/Simplex/RemoteControl/Discovery.hs | 20 +++++++++++------- tests/RemoteControl.hs | 29 ++++++++++++++++++++++++-- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/protocol/xrcp.md b/protocol/xrcp.md index a9df02f5a..9a2a532c3 100644 --- a/protocol/xrcp.md +++ b/protocol/xrcp.md @@ -71,7 +71,7 @@ The session invitation contains this data: Host application decrypts (except the first session) and validates the invitation: - Session signature is valid. -- Timestamp is within some window from the current time. +- Timestamp of a multicast announcement is not earlier than 3660 seconds before and not later than 3600 seconds after the current time of the host. The host ignores announcements outside of this interval and continues listening. - Long-term key signature is valid. - Long-term CA and signature key are the same as in the first session. - Some version in the offered range is supported. diff --git a/src/Simplex/RemoteControl/Client.hs b/src/Simplex/RemoteControl/Client.hs index a9970c273..1602f1993 100644 --- a/src/Simplex/RemoteControl/Client.hs +++ b/src/Simplex/RemoteControl/Client.hs @@ -26,6 +26,7 @@ module Simplex.RemoteControl.Client -- for tests only sendRCPacket, receiveRCPacket, + findRCCtrlPairing, ) where import Control.Applicative ((<|>)) @@ -46,7 +47,7 @@ import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Maybe (isNothing) import qualified Data.Text as T -import Data.Time.Clock.System (getSystemTime) +import Data.Time.Clock.System (SystemTime (..), getSystemTime) import Data.Tuple (swap) import Data.Word (Word16) import qualified Data.X509 as X @@ -395,8 +396,10 @@ findRCCtrlPairing :: NonEmpty RCCtrlPairing -> RCEncInvitation -> ExceptT RCErro findRCCtrlPairing pairings RCEncInvitation {dhPubKey, nonce, encInvitation} = do (pairing, signedInvStr) <- liftEither $ decrypt (L.toList pairings) signedInv <- liftEitherWith RCESyntax $ strDecode signedInvStr - inv@(RCVerifiedInvitation RCInvitation {dh = invDh}) <- maybe (throwE RCEInvitation) pure $ verifySignedInvitation signedInv + inv@(RCVerifiedInvitation RCInvitation {dh = invDh, ts}) <- maybe (throwE RCEInvitation) pure $ verifySignedInvitation signedInv unless (invDh == dhPubKey) $ throwE RCEInvitation + now <- systemSeconds <$> liftIO getSystemTime + unless (now - 3660 <= systemSeconds ts && systemSeconds ts <= now + 3600) $ throwE RCEInvitation pure (pairing, inv) where decrypt :: [RCCtrlPairing] -> Either RCErrorType (RCCtrlPairing, ByteString) diff --git a/src/Simplex/RemoteControl/Discovery.hs b/src/Simplex/RemoteControl/Discovery.hs index 4a69a57a1..baff4adaa 100644 --- a/src/Simplex/RemoteControl/Discovery.hs +++ b/src/Simplex/RemoteControl/Discovery.hs @@ -122,18 +122,22 @@ closeListener subscribers sock = joinMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () joinMulticast subscribers sock group = do now <- atomically $ takeTMVar subscribers - when (now == 0) $ do - setMembership sock group True >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now + 1) + if now == 0 + then + setMembership sock group True >>= \case + Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now + 1) + else atomically $ putTMVar subscribers (now + 1) partMulticast :: TMVar Int -> N.Socket -> N.HostAddress -> IO () partMulticast subscribers sock group = do now <- atomically $ takeTMVar subscribers - when (now == 1) $ - setMembership sock group False >>= \case - Left e -> atomically (putTMVar subscribers now) >> logError ("setMembership failed " <> tshow e) - Right () -> atomically $ putTMVar subscribers (now - 1) + if now == 1 + then + setMembership sock group False >>= \case + Left e -> atomically (putTMVar subscribers (now - 1)) >> logError ("setMembership failed " <> tshow e) + Right () -> atomically $ putTMVar subscribers (now - 1) + else atomically $ putTMVar subscribers (max 0 (now - 1)) listenerHostAddr4 :: UDP.ListenSocket -> N.HostAddress listenerHostAddr4 sock = case UDP.mySockAddr sock of diff --git a/tests/RemoteControl.hs b/tests/RemoteControl.hs index 630e774c0..9f7ecc029 100644 --- a/tests/RemoteControl.hs +++ b/tests/RemoteControl.hs @@ -9,13 +9,15 @@ module RemoteControl where import AgentTests.FunctionalAPITests (runRight) import Control.Logger.Simple +import Control.Monad (void) +import Control.Monad.Trans.Except (runExceptT) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (stripPrefix) import Data.List.NonEmpty (NonEmpty (..)) -import Data.Time.Clock.System (SystemTime (..)) +import Data.Time.Clock.System (SystemTime (..), getSystemTime) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.Messaging.Transport (TSbChainKeys (..)) @@ -24,8 +26,10 @@ import qualified Simplex.RemoteControl.Client as HC (RCHostClient (action)) import qualified Simplex.RemoteControl.Client as RC import Simplex.RemoteControl.Discovery (mkLastLocalHost, preferAddress) import Simplex.RemoteControl.Invitation - ( RCInvitation (..), + ( RCEncInvitation (..), + RCInvitation (..), RCSignedInvitation, + signInvitation, verifySignedInvitation, ) import Simplex.RemoteControl.Types @@ -45,6 +49,7 @@ remoteControlTests = do it "should connect to existing pairing" testExistingPairing describe "Multicast discovery" $ do it "should find paired host and connect" testMulticast + it "should accept announcement only within timestamp window" testAnnouncementTimestamp testPreferAddress :: Spec testPreferAddress = do @@ -244,6 +249,26 @@ testMulticast = do Nothing -> fail "timeout" Just _ -> pure () +testAnnouncementTimestamp :: IO () +testAnnouncementTimestamp = do + drg <- C.newRandom + RCHostPairing {caKey, caCert, idPrivKey} <- RC.newRCHostPairing drg + (hostDhPubKey, dhPrivKey) <- atomically $ C.generateKeyPair @'C.X25519 drg + (skey, sessPrivKey) <- atomically $ C.generateKeyPair @'C.Ed25519 drg + (dh, ctrlDhPrivKey) <- atomically $ C.generateKeyPair @'C.X25519 drg + nonce <- atomically $ C.randomCbNonce drg + now <- systemSeconds <$> getSystemTime + let pairing = RCCtrlPairing {caKey, caCert, ctrlFingerprint = C.KeyHash "test-ca", idPubKey = C.publicKey idPrivKey, dhPrivKey, prevDhPrivKey = Nothing} + announce offset = do + let inv = RCInvitation {ca = C.KeyHash "test-ca", host = "127.0.0.1", port = 5223, v = supportedRCPVRange, app = J.String "app", ts = MkSystemTime (now + offset) 0, skey, idkey = C.publicKey idPrivKey, dh} + encInvitation <- either (fail . show) pure $ C.cbEncrypt (C.dh' hostDhPubKey ctrlDhPrivKey) nonce (strEncode $ signInvitation sessPrivKey idPrivKey inv) 900 + runExceptT . void $ RC.findRCCtrlPairing (pairing :| []) RCEncInvitation {dhPubKey = dh, nonce, encInvitation} + announce 0 `shouldReturn` Right () + announce (-3600) `shouldReturn` Right () + announce 3500 `shouldReturn` Right () + announce (-3700) `shouldReturn` Left RCEInvitation + announce 3700 `shouldReturn` Left RCEInvitation + runCtrl :: TVar ChaChaDRG -> Bool -> RCHostPairing -> MVar RCSignedInvitation -> IO (Async RCHostPairing) runCtrl drg multicast hp invVar = async . runRight $ do (_found, inv, hc, r) <- RC.connectRCHost drg hp (J.String "app") multicast Nothing Nothing From 9fe3745401f17a31cb830ac68aa30cc54f801135 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:59:22 +0400 Subject: [PATCH 4/8] docs: use role names for key exchange parties (#1912) --- protocol/agent-protocol.md | 2 +- protocol/pqdr.md | 10 ++++++---- protocol/security.md | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/protocol/agent-protocol.md b/protocol/agent-protocol.md index 1a047ee13..00b099e40 100644 --- a/protocol/agent-protocol.md +++ b/protocol/agent-protocol.md @@ -85,7 +85,7 @@ SMP agent protocol has 2 main parts: ![Duplex connection procedure](./diagrams/duplex-messaging/duplex-creating.svg) -The procedure of establishing a duplex connection is explained on the example of Alice and Bob creating a bi-directional connection consisting of two unidirectional (simplex) queues, using SMP agents (A and B) to facilitate it, and two different SMP routers (which could be the same router). It is shown on the diagram above and has these steps: +The procedure of establishing a duplex connection is explained on the example of Alice (the initiating party) and Bob (the joining party) creating a bi-directional connection consisting of two unidirectional (simplex) queues, using SMP agents (A and B) to facilitate it, and two different SMP routers (which could be the same router). It is shown on the diagram above and has these steps: 1. Alice requests the new connection from the SMP agent A using agent `createConnection` api function. 2. Agent A creates an SMP queue on the router (using [SMP protocol](./simplex-messaging.md) `NEW` command) and responds to Alice with the invitation that contains queue information and the encryption keys Bob's agent B should use. The invitation format is described in [Connection link](connection-link-1-time-invitation-and-contact-address). diff --git a/protocol/pqdr.md b/protocol/pqdr.md index d3d3e2b48..60990e5b8 100644 --- a/protocol/pqdr.md +++ b/protocol/pqdr.md @@ -62,14 +62,16 @@ It is possible to reduce size overhead by using only one KEM agreement and makin ## Double ratchet with encrypted headers augmented with double PQ KEM -Algorithm below assumes that in addition to shared secret from the initial key agreement, there will be an encapsulation key available from the party that published its keys (Bob). +Algorithm below assumes that in addition to shared secret from the initial key agreement, there will be an encapsulation key available from the initiating party, that sent its keys in the connection invitation to the joining party (see [agent protocol](./agent-protocol.md)). Following the double ratchet specification, the pseudo-code below names the joining party Alice and the initiating party Bob. + +Unlike X3DH prekey bundles, these keys are not reusable keys published for any party to use. The initiating party generates two X448 key pairs and, optionally, a sntrup761 KEM key pair for each connection, sends the public keys in the invitation, and deletes the stored private keys once the ratchet is initialized; only the second X448 private key and the agreed KEM key pair remain in the ratchet state as its initial ratchet keys. The joining party generates its keys when joining and keeps only its KEM key pair, in the ratchet state. The exception is a contact address that publishes ratchet keys in its link data: all requesters use these keys until the address owner rotates them, and the owner keeps the private keys of a few recent generations. ### Initialization The double ratchet initialization is defined in pseudo-code. This pseudo-code is identical to Signal algorithm specification except for that parts that add post-quantum key agreement. ``` -// Alice obtained Bob's keys and initializes ratchet first +// Alice (joining party) received Bob's keys in the invitation and initializes ratchet first def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, bob_pq_kem_encapsulation_key): state.DHRs = GENERATE_DH() state.DHRr = bob_dh_public_key @@ -89,7 +91,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, state.HKr = None state.NHKr = shared_nhkb -// Bob initializes ratchet second, having received Alice's connection request +// Bob (initiating party) initializes ratchet second, having received Alice's first message def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob_pq_kem_key_pair): state.DHRs = bob_dh_key_pair state.DHRr = None @@ -274,7 +276,7 @@ As SimpleX Messaging Protocol pads messages to a fixed size, using 16kb transpor Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the invitation link. As encapsulation key is large, it may be inconvenient to share it in the link in some contexts, e.g. when QR codes are used. -It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. +It is possible to postpone sharing the encapsulation key until the first message from the joining party (confirmation message in SMP protocol). The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can be as slow as 10-20 keys per second, so using this primitive in groups larger than 10-20 members would result in slow performance. diff --git a/protocol/security.md b/protocol/security.md index 996b82f78..cf4db5dcc 100644 --- a/protocol/security.md +++ b/protocol/security.md @@ -15,7 +15,7 @@ This document describes the cryptographic primitives and threat model for the Si - [SimpleX Messaging Protocol router that proxies the messages to another SMP router](#simplex-messaging-protocol-router-that-proxies-the-messages-to-another-smp-router) - [An attacker who obtained Alice's (decrypted) chat database](#an-attacker-who-obtained-alices-decrypted-chat-database) - [A user's contact](#a-users-contact) - - [An attacker who observes Alice showing an introduction message to Bob](#an-attacker-who-observes-alice-showing-an-introduction-message-to-bob) + - [An attacker who observes the initiating party showing an introduction message to the joining party](#an-attacker-who-observes-the-initiating-party-showing-an-introduction-message-to-the-joining-party) - [An attacker with Internet access](#an-attacker-with-internet-access) @@ -192,15 +192,15 @@ All primitives in use, their lengths, domain-separation strings and the policy f - cannot collaborate with another of the user's contacts to confirm they are communicating with the same user. -### An attacker who observes Alice showing an introduction message to Bob +### An attacker who observes the initiating party showing an introduction message to the joining party *can:* -- Impersonate Bob to Alice. +- Impersonate the joining party to the initiating party. *cannot:* -- Impersonate Alice to Bob. +- Impersonate the initiating party to the joining party. ### An attacker with Internet access From 91e070cb5e4d77e5d47033cb94aa2001a7464483 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:00:18 +0400 Subject: [PATCH 5/8] crypto: document 16-byte GCM IV in double ratchet (#1914) --- protocol/pqdr.md | 2 ++ src/Simplex/Messaging/Crypto.hs | 15 +++++---------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/protocol/pqdr.md b/protocol/pqdr.md index 60990e5b8..1519a59c9 100644 --- a/protocol/pqdr.md +++ b/protocol/pqdr.md @@ -212,6 +212,8 @@ The outer envelope contains the encrypted header (used as associated data for bo The message body is encrypted with AES-256-GCM using the message key derived from the sending chain key (`KDF_CK`). The associated data for body encryption is the concatenation of the ratchet associated data and the encoded encrypted header. +`KDF_CK(CK)` is HKDF-SHA512 with empty salt, `CK` as input key material and info `"SimpleXChainRatchet"`, producing 96 bytes split into the next chain key (32 bytes), the message key (32 bytes), the message body IV (16 bytes, not transmitted) and `headerIV` (16 bytes). Both IVs are used as 16-byte AES-256-GCM IVs, not the 12-byte IVs recommended by NIST SP 800-38D, so the initial counter block is J0 = GHASH(IV || 0^64 || [128]_64) as defined there for non-96-bit IVs. WebCrypto and other conforming implementations compute it when given the full 16-byte IV; truncating the IV to 12 bytes produces different ciphertext. + ```abnf encRatchetMessage = versionedLength encMessageHeader msgAuthTag encMsgBody ; encMessageHeader is used as associated data for body decryption: AD = rcAD || encMessageHeader diff --git a/src/Simplex/Messaging/Crypto.hs b/src/Simplex/Messaging/Crypto.hs index 0bc5238c4..f73b61e17 100644 --- a/src/Simplex/Messaging/Crypto.hs +++ b/src/Simplex/Messaging/Crypto.hs @@ -1045,9 +1045,7 @@ md5Hash = BA.convert . (hash :: ByteString -> Digest MD5) -- | AEAD-GCM encryption with associated data. -- --- Used as part of double ratchet encryption. --- This function requires 16 bytes IV, it transforms IV in cryptonite_aes_gcm_init here: --- https://github.com/haskell-crypto/cryptonite/blob/master/cbits/cryptonite_aes.c +-- Used as part of double ratchet encryption, with a 16-byte IV (see @initAEAD@). encryptAEAD :: Key -> IV -> Int -> ByteString -> ByteString -> ExceptT CryptoError IO (AuthTag, ByteString) encryptAEAD aesKey ivBytes paddedLen ad msg = do aead <- initAEAD @AES256 aesKey ivBytes @@ -1067,10 +1065,7 @@ encryptAEADNoPad aesKey ivBytes ad msg = do -- | AEAD-GCM decryption with associated data. -- --- Used as part of double ratchet encryption. --- This function requires 16 bytes IV, it transforms IV in cryptonite_aes_gcm_init here: --- https://github.com/haskell-crypto/cryptonite/blob/master/cbits/cryptonite_aes.c --- To make it compatible with WebCrypto we will need to start using initAEADGCM. +-- Used as part of double ratchet encryption, with a 16-byte IV (see @initAEAD@). decryptAEAD :: Key -> IV -> ByteString -> ByteString -> AuthTag -> ExceptT CryptoError IO ByteString decryptAEAD aesKey ivBytes ad msg (AuthTag authTag) = do aead <- initAEAD @AES256 aesKey ivBytes @@ -1148,9 +1143,9 @@ maxLength :: forall i. KnownNat i => Int maxLength = fromIntegral (natVal $ Proxy @i) {-# INLINE maxLength #-} --- this function requires 16 bytes IV, it transforms IV in cryptonite_aes_gcm_init here: --- https://github.com/haskell-crypto/cryptonite/blob/master/cbits/cryptonite_aes.c --- This is used for double ratchet encryption, so to make it compatible with WebCrypto we will need to deprecate it and start using initAEADGCM +-- The 16-byte double ratchet IV is intentionally not the 96-bit IV recommended by NIST SP 800-38D, so GCM derives J0 = GHASH(IV || 0^64 || [128]_64), +-- as in crypton_aes_gcm_init: https://hackage.haskell.org/package/crypton-0.34/src/cbits/crypton_aes.c +-- WebCrypto and other SP 800-38D implementations interoperate only when given all 16 IV bytes. initAEAD :: forall c. AES.BlockCipher c => Key -> IV -> ExceptT CryptoError IO (AES.AEAD c) initAEAD (Key aesKey) (IV ivBytes) = do iv <- makeIV @c ivBytes From 9c641fed9171311bc559740499d0dfaa49bcbaa5 Mon Sep 17 00:00:00 2001 From: brenzi Date: Sat, 3 Oct 2026 16:10:42 +0200 Subject: [PATCH 6/8] reject e2e params with kem_ct and without kem_key (#1901) --- src/Simplex/Messaging/Crypto/Ratchet.hs | 12 ++++++++---- tests/AgentTests/ConnectionRequestTests.hs | 5 +++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Messaging/Crypto/Ratchet.hs b/src/Simplex/Messaging/Crypto/Ratchet.hs index b38f2b5d1..fe74d772a 100644 --- a/src/Simplex/Messaging/Crypto/Ratchet.hs +++ b/src/Simplex/Messaging/Crypto/Ratchet.hs @@ -119,7 +119,7 @@ import Simplex.Messaging.Crypto.SNTRUP761.Bindings import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, parseE, parseE') -import Simplex.Messaging.Util (($>>=), (<$?>)) +import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import UnliftIO.STM @@ -327,9 +327,13 @@ instance StrEncoding AnyE2ERatchetParamsUri where Nothing -> pure $ AnyE2ERatchetParamsUri SRKSProposed a $ E2ERatchetParamsUri vr k1 k2 Nothing _ -> fail "bad e2e params" where - kemP query = - queryParam_ "kem_key" query - $>>= \k -> Just . kemParams k <$> queryParam_ "kem_ct" query + kemP query = do + k_ <- queryParam_ "kem_key" query + ct_ <- queryParam_ "kem_ct" query + case (k_, ct_) of + (Just k, _) -> pure $ Just $ kemParams k ct_ + (Nothing, Nothing) -> pure Nothing + (Nothing, Just _) -> fail "bad e2e params: kem_ct without kem_key" kemParams k = \case Nothing -> ARKP SRKSProposed $ RKParamsProposed k Just ct -> ARKP SRKSAccepted $ RKParamsAccepted ct k diff --git a/tests/AgentTests/ConnectionRequestTests.hs b/tests/AgentTests/ConnectionRequestTests.hs index c3b255139..d76d8d305 100644 --- a/tests/AgentTests/ConnectionRequestTests.hs +++ b/tests/AgentTests/ConnectionRequestTests.hs @@ -20,6 +20,8 @@ module AgentTests.ConnectionRequestTests import AgentTests.EqInstances () import Data.ByteString (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (isLeft) import Network.HTTP.Types (urlEncode) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C @@ -285,6 +287,9 @@ connectionRequestTests = contactAddressV6 #== ("https://simplex.chat/contact#/?v=1-2&smp=" <> url queueStr) -- adjusted to v6 contactAddressV6 #== ("https://simplex.chat/contact#/?v=2-2&smp=" <> url queueStr) contactAddressClientData #==# ("simplex:/contact#/?v=6-8&smp=" <> url queueStr <> "&data=" <> url "{\"type\":\"group_link\", \"group_link_id\":\"abc\"}") + it "should reject KEM ciphertext without KEM key in e2e params" $ + strDecode @(RcvE2ERatchetParamsUri 'C.X448) (strEncode testE2ERatchetParams <> "&kem_ct=" <> strEncode (B.replicate 1039 '\0')) + `shouldSatisfy` isLeft it "should serialize / parse queue address, connection invitations and contact addresses as binary" $ do smpEncodingTest queue smpEncodingTest queueNoQM -- this passes, no queue mode patch in SMPQueueUri encoding From 76248dd76770c6518cba9bffd59d138ba1452312 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 17:01:36 +0100 Subject: [PATCH 7/8] sntrup761: bound KeyGen retries on R3 inversion (#1918) Co-authored-by: shum --- cbits/sntrup761.c | 26 ++++++++++++---- cbits/sntrup761.h | 3 +- src/Simplex/Messaging/Crypto.hs | 2 ++ .../Messaging/Crypto/SNTRUP761/Bindings.hs | 18 ++++++----- .../Crypto/SNTRUP761/Bindings/FFI.hs | 4 +-- tests/CoreTests/CryptoTests.hs | 30 ++++++++++++++++++- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/cbits/sntrup761.c b/cbits/sntrup761.c index 7d64eeb08..487e84b48 100644 --- a/cbits/sntrup761.c +++ b/cbits/sntrup761.c @@ -719,23 +719,32 @@ Small_random (small * out, void *random_ctx, sntrup761_random_func * random) /* ----- Streamlined NTRU Prime Core */ +/* x^p-x-1 has a degree-19 factor mod 3, so a random g is not invertible in R3 + with probability about 3^-19; KeyGen_attempts failures in a row mean a broken RNG */ +#define KeyGen_attempts 10 + /* h,(f,ginv) = KeyGen() */ -static void +/* returns 0 if KeyGen succeeded; else -1 */ +static int KeyGen (Fq * h, small * f, small * ginv, void *random_ctx, sntrup761_random_func * random) { small g[p]; Fq finv[p]; + int i; - for (;;) + for (i = 0; i < KeyGen_attempts; ++i) { Small_random (g, random_ctx, random); if (R3_recip (ginv, g) == 0) break; } + if (i == KeyGen_attempts) + return -1; Short_random (f, random_ctx, random); Rq_recip3 (finv, f); /* always works */ Rq_mult_small (h, finv, g); + return 0; } /* c = Encrypt(r,h) */ @@ -884,18 +893,21 @@ typedef small Inputs[p]; /* passed by reference */ #define PublicKeys_bytes Rq_bytes /* pk,sk = ZKeyGen() */ -static void +/* returns 0 if KeyGen succeeded; else -1 */ +static int ZKeyGen (unsigned char *pk, unsigned char *sk, void *random_ctx, sntrup761_random_func * random) { Fq h[p]; small f[p], v[p]; - KeyGen (h, f, v, random_ctx, random); + if (KeyGen (h, f, v, random_ctx, random) != 0) + return -1; Rq_encode (pk, h); Small_encode (sk, f); sk += Small_bytes; Small_encode (sk, v); + return 0; } /* C = ZEncrypt(r,pk) */ @@ -960,19 +972,21 @@ HashSession (unsigned char *k, int b, const unsigned char *y, /* ----- Streamlined NTRU Prime */ /* pk,sk = KEM_KeyGen() */ -void +int sntrup761_keypair (unsigned char *pk, unsigned char *sk, void *random_ctx, sntrup761_random_func * random) { int i; - ZKeyGen (pk, sk, random_ctx, random); + if (ZKeyGen (pk, sk, random_ctx, random) != 0) + return -1; sk += SecretKeys_bytes; for (i = 0; i < PublicKeys_bytes; ++i) *sk++ = pk[i]; random (random_ctx, Inputs_bytes, sk); sk += Inputs_bytes; Hash_prefix (sk, 4, pk, PublicKeys_bytes); + return 0; } /* c,r_enc = Hide(r,pk,cache); cache is Hash4(pk) */ diff --git a/cbits/sntrup761.h b/cbits/sntrup761.h index 4b1a23bd9..acb221611 100644 --- a/cbits/sntrup761.h +++ b/cbits/sntrup761.h @@ -19,7 +19,8 @@ typedef void sntrup761_random_func (void *ctx, size_t length, uint8_t *dst); -void +/* returns 0 on success, -1 if the RNG never produced an invertible polynomial */ +int sntrup761_keypair (uint8_t *pk, uint8_t *sk, void *random_ctx, sntrup761_random_func *random); diff --git a/src/Simplex/Messaging/Crypto.hs b/src/Simplex/Messaging/Crypto.hs index f73b61e17..a22833976 100644 --- a/src/Simplex/Messaging/Crypto.hs +++ b/src/Simplex/Messaging/Crypto.hs @@ -931,6 +931,8 @@ data CryptoError CERatchetEarlierMessage Word32 | -- | duplicate message number CERatchetDuplicateMessage + | -- | KEM key generation failed, indicating a broken RNG + CryptoKEMKeyGenError deriving (Eq, Show, Exception) aesKeySize :: Int diff --git a/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings.hs b/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings.hs index 861abf69e..a99567e9a 100644 --- a/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings.hs +++ b/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings.hs @@ -16,6 +16,8 @@ module Simplex.Messaging.Crypto.SNTRUP761.Bindings ) where import Control.Concurrent.STM +import Control.Exception (throwIO) +import Control.Monad (when) import Crypto.Random (ChaChaDRG) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Bifunctor (bimap) @@ -23,6 +25,7 @@ import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..)) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.SNTRUP761.Bindings.Defines import Simplex.Messaging.Crypto.SNTRUP761.Bindings.FFI import Simplex.Messaging.Crypto.SNTRUP761.Bindings.RNG (rngFuncPtr, withDRG) @@ -55,14 +58,13 @@ pattern KEMSharedKey s <- KEMSharedKey_ s type KEMKeyPair = (KEMPublicKey, KEMSecretKey) sntrup761Keypair :: TVar ChaChaDRG -> IO KEMKeyPair -sntrup761Keypair drg = - bimap KEMPublicKey_ KEMSecretKey - <$> BA.allocRet - c_SNTRUP761_SECRETKEY_SIZE - ( \skPtr -> - BA.alloc c_SNTRUP761_PUBLICKEY_SIZE $ \pkPtr -> - withDRG drg $ \cxtPtr -> c_sntrup761_keypair pkPtr skPtr cxtPtr rngFuncPtr - ) +sntrup761Keypair drg = do + ((r, pk), sk) <- + BA.allocRet c_SNTRUP761_SECRETKEY_SIZE $ \skPtr -> + BA.allocRet c_SNTRUP761_PUBLICKEY_SIZE $ \pkPtr -> + withDRG drg $ \cxtPtr -> c_sntrup761_keypair pkPtr skPtr cxtPtr rngFuncPtr + when (r /= 0) $ throwIO C.CryptoKEMKeyGenError + pure (KEMPublicKey_ pk, KEMSecretKey sk) sntrup761Enc :: TVar ChaChaDRG -> KEMPublicKey -> IO (KEMCiphertext, KEMSharedKey) sntrup761Enc drg (KEMPublicKey pk) = diff --git a/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings/FFI.hs b/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings/FFI.hs index 4983e9210..fc0093144 100644 --- a/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings/FFI.hs +++ b/src/Simplex/Messaging/Crypto/SNTRUP761/Bindings/FFI.hs @@ -10,9 +10,9 @@ import Foreign import Foreign.C import Simplex.Messaging.Crypto.SNTRUP761.Bindings.RNG (RNGContext, RNGFunc) --- void sntrup761_keypair (uint8_t *pk, uint8_t *sk, void *random_ctx, sntrup761_random_func *random); +-- int sntrup761_keypair (uint8_t *pk, uint8_t *sk, void *random_ctx, sntrup761_random_func *random); foreign import ccall "sntrup761_keypair" - c_sntrup761_keypair :: Ptr Word8 -> Ptr Word8 -> Ptr RNGContext -> FunPtr RNGFunc -> IO () + c_sntrup761_keypair :: Ptr Word8 -> Ptr Word8 -> Ptr RNGContext -> FunPtr RNGFunc -> IO CInt -- void sntrup761_enc (uint8_t *c, uint8_t *k, const uint8_t *pk, void *random_ctx, sntrup761_random_func *random); foreign import ccall "sntrup761_enc" diff --git a/tests/CoreTests/CryptoTests.hs b/tests/CoreTests/CryptoTests.hs index edeb097bf..268611990 100644 --- a/tests/CoreTests/CryptoTests.hs +++ b/tests/CoreTests/CryptoTests.hs @@ -6,8 +6,9 @@ module CoreTests.CryptoTests (cryptoTests) where +import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM -import Control.Exception (evaluate) +import Control.Exception (bracket, evaluate) import Control.Monad.Except import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -22,9 +23,12 @@ import Data.Time.Clock (UTCTime (..)) import qualified Data.Text.Lazy as LT import qualified Data.Text.Lazy.Encoding as LE import Data.Type.Equality +import Data.Word (Word8) import qualified Data.X509 as X import qualified Data.X509.CertificateStore as XS import qualified Data.X509.Validation as XV +import Foreign (FunPtr, allocaBytes, fillBytes, freeHaskellFunPtr, nullPtr) +import Foreign.C.Types (CInt, CSize (..)) import qualified SMPClient import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Lazy as LC @@ -32,9 +36,12 @@ import Simplex.Messaging.Crypto.BBS import Simplex.Messaging.Crypto.Entitlement import Simplex.Messaging.Crypto.SNTRUP761.Bindings import Simplex.Messaging.Crypto.SNTRUP761.Bindings.Defines +import Simplex.Messaging.Crypto.SNTRUP761.Bindings.FFI (c_sntrup761_keypair) +import Simplex.Messaging.Crypto.SNTRUP761.Bindings.RNG (RNGFunc) import Simplex.Messaging.Encoding (Large (..), smpDecode, smpEncode) import Simplex.Messaging.Encoding.String (strDecode, strEncode) import Simplex.Messaging.Transport.Client +import System.Timeout (timeout) import Test.Hspec hiding (fit, it) import Test.Hspec.QuickCheck (modifyMaxSuccess) import Test.QuickCheck hiding (Large) @@ -113,6 +120,7 @@ cryptoTests = do describe "sntrup761" $ do it "should enc/dec key" testSNTRUP761 it "should reject malformed KEM encodings" testSNTRUP761RejectsMalformedEncodings + it "should fail key generation with degenerate RNG" testSNTRUP761KeypairDegenerateRNG describe "BBS+" $ do it "should sign and verify" testBBSSignVerify it "should derive public key from secret key" testBBSPublicKeyDerivation @@ -298,6 +306,26 @@ testSNTRUP761 = do KEMSharedKey k' <- sntrup761Dec c sk k' `shouldBe` k +foreign import ccall "wrapper" + mkRNGFunc :: RNGFunc -> IO (FunPtr RNGFunc) + +testSNTRUP761KeypairDegenerateRNG :: IO () +testSNTRUP761KeypairDegenerateRNG = do + -- constant byte 0 draws invertible g = -(1 + x + ... + x^760), byte 0x20 draws g = 0 + keypairWithConstantRNG 0 `shouldReturn` Just 0 + keypairWithConstantRNG 0x20 `shouldReturn` Just (-1) + where + keypairWithConstantRNG :: Word8 -> IO (Maybe CInt) + keypairWithConstantRNG b = do + result <- newEmptyMVar + -- timeout cannot interrupt a foreign call, so the call runs in another thread + _ <- forkIO $ + bracket (mkRNGFunc $ \_ sz buf -> fillBytes buf b (fromIntegral sz)) freeHaskellFunPtr $ \rng -> + allocaBytes c_SNTRUP761_PUBLICKEY_SIZE $ \pkPtr -> + allocaBytes c_SNTRUP761_SECRETKEY_SIZE $ \skPtr -> + c_sntrup761_keypair pkPtr skPtr nullPtr rng >>= putMVar result + timeout 10000000 $ takeMVar result + testSNTRUP761RejectsMalformedEncodings :: IO () testSNTRUP761RejectsMalformedEncodings = do smpDecode @KEMPublicKey (smpEncode $ Large shortPublicKey) `shouldSatisfy` isLeft From e11dfb985dc725c91c4ef47cfe74d5078f08c989 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 22:45:44 +0100 Subject: [PATCH 8/8] consider IP address protected only if it is used for the chosen host (#1895) * consider IP address protected only if it is used for the chosen host * simplify * simplify * simplify --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Messaging/Agent/Client.hs | 7 +++++-- tests/CoreTests/SOCKSSettings.hs | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Messaging/Agent/Client.hs b/src/Simplex/Messaging/Agent/Client.hs index 8cbb4c17b..453acf106 100644 --- a/src/Simplex/Messaging/Agent/Client.hs +++ b/src/Simplex/Messaging/Agent/Client.hs @@ -1261,9 +1261,12 @@ sendOrProxySMPCommand c nm userId destSrv@ProtocolServer {host = destHosts} conn Left e -> throwE e ipAddressProtected :: NetworkConfig -> ProtocolServer p -> Bool -ipAddressProtected NetworkConfig {socksProxy, hostMode} (ProtocolServer _ hosts _ _) = do - isJust socksProxy || (hostMode == HMOnion && any isOnionHost hosts) +ipAddressProtected NetworkConfig {socksProxy, socksMode, hostMode} (ProtocolServer _ hosts _ _) + | isJust socksProxy = socksMode == SMAlways || if hostMode == HMPublic then allOnion else anyOnion + | otherwise = hostMode == HMOnion && anyOnion where + anyOnion = any isOnionHost hosts + allOnion = all isOnionHost hosts isOnionHost = \case THOnionHost _ -> True; _ -> False withNtfClient :: AgentClient -> NetworkRequestMode -> NtfServer -> EntityId -> ByteString -> (NtfClient -> ExceptT NtfClientError IO a) -> AM a diff --git a/tests/CoreTests/SOCKSSettings.hs b/tests/CoreTests/SOCKSSettings.hs index 3dbf5e5e7..a56be966e 100644 --- a/tests/CoreTests/SOCKSSettings.hs +++ b/tests/CoreTests/SOCKSSettings.hs @@ -2,15 +2,18 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module CoreTests.SOCKSSettings where import Network.Socket (SockAddr (..), tupleToHostAddress) +import Simplex.Messaging.Agent.Client (ipAddressProtected) import Simplex.Messaging.Client +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Protocol (ErrorType) +import Simplex.Messaging.Protocol (ErrorType, pattern SMPServer) import Simplex.Messaging.Transport.Client import Test.Hspec hiding (fit, it) import Util @@ -19,6 +22,7 @@ socksSettingsTests :: Spec socksSettingsTests = do describe "hostMode and requiredHostMode settings" testHostMode describe "socksMode setting, independent of hostMode setting" testSocksMode + describe "ipAddressProtected, consistent with chosen host and socksMode" testIPAddressProtected describe "socks proxy address encoding" testSocksProxyEncoding testPublicHost :: TransportHost @@ -94,6 +98,19 @@ testSocksMode = do let TransportClientConfig {socksProxy} = transportClientConfig cfg NRMInteractive host False Nothing in socksProxy +testIPAddressProtected :: Spec +testIPAddressProtected = do + it "should be protected if SOCKS proxy is used for the chosen host" $ do + protected SMAlways HMOnionViaSocks [testPublicHost] `shouldBe` True + protected SMOnion HMOnionViaSocks [testPublicHost, testOnionHost] `shouldBe` True + protected SMOnion HMPublic [testOnionHost] `shouldBe` True + it "should not be protected if SOCKS proxy is not used for the chosen host" $ do + protected SMOnion HMOnionViaSocks [testPublicHost] `shouldBe` False + protected SMOnion HMPublic [testPublicHost, testOnionHost] `shouldBe` False + where + protected socksMode hostMode hosts = + ipAddressProtected defaultNetworkConfig {socksProxy = Just defaultSocksProxyWithAuth, socksMode, hostMode} (SMPServer hosts "" (C.KeyHash "")) + testSocksProxyEncoding :: Spec testSocksProxyEncoding = do it "should decode SOCKS proxy with isolate-by-auth mode" $ do