From bb9bb142eb421b4d1663e1552b5762bd32a7a2c3 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:20:36 -0400 Subject: [PATCH] Track verified group name hash --- protos/SignalStorage.proto | 14 ++---- .../ConversationHero.dom.stories.tsx | 10 ++--- .../conversation/ConversationHero.dom.tsx | 7 ++- .../conversation/Timeline.dom.stories.tsx | 5 ++- ts/groups.preload.ts | 13 ++++++ ts/model-types.d.ts | 1 + ts/services/storageRecordOps.preload.ts | 16 ++++++- ts/state/ducks/conversations.preload.ts | 9 ++++ ts/state/smart/HeroRow.preload.tsx | 45 ++++++++----------- ts/util/Conversation.preload.ts | 6 +++ ts/util/getConversation.preload.ts | 1 + 11 files changed, 78 insertions(+), 49 deletions(-) diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 9f291f9d1f..d6e9fce682 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -174,6 +174,7 @@ message GroupV2Record { reserved 9; StorySendMode storySendMode = 10; optional AvatarColor avatarColor = 11; + bytes verifiedNameHash = 12; // SHA-256 of UTF-8 encoded decrypted group title that was last verified } message Payments { @@ -232,12 +233,6 @@ message AccountRecord { } } - message BackupTierHistory { - // See zkgroup for integer particular values. Unset if backups are not enabled. - optional uint64 backupTier = 1; - optional uint64 endedAtTimestamp = 2; - } - message NotificationProfileManualOverride { message ManuallyEnabled { bytes id = 1; @@ -293,12 +288,12 @@ message AccountRecord { reserved /*backupsSubscriberId*/ 36; reserved /*backupsSubscriberCurrencyCode*/ 37; reserved /*backupsSubscriptionManuallyCancelled*/ 38; - // Set to true after backups are enabled and one is uploaded. reserved /*hasBackup*/ 39; // See zkgroup for integer particular values. Unset if backups are not enabled. optional uint64 backupTier = 40; IAPSubscriberData backupSubscriberData = 41; optional AvatarColor avatarColor = 42; + reserved 43; // backupTierHistory NotificationProfileManualOverride notificationProfileManualOverride = 44; bool notificationProfileSyncDisabled = 45; bool automaticKeyVerificationDisabled = 46; @@ -339,9 +334,8 @@ message StickerPackRecord { message CallLinkRecord { bytes rootKey = 1; // 16 bytes bytes adminPasskey = 2; // Non-empty when the current user is an admin - uint64 deletedAtTimestampMs = 3; // When present and non-zero, `adminPasskey` - // should be cleared - reserved 4; // was epoch, never used + uint64 deletedAtTimestampMs = 3; // When present and non-zero, `adminPasskey` should be cleared + reserved /*epoch*/ 4; } message Recipient { diff --git a/ts/components/conversation/ConversationHero.dom.stories.tsx b/ts/components/conversation/ConversationHero.dom.stories.tsx index 6f0e761fd7..0afae7f31d 100644 --- a/ts/components/conversation/ConversationHero.dom.stories.tsx +++ b/ts/components/conversation/ConversationHero.dom.stories.tsx @@ -48,10 +48,10 @@ export default { component: ConversationHero, args: { conversationType: 'direct', - fromOrAddedByTrustedContact: false, - i18n, hasNickname: false, hasProfileName: true, + i18n, + isGroupNameVerified: false, isInSystemContacts: false, theme: ThemeType.light, sharedGroupNames: [], @@ -178,10 +178,10 @@ GroupMessageRequest.args = { acceptedMessageRequest: false, }; -export const GroupFromTrustedContact = Template.bind({}); -GroupFromTrustedContact.args = { +export const GroupVerifiedName = Template.bind({}); +GroupVerifiedName.args = { ...groupArgs, - fromOrAddedByTrustedContact: true, + isGroupNameVerified: true, }; export function GroupMemberNames(args: Props): React.JSX.Element { diff --git a/ts/components/conversation/ConversationHero.dom.tsx b/ts/components/conversation/ConversationHero.dom.tsx index d489986edc..c37903c46c 100644 --- a/ts/components/conversation/ConversationHero.dom.tsx +++ b/ts/components/conversation/ConversationHero.dom.tsx @@ -22,7 +22,6 @@ import { AxoButton } from '../../axo/AxoButton.dom.tsx'; export type Props = { about?: string; acceptedMessageRequest?: boolean; - fromOrAddedByTrustedContact?: boolean; groupDescription?: string; hasAvatar?: boolean; hasNickname: boolean; @@ -30,6 +29,7 @@ export type Props = { hasStories?: HasStories; id: string; i18n: LocalizerType; + isGroupNameVerified: boolean; isInSystemContacts: boolean; isMe: boolean; invitesCount?: number; @@ -58,13 +58,13 @@ export function ConversationHero({ badge, color, conversationType, - fromOrAddedByTrustedContact, groupDescription, hasAvatar, hasNickname, hasProfileName, hasStories, id, + isGroupNameVerified, isInSystemContacts, isMe, invitesCount, @@ -210,12 +210,11 @@ export function ConversationHero({ } if (conversationType === 'group') { - const nameIsVerified = Boolean(fromOrAddedByTrustedContact); return ( {avatar} - {!nameIsVerified ? ( + {!isGroupNameVerified ? ( <NameNotVerifiedWarning conversationType={conversationType} onClick={() => toggleProfileNameWarningModal(conversationType)} diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 358bcdc969..168df2253f 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -413,10 +413,11 @@ const renderHeroRow = () => { conversationType="direct" hasNickname={false} hasProfileName - id={getDefaultConversation().id} i18n={i18n} - isMe={false} + id={getDefaultConversation().id} + isGroupNameVerified={false} isInSystemContacts={false} + isMe={false} phoneNumber={getPhoneNumber()} profileName={getProfileName()} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} diff --git a/ts/groups.preload.ts b/ts/groups.preload.ts index ca93ca5133..89e670c98a 100644 --- a/ts/groups.preload.ts +++ b/ts/groups.preload.ts @@ -137,6 +137,7 @@ import { toNumber } from './util/toNumber.std.ts'; import Actions = Proto.GroupChange.Actions; import AccessRequired = Proto.AccessControl.AccessRequired; import MemberRole = Proto.Member.Role; +import { computeGroupNameHash } from './util/Conversation.preload.ts'; const { compact, difference, flatten, fromPairs, isNumber, omit, values } = lodash; @@ -2030,6 +2031,7 @@ export async function createGroupV2( avatar: avatarAttribute, avatars, groupVersion: 2, + groupVerifiedNameHash: computeGroupNameHash(name), masterKey, profileSharing: true, timestamp: now, @@ -5543,6 +5545,13 @@ async function applyGroupChange({ const title = actions.modifyTitle.title?.content?.title; if (title != null) { result.name = title.trim(); + if (ourAci === sourceServiceId) { + result.groupVerifiedNameHash = computeGroupNameHash(result.name); + // TODO (DESKTOP-10060) + window.ConversationController.get(group.id)?.captureChange( + 'groupVerifiedNameHash' + ); + } } else { log.warn( `applyGroupChange/${logId}: Clearing group title due to missing data.` @@ -6239,6 +6248,10 @@ async function applyGroupState({ return member; }); + if (groupState.version === 0 && sourceServiceId === ourAci && result.name) { + result.groupVerifiedNameHash = computeGroupNameHash(result.name); + } + // avatar result = { ...result, diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 43c4cfa04d..45c2ac6fed 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -486,6 +486,7 @@ export type ConversationAttributesType = { left?: boolean; groupVersion?: number; storySendMode?: StorySendMode; + groupVerifiedNameHash?: string; // GroupV1 only members?: Array<string>; diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index 4a69389b16..a6a6445e39 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -627,8 +627,8 @@ export function toGroupV2Record( } const avatarColor = conversation.get('colorFromPrimary'); - const masterKey = conversation.get('masterKey'); + const verifiedNameHash = conversation.get('groupVerifiedNameHash'); return { masterKey: masterKey != null ? Bytes.fromBase64(masterKey) : null, @@ -646,7 +646,9 @@ export function toGroupV2Record( hideStory: Boolean(conversation.get('hideStory')), avatarColor: avatarColor ?? null, storySendMode, - + verifiedNameHash: verifiedNameHash + ? Bytes.fromBase64(verifiedNameHash) + : null, $unknown: conversationUnknownFieldsToRecord(conversation), }; } @@ -1243,9 +1245,19 @@ export async function mergeGroupV2Record( storageID, storageVersion, storySendMode, + needsStorageServiceSync: false, }); + // We only update verified name hash if it is truthy, to avoid races where a linked + // device creates the GroupV2Record (with nullish hash) before the creating device + // updates storage service with the verified name hash. + if (Bytes.isNotEmpty(groupV2Record.verifiedNameHash)) { + conversation.set({ + groupVerifiedNameHash: Bytes.toBase64(groupV2Record.verifiedNameHash), + }); + } + conversation.setMuteExpiration( getTimestampFromLong( groupV2Record.mutedUntilTimestamp, diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 6bacc6233f..a15d49b332 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -255,6 +255,7 @@ import { getPanels, getSelectedConversationId, } from '../selectors/nav.std.ts'; +import { computeGroupNameHash } from '../../util/Conversation.preload.ts'; const { chunk, difference, fromPairs, omit, orderBy, pick, values, without } = lodash; @@ -442,6 +443,7 @@ export type ConversationType = ReadonlyDeep< groupVersion?: 1 | 2; groupId?: string; groupLink?: string; + groupVerifiedNameHash?: string; terminated?: boolean; acceptedMessageRequest: boolean; secretParams?: string; @@ -4649,6 +4651,13 @@ function updateGroupAttributes( attributes ), }); + if (attributes.title) { + conversation.set({ + groupVerifiedNameHash: computeGroupNameHash(attributes.title), + }); + await DataWriter.updateConversation(conversation.attributes); + conversation.captureChange('groupVerifiedNameHash'); + } onSuccess?.(); } catch { onFailure?.(); diff --git a/ts/state/smart/HeroRow.preload.tsx b/ts/state/smart/HeroRow.preload.tsx index d136a9868b..77c84655e0 100644 --- a/ts/state/smart/HeroRow.preload.tsx +++ b/ts/state/smart/HeroRow.preload.tsx @@ -1,6 +1,6 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { PanelType } from '../../types/Panels.std.ts'; import { ConversationHero } from '../../components/conversation/ConversationHero.dom.tsx'; @@ -14,39 +14,19 @@ import { getPendingAvatarDownloadSelector, } from '../selectors/conversations.dom.ts'; import { useSharedGroupNamesOnMount } from '../../util/sharedGroupNames.dom.ts'; -import { - type ConversationType, - useConversationsActions, -} from '../ducks/conversations.preload.ts'; +import { useConversationsActions } from '../ducks/conversations.preload.ts'; import { useGlobalModalActions } from '../ducks/globalModals.preload.ts'; import { useStoriesActions } from '../ducks/stories.preload.ts'; -import { getAddedByForGroup } from '../../util/getAddedByForGroup.preload.ts'; import { getGroupMemberships } from '../../util/getGroupMemberships.dom.ts'; import { useNavActions } from '../ducks/nav.std.ts'; import { tw } from '../../axo/tw.dom.tsx'; import { isInSystemContacts } from '../../util/isInSystemContacts.std.ts'; +import { computeGroupNameHash } from '../../util/Conversation.preload.ts'; type SmartHeroRowProps = Readonly<{ id: string; }>; -function isFromOrAddedByTrustedContact( - conversation: ConversationType -): boolean { - if (conversation.type === 'direct') { - return Boolean(conversation.name) || Boolean(conversation.profileSharing); - } - - const addedByConv = getAddedByForGroup(conversation); - if (!addedByConv) { - return false; - } - - return Boolean( - addedByConv.isMe || addedByConv.name || addedByConv.profileSharing - ); -} - export const SmartHeroRow = memo(function SmartHeroRow({ id, }: SmartHeroRowProps) { @@ -73,8 +53,6 @@ export const SmartHeroRow = memo(function SmartHeroRow({ const badge = getPreferredBadge(conversation.badges); const hasStories = hasStoriesSelector(id); const isSignalConversationValue = isSignalConversation(conversation); - const fromOrAddedByTrustedContact = - isFromOrAddedByTrustedContact(conversation); const { startAvatarDownload } = useConversationsActions(); const { pushPanelForConversation } = useNavActions(); const { toggleAboutContactModal, toggleProfileNameWarningModal } = @@ -89,6 +67,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({ avatarUrl, color, groupDescription, + groupVerifiedNameHash, hasAvatar, isMe, membersCount, @@ -96,10 +75,24 @@ export const SmartHeroRow = memo(function SmartHeroRow({ nicknameFamilyName, profileName, title, + titleNoDefault, type, } = conversation; const invitesCount = pendingMemberships.length + pendingApprovalMemberships.length; + + const isGroupNameVerified = useMemo(() => { + if (type !== 'group') { + return false; + } + + if (!groupVerifiedNameHash || !titleNoDefault) { + return false; + } + + return computeGroupNameHash(titleNoDefault) === groupVerifiedNameHash; + }, [groupVerifiedNameHash, titleNoDefault, type]); + return ( <div className={tw('mt-10 flex justify-center')}> <ConversationHero @@ -109,7 +102,6 @@ export const SmartHeroRow = memo(function SmartHeroRow({ badge={badge} color={color} conversationType={type} - fromOrAddedByTrustedContact={fromOrAddedByTrustedContact} groupDescription={groupDescription} hasAvatar={hasAvatar} hasNickname={Boolean(nicknameGivenName || nicknameFamilyName)} @@ -120,6 +112,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({ isMe={isMe} invitesCount={invitesCount} isInSystemContacts={isInSystemContacts(conversation)} + isGroupNameVerified={isGroupNameVerified} isSignalConversation={isSignalConversationValue} membersCount={membersCount} memberships={memberships} diff --git a/ts/util/Conversation.preload.ts b/ts/util/Conversation.preload.ts index 53d28aafba..43e0281852 100644 --- a/ts/util/Conversation.preload.ts +++ b/ts/util/Conversation.preload.ts @@ -4,6 +4,8 @@ import { DataReader, DataWriter } from '../sql/Client.preload.ts'; import type { ConversationAttributesType } from '../model-types.d.ts'; import { maybeDeleteAttachmentFile } from './migrations.preload.ts'; +import * as Bytes from '../Bytes.std.ts'; +import { sha256 } from '../Crypto.node.ts'; async function deleteExternalFiles( conversation: ConversationAttributesType @@ -33,3 +35,7 @@ export async function removeConversation(id: string): Promise<void> { await deleteExternalFiles(existing); } } + +export function computeGroupNameHash(name: string): string { + return Bytes.toBase64(sha256(Bytes.fromString(name))); +} diff --git a/ts/util/getConversation.preload.ts b/ts/util/getConversation.preload.ts index 07e73fdb50..acee8c3cd1 100644 --- a/ts/util/getConversation.preload.ts +++ b/ts/util/getConversation.preload.ts @@ -191,6 +191,7 @@ export function getConversation(model: ConversationModel): ConversationType { familyName: attributes.nicknameFamilyName ?? attributes.profileFamilyName, firstName: attributes.nicknameGivenName ?? attributes.profileName, groupDescription: attributes.description, + groupVerifiedNameHash: attributes.groupVerifiedNameHash, groupVersion, groupId: attributes.groupId, groupLink: buildGroupLink(attributes),