From ebe90f7169f21d05968a0746425a442735fa2df2 Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 6 Jun 2026 08:33:25 +0000 Subject: [PATCH] chat: APIVerifySimplexName command + CEvtSimplexNameUnverified warning Addresses the TOFU vulnerability where peer-claimed simplex_name was accepted unverified. Adds: - contacts.simplex_name_verified_at + groups.simplex_name_verified_at (M20260606_simplex_name_verified) - APIVerifySimplexName ChatRef command: RSLV-resolves the claimed name and compares the resolved link to the peer's stored connection link; on match writes verified_at and emits CEvtSimplexNameVerified; on mismatch emits CEvtSimplexNameVerifyFailed - CEvtSimplexNameUnverified passive warning emitted on incoming XInfo / XGrpInfo when a name claim arrives without a current verification - updateContactProfileWithConflict / updateGroupProfileWithConflict clear simplex_name_verified_at whenever the peer's claim transitions (any value change including Nothing<->Just): the prior verification was bound to the prior claim. UI can surface the unverified indicator next to a contact / group's name, and prompt the user to invoke the verify command. This shifts the security model from "TOFU + last-writer-wins" to "TOFU + on-demand RSLV verification". --- simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 36 +++++++++ src/Simplex/Chat/Library/Commands.hs | 74 +++++++++++++++++++ src/Simplex/Chat/Library/Subscriber.hs | 15 +++- src/Simplex/Chat/Messages.hs | 2 + src/Simplex/Chat/Store/Connections.hs | 6 +- src/Simplex/Chat/Store/ContactRequest.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 35 +++++++-- src/Simplex/Chat/Store/Groups.hs | 33 +++++++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260606_simplex_name_verified.hs | 21 ++++++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260606_simplex_name_verified.hs | 26 +++++++ .../Store/SQLite/Migrations/chat_schema.sql | 4 +- src/Simplex/Chat/Store/Shared.hs | 14 ++-- src/Simplex/Chat/Types.hs | 12 ++- src/Simplex/Chat/View.hs | 10 +++ tests/ResolveNameTests.hs | 54 +++++++++++++- 18 files changed, 322 insertions(+), 32 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260606_simplex_name_verified.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260606_simplex_name_verified.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9db91b88ac..dd786f2029 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -139,6 +139,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name Simplex.Chat.Store.Postgres.Migrations.M20260604_simplex_name_profiles + Simplex.Chat.Store.Postgres.Migrations.M20260606_simplex_name_verified else exposed-modules: Simplex.Chat.Archive @@ -300,6 +301,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name Simplex.Chat.Store.SQLite.Migrations.M20260604_simplex_name_profiles + Simplex.Chat.Store.SQLite.Migrations.M20260606_simplex_name_verified other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 4e50acfb54..067fdc5ea9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -486,6 +486,11 @@ data ChatCommand | APIConnectPreparedGroup {groupId :: GroupId, incognito :: IncognitoEnabled, ownerContact :: Maybe GroupOwnerContact, msgContent_ :: Maybe MsgContent} | APIConnect {userId :: UserId, incognito :: IncognitoEnabled, preparedLink_ :: Maybe ACreatedConnLink} -- Maybe is used to report link parsing failure as special error | Connect {incognito :: IncognitoEnabled, connTarget_ :: Maybe ConnectTarget} + | -- Resolves the simplex_name claim on the chat row (contact or group) via + -- RSLV and compares the resolved link to the peer's stored connection link. + -- On match: writes simplex_name_verified_at and emits CEvtSimplexNameVerified. + -- On link mismatch or resolver failure: emits CEvtSimplexNameVerifyFailed. + APIVerifySimplexName {chatRef :: ChatRef} | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName ChatDeleteMode @@ -952,11 +957,40 @@ data ChatEvent -- (newer-claim-wins per RSLV); displacedFrom is the old row's local -- display_name, claimedBy is the peer / group whose claim won. CEvtSimplexNameConflict {user :: User, simplexName :: SimplexNameInfo, entity :: SimplexNameConflictEntity, claimedBy :: ContactName, displacedFrom :: ContactName} + | -- Emitted by APIVerifySimplexName when the RSLV-resolved link for the + -- claimed name matches the peer's stored connection link. simplex_name_verified_at + -- has been written; UI should clear the unverified indicator. + CEvtSimplexNameVerified {user :: User, chatRef :: ChatRef, simplexName :: SimplexNameInfo, verifiedAt :: UTCTime} + | -- Emitted by APIVerifySimplexName when verification did not succeed. + -- The simplex_name claim is NOT cleared; the user may still wish to keep + -- the contact/group. UI should surface the failure reason. + CEvtSimplexNameVerifyFailed {user :: User, chatRef :: ChatRef, simplexName :: SimplexNameInfo, reason :: SimplexNameVerifyFailReason} + | -- Passive warning emitted when an incoming XInfo / XGrpInfo carries a + -- simplex_name claim that the user has not (yet) verified — i.e. + -- simplex_name_verified_at is NULL. The UI is expected to show an + -- unverified indicator; the user can invoke APIVerifySimplexName to clear it. + CEvtSimplexNameUnverified {user :: User, chatRef :: ChatRef, simplexName :: SimplexNameInfo} deriving (Show) data SimplexNameConflictEntity = SNCEContact | SNCEGroup deriving (Show) +-- | Why APIVerifySimplexName failed. The resolved record is not stashed: we +-- intentionally do not allow a "verified to point at a DIFFERENT contact" +-- state; the user must decide whether to keep the existing contact or start +-- a fresh connection with the resolved link. +data SimplexNameVerifyFailReason + = -- | Resolver returned a NameRecord but its link for this entity's type + -- (nrSimplexContact for contacts, nrSimplexChannel for groups) differs + -- from the link stored locally for the peer. + SNVFLinkMismatch + | -- | RSLV returned AUTH (NameNotRegistered): no on-chain record exists. + SNVFNameNotRegistered + | -- | Transport / proxy / other resolver-side failure. The agent error is + -- surfaced verbatim so the UI can reuse existing agent-error rendering. + SNVFResolverError {agentError :: AgentErrorType} + deriving (Eq, Show) + data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} | TERelayRejected {user :: User, groupInfo :: GroupInfo, relayRejectionReason :: RelayRejectionReason} @@ -1783,6 +1817,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveJSON (enumJSON $ dropPrefix "SNCE") ''SimplexNameConflictEntity) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SNVF") ''SimplexNameVerifyFailReason) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 09e8c5eaa6..7dfa57e7ea 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2248,6 +2248,7 @@ processChatCommand vr nm = \case (ccLink, plan) <- connectPlanName user ni connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq + APIVerifySimplexName chatRef -> withUser $ \user -> apiVerifySimplexName user chatRef APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db vr user contactId ccLink <- case contactLink of @@ -4749,6 +4750,78 @@ resolveErrorToChatError ni = \case ResolverUnavailable -> ChatError $ CESimplexNameResolverUnavailable ni ResolverTransport e -> chatErrorAgent e +-- | Best-effort comparison between an RSLV-resolved link (a 'Text' from the +-- name record) and the peer's stored connection link. Both are normalized via +-- 'strDecode' + 'strEncode' so scheme drift (simplex:/ vs https://simplex.chat) +-- doesn't cause a false negative. If the RSLV text fails to parse as a contact +-- link, we treat it as a mismatch — the resolver returned something we don't +-- understand, which is not a valid verification. +linksMatch :: Text -> ConnLinkContact -> Bool +linksMatch resolved stored = case strDecode (encodeUtf8 resolved) :: Either String AConnectionLink of + Right (ACL SCMContact resolvedLink) -> normalize resolvedLink == normalize stored + _ -> False + where + -- Mirror the inline 'serverShortLink' helper used elsewhere in this module: + -- the agent's simplex:/ scheme and the server-hostname scheme encode the + -- same link; verification must be scheme-insensitive. + normalize :: ConnLinkContact -> ByteString + normalize = \case + CLFull cReq -> strEncode cReq + CLShort (CSLContact _ ct srv linkKey) -> + strEncode (CSLContact SLSServer ct srv linkKey :: ConnShortLink 'CMContact) + +-- | Resolves the chat row's simplex_name claim via RSLV and compares the +-- resolved per-type link to the peer's stored connection link. On match, +-- timestamps the contact/group row and emits CEvtSimplexNameVerified. +-- On mismatch / RSLV failure, emits CEvtSimplexNameVerifyFailed. +-- Throws CESimplexNameNotFound when the row has no claim to verify. +apiVerifySimplexName :: User -> ChatRef -> CM ChatResponse +apiVerifySimplexName user chatRef = do + vr <- chatVersionRange + (claim, storedLink, persistVerified) <- loadClaimAndLink vr + let domain = (\SimplexNameInfo {nameDomain} -> nameDomain) claim + nameType' = (\SimplexNameInfo {nameType} -> nameType) claim + resolveOnUserServers user domain >>= \case + Right NameRecord {nrSimplexContact, nrSimplexChannel} -> do + let resolvedLink = case nameType' of + NTContact -> nrSimplexContact + NTPublicGroup -> nrSimplexChannel + case resolvedLink of + Just lnk | linksMatch lnk storedLink -> do + ts <- liftIO getCurrentTime + withStore' $ \db -> persistVerified db ts + toView $ CEvtSimplexNameVerified user chatRef claim ts + _ -> + toView $ CEvtSimplexNameVerifyFailed user chatRef claim SNVFLinkMismatch + Left NameNotRegistered -> + toView $ CEvtSimplexNameVerifyFailed user chatRef claim SNVFNameNotRegistered + Left ResolverUnavailable -> + throwChatError $ CESimplexNameResolverUnavailable claim + Left (ResolverTransport e) -> + toView $ CEvtSimplexNameVerifyFailed user chatRef claim (SNVFResolverError e) + pure $ CRCmdOk (Just user) + where + -- Returns the claim to verify, the peer's stored link, and a callback that + -- persists the verified_at timestamp to the appropriate table. Throws a + -- command error when the row has no claim or no link (nothing to verify). + loadClaimAndLink :: VersionRangeChat -> CM (SimplexNameInfo, ConnLinkContact, DB.Connection -> UTCTime -> IO ()) + loadClaimAndLink vr = case chatRef of + ChatRef CTDirect cId _ -> do + ct <- withFastStore $ \db -> getContact db vr user cId + let Contact {contactId, simplexName = ctSimplexName, profile = LocalProfile {contactLink}} = ct + claim <- maybe (throwCmdError "contact has no simplex_name to verify") pure ctSimplexName + lnk <- maybe (throwCmdError "contact has no stored link to verify against") pure contactLink + pure (claim, lnk, \db ts -> setContactSimplexNameVerifiedAt db user contactId ts) + ChatRef CTGroup gId _ -> do + g <- withFastStore $ \db -> getGroupInfo db vr user gId + let GroupInfo {groupId, simplexName = gSimplexName, preparedGroup} = g + claim <- maybe (throwCmdError "group has no simplex_name to verify") pure gSimplexName + PreparedGroup {connLinkToConnect = CCLink cReq shortLink_} <- + maybe (throwCmdError "group has no stored link to verify against") pure preparedGroup + let lnk = maybe (CLFull cReq) CLShort shortLink_ + pure (claim, lnk, \db ts -> setGroupSimplexNameVerifiedAt db user groupId ts) + _ -> throwCmdError "APIVerifySimplexName supports only direct and group chat refs" + -- | Pure iteration logic for 'resolveOnUserServers'. Extracted so tests can -- supply a stub resolver without standing up a real agent / proxy. iterateResolvers :: @@ -5395,6 +5468,7 @@ chatCommandP = "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), + "/_verify simplex name " *> (APIVerifySimplexName <$> chatRefP), ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 6357e4a3ba..015830a850 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2566,8 +2566,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else do c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' updateContactProfileWithConflict db user c' p' - let Contact {localDisplayName = newLDN} = c' + let Contact {contactId = ctId, localDisplayName = newLDN, simplexNameVerifiedAt = vAt} = c' surfaceSimplexNameConflict user p'SimplexName displaced_ SNCEContact newLDN + -- Passive unverified warning: a non-empty incoming claim that the user + -- has not verified (or whose prior verification was cleared by the + -- claim transition in updateContactProfileWithConflict) should be + -- surfaced so the UI can prompt the user to invoke APIVerifySimplexName. + forM_ p'SimplexName $ \ni -> + when (isNothing vAt) $ + toView $ CEvtSimplexNameUnverified user (ChatRef CTDirect ctId Nothing) ni when (directOrUsed c' && createItems) $ do createProfileUpdatedItem c' lift $ createRcvFeatureItems user c c' @@ -3307,8 +3314,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case businessChat of Nothing -> unless (p == p') $ do (g', displaced_) <- withStore $ \db -> updateGroupProfileWithConflict db user g p' - let GroupInfo {localDisplayName = newLDN} = g' + let GroupInfo {groupId = gId, localDisplayName = newLDN, simplexNameVerifiedAt = vAt} = g' surfaceSimplexNameConflict user p'GroupSimplexName displaced_ SNCEGroup newLDN + -- Passive unverified warning, mirrors processContactProfileUpdate. + forM_ p'GroupSimplexName $ \ni -> + when (isNothing vAt) $ + toView $ CEvtSimplexNameUnverified user (ChatRef CTGroup gId Nothing) ni (g'', m', scopeInfo) <- mkGroupChatScope g' m toView $ CEvtGroupUpdated user g g'' (Just m') msgSigned let cd = CDGroupRcv g'' scopeInfo m' diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5800ab5bdd..2655d53161 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1461,6 +1461,8 @@ instance MsgDirectionI d => ToJSON (CIFile d) where $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GCS") ''GroupChatScope) +$(JQ.deriveJSON defaultJSON ''ChatRef) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCI") ''JSONCIDirection) instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIDirection c d) where diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 6b46f777aa..fdb44b3707 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -120,7 +120,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do |] (userId, contactId, CSActive) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) = let simplexName = decodeSimplexName ctSimplexNameRaw profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} @@ -128,7 +128,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do activeConn = Just conn preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData, simplexName} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData, simplexName, simplexNameVerifiedAt} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -149,7 +149,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, + g.simplex_name, gp.simplex_name, g.simplex_name_verified_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index a3807fdbdb..bd2179cbd6 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -113,7 +113,7 @@ createOrUpdateContactRequest ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 44e7ebc34c..71eb124bb9 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -54,6 +54,7 @@ module Simplex.Chat.Store.Direct getContactIdBySimplexName, updateContactProfile, updateContactProfileWithConflict, + setContactSimplexNameVerifiedAt, updateContactUserPreferences, updateContactAlias, updateContactConnectionAlias, @@ -322,7 +323,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -573,24 +574,45 @@ updateContactProfile db user c p' = fst <$> updateContactProfileWithConflict db -- (user_id, simplex_name) — returning that row's display_name so the caller -- can emit CEvtSimplexNameConflict. Used by the incoming-XInfo path; local -- updates that don't expect conflicts can continue to use updateContactProfile. +-- +-- Also clears contacts.simplex_name_verified_at when the peer's simplex_name +-- claim changes (any value transition, including Nothing<->Just): the prior +-- verification was tied to the prior claim and must be re-issued by the user. updateContactProfileWithConflict :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO (Contact, Maybe ContactName) updateContactProfileWithConflict db user@User {userId} c p' | displayName == newName = do displaced <- liftIO $ clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName liftIO $ updateContactProfile_ db userId profileId p' - pure (c {profile, mergedPreferences}, displaced) + liftIO clearVerifiedAtIfClaimChanged + pure (c' {profile, mergedPreferences}, displaced) | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime displaced <- clearConflictingContactProfileSimplexName_ db userId (Just profileId) profileSimplexName updateContactProfile_' db userId profileId p' currentTs updateContactLDN_ db user contactId localDisplayName ldn currentTs - pure $ Right (c {localDisplayName = ldn, profile, mergedPreferences}, displaced) + clearVerifiedAtIfClaimChanged + pure $ Right (c' {localDisplayName = ldn, profile, mergedPreferences}, displaced) where - Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c + Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias, simplexName = prevClaim}, userPreferences} = c Profile {displayName = newName, simplexName = profileSimplexName, preferences} = p' profile = toLocalProfile profileId p' localAlias mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c + claimChanged = prevClaim /= profileSimplexName + c' = if claimChanged then (c :: Contact) {simplexNameVerifiedAt = Nothing} else c + clearVerifiedAtIfClaimChanged = + when claimChanged $ + DB.execute db "UPDATE contacts SET simplex_name_verified_at = NULL WHERE user_id = ? AND contact_id = ?" (userId, contactId) + +-- | Records that the user successfully RSLV-verified the peer's simplex_name +-- claim against the contact's stored connection link. Cleared back to NULL by +-- updateContactProfileWithConflict whenever the peer's claim transitions. +setContactSimplexNameVerifiedAt :: DB.Connection -> User -> ContactId -> UTCTime -> IO () +setContactSimplexNameVerifiedAt db User {userId} contactId ts = + DB.execute + db + "UPDATE contacts SET simplex_name_verified_at = ? WHERE user_id = ? AND contact_id = ?" + (ts, userId, contactId) updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do @@ -908,7 +930,8 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc uiThemes = Nothing, chatDeleted = False, customData = Nothing, - simplexName = Nothing + simplexName = Nothing, + simplexNameVerifiedAt = Nothing } pure (ct, conn) @@ -955,7 +978,7 @@ getContact_ db vr user@User {userId} contactId deleted = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.simplex_name, cp.simplex_name, ct.simplex_name_verified_at, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7ecf2b9e16..7004d61066 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -47,6 +47,7 @@ module Simplex.Chat.Store.Groups getGroupInfoByGroupLinkHash, updateGroupProfile, updateGroupProfileWithConflict, + setGroupSimplexNameVerifiedAt, clearConflictingGroupProfileSimplexName_, updateGroupPreferences, updateGroupProfileFromMember, @@ -425,7 +426,8 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays membersRequireAttention = 0, viaGroupLinkUri = Nothing, groupKeys, - simplexName = Nothing + simplexName = Nothing, + simplexNameVerifiedAt = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -503,7 +505,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ membersRequireAttention = 0, viaGroupLinkUri = Nothing, groupKeys = Nothing, - simplexName = Nothing + simplexName = Nothing, + simplexNameVerifiedAt = Nothing }, groupMemberId ) @@ -2393,13 +2396,14 @@ updateGroupProfile db user g p' = fst <$> updateGroupProfileWithConflict db user -- (user_id, simplex_name) — returning that row's display_name so the caller -- can emit CEvtSimplexNameConflict. Used by the XGrpInfo path. updateGroupProfileWithConflict :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO (GroupInfo, Maybe GroupName) -updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, simplexName, groupPreferences, memberAdmission} +updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, simplexName = prevClaim}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, simplexName, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime profileId_ <- getGroupProfileId_ displaced <- clearConflictingGroupProfileSimplexName_ db userId profileId_ simplexName updateGroupProfile_ currentTs - pure ((g :: GroupInfo) {groupProfile = p', fullGroupPreferences}, displaced) + clearVerifiedAtIfClaimChanged + pure ((g' :: GroupInfo) {groupProfile = p', fullGroupPreferences}, displaced) | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime @@ -2407,9 +2411,18 @@ updateGroupProfileWithConflict db user@User {userId} g@GroupInfo {groupId, local displaced <- clearConflictingGroupProfileSimplexName_ db userId profileId_ simplexName updateGroupProfile_ currentTs updateGroup_ ldn currentTs - pure $ Right ((g :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}, displaced) + clearVerifiedAtIfClaimChanged + pure $ Right ((g' :: GroupInfo) {localDisplayName = ldn, groupProfile = p', fullGroupPreferences}, displaced) where fullGroupPreferences = mergeGroupPreferences groupPreferences + claimChanged = prevClaim /= simplexName + g' = if claimChanged then (g :: GroupInfo) {simplexNameVerifiedAt = Nothing} else g + -- Mirrors updateContactProfileWithConflict: clear the verification when + -- the peer's claim transitions to/from/between values; prior verification + -- was bound to the prior claim. + clearVerifiedAtIfClaimChanged = + when claimChanged $ + DB.execute db "UPDATE groups SET simplex_name_verified_at = NULL WHERE user_id = ? AND group_id = ?" (userId, groupId) (groupType_, groupLink_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink} -> (Just groupType, Just groupLink) Nothing -> (Nothing, Nothing) @@ -2475,6 +2488,14 @@ clearConflictingGroupProfileSimplexName_ db userId (Just profileId) (Just simple |] (userId, simplexName, profileId) +-- | Mirror of setContactSimplexNameVerifiedAt for groups. +setGroupSimplexNameVerifiedAt :: DB.Connection -> User -> GroupId -> UTCTime -> IO () +setGroupSimplexNameVerifiedAt db User {userId} groupId ts = + DB.execute + db + "UPDATE groups SET simplex_name_verified_at = ? WHERE user_id = ? AND group_id = ?" + (ts, userId, groupId) + updateGroupPreferences :: DB.Connection -> User -> GroupInfo -> GroupPreferences -> IO GroupInfo updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} ps = do currentTs <- getCurrentTime @@ -2962,7 +2983,7 @@ createMemberContact simplexName = Nothing } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing, simplexName = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing, simplexName = Nothing, simplexNameVerifiedAt = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9d7dc115d5..02e9454b7f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -37,6 +37,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name import Simplex.Chat.Store.Postgres.Migrations.M20260604_simplex_name_profiles +import Simplex.Chat.Store.Postgres.Migrations.M20260606_simplex_name_verified import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -73,7 +74,8 @@ schemaMigrations = ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), - ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles) + ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles), + ("20260606_simplex_name_verified", m20260606_simplex_name_verified, Just down_m20260606_simplex_name_verified) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260606_simplex_name_verified.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260606_simplex_name_verified.hs new file mode 100644 index 0000000000..bdada69239 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260606_simplex_name_verified.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260606_simplex_name_verified where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260606_simplex_name_verified :: Text +m20260606_simplex_name_verified = + [r| +ALTER TABLE contacts ADD COLUMN simplex_name_verified_at TIMESTAMPTZ; +ALTER TABLE groups ADD COLUMN simplex_name_verified_at TIMESTAMPTZ; +|] + +down_m20260606_simplex_name_verified :: Text +down_m20260606_simplex_name_verified = + [r| +ALTER TABLE groups DROP COLUMN simplex_name_verified_at; +ALTER TABLE contacts DROP COLUMN simplex_name_verified_at; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 9269b98f80..f2a521eeff 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -160,6 +160,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at import Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name import Simplex.Chat.Store.SQLite.Migrations.M20260604_simplex_name_profiles +import Simplex.Chat.Store.SQLite.Migrations.M20260606_simplex_name_verified import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -319,7 +320,8 @@ schemaMigrations = ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), ("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name), - ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles) + ("20260604_simplex_name_profiles", m20260604_simplex_name_profiles, Just down_m20260604_simplex_name_profiles), + ("20260606_simplex_name_verified", m20260606_simplex_name_verified, Just down_m20260606_simplex_name_verified) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260606_simplex_name_verified.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260606_simplex_name_verified.hs new file mode 100644 index 0000000000..2287d540e7 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260606_simplex_name_verified.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260606_simplex_name_verified where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- contacts.simplex_name_verified_at and groups.simplex_name_verified_at record +-- the timestamp when the user last verified that the peer's claimed simplex_name +-- resolves (via RSLV) to the link stored locally for the contact/group. +-- NULL means the claim is unverified and the UI should show an indicator. +-- The column is cleared back to NULL whenever the simplex_name claim changes +-- (updateContactProfileWithConflict / updateGroupProfileWithConflict). +m20260606_simplex_name_verified :: Query +m20260606_simplex_name_verified = + [sql| +ALTER TABLE contacts ADD COLUMN simplex_name_verified_at TEXT; +ALTER TABLE groups ADD COLUMN simplex_name_verified_at TEXT; +|] + +down_m20260606_simplex_name_verified :: Query +down_m20260606_simplex_name_verified = + [sql| +ALTER TABLE groups DROP COLUMN simplex_name_verified_at; +ALTER TABLE contacts DROP COLUMN simplex_name_verified_at; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 79d833b044..11e27bf8d6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -94,6 +94,7 @@ CREATE TABLE contacts( grp_direct_inv_from_member_conn_id INTEGER REFERENCES connections(connection_id) ON DELETE SET NULL, grp_direct_inv_started_connection INTEGER NOT NULL DEFAULT 0, simplex_name TEXT, + simplex_name_verified_at TEXT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -186,7 +187,8 @@ CREATE TABLE groups( relay_request_delay INTEGER NOT NULL DEFAULT 0, relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', relay_inactive_at TEXT, - simplex_name TEXT, -- received + simplex_name TEXT, + simplex_name_verified_at TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index b8a7408e46..893494cd06 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -544,14 +544,14 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64, Maybe Text, Maybe Text) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64, Maybe Text, Maybe Text, Maybe UTCTime) type ContactRow = Only ContactId :. ContactRow' -- ct.simplex_name -> Contact.simplexName (user's locally-known label) -- cp.simplex_name -> LocalProfile.simplexName (peer's broadcast claim) toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, ctSimplexNameRaw, cpSimplexNameRaw, simplexNameVerifiedAt)) :. connRow) = let simplexName = decodeSimplexName ctSimplexNameRaw profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, simplexName = decodeSimplexName cpSimplexNameRaw, peerType, preferences, localAlias} activeConn = toMaybeConnection vr connRow @@ -560,7 +560,7 @@ toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, dis mergedPreferences = contactUserPreferences user userPreferences preferences incognito preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData, simplexName} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData, simplexName, simplexNameVerifiedAt} toPreparedContact :: PreparedContactRow -> Maybe PreparedContact toPreparedContact (connFullLink, connShortLink, welcomeSharedMsgId, requestSharedMsgId) = @@ -728,7 +728,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. (Maybe Text, Maybe Text) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. (Maybe Text, Maybe Text, Maybe UTCTime) :. GroupMemberRow type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt) @@ -737,7 +737,7 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences, Maybe Text) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. (gSimplexNameRaw, gpSimplexNameRaw) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. (gSimplexNameRaw, gpSimplexNameRaw, simplexNameVerifiedAt) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -752,7 +752,7 @@ toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, simplexName} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys, simplexName, simplexNameVerifiedAt} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -854,7 +854,7 @@ groupInfoQueryFields = g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, g.root_priv_key, g.root_pub_key, g.member_priv_key, - g.simplex_name, gp.simplex_name, + g.simplex_name, gp.simplex_name, g.simplex_name_verified_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1b91dd5324..d5c2106876 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -210,7 +210,12 @@ data Contact = Contact uiThemes :: Maybe UIThemeEntityOverrides, chatDeleted :: Bool, customData :: Maybe CustomData, - simplexName :: Maybe SimplexNameInfo + simplexName :: Maybe SimplexNameInfo, + -- | Timestamp of the most recent successful RSLV verification of the peer's + -- simplex_name claim against this contact's connection link. NULL means the + -- claim is unverified (UI should surface an indicator). Cleared back to NULL + -- whenever simplex_name changes in updateContactProfileWithConflict. + simplexNameVerifiedAt :: Maybe UTCTime } deriving (Eq, Show) @@ -491,7 +496,10 @@ data GroupInfo = GroupInfo membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, groupKeys :: Maybe GroupKeys, - simplexName :: Maybe SimplexNameInfo + simplexName :: Maybe SimplexNameInfo, + -- | See 'Contact.simplexNameVerifiedAt'. Verified against the channel link + -- stored for the group; cleared by updateGroupProfileWithConflict. + simplexNameVerifiedAt :: Maybe UTCTime } deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9feec34106..a1d7d2887a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -567,6 +567,16 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} where entityLabel SNCEContact n = "@" <> n entityLabel SNCEGroup n = "#" <> n + CEvtSimplexNameVerified u _chatRef ni _ts -> + ttyUser u ["simplex name " <> plain (shortNameInfoStr ni) <> " verified"] + CEvtSimplexNameVerifyFailed u _chatRef ni r -> + ttyUser u ["simplex name " <> plain (shortNameInfoStr ni) <> " verification failed: " <> reason r] + where + reason SNVFLinkMismatch = "link mismatch" + reason SNVFNameNotRegistered = "name not registered" + reason (SNVFResolverError e) = "resolver error: " <> sShow e + CEvtSimplexNameUnverified u _chatRef ni -> + ttyUser u ["simplex name " <> plain (shortNameInfoStr ni) <> " is unverified"] where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss diff --git a/tests/ResolveNameTests.hs b/tests/ResolveNameTests.hs index a440bfee6b..cd0bcf2be1 100644 --- a/tests/ResolveNameTests.hs +++ b/tests/ResolveNameTests.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} @@ -7,11 +9,13 @@ import Data.Functor.Identity (Identity (..)) import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import qualified Data.Map.Strict as M import Data.Text (Text) +import qualified Data.Text.Encoding as T import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) -import Simplex.Chat.Library.Commands (ResolveError (..), firstNameLink, iterateResolvers, resolveErrorToChatError) +import Simplex.Chat.Library.Commands (ResolveError (..), firstNameLink, iterateResolvers, linksMatch, resolveErrorToChatError) import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), AgentErrorType (..), ConnShortLink, ConnectionLink (..), ConnectionMode (..), SConnectionMode (..), SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Client (ProxyClientError (..)) +import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Protocol (BrokerErrorType (..), NameRecord (..), NetworkError (..), SMPServer, mkNameOwner, pattern SMPServer) import qualified Simplex.Messaging.Protocol as SMP import Test.Hspec @@ -107,6 +111,52 @@ resolveNameTests = do case firstNameLink NTContact (Just channelLink) Nothing aliceNi of Left (ChatError (CESimplexNameNotFound _)) -> pure () other -> expectationFailure $ "expected CESimplexNameNotFound, got " <> show other + -- linksMatch is the byte-equal-after-normalize comparator that gates + -- APIVerifySimplexName. The agent's simplex:/ scheme and the server-hostname + -- scheme encode the same link, so a successful verification must accept + -- either side using either scheme. A malformed RSLV link (anything that + -- doesn't parse as a contact link) is rejected. + describe "linksMatch" $ do + let storedShort = CLShort sampleShortLinkServer + it "matches an RSLV link in server scheme against a stored short-link" $ + linksMatch sampleShortLinkServerText storedShort `shouldBe` True + it "matches across scheme normalization (simplex:/ vs https://)" $ + linksMatch sampleShortLinkSimplexText storedShort `shouldBe` True + it "rejects a non-contact-link RSLV payload" $ + linksMatch "not-a-link" storedShort `shouldBe` False + it "rejects a structurally different short-link" $ + linksMatch differentShortLinkText storedShort `shouldBe` False + it "matches an invitation-shaped link only if both sides parse as contact" $ + -- invitation-typed RSLV link is not CMContact and must be rejected even + -- if the bytes look superficially similar. + linksMatch invitationLikeText storedShort `shouldBe` False + +-- | Known-good short contact link in server-hostname scheme. Mirrors the +-- canonical encoding from simplexmq's ConnectionRequestTests.hs: +-- @CSLContact SLSServer CCTContact srv (LinkKey ...)@. +sampleShortLinkServerText :: Text +sampleShortLinkServerText = "https://smp.simplex.im/a#MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY?h=jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&p=5223&c=1234-w" + +-- | The same link as 'sampleShortLinkServerText' but in the agent's +-- simplex:/ scheme. normalize must collapse these to byte-equal forms. +sampleShortLinkSimplexText :: Text +sampleShortLinkSimplexText = "simplex:/a#MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY?h=smp.simplex.im%2Cjjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&p=5223&c=1234-w" + +-- | Structurally different short link (different LinkKey). Must NOT match. +differentShortLinkText :: Text +differentShortLinkText = "https://smp.simplex.im/a#YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE?h=jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&p=5223&c=1234-w" + +-- | An invitation-shaped link (path /i not /a). Even if the bytes happen to +-- parse as some AConnectionLink, the SCMContact projection must fail. +invitationLikeText :: Text +invitationLikeText = "https://smp.simplex.im/i#MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY?h=jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&p=5223&c=1234-w" + +-- | Parsed form of 'sampleShortLinkServerText' for use as the stored side +-- of the linksMatch comparison. +sampleShortLinkServer :: ConnShortLink 'CMContact +sampleShortLinkServer = case strDecode (T.encodeUtf8 sampleShortLinkServerText) of + Right (ACL SCMContact (CLShort l)) -> l + other -> error $ "ResolveNameTests fixture failed to parse: " <> show other -- | Wrap a resolver to record which servers it was called for. recording :: IORef [SMPServer] -> (SMPServer -> IO (Either AgentErrorType NameRecord)) -> SMPServer -> IO (Either AgentErrorType NameRecord)