Track verified group name hash

This commit is contained in:
trevor-signal
2026-04-17 16:20:36 -04:00
committed by GitHub
parent dd19ab4777
commit bb9bb142eb
11 changed files with 78 additions and 49 deletions
+4 -10
View File
@@ -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 {
@@ -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 {
@@ -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 (
<Root>
{avatar}
<Title title={title} />
{!nameIsVerified ? (
{!isGroupNameVerified ? (
<NameNotVerifiedWarning
conversationType={conversationType}
onClick={() => toggleProfileNameWarningModal(conversationType)}
@@ -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']}
+13
View File
@@ -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,
+1
View File
@@ -486,6 +486,7 @@ export type ConversationAttributesType = {
left?: boolean;
groupVersion?: number;
storySendMode?: StorySendMode;
groupVerifiedNameHash?: string;
// GroupV1 only
members?: Array<string>;
+14 -2
View File
@@ -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,
+9
View File
@@ -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?.();
+19 -26
View File
@@ -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}
+6
View File
@@ -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)));
}
+1
View File
@@ -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),