core, web: correctly process name verification for channels, show names in directory

This commit is contained in:
Evgeny @ SimpleX Chat
2026-07-11 12:48:15 +00:00
parent ee51168374
commit 298c1ba50d
10 changed files with 736 additions and 37 deletions
@@ -70,6 +70,7 @@ $(JQ.deriveJSON defaultJSON ''PublicLink)
data DirectoryEntry = DirectoryEntry
{ entryType :: DirectoryEntryType,
displayName :: Text,
simplexName :: Maybe Text,
groupLink :: PublicLink,
shortDescr :: Maybe MarkdownList,
welcomeMessage :: Maybe MarkdownList,
@@ -97,7 +98,7 @@ recentRoundedTime roundTo now t
in Just $ systemToUTCTime $ MkSystemTime secs 0
groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData))
groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ =
groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ =
let GroupProfile {displayName, shortDescr, description, image, memberAdmission, publicGroup} = groupProfile
gt = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup
entryType = DETGroup gt memberAdmission groupSummary
@@ -112,6 +113,7 @@ groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary
DirectoryEntry
{ entryType,
displayName,
simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g,
groupLink,
shortDescr = toFormattedText <$> shortDescr,
welcomeMessage = toFormattedText <$> description',
@@ -64,11 +64,12 @@ import Simplex.Chat.Terminal.Main (simplexChatCLI')
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName)
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact)
import Simplex.Chat.View (groupSimplexDomain, serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName)
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), SimplexDomain, sameConnReqContact, sameShortLinkContact)
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol (ErrorType (..))
import Simplex.Messaging.SimplexName (SimplexNameInfo (..), SimplexNameType (..), shortNameInfoStr)
import Simplex.Messaging.TMap (TMap)
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>))
@@ -361,7 +362,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e
notifyAdminUsers msg
logError msg
groupInfoText p@GroupProfile {description = d, publicGroup} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d <> linkToJoin
-- validates that resolving the claimed name leads to the link in the group profile,
-- updating the group's verification status; leaves it unchanged on network errors
verifyGroupDomain_ :: GroupInfo -> IO GroupInfo
verifyGroupDomain_ g@GroupInfo {groupId}
| isJust (groupSimplexDomain g) =
sendChatCmd cc (APIVerifyGroupDomain groupId) >>= \case
Right CRGroupDomainVerified {groupInfo = g'} -> pure g'
Right r -> g <$ logError ("verifyGroupDomain_: unexpected response " <> tshow r)
Left e -> g <$ logInfo ("verifyGroupDomain_: error " <> tshow e)
| otherwise = pure g
groupInfoText simplexName_ p@GroupProfile {description = d, publicGroup} = groupNameDescr p <> maybe "" ("\nSimpleX name: " <>) simplexName_ <> maybe "" ("\nWelcome message:\n" <>) d <> linkToJoin
where
linkToJoin = case publicGroup of
Just pg@PublicGroupProfile {groupLink} ->
@@ -800,13 +811,18 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
alwaysCaptcha || useMemberFilter image (passCaptcha a)
sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO ()
sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image', publicGroup = pg_}, groupSummary} GroupReg {dbContactId, promoted} gaId = do
sendToApprove g0 GroupReg {dbContactId, promoted} gaId = do
-- verify the claimed name first, so admins see its actual state
g@GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image', publicGroup = pg_}, groupSummary} <-
if groupDomainVerified g0 /= Just True then verifyGroupDomain_ g0 else pure g0
ct_ <- getContact' cc user dbContactId
let gt = maybe "group" groupTypeStr' pg_
-- admins see an unverified claim, marked; users never do
nameStr_ = (\d -> simplexNameStr d <> (if groupDomainVerified g == Just True then "" else " (NOT verified - will not be shown)")) <$> groupSimplexDomain g
membersStr = "_" <> membersCountStr p groupSummary <> "_\n"
text =
either (\_ -> "The " <> gt <> " ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the " <> gt <> " ID " <> tshow groupId <> ": ") ct_
<> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:")
<> ("\n" <> groupInfoText nameStr_ p <> "\n" <> membersStr <> "\nTo approve send:")
msg = maybe (MCText text) (\image -> MCImage {text, image}) image'
withAdminUsers $ \cId -> do
let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else ""
@@ -821,8 +837,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups Nothing) >>= \case
Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated, linkOwners = ListDef owners}))) ->
checkValidOwner dbOwnerMemberId owners $ do
when groupUpdated $ reapprove pg gr groupRegStatus g'
when (groupUpdated || summary /= groupSummary g') $ listingsUpdated env
-- re-verified every cycle: the directory re-publishes the name, so it must
-- know the name still resolves to the link in the (just refreshed) profile
g'' <- verifyGroupDomain_ g'
when groupUpdated $ reapprove pg gr groupRegStatus g''
when (groupUpdated || summary /= groupSummary g'' || groupDomainVerified g'' /= groupDomainVerified gInfo) $ listingsUpdated env
Left (ChatErrorAgent {agentError = SMP _ err}) | linkDeleted err ->
setGroupStatus logError st env cc groupId GRSRemoved $ \gr' ->
notifyOwner gr' "The channel link is no longer valid.\nThe channel is removed from the directory."
@@ -1202,9 +1221,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
Just PCAll -> "_enabled_"
Just PCNoImage -> "_enabled for profiles without image_"
DCShowUpgradeGroupLink gId gName_ ->
(if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of
(if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of
Just pg@PublicGroupProfile {groupLink} ->
sendReply $ "The link to join the " <> groupTypeStr' pg <> " " <> groupReference' gId gName <> ":\n" <> strEncodeTxt groupLink
<> maybe "" (("\nSimpleX name: " <>) . simplexNameStr) (verifiedGroupDomain g)
Nothing -> do
let groupRef = groupReference' gId gName
withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $
@@ -1289,10 +1309,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
where
msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0]
replyMsg = (Just ciId, MCText reply)
foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _) =
foundGroup (g@GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _) =
let membersStr = "_" <> membersCountStr p groupSummary <> "_"
showId = if isAdmin then tshow groupId <> ". " else ""
text = T.unlines $ [showId <> groupInfoText p, membersStr] ++ knockingStr memberAdmission
text = T.unlines $ [showId <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p, membersStr] ++ knockingStr memberAdmission
in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_)
moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).")
@@ -1490,7 +1510,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
membersStr = "_" <> membersCountStr p groupSummary <> "_"
cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'"
ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_
text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds]
text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds]
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
in (Nothing, msg)
@@ -1563,3 +1583,6 @@ unexpectedError err = "Unexpected error: " <> err <> ", please notify the develo
strEncodeTxt :: StrEncoding a => a -> Text
strEncodeTxt = safeDecodeUtf8 . strEncode
simplexNameStr :: SimplexDomain -> Text
simplexNameStr = shortNameInfoStr . SimplexNameInfo NTPublicGroup
@@ -43,6 +43,7 @@ module Directory.Store
getAllListedGroups,
getAllListedGroups_,
searchListedGroups,
verifiedGroupDomain,
groupRegStatusText,
pendingApproval,
groupRemoved,
@@ -86,11 +87,13 @@ import Data.Time.Clock.System (systemEpochDay)
import Directory.Search
import Directory.Util
import Simplex.Chat.Controller
import Simplex.Chat.Names (claimDomain)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Store
import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (SimplexDomain)
import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Encoding.String
@@ -222,6 +225,13 @@ grDirectoryStatus = \case
GRSRemoved -> DSRemoved
_ -> DSRegistered
-- the group's claimed SimpleX name, only when its verification status is recorded as
-- verified - the display criterion for all user-facing directory surfaces
verifiedGroupDomain :: GroupInfo -> Maybe SimplexDomain
verifiedGroupDomain GroupInfo {groupProfile = GroupProfile {publicGroup}, groupDomainVerified}
| groupDomainVerified == Just True = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)
| otherwise = Nothing
$(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition)
$(JQ.deriveJSON defaultJSON ''DirectoryMemberAcceptance)
@@ -385,15 +395,20 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC "
STSearch search -> case lastGroup_ of
Nothing -> do
gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize)
n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") ((userId, userContactId, GRSActive, s, s, s, s) :. (sDomain, pageSize))
n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s, sDomain)
pure (gs, n)
Just gId -> do
gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize)
n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") ((userId, userContactId, GRSActive, gId, s, s, s, s) :. (sDomain, pageSize))
n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s, sDomain)
pure (gs, n)
where
s = T.toLower search
-- names are searched without the # / @ prefix; "#" (empty after stripping) cannot
-- match any stored domain, so a bare prefix does not match every named group
sDomain = case T.uncons s of
Just (c, rest) | c == '#' || c == '@' -> if T.null rest then "#" else rest
_ -> s
countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? "
orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC "
where
@@ -407,6 +422,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
OR LOWER(gp.full_name) LIKE '%' || ? || '%'
OR LOWER(gp.short_descr) LIKE '%' || ? || '%'
OR LOWER(gp.description) LIKE '%' || ? || '%'
OR (LOWER(gp.group_domain) LIKE '%' || ? || '%' AND g.group_domain_verified = 1)
)
|]
+465
View File
@@ -0,0 +1,465 @@
# Directory service: SimpleX domain names
Add SimpleX domain names (`#name`) of listed channels to the directory: show them in the
bot's chat output, include them in the generated web listing and on the directory web page,
and make both bot search and web search match them.
Prerequisites (part A, core):
1. `updateGroupFromLinkData` must stop marking unresolved claims as verified — today the
link-refresh paths mark self-claimed names verified, which would make the directory an
impersonation amplifier. It must also NOT resolve names itself: automatic resolution
from a member's device is a metadata leak (the resolver learns which names the
device's channels claim — membership metadata, on a refresh cadence).
2. `APIVerifyGroupDomain` must be fixed to do the verification actually needed:
validate that the group name and the group link are **consistent** — resolving the
claimed name leads to the link declared in the profile (`publicGroup.groupLink`).
The link may differ from the one we joined through, and that is fine: link rotation
is a supported direction, so no join-link comparison. Verification is explicit:
users trigger it manually (or via the client's own policy); the directory — a public
bot whose resolver queries reveal nothing personal — calls it directly.
Line refs are to the working tree at planning time; re-check before editing.
## 1. Current state (facts, with references)
### Names in core
- A channel's name is a claim on its group profile:
`PublicGroupAccess.groupDomainClaim :: Maybe SimplexDomainClaim` (`Types.hs:847-853`),
reachable as `publicGroup >>= publicGroupAccess >>= groupDomainClaim`. Only public,
relay-backed groups (channels) have `publicGroup`; legacy p2p groups cannot have names.
- `SimplexDomainClaim = { domain :: StrJSON SimplexDomain, proof :: Maybe SimplexDomainProof }`
(`Names.hs:49-53`). `SimplexDomain` (simplexmq `SimplexName.hs:40`) stores as TEXT via
`fullDomainName` — e.g. `team.simplex` — in `group_profiles.group_domain`
(row mapping `Store/Shared.hs:688,720-727`).
- Display form: `shortNameInfoStr (SimplexNameInfo NTPublicGroup domain)``#team` for a
plain `.simplex` name, otherwise `#`+full domain (simplexmq `SimplexName.hs:118-125`).
`groupSimplexDomain :: GroupInfo -> Maybe SimplexDomain` exists (`View.hs:1131-1133`;
the module has no export list, so it is importable).
- Local status: `GroupInfo.groupDomainVerified :: Maybe Bool` (`Types.hs:502`), column
`groups.group_domain_verified`, included in `groupInfoQueryFields`
(`Store/Shared.hs:799-805`) — every directory store query already returns claim + status.
`setGroupDomainVerified` (`Store/Groups.hs:2754`) writes it; `updateGroupProfile`
already resets it to NULL when (and only when) the claim changes
(`Store/Groups.hs:2712-2731`).
- The owner sets the name with `/public group access #g domain=...`
(`Commands.hs:3143-3152`): the client resolves the domain and requires
`nameResolvesTo groupLink` (`Commands.hs:4837-4838`) before storing. Client-side
enforcement only — a modified client can claim any name.
- Core's own by-name lookup criterion: claim + `group_domain_verified = 1`
(`getGroupToConnect`, `Store/Groups.hs:1086-1104`).
- Connect-by-name creates the group with the domain set and marked verified
(`Store/Groups.hs:2759-2765`) — sound: the user's explicit action resolved the name.
### Defect 1: link-data refresh marks claims verified without resolution
`updateGroupFromLinkData` (`Internal.hs:1466-1488`) marks **any** claim present in
fetched link data as verified (`verifyChanged`, `:1488`). Call sites:
| # | Site | Reached by | Name resolved first? | Claim=name checked? |
|---|------|-----------|----------------------|---------------------|
| 1 | `APIGetUpdatedGroupLinkData`, `Commands.hs:1858` | refresh of the channel's own link data | NO (pure link fetch, `:1855`) | n/a |
| 2 | name-plan branch, `Commands.hs:4326` | `APIConnectPlan`, NAME target, group known by link | YES (`resolveNameLink` `:4303,4357-4363`) — explicit user action | yes, but AFTER the mark — `:4335` throws after `:4326` wrote the flag |
| 3 | `resolveKnownGroup` `Commands.hs:4346-4355`, LINK target | known group + `PRMAllGroups` — the directory's `deGroupLinkCheck` (`Service.hs:820-821`) and app link taps | NO (`resolveSLink` = `pure l'`, `:4290-4292`) | n/a |
| 4 | `resolveKnownGroup`, NAME target | reachable only under `PRMAllGroups`, which no caller sends with a name target (the directory sends link targets only, `Service.hs:563,821,957`; user connects use `PRMUnknown`, `Commands.hs:2288`) — currently unexercised | YES | n/a — the group was found by the verified-name lookup, so it is already verified |
The **contact** twin has sound semantics (`Commands.hs:4246-4253`):
`updateContactFromLinkData` runs only on the name path (`planDomain` guard, `:4249-4250`)
and the claim-equality check throws **before** it runs (`:4252-4253`). Note sites 1 and 3
are still needed for profile / member-count refresh — the fix is the verification rule
inside, not the calls.
### Defect 2: `APIVerifyGroupDomain` does not do the needed verification
`APIVerifyGroupDomain` (`Commands.hs:2301-2307`) delegates to `verifyEntityDomain`
(`:4840-4864`), which requires `claim.proof` and verifies a proof signature. For a public
group this is the wrong check: the claim arrives owner-signed (link data or signed
`XGrpInfo`), so claim authenticity is already given; what needs verification is
**consistency** — that resolving the claimed name leads to the link the profile
declares (not to the link we joined through, which may have rotated). The proof requirement
also makes the command inoperative today (nothing in this tree generates
`SimplexDomainProof`; it always returns "no name proof to verify").
`APIVerifyContactDomain` (`:2294-2300`) is a separate case (contact claims are not
link-bound the same way) and is out of scope.
### Directory service integration points
- Bot output text: `groupInfoText` (`Service.hs:364-370`) — used by `sendToApprove`
(`:802-813`, admin approval message), `sendFoundGroups` (`:1287-1297`, search results),
and `sendGroupsInfo` (`:1478-1495`, `/list`, `/last`, `/pending`). All call sites also
have the `GroupReg`. The `/link` command's public-group branch prints the channel's
link — the profile-declared `groupLink` (`:1204-1207`).
- Bot search: `searchListedGroups` (`Store.hs:357-411`); `searchCond` (`:404-411`) matches
`gp.display_name`, `gp.full_name`, `gp.short_descr`, `gp.description` with `LIKE`.
- Web listing: `generateListing` (`Listing.hs:146-168`) writes `listing.json` /
`promoted.json`; entries built by `groupDirectoryEntry` (`:99-144`) into
`DirectoryEntry` (`:70-81`), JSON via `defaultJSON` — an optional field is
wire-compatible.
- Web page: `website/src/js/directory.jsc` — display in `displayEntries` (`:212-341`),
client-side search in `filterEntries` (`:98-112`); search input
`website/src/directory.html:273`.
- Periodic link check: `linkCheckThread_` (`Service.hs:199-212`), default 1800s
(`Options.hs:175-182`); `deGroupLinkCheck` (`:816-854`) sends a link-target
`APIConnectPlan … PRMAllGroups` per active/pending group — targeting the
profile-declared `groupLink` (`:818-820`) — and triggers `listingsUpdated` when
`groupUpdated || summary /= groupSummary g'` (`:825`).
## 2. Design decisions (alternatives considered)
1. **Verification = name ↔ profile-link consistency.** Resolving the claimed name must
lead to the link the profile declares (`nameResolvesTo (publicGroup.groupLink)`,
helper at `Commands.hs:4837-4838`) — the same check the owner's client runs at set
time (`APISetPublicGroupAccess`, `:3149-3150`), so the rule is uniform. The join
link is NOT compared: link rotation is supported, a name may legitimately resolve to
a link we never joined through. This is also exactly the property the directory
publishes — the listing's join link IS the profile-declared link
(`Listing.hs:124-126`), so a verified name is one that leads users to the same place
as the entry itself. The profile is trusted (owner-signed provenance: link data or
signed `XGrpInfo`; a profile update carrying a different `publicGroupId` is rejected —
`Subscriber.hs:3684-3685` for `XGrpInfo`, extended to link-data-borne updates by
A1's mirror guard). No proof is involved. Rejected alternatives:
(a) comparing against the join link — breaks on every rotation; (b) fetching the
resolved link's data and comparing `publicGroupId` — an extra network round-trip
for a binding the consistency rule does not need.
2. **Verification is explicit, never automatic on a user's device.** The distinction
is the request payload, not the destination — both operations are served via SMP
servers: a link-data fetch references an opaque link ID (the serving host learns
only that some client fetched that link — nothing name-shaped), while a resolution
query carries the **readable name**, so whoever serves it learns which name this
device is interested in. Automatic resolution on profile/link refresh would
therefore leak membership metadata on a cadence; scheduled link-data fetches (the
UI's chat-open refresh, the directory's link checks) are fine. So: claim change
drops the status locally (already the case, `Groups.hs:2717`); resolution happens
only when the user acts (verify command / client's own verify policy, or connecting
by name, where resolution is the action itself). The directory, a public service,
calls the verify API explicitly — its resolver queries reveal nothing personal.
3. **Connect-by-name paths still record verified** (sites 2 and 4 with resolved-domain
context): the resolution already happened as the user's explicit action; storing the
result adds no resolver call and no leak, and matches the existing
created-as-verified behavior (`Groups.hs:2759-2765`).
4. **The directory displays claim + `group_domain_verified = 1`** — the identical
criterion core uses for by-name lookup (`Groups.hs:1103`). User-facing surfaces
(search results, `/list`, `/link`, listing JSON, web page) show only verified names;
the admin approval message additionally shows an unverified claim, marked, because
admins decide approval.
5. **Directory triggers**: call `APIVerifyGroupDomain` from the existing periodic link
check for **every group with a claim, every cycle** — the directory re-publishes the
name, so it must know the name is still working, not only that it worked once. One
trigger covers the initial claim, claim changes, resolver-failure retries, an owner
fixing the registry after a failed check, and a registry entry lapsing or being
re-pointed (the name disappears within one link-check interval). No staleness state
or timestamps needed. Clients do NOT re-verify on a schedule — only the directory.
Plus a synchronous check when an approval request is composed (`sendToApprove`, B2)
so admins always see the actual state. Cost: one resolver query per claimed group
per cycle (default 30 min), plus one per approval request.
6. **No directory schema change, no directory-log change.**
7. **`groupInfoText` gains a `Maybe Text` name parameter** (the already-formatted line
content), keeping the admin/user display policy visible at the call sites.
Alternative: pass `(GroupInfo, GroupReg)` and decide inside — rejected: hides the
policy in a text builder.
## 3. Part A — core fixes
### A1. `updateGroupFromLinkData` (`Internal.hs:1466-1488`): no marking without resolution, no resolving
The contract, on any group profile update:
1. name unchanged → verification status retained (including `Just False`);
2. name changed or removed → status reset to unknown (NULL);
3. name added → status remains unknown (NULL).
The store layer already enforces all three: `updateGroupProfile` compares old vs new
claim domain and resets to NULL exactly when it changed (`Groups.hs:2712-2717`,
in-memory copy at `:2714`). Every caller is clean — `xGrpInfo` (`Subscriber.hs:3690`,
with `publicGroupId` immutability guards `:3684-3686`), the relay accept path
(`Subscriber.hs:4403`, `publicGroupId` check `:4396-4399`), `runUpdateGroupProfile`
(`Commands.hs:3913`), `createGroupInvitation` (`Groups.hs:474`), the member-update path
(`Groups.hs:815-816`) — **except** `updateGroupFromLinkData` (`Internal.hs:1478`), which
overrides the reset and marks `Just True` whenever a claim is present (`verifyChanged`
`:1488`). It thereby violates all three invariants; the worst case is invariant 1: an
unchanged claim with status `Just False` is overwritten to `Just True` — apps call
`APIGetUpdatedGroupLinkData` on every chat open (`ChatView.swift:773`,
`ChatView.kt:216`), so a failed manual verification is silently erased to "verified"
the next time the user opens the channel.
Fix: add a parameter `resolvedDomain_ :: Maybe SimplexDomain` — the domain the caller
just resolved (by explicit user action) to reach this link data; `Nothing` otherwise.
Rule:
- set verified (`Just True`) exactly when `resolvedDomain_` equals the incoming claim's
domain (the connect-by-name exception, decision 3 — same class as prepared-group
creation, `Groups.hs:671-674`). Do NOT also gate on the current status being
not-`Just True`: `updateGroupProfile` runs first and, on a claim rotation, resets the
status to NULL, so a stale pre-update `Just True` would wrongly suppress the re-mark of
the newly-resolved name — leaving it NULL. The write is idempotent, so re-marking an
already-verified same-name group is a harmless no-op;
- this marking condition is part of the function's **entry guard**
(`profileChanged || countChanged || verifyResolved`) — a name-plan hit on an unchanged
profile with a NULL status must still mark, even though nothing else changed;
- otherwise never touch the status — the store layer's three invariants then hold on
every path;
- **no resolver calls in this function** (decision 2 — metadata leak).
Call sites:
The whole call-site diff is one added argument in three places:
- `Commands.hs:1858` (`APIGetUpdatedGroupLinkData`): pass `Nothing` — pure refresh.
- `Commands.hs:4326` (name-plan branch): pass `Just nameDomain` (bind it in the case
alternative that currently discards it, `(Just _, …)``(Just nameDomain, …)`).
No reordering: the existing claim check at `:4328-4335` stays where it is — the
marking rule itself prevents the status write when the fresh claim differs from the
resolved name, so a mismatched claim throws with no status written. The profile
refresh that runs before the throw is the group's own link data and is legitimate.
- `Commands.hs:4353` (`resolveKnownGroup`): pass `Nothing` — a link-refresh path.
(With a name target it is reachable only under `PRMAllGroups`, which no caller sends
with a name — see §6; and a group found by name is already verified,
`Groups.hs:1103`, so marking would be a no-op there anyway.)
Nothing moves, nothing reorders; lookup semantics (name → local claim+verified match,
link → stored link match, miss → resolve → OK plan) are untouched.
Behavior change: members who joined via link no longer see
"SimpleX name: #x (verified)" (`simplexDomainLine`, `View.hs:1145-1154`) from the
self-claim alone; the status is NULL (shown unverified) until the user verifies — or
the client's verify policy does on opening group info (`SimplexNameView.kt:62-63`).
### A2. Fix `APIVerifyGroupDomain` (`Commands.hs:2301-2307`)
Applies to public groups (channels) only — claims exist only on `publicGroup` profiles;
p2p groups cannot carry one (`xGrpInfo` rejects it, `Subscriber.hs:3686`); business
groups use the separate address-anchored mechanism (`setPreparedGroupDomain`,
`Groups.hs:2758-2769`, domain on `businessChat.businessDomain`); contacts keep
`APIVerifyContactDomain` untouched.
The check is name ↔ profile-link consistency (decision 1); no join-link comparison and
no `preparedGroup` involvement — the flow works identically for the owner, a member,
and the directory bot, before or after link rotation:
1. Require a claim (`publicGroup >>= publicGroupAccess >>= groupDomainClaim`) — as now.
2. `resolveSimplexName` (agent) on the claimed domain →
`nameResolvesTo (publicGroup.groupLink) nrSimplexChannel`
(helper at `Commands.hs:4837-4838`; `groupLink` is a mandatory field of
`PublicGroupProfile`, `Types.hs:855-861`) — the same consistency check the owner's
client runs at set time (`:3149-3150`).
3. Holds → `Just True`, else `Just False`, via `setGroupDomainVerified`; respond
`CRGroupDomainVerified user g' reason` as now. A definite NAME NOT_FOUND resolver
answer (cf. `tests/ChatTests/Names.hs:100-102`) is `Just False`; network/transport
errors remain thrown `ChatErrorAgent` (retryable, status unchanged).
4. No proof involvement for groups. (`verifyEntityDomain`'s proof logic remains for
`APIVerifyContactDomain`, untouched.)
Division of labor — the verify API itself performs only the resolver query; it does
NOT fetch link data, because the profile it checks against is kept fresh by the
existing link-data fetches, which continue on their own cadence:
- the directory fetches link data on every link-check cycle (`deGroupLinkCheck`
connect plan, `Service.hs:820-821`), refreshing the profile (claim and `groupLink`)
before the verify call runs in the same cycle;
- the UI fetches on chat open (`APIGetUpdatedGroupLinkData``ChatView.swift:773`,
`ChatView.kt:216`), so by the time group info's auto/manual verify fires
(`SimplexNameView.kt:62-63`) the profile is current;
- per A1, none of those fetches touch the verification status — they only refresh the
data that verification is checked against.
The verify API is called only on user interaction (manual, or the client's verify
policy) and from the directory (B2) — never on a schedule from a user's device.
### A3. Owner set-name marks verified (`APISetPublicGroupAccess`, `Commands.hs:3143-3152`)
Setting a new/changed domain already resolves it and requires
`nameResolvesTo groupLink` as a precondition (`:3147-3150`) — but the subsequent
`runUpdateGroupProfile` resets the status to NULL (claim changed), so the owner's own
channel reads as unverified right after a successful set. Fix: in the
`APISetPublicGroupAccess` handler, after the profile update, set the status `Just True`
— only on the branch where the resolution check ran (new/changed domain). Setting the
same domain skips resolution and retains the status (invariant 1); clearing the domain
leaves NULL (invariant 2). This is the same trust event as connect-by-name: resolution
by explicit user action, persisted immediately. The UI sets channel names through this
command (`GroupChatInfoView.kt:192`), so the fix covers the app flow. Note the returned
`CRGroupUpdated` must carry the updated status (the UI reads the flag off the returned
`GroupInfo`) — set the flag after `runUpdateGroupProfile` and patch or re-read the
group for the response.
### A4. Core tests (`tests/ChatTests/Names.hs`, resolver stub `tests/NameResolver.hs`)
1. Link-target plan / link-data refresh does NOT change the verification status and does
NOT query the resolver (register a counter in the `NameResolver` stub registry, or
assert status stays NULL after refresh) — regression for sites 1 and 3 and for the
no-auto-resolution rule.
2. The three invariants across a profile update cycle: unchanged claim retains the
status — specifically `Just False` survives a link-data refresh / chat open
(regression for the erased-failure defect); changed claim resets to NULL; added
claim stays NULL.
3. `/_verify domain #<gId>`: name resolving to the profile-declared link → verified
(this is also the rotation case — the check never touches the join link); resolving
to a different link → failed; NOT_FOUND → failed; resolver unreachable → error,
status unchanged.
4. Claim change resets the status; the by-name lookup (`getGroupToConnect`) stops
matching until re-verified.
5. Name-plan mismatch: link data claiming a different name than the resolved one throws
`CESimplexDomainNotReady` and the status is not written (the marking rule requires
the fresh claim to equal the resolved name).
6. Owner sets the name (`/public group access ... domain=`): status is `Just True`
after a successful set of a new domain; re-sending the same domain retains the
status; clearing it resets to NULL.
## 4. Part B — directory service
### B1. Verified-name accessor
`verifiedGroupDomain :: GroupInfo -> Maybe SimplexDomain` — the claim
(`groupSimplexDomain`) when `groupDomainVerified == Just True`; in `Directory.Store`
(used by both `Service.hs` and `Listing.hs`). Display string:
`shortNameInfoStr . SimplexNameInfo NTPublicGroup`.
### B2. Verification trigger (`Service.hs`)
- In `deGroupLinkCheck` (`:816-854`), after the existing owner check: when the group
profile has a claim, send `APIVerifyGroupDomain groupId` — every cycle, regardless of
the current status (decision 5: the directory must know the name is still working).
The ordering matters: the verify call runs after the cycle's connect-plan call has
fetched fresh link data, so consistency is checked against the current claim and
`groupLink`, not day-old state.
On `CRGroupDomainVerified {groupInfo = g'}` with a status different from before and
the group listed → `listingsUpdated env`. Errors (`Left` network/timeout) → log,
status unchanged, retry next cycle.
- Also extend the existing `listingsUpdated` condition (`:825`) with
`groupDomainVerified g' /= groupDomainVerified gInfo` for the plan-driven updates.
- Verify **synchronously in `sendToApprove`** (`:802-813`), before composing the admin
message: when the group has a claim and the status is not `Just True`, run
`APIVerifyGroupDomain` and use the returned `GroupInfo` for the message. This is the
single choke point through which every approval request flows — registration
(`deMemberUpdated` `:1067-1070`), re-registration (`deReregistration`
`pendingApprovalTransition` `:1051-1056`), profile changes
(`publicGroupProfileChange` `:552-577`), and link-check reapprovals (`:843-853`) — so
admins always see the actual verification state. A registration-time
`DEGroupLinkCheck` nudge would NOT work: at `joinAndRegisterPublicGroup` the group is
still connecting (the link-target plan returns `GLPConnectingProhibit`, and
`deGroupLinkCheck` only covers `GRSActive`/pending-approval states, `:819`), and an
async verify would race the approval message. The event loop is already sequential
and network-bearing; one added round-trip per approval is in pattern.
### B3. Bot output (`Service.hs`)
- `groupInfoText` (`:364`): add `Maybe Text` name parameter; when present, a line
`SimpleX name: #team` after `groupNameDescr` (wording mirrors core `View.hs:1175`).
- User-facing call sites pass the display string of `verifiedGroupDomain`:
`sendFoundGroups` (`:1292-1296`), `sendGroupsInfo` (`:1486-1494`).
- Admin call site `sendToApprove` (`:802-813`): verified → `#team`; claimed but not
verified → `#team (NOT verified - will not be shown)`.
- `/link` command public branch (`:1204-1207`): add the verified-name line under the link.
### B4. Bot search (`Store.hs`)
`searchListedGroups` `STSearch` (`:386-398`): normalize the query — after `T.toLower`,
strip one leading `#` or `@` (aligning with the web page's normalization) — and extend
`searchCond` (`:404-411`) with a parenthesized branch:
`OR (LOWER(gp.group_domain) LIKE '%' || ? || '%' AND g.group_domain_verified = 1)`.
The stored form is the full domain (`team.simplex`), so `team`, `#team`, and
`team.simplex` all match. Parameter tuples in all four query/count variants gain one
param (the `STSearch` count query already joins `group_profiles`; `g` is in scope in
both — the main query via `groupInfoQueryFrom`, `Store/Shared.hs:816-823`). Guard the
edge case: when the normalized domain query is empty (the search was just `#`),
`LIKE '%%'` would match every named group — pass a value that cannot match (or skip the
branch) instead.
### B5. Web listing (`Listing.hs`)
- `DirectoryEntry` (`:70-81`): add `simplexName :: Maybe Text` (display form `#team`);
`defaultJSON` omits `Nothing` (simplexmq `Parsers.hs:153`), so the field is
wire-compatible with existing consumers. `promoted.json` inherits.
- `groupDirectoryEntry` (`:99`): no new parameter — it already takes `GroupInfo`, and
`verifiedGroupDomain` needs only `GroupInfo` (claim and status both live there);
compute the field inside. `Listing.hs` already imports `Directory.Store` (`:34`).
### B6. Web page (`website/src/js/directory.jsc`, `website/src/directory.html`)
- `displayEntries` (`:225-228`): when `entry.simplexName` is set, render it under the
`h2` display name (small accent-styled line, same pattern as other meta lines). Use
`textContent`, not `innerHTML` — same as `displayName` (`:226`); domains are
parser-constrained to ASCII (`nameLabelP`, simplexmq `SimplexName.hs:59-71`), but the
listing file is an external input to the page.
- `filterEntries` (`:98-112`): add a `simplexName` match, normalizing both sides:
lowercase, strip leading `#`/`@`, strip trailing `.simplex` — so `#team`, `team`, and
`team.simplex` all match an entry displaying `#team`.
- Optional: `#search` placeholder copy in `directory.html`.
### B7. Directory tests (`tests/Bots/DirectoryTests.hs`)
Directory name tests run under `withSmpServerAndNames` (`tests/ChatClient.hs:596`) — the
directory harness uses the ambient SMP server, so they are a **separate spec**
(`directoryNameTests`) wired in `Test.hs` under the names-capable `tmpTestBracket`
(mirroring `chatNamesTests`), not folded into `directoryServiceTests` (which runs under a
plain SMP server). Channel setup follows `testRegisterChannelViaCard` (`:2042`).
Implemented:
1. **Verified name shown** (`testDirectoryChannelName`): resolver maps `news` to the
channel's link → owner sets `domain=news.simplex` → register via card → the directory
verifies name↔link consistency and the admin approval message shows `SimpleX name: #news`.
2. **Unverified claim marked** (`testDirectoryChannelNameNotVerified`): the registry entry
is re-pointed to a different link after the owner set the name → the directory's
verification fails → the admin sees `SimpleX name: #news (NOT verified - will not be shown)`.
These exercise the security-critical properties end-to-end: verification (A2), the
`sendToApprove` trigger (B2), the display line (B3), and the verified-only display gating.
Not yet written (lower-risk; the underlying code is covered by the core tests A4 and the
two above): search-by-name (B4, `#team`/`team`/`team.simplex`), a `listing.json`
`simplexName` assertion (B5, extend `checkListings` `:1208-1217`), and re-verification
across a `deGroupLinkCheck` cycle (B2).
## 5. Sequencing (one logical change per commit)
1. Core: `updateGroupFromLinkData` — resolved-domain parameter (one added argument at
three call sites), no marking without it, no auto-resolution
(+ core tests A4 1, 2, 5).
2. Core: `APIVerifyGroupDomain` — name ↔ profile-link consistency (resolve +
`nameResolvesTo (publicGroup.groupLink)`) (+ core tests A4 3, 4).
3. Core: owner set-name marks verified (A3) (+ core test A4 6).
4. Directory: every-cycle verification in `deGroupLinkCheck` + approval-time
verification in `sendToApprove` (B2).
5. Directory: bot output (B3) + test 1 bot assertions.
6. Directory: search (B4) + tests 2, 3.
7. Directory: listing JSON (B5) + `checkListings` extension, test 4.
8. Website (B6) — manual verification; no JS test harness exists.
## 6. Open points / follow-ups
- Client UX for manual verification (verify button / client-side auto-verify policy per
`plans/2026-06-27-namespace-ui-display-set.md`) is separate UI work; part A only makes
the API do the right check. The UI in this tree already renders the 3-state indicator
and auto-verifies on open when the status is NULL (`SimplexNameView.kt:62-63`,
toggle default ON) — so after A1, link-joined members get a real verification on
opening channel info instead of today's spurious "(verified)".
- `APIVerifyContactDomain` and contact names (`@name`) are out of scope — the directory
lists only groups, and contact claims are not bound to a joined link the same way.
- Observed but NOT in scope (no change requested; decide separately if ever relevant):
(a) `resolveKnownGroup` with a name target would apply the registry-resolved link's
data to the locally-found group without checking the resolved link is that group's —
the path is currently unexercised (defect table row 4); (b) link-data-borne profile
updates lack the `publicGroupId` mismatch rejection that `xGrpInfo` applies
(`Subscriber.hs:3684-3685`).
Resolved decisions (were open points):
- Owner's own status after setting the name — fix in scope (A3): mark verified after a
successful set, since resolution ran as its precondition.
- Invariant 3 ("added → unknown") admits exactly one class of exception — resolution by
explicit user action, persisted where the group happens to live: at creation for an
unknown group (`APIPrepareGroup domain=`, `Groups.hs:671-674`), on the existing row
for a known group (A1's `resolvedDomain_` on the name-plan paths), and at set time
for the owner (A3). All confirmed.
- Stale verified names: the directory re-verifies every claimed group on every link
check (decision 5), so a lapsed or re-pointed registry entry drops the name within
one interval. Clients never re-verify on a schedule — the freshness requirement
belongs to the publisher, not the member.
- **Link rotation is supported** — no link-immutability invariants anywhere: joining
does not require the profile link to equal the joining link, `xGrpInfo` does not
freeze `groupLink`, and verification never compares against the join link. The
earlier rule "verification fails when the profile link differs from the join link"
is superseded: verification is name ↔ profile-link consistency only. The only link
ever compared is the profile-declared one; the join link is never used. Incoming
profile updates remain subject to the existing `publicGroupId` mismatch rejection
(`xGrpInfo`), mirrored for link data by A1.
+30 -12
View File
@@ -111,7 +111,8 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), patt
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NameRecord (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NameRecord (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth)
@@ -1855,7 +1856,7 @@ processChatCommand cxt nm = \case
(_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- getShortLinkConnReq' nm user sLnk
groupSLinkData_ <- liftIO $ decodeLinkUserData cData
gInfo' <- case groupSLinkData_ of
Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData
Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData Nothing
_ -> pure gInfo
when (memberRole' (membership gInfo) /= GROwner && memberCurrent (membership gInfo)) $
withGroupLock "syncSubscriberRelays" groupId $
@@ -2299,11 +2300,20 @@ processChatCommand cxt nm = \case
ct' <- maybe (pure ct) (\v -> withFastStore' $ \db -> setContactDomainVerified db user ct v) verified
pure $ CRContactDomainVerified user ct' reason
APIVerifyGroupDomain groupId -> withUser $ \user -> do
g@GroupInfo {groupProfile = GroupProfile {publicGroup}, preparedGroup} <- withFastStore $ \db -> getGroupInfo db cxt user groupId
let connLink_ = preparedGroup >>= \PreparedGroup {connLinkToConnect = CCLink _ sLnk_} -> ACSL SCMContact <$> sLnk_
domain <- maybe (throwCmdError "group has no name to verify") pure $ publicGroup >>= publicGroupAccess >>= groupDomainClaim
(verified, reason) <- verifyEntityDomain user nm NTPublicGroup domain connLink_
g' <- maybe (pure g) (\v -> withFastStore' $ \db -> setGroupDomainVerified db user g v) verified
g@GroupInfo {groupProfile = GroupProfile {publicGroup}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId
PublicGroupProfile {groupLink, publicGroupAccess} <- maybe (throwCmdError "not a public group") pure publicGroup
claim <- maybe (throwCmdError "group has no name to verify") pure $ publicGroupAccess >>= groupDomainClaim
-- name <-> link consistency: resolving the claimed name must lead to the link in the group
-- profile - the same check the owner's client runs when setting the name; the link used
-- to join is not compared, links may rotate
(verified, reason) <-
tryAllErrors (withAgent $ \a -> resolveSimplexName a nm (aUserId user) (claimDomain claim)) >>= \case
Right NameRecord {nrSimplexChannel}
| nameResolvesTo groupLink nrSimplexChannel -> pure (True, Nothing)
| otherwise -> pure (False, Just "the name does not resolve to the link in the group profile")
Left (ChatErrorAgent {agentError = SMP _ (NAME SMP.NOT_FOUND)}) -> pure (False, Just "the name is not registered")
Left e -> throwError e
g' <- withFastStore' $ \db -> setGroupDomainVerified db user g verified
pure $ CRGroupDomainVerified user g' reason
APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do
ct@Contact {profile = LocalProfile {contactLink}} <- withFastStore $ \db -> getContact db cxt user contactId
@@ -3144,11 +3154,19 @@ processChatCommand cxt nm = \case
gInfo@GroupInfo {groupProfile = p@GroupProfile {publicGroup}} <- withStore $ \db -> getGroupInfo db cxt user gId
case publicGroup of
Just pg@PublicGroupProfile {groupLink, publicGroupAccess = existingAccess} -> do
let domainChanged = (claimDomain <$> newClaim) /= (claimDomain <$> (existingAccess >>= groupDomainClaim))
forM_ (claimDomain <$> newClaim) $ \newDomain ->
when (Just newDomain /= (claimDomain <$> (existingAccess >>= groupDomainClaim))) $ do
when domainChanged $ do
NameRecord {nrSimplexChannel} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) newDomain
unless (nameResolvesTo groupLink nrSimplexChannel) $ throwChatError $ CESimplexDomainNotReady newDomain SDENoValidLink
runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}}
r <- runUpdateGroupProfile user gInfo p {publicGroup = Just pg {publicGroupAccess = Just access}}
-- the resolution above proved name <-> link consistency; record it (the profile
-- update resets the status on a claim change)
case r of
CRGroupUpdated {fromGroup, toGroup, member_, msgSigned} | isJust newClaim && domainChanged -> do
toGroup' <- withFastStore' $ \db -> setGroupDomainVerified db user toGroup True
pure CRGroupUpdated {user, fromGroup, toGroup = toGroup', member_, msgSigned}
_ -> pure r
Nothing -> throwChatError $ CECommandError "not a public group"
APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do
gInfo@GroupInfo {groupProfile} <- withFastStore $ \db -> getGroupInfo db cxt user groupId
@@ -4322,8 +4340,8 @@ processChatCommand cxt nm = \case
-- e.g. an un-upgraded relay dropped the claim); refresh its profile from the fresh link
-- data and mark it verified, so the check below passes and future by-name lookups match
plan <- case (planDomain, plan0, groupSLinkData_) of
(Just _, CPGroupLink (GLPKnown g u o os), Just sLinkData) ->
(\(g', _) -> CPGroupLink (GLPKnown g' u o os)) <$> updateGroupFromLinkData user g sLinkData
(Just nameDomain, CPGroupLink (GLPKnown g u o os), Just sLinkData) ->
(\(g', _) -> CPGroupLink (GLPKnown g' u o os)) <$> updateGroupFromLinkData user g sLinkData (Just nameDomain)
_ -> pure plan0
forM_ planDomain $ \nameDomain ->
let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of
@@ -4350,7 +4368,7 @@ processChatCommand cxt nm = \case
let ov = verifyLinkOwner rk owners l' sig_
glOwners = map (\OwnerAuth {ownerId, ownerKey} -> GroupLinkOwner {memberId = MemberId ownerId, memberKey = ownerKey}) owners
(g', updated) <- case groupSLinkData_ of
Just sLinkData -> updateGroupFromLinkData user g sLinkData
Just sLinkData -> updateGroupFromLinkData user g sLinkData Nothing
_ -> pure (g, False)
pure (con l' (linkConnReq fd), CPGroupLink (GLPKnown g' updated ov (ListDef glOwners)))
-- resolve a name to its first contact/channel short link
+12 -7
View File
@@ -1463,9 +1463,12 @@ updatePublicGroupData user gInfo
withStore $ \db -> updatePublicMemberCount db cxt user gInfo
| otherwise = pure gInfo
updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool)
updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupDomainVerified, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData}
| profileChanged || countChanged || verifyChanged = do
-- resolvedDomain_ is the name the caller just resolved, by explicit user action, to reach
-- this link data; a link-data refresh alone (resolvedDomain_ = Nothing) never marks a
-- claim verified, and names are never resolved here.
updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> Maybe SimplexDomain -> CM (GroupInfo, Bool)
updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData} resolvedDomain_
| profileChanged || countChanged || verifyResolved = do
cxt <- chatStoreCxt
withStore $ \db -> do
g <- if profileChanged then updateGroupProfile db user gInfo groupProfile else pure gInfo
@@ -1473,9 +1476,9 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupDomainVerif
Just PublicGroupData {publicMemberCount} | countChanged ->
setPublicMemberCount db cxt user g publicMemberCount
_ -> pure g
-- the group's own link is authoritative for its domain claim, so a claim in the link profile is
-- verified; updateGroupProfile above clears verification on a claim change, so set it afterwards
g'' <- if verifyChanged then liftIO $ setGroupDomainVerified db user g' True else pure g'
-- the claim is marked verified only when the caller resolved this name to reach this link
-- data; updateGroupProfile above clears verification on a claim change, so set it afterwards
g'' <- if verifyResolved then liftIO $ setGroupDomainVerified db user g' True else pure g'
pure (g'', profileChanged)
| otherwise = pure (gInfo, False)
where
@@ -1485,7 +1488,9 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupDomainVerif
_ -> False
groupClaim GroupProfile {publicGroup} = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)
newClaim = groupClaim groupProfile
verifyChanged = isJust newClaim && (groupDomainVerified /= Just True || groupClaim p /= newClaim)
-- mark verified whenever the caller resolved exactly this claim (connect-by-name); idempotent,
-- so no need to special-case an already-verified status (which updateGroupProfile may just have reset)
verifyResolved = isJust resolvedDomain_ && resolvedDomain_ == newClaim
updateContactFromLinkData :: User -> Contact -> Profile -> CM Contact
updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} linkProfile@Profile {contactDomain = newClaim}
+92
View File
@@ -30,7 +30,9 @@ import Simplex.Chat.Options.DB
import Simplex.Chat.Protocol (memberSupportVoiceVersion)
import Simplex.Chat.Types (ChatPeerType (..), Profile (..))
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Simplex.Messaging.Version
import NameResolver
import System.FilePath ((</>))
import Test.Hspec hiding (it)
@@ -100,6 +102,13 @@ directoryServiceTests = do
it "should handle re-registration when already listed" testReregistrationAlreadyListed
it "should update subscriber count periodically" testLinkCheckUpdatesCount
-- these run under a names-enabled SMP server (withSmpServerAndNames), so they are a
-- separate spec from directoryServiceTests (which runs under a plain SMP server)
directoryNameTests :: SpecWith TestParams
directoryNameTests = do
it "should verify and show a channel's SimpleX name" testDirectoryChannelName
it "should mark an inconsistent SimpleX name as not verified" testDirectoryChannelNameNotVerified
directoryProfile :: Profile
directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
@@ -2117,6 +2126,89 @@ testRegisterChannelViaCard ps =
superUser <# "'SimpleX Directory'> The channel ID 1 (news) is de-listed (channel owner left)."
relay <## "#news: 'SimpleX Directory' left the group (signed)"
-- the owner sets a SimpleX name on a channel; the directory verifies name<->link consistency
-- against the resolver and shows the verified name in the admin approval message
testDirectoryChannelName :: HasCallStack => TestParams -> IO ()
testDirectoryChannelName ps = withSmpServerAndNames $ \reg ->
withDirectoryServiceCfg ps testCfg $ \superUser dsLink ->
withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob ->
withRelay ps $ \relay -> do
bob `connectVia` dsLink
(shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay
registerName reg newsName (channelNameRecord "news" (T.pack shortLink))
bob ##> "/public group access #news domain=news.simplex"
bob <## "updated public group access: domain=news.simplex"
relay <## "bob updated group #news: (signed)"
relay <## "updated public group access: domain=news.simplex"
bob ##> "/share chat #news @'SimpleX Directory'"
bob <# "@'SimpleX Directory' link to join channel #news (signed):"
_ <- getTermLine bob -- short link
_ <- getTermLine bob -- ownerSig JSON
bob <# "'SimpleX Directory'> Joining the channel news…"
concurrentlyN_
[ do
relay <## "'SimpleX Directory': accepting request to join group #news..."
relay <## "#news: 'SimpleX Directory' joined the group",
bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel"
]
bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours."
bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them."
bob <## "Captcha verification is enabled. Use /'filter 1' to change it."
-- the directory verified the name against the channel link and shows it to the admin
superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:"
superUser <## "news"
superUser <## "SimpleX name: #news"
superUser <##. "Link to join channel: "
superUser <## "You need SimpleX Chat app v6.5 to join."
superUser <## "1 subscribers"
superUser <## ""
superUser <## "To approve send:"
superUser <# "'SimpleX Directory'> /approve 1:news 1"
where
newsName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "news" [])
-- after the owner set the name, the registry entry is re-pointed to a different link; the
-- directory's verification fails, so the admin sees the name marked as not verified
testDirectoryChannelNameNotVerified :: HasCallStack => TestParams -> IO ()
testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg ->
withDirectoryServiceCfg ps testCfg $ \superUser dsLink ->
withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob ->
withRelay ps $ \relay -> do
bob `connectVia` dsLink
(shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay
registerName reg newsName (channelNameRecord "news" (T.pack shortLink))
bob ##> "/public group access #news domain=news.simplex"
bob <## "updated public group access: domain=news.simplex"
relay <## "bob updated group #news: (signed)"
relay <## "updated public group access: domain=news.simplex"
-- the name is re-pointed to a different link after the owner set it
registerName reg newsName (channelNameRecord "news" "https://simplex.chat/other")
bob ##> "/share chat #news @'SimpleX Directory'"
bob <# "@'SimpleX Directory' link to join channel #news (signed):"
_ <- getTermLine bob -- short link
_ <- getTermLine bob -- ownerSig JSON
bob <# "'SimpleX Directory'> Joining the channel news…"
concurrentlyN_
[ do
relay <## "'SimpleX Directory': accepting request to join group #news..."
relay <## "#news: 'SimpleX Directory' joined the group",
bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel"
]
bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours."
bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them."
bob <## "Captcha verification is enabled. Use /'filter 1' to change it."
superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:"
superUser <## "news"
superUser <## "SimpleX name: #news (NOT verified - will not be shown)"
superUser <##. "Link to join channel: "
superUser <## "You need SimpleX Chat app v6.5 to join."
superUser <## "1 subscribers"
superUser <## ""
superUser <## "To approve send:"
superUser <# "'SimpleX Directory'> /approve 1:news 1"
where
newsName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "news" [])
testLinkAsTextSearch :: HasCallStack => TestParams -> IO ()
testLinkAsTextSearch ps =
withDirectoryServiceCfg ps testCfg $ \_superUser dsLink ->
+56 -1
View File
@@ -5,7 +5,7 @@ module ChatTests.Names where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Groups (prepareChannel1Relay)
import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay)
import ChatTests.Utils
import Control.Concurrent.Async (concurrently_)
import qualified Data.Text as T
@@ -20,6 +20,8 @@ chatNamesTests = do
it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed
it "connect by unregistered name fails to resolve" testConnectByNameNotFound
it "set name not resolving to own address is rejected" testSetNameNotOwnAddress
it "channel name is not verified just by joining via link" testChannelDomainLinkJoinUnverified
it "verify channel name, fail on re-point, retain status on refresh" testChannelDomainVerify
it "connect by channel name" testConnectByChannelName
it "connect by name resolving to channel (primary) and direct contact" testConnectByNameChannelAndContact
it "connect by name resolving to direct contact (primary) and channel" testConnectByNameContactAndChannel
@@ -115,6 +117,59 @@ testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg ->
alice ##> "/_set domain 1 alice.simplex"
alice <## "SimpleX name alice.simplex has no valid connection link"
-- A member who joined via link does not get the claimed name verified, even after a link-data
-- refresh - the group's self-claim is not proof of name ownership.
testChannelDomainLinkJoinUnverified :: HasCallStack => TestParams -> IO ()
testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
(shortLink, fullLink) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
alice <## "updated public group access: domain=team.simplex"
cath <## "alice updated group #team: (signed)"
cath <## "updated public group access: domain=team.simplex"
memberJoinChannel "team" [cath] [alice] shortLink fullLink bob
-- a link-data refresh must not mark the self-claimed name verified
bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=allGroups")
bob <## "group link: known group #team"
bob <## "use #team <message> to send messages" -- no "SimpleX name" line: status stays unknown
where
teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" [])
-- A2 (verify by name<->profile-link consistency), A3 (owner verified on set), and A1 (a failed
-- status is retained across a link-data refresh, not erased to verified).
testChannelDomainVerify :: HasCallStack => TestParams -> IO ()
testChannelDomainVerify ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
(shortLink, fullLink) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
alice <## "updated public group access: domain=team.simplex"
cath <## "alice updated group #team: (signed)"
cath <## "updated public group access: domain=team.simplex"
-- A3: setting the name resolved it, so the owner's channel is verified
alice ##> "/_verify domain #1"
alice <## "SimpleX name #team verified"
memberJoinChannel "team" [cath] [alice] shortLink fullLink bob
-- A2: resolving the name leads to the profile's link
bob ##> "/_verify domain #1"
bob <## "SimpleX name #team verified"
-- the name is re-pointed to a different link: verification fails
registerName reg teamName (channelNameRecord "team" "https://simplex.chat/other")
bob ##> "/_verify domain #1"
bob <## "SimpleX name #team not verified: the name does not resolve to the link in the group profile"
-- A1: a link-data refresh keeps the failed status (it does not overwrite it with verified)
bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=allGroups")
bob <## "group link: known group #team"
bob <## "SimpleX name: #team (verification failed)"
bob <## "use #team <message> to send messages"
where
teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" [])
testConnectByChannelName :: HasCallStack => TestParams -> IO ()
testConnectByChannelName ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
+1
View File
@@ -74,6 +74,7 @@ main = do
describe "Random servers" randomServersTests
#if !defined(dbPostgres)
around (tmpTestBracket chatQueryStats agentQueryStats) $ describe "names tests" chatNamesTests
around (tmpTestBracket chatQueryStats agentQueryStats) $ xdescribe'' "SimpleX Directory names" directoryNameTests
#endif
#if defined(dbPostgres)
createdDropDb . around testBracket
+23 -1
View File
@@ -107,10 +107,25 @@ function filterEntries(mode, s) {
|| (entry.displayName || '').toLowerCase().includes(query)
|| includesQuery(entry.shortDescr, query)
|| includesQuery(entry.welcomeMessage, query)
|| simplexNameIncludesQuery(entry.simplexName, query)
)
);
}
// matches '#team', 'team' and 'team.simplex' against an entry displaying '#team'
function simplexNameIncludesQuery(name, query) {
if (!name) return false;
const q = normalizeSimplexName(query);
return q !== '' && normalizeSimplexName(name).includes(q);
}
function normalizeSimplexName(s) {
s = s.toLowerCase();
if (s.startsWith('#') || s.startsWith('@')) s = s.slice(1);
if (s.endsWith('.simplex')) s = s.slice(0, -'.simplex'.length);
return s;
}
function includesQuery(field, query) {
return field
&& Array.isArray(field)
@@ -215,7 +230,7 @@ function displayEntries(entries) {
for (let entry of entries) {
try {
const { entryType, displayName, groupLink, shortDescr, welcomeMessage, imageFile } = entry;
const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry;
const entryDiv = document.createElement('div');
entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8';
@@ -227,6 +242,13 @@ function displayEntries(entries) {
nameElement.className = 'text-grey-black dark:text-white !text-lg md:!text-xl font-bold';
textContainer.appendChild(nameElement);
if (simplexName) {
const simplexNameElement = document.createElement('p');
simplexNameElement.textContent = simplexName;
simplexNameElement.className = 'text-sm font-medium';
textContainer.appendChild(simplexNameElement);
}
const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined;
const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined;
if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) {