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".
This commit is contained in:
shum
2026-06-06 08:33:25 +00:00
parent 3104291609
commit ebe90f7169
18 changed files with 322 additions and 32 deletions
+2
View File
@@ -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:
+36
View File
@@ -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)
+74
View File
@@ -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,
+13 -2
View File
@@ -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'
+2
View File
@@ -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
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
+29 -6
View File
@@ -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,
+27 -6
View File
@@ -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
@@ -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
@@ -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;
|]
+3 -1
View File
@@ -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
@@ -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;
|]
@@ -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
+7 -7
View File
@@ -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,
+10 -2
View File
@@ -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)
+10
View File
@@ -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
+52 -2
View File
@@ -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)