diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 1055cd6d3..aaef4899b 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -17,7 +17,7 @@ import { } from '@signalapp/libsignal-client'; import * as Bytes from './Bytes'; -import { constantTimeEqual } from './Crypto'; +import { constantTimeEqual, sha256 } from './Crypto'; import { assert, strictAssert } from './util/assert'; import { isNotNil } from './util/isNotNil'; import { Zone } from './util/Zone'; @@ -1565,6 +1565,23 @@ export class SignalProtocolStore extends EventsMixin { return undefined; } + async getFingerprint(uuid: UUID): Promise { + if (uuid === null || uuid === undefined) { + throw new Error('loadIdentityKey: uuid was undefined/null'); + } + + const pubKey = await this.loadIdentityKey(uuid); + + if (!pubKey) { + return; + } + + const hash = sha256(pubKey); + const fingerprint = hash.slice(0, 4); + + return Bytes.toBase64(fingerprint); + } + private async _saveIdentityKey(data: IdentityKeyType): Promise { if (!this.identityKeys) { throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); @@ -1831,7 +1848,7 @@ export class SignalProtocolStore extends EventsMixin { return false; } - isUntrusted(uuid: UUID): boolean { + isUntrusted(uuid: UUID, timestampThreshold = TIMESTAMP_THRESHOLD): boolean { if (uuid === null || uuid === undefined) { throw new Error('isUntrusted: uuid was undefined/null'); } @@ -1842,7 +1859,7 @@ export class SignalProtocolStore extends EventsMixin { } if ( - isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && + isMoreRecentThan(identityRecord.timestamp, timestampThreshold) && !identityRecord.nonblockingApproval && !identityRecord.firstUse ) { diff --git a/ts/components/SafetyNumberChangeDialog.tsx b/ts/components/SafetyNumberChangeDialog.tsx index e4b949513..03e5165d6 100644 --- a/ts/components/SafetyNumberChangeDialog.tsx +++ b/ts/components/SafetyNumberChangeDialog.tsx @@ -17,6 +17,7 @@ import { isInSystemContacts } from '../util/isInSystemContacts'; export enum SafetyNumberChangeSource { Calling = 'Calling', MessageSend = 'MessageSend', + Story = 'Story', } export type SafetyNumberProps = { diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index 903cd47e9..8cd811516 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -22,6 +22,7 @@ import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories'; import { Modal } from './Modal'; import { StoryDistributionListName } from './StoryDistributionListName'; import { Theme } from '../util/theme'; +import { isNotNil } from '../util/isNotNil'; export type PropsType = { candidateConversations: Array; @@ -36,6 +37,7 @@ export type PropsType = { name: string, viewerUuids: Array ) => unknown; + onSelectedStoryList: (memberUuids: Array) => unknown; onSend: ( listIds: Array, conversationIds: Array @@ -56,6 +58,21 @@ const Page = { type PageType = SendStoryPage | StoriesSettingsPage; +function getListMemberUuids( + list: StoryDistributionListDataType, + signalConnections: Array +): Array { + if (list.id === MY_STORIES_ID && list.isBlockList) { + const excludeUuids = new Set(list.memberUuids); + return signalConnections + .map(conversation => conversation.uuid) + .filter(isNotNil) + .filter(uuid => !excludeUuids.has(uuid)); + } + + return list.memberUuids; +} + function getListViewers( list: StoryDistributionListDataType, i18n: LocalizerType, @@ -85,6 +102,7 @@ export const SendStoryModal = ({ onClose, onDistributionListCreated, onSend, + onSelectedStoryList, signalConnections, tagGroupsAsNewGroupStory, }: PropsType): JSX.Element => { @@ -300,6 +318,11 @@ export const SendStoryModal = ({ } return new Set([...listIds]); }); + if (value) { + onSelectedStoryList( + getListMemberUuids(list, signalConnections) + ); + } }} > {({ id, checkboxNode }) => ( @@ -352,6 +375,10 @@ export const SendStoryModal = ({ moduleClassName="SendStoryModal__distribution-list" name="SendStoryModal__distribution-list" onChange={(value: boolean) => { + if (!group.memberships) { + return; + } + setSelectedGroupIds(groupIds => { if (value) { groupIds.add(group.id); @@ -360,6 +387,9 @@ export const SendStoryModal = ({ } return new Set([...groupIds]); }); + if (value) { + onSelectedStoryList(group.memberships.map(({ uuid }) => uuid)); + } }} > {({ id, checkboxNode }) => ( diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index c21c3157e..d13fc66b7 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -43,6 +43,7 @@ export type PropsType = { name: string, viewerUuids: Array ) => unknown; + onSelectedStoryList: (memberUuids: Array) => unknown; onSend: ( listIds: Array, conversationIds: Array, @@ -51,6 +52,7 @@ export type PropsType = { processAttachment: ( file: File ) => Promise; + sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown; signalConnections: Array; tagGroupsAsNewGroupStory: (cids: Array) => unknown; } & Pick; @@ -69,9 +71,11 @@ export const StoryCreator = ({ me, onClose, onDistributionListCreated, + onSelectedStoryList, onSend, processAttachment, recentStickers, + sendStoryModalOpenStateChanged, signalConnections, tagGroupsAsNewGroupStory, }: PropsType): JSX.Element => { @@ -112,6 +116,10 @@ export const StoryCreator = ({ }; }, [file, processAttachment]); + useEffect(() => { + sendStoryModalOpenStateChanged(Boolean(draftAttachment)); + }, [draftAttachment, sendStoryModalOpenStateChanged]); + return ( <> {draftAttachment && ( @@ -125,6 +133,7 @@ export const StoryCreator = ({ me={me} onClose={() => setDraftAttachment(undefined)} onDistributionListCreated={onDistributionListCreated} + onSelectedStoryList={onSelectedStoryList} onSend={(listIds, groupIds) => { onSend(listIds, groupIds, draftAttachment); setDraftAttachment(undefined); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 643c84fc7..c45dccf2f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2822,19 +2822,22 @@ export class ConversationModel extends window.Backbone return window.textsecure.storage.protocol.setApproval(uuid, true); } - safeIsUntrusted(): boolean { + safeIsUntrusted(timestampThreshold?: number): boolean { try { const uuid = this.getUuid(); strictAssert(uuid, `No uuid for conversation: ${this.id}`); - return window.textsecure.storage.protocol.isUntrusted(uuid); + return window.textsecure.storage.protocol.isUntrusted( + uuid, + timestampThreshold + ); } catch (err) { return false; } } - isUntrusted(): boolean { + isUntrusted(timestampThreshold?: number): boolean { if (isDirectConversation(this.attributes)) { - return this.safeIsUntrusted(); + return this.safeIsUntrusted(timestampThreshold); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (!this.contactCollection!.length) { @@ -2846,13 +2849,13 @@ export class ConversationModel extends window.Backbone if (isMe(contact.attributes)) { return false; } - return contact.safeIsUntrusted(); + return contact.safeIsUntrusted(timestampThreshold); }); } - getUntrusted(): Array { + getUntrusted(timestampThreshold?: number): Array { if (isDirectConversation(this.attributes)) { - if (this.isUntrusted()) { + if (this.isUntrusted(timestampThreshold)) { return [this]; } return []; @@ -2863,7 +2866,7 @@ export class ConversationModel extends window.Backbone if (isMe(contact.attributes)) { return false; } - return contact.isUntrusted(); + return contact.isUntrusted(timestampThreshold); }) || [] ); } diff --git a/ts/services/profiles.ts b/ts/services/profiles.ts index 514eed213..a46ceee88 100644 --- a/ts/services/profiles.ts +++ b/ts/services/profiles.ts @@ -12,6 +12,7 @@ import type { GetProfileOptionsType, GetProfileUnauthOptionsType, } from '../textsecure/WebAPI'; +import type { UUID } from '../types/UUID'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; import * as Bytes from '../Bytes'; @@ -359,20 +360,7 @@ async function doGetProfile(c: ConversationModel): Promise { } if (profile.identityKey) { - const identityKey = Bytes.fromBase64(profile.identityKey); - const changed = await window.textsecure.storage.protocol.saveIdentity( - new Address(uuid, 1), - identityKey, - false - ); - if (changed) { - // save identity will close all sessions except for .1, so we - // must close that one manually. - const ourUuid = window.textsecure.storage.user.getCheckedUuid(); - await window.textsecure.storage.protocol.archiveSession( - new QualifiedAddress(ourUuid, new Address(uuid, 1)) - ); - } + await updateIdentityKey(profile.identityKey, uuid); } // Update accessKey to prevent race conditions. Since we run asynchronous @@ -655,3 +643,27 @@ async function maybeGetPNICredential( log.info('maybeGetPNICredential: updated PNI credential'); } + +export async function updateIdentityKey( + identityKey: string, + uuid: UUID +): Promise { + if (!identityKey) { + return; + } + + const identityKeyBytes = Bytes.fromBase64(identityKey); + const changed = await window.textsecure.storage.protocol.saveIdentity( + new Address(uuid, 1), + identityKeyBytes, + false + ); + if (changed) { + // save identity will close all sessions except for .1, so we + // must close that one manually. + const ourUuid = window.textsecure.storage.user.getCheckedUuid(); + await window.textsecure.storage.protocol.archiveSession( + new QualifiedAddress(ourUuid, new Address(uuid, 1)) + ); + } +} diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index b61a62e2c..9f8098e93 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -1,10 +1,11 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import { isEqual, noop, pick } from 'lodash'; import type { AttachmentType } from '../../types/Attachment'; import type { BodyRangeType } from '../../types/Util'; +import type { ConversationModel } from '../../models/conversations'; import type { MessageAttributesType } from '../../model-types.d'; import type { MessageChangedActionType, @@ -20,9 +21,12 @@ import * as log from '../../logging/log'; import dataInterface from '../../sql/Client'; import { DAY } from '../../util/durations'; import { ReadStatus } from '../../messages/MessageReadStatus'; +import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; import { StoryRecipientUpdateEvent } from '../../textsecure/messageReceiverEvents'; import { ToastReactionFailed } from '../../components/ToastReactionFailed'; +import { assert } from '../../util/assert'; +import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { getMessageById } from '../../messages/getMessageById'; import { markViewed } from '../../services/MessageUpdater'; @@ -46,6 +50,7 @@ import { isStory } from '../../messages/helpers'; import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate'; import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage'; import { useBoundActions } from '../../hooks/useBoundActions'; +import { verifyStoryListMembers as doVerifyStoryListMembers } from '../../util/verifyStoryListMembers'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; @@ -78,12 +83,16 @@ export type SelectedStoryDataType = { // State export type StoriesStateType = { - readonly isShowingStoriesView: boolean; + readonly openedAtTimestamp: number | undefined; readonly replyState?: { messageId: string; replies: Array; }; readonly selectedStoryData?: SelectedStoryDataType; + readonly sendStoryModalData?: { + untrustedUuids: Array; + verifiedUuids: Array; + }; readonly stories: Array; readonly storyViewMode?: StoryViewModeType; }; @@ -91,11 +100,14 @@ export type StoriesStateType = { // Actions const DOE_STORY = 'stories/DOE'; +const LIST_MEMBERS_VERIFIED = 'stories/LIST_MEMBERS_VERIFIED'; const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES'; const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD'; const REPLY_TO_STORY = 'stories/REPLY_TO_STORY'; export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL'; +const SEND_STORY_MODAL_OPEN_STATE_CHANGED = + 'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; const VIEW_STORY = 'stories/VIEW_STORY'; @@ -105,6 +117,14 @@ type DOEStoryActionType = { payload: string; }; +type ListMembersVerified = { + type: typeof LIST_MEMBERS_VERIFIED; + payload: { + untrustedUuids: Array; + verifiedUuids: Array; + }; +}; + type LoadStoryRepliesActionType = { type: typeof LOAD_STORY_REPLIES; payload: { @@ -136,6 +156,11 @@ type ResolveAttachmentUrlActionType = { }; }; +type SendStoryModalOpenStateChanged = { + type: typeof SEND_STORY_MODAL_OPEN_STATE_CHANGED; + payload: number | undefined; +}; + type StoryChangedActionType = { type: typeof STORY_CHANGED; payload: StoryDataType; @@ -157,6 +182,7 @@ type ViewStoryActionType = { export type StoriesActionType = | DOEStoryActionType + | ListMembersVerified | LoadStoryRepliesActionType | MarkStoryReadActionType | MessageChangedActionType @@ -165,6 +191,7 @@ export type StoriesActionType = | QueueStoryDownloadActionType | ReplyToStoryActionType | ResolveAttachmentUrlActionType + | SendStoryModalOpenStateChanged | StoryChangedActionType | ToggleViewActionType | ViewStoryActionType; @@ -580,14 +607,52 @@ function sendStoryMessage( listIds: Array, conversationIds: Array, attachment: AttachmentType -): ThunkAction { - return async dispatch => { - await doSendStoryMessage(listIds, conversationIds, attachment); +): ThunkAction { + return async (dispatch, getState) => { + const { stories } = getState(); + const { openedAtTimestamp, sendStoryModalData } = stories; + assert( + openedAtTimestamp, + 'sendStoryMessage: openedAtTimestamp is undefined, cannot send' + ); + assert( + sendStoryModalData, + 'sendStoryMessage: sendStoryModalData is not defined, cannot send' + ); dispatch({ - type: 'NOOP', - payload: null, + type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, + payload: undefined, }); + + if (sendStoryModalData.untrustedUuids.length) { + log.info('sendStoryMessage: SN changed for some conversations'); + + const conversationsNeedingVerification: Array = + sendStoryModalData.untrustedUuids + .map(uuid => window.ConversationController.get(uuid)) + .filter(isNotNil); + + if (!conversationsNeedingVerification.length) { + log.warn( + 'sendStoryMessage: Could not retrieve conversations for untrusted uuids' + ); + return; + } + + const result = await blockSendUntilConversationsAreVerified( + conversationsNeedingVerification, + SafetyNumberChangeSource.Story, + Date.now() - openedAtTimestamp + ); + + if (!result) { + log.info('sendStoryMessage: did not send'); + return; + } + } + + await doSendStoryMessage(listIds, conversationIds, attachment); }; } @@ -598,12 +663,69 @@ function storyChanged(story: StoryDataType): StoryChangedActionType { }; } +function sendStoryModalOpenStateChanged( + value: boolean +): ThunkAction { + return (dispatch, getState) => { + const { stories } = getState(); + + if (!stories.sendStoryModalData && value) { + dispatch({ + type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, + payload: Date.now(), + }); + } + + if (stories.sendStoryModalData && !value) { + dispatch({ + type: SEND_STORY_MODAL_OPEN_STATE_CHANGED, + payload: undefined, + }); + } + }; +} + function toggleStoriesView(): ToggleViewActionType { return { type: TOGGLE_VIEW, }; } +function verifyStoryListMembers( + memberUuids: Array +): ThunkAction { + return async (dispatch, getState) => { + const { stories } = getState(); + const { sendStoryModalData } = stories; + + if (!sendStoryModalData) { + return; + } + + const alreadyVerifiedUuids = new Set([...sendStoryModalData.verifiedUuids]); + + const uuidsNeedingVerification = memberUuids.filter( + uuid => !alreadyVerifiedUuids.has(uuid) + ); + + if (!uuidsNeedingVerification.length) { + return; + } + + const { untrustedUuids, verifiedUuids } = await doVerifyStoryListMembers( + uuidsNeedingVerification + ); + + dispatch({ + type: LIST_MEMBERS_VERIFIED, + payload: { + untrustedUuids: Array.from(untrustedUuids), + verifiedUuids: Array.from(verifiedUuids), + }, + }); + }; +} + const getSelectedStoryDataForConversationId = ( dispatch: ThunkDispatch< RootStateType, @@ -946,8 +1068,10 @@ export const actions = { reactToStory, replyToStory, sendStoryMessage, + sendStoryModalOpenStateChanged, storyChanged, toggleStoriesView, + verifyStoryListMembers, viewUserStories, viewStory, }; @@ -960,7 +1084,7 @@ export function getEmptyState( overrideState: Partial = {} ): StoriesStateType { return { - isShowingStoriesView: false, + openedAtTimestamp: undefined, stories: [], ...overrideState, }; @@ -971,15 +1095,17 @@ export function reducer( action: Readonly ): StoriesStateType { if (action.type === TOGGLE_VIEW) { + const isShowingStoriesView = Boolean(state.openedAtTimestamp); + return { ...state, - isShowingStoriesView: !state.isShowingStoriesView, - selectedStoryData: state.isShowingStoriesView + openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(), + replyState: undefined, + sendStoryModalData: undefined, + selectedStoryData: isShowingStoriesView ? undefined : state.selectedStoryData, - storyViewMode: state.isShowingStoriesView - ? undefined - : state.storyViewMode, + storyViewMode: isShowingStoriesView ? undefined : state.storyViewMode, }; } @@ -1244,5 +1370,52 @@ export function reducer( }; } + if (action.type === SEND_STORY_MODAL_OPEN_STATE_CHANGED) { + if (action.payload) { + return { + ...state, + sendStoryModalData: { + untrustedUuids: [], + verifiedUuids: [], + }, + }; + } + + return { + ...state, + sendStoryModalData: undefined, + }; + } + + if (action.type === LIST_MEMBERS_VERIFIED) { + const sendStoryModalData = { + untrustedUuids: [], + verifiedUuids: [], + ...(state.sendStoryModalData || {}), + }; + + const untrustedUuids = Array.from( + new Set([ + ...sendStoryModalData.untrustedUuids, + ...action.payload.untrustedUuids, + ]) + ); + const verifiedUuids = Array.from( + new Set([ + ...sendStoryModalData.verifiedUuids, + ...action.payload.verifiedUuids, + ]) + ); + + return { + ...state, + sendStoryModalData: { + ...sendStoryModalData, + untrustedUuids, + verifiedUuids, + }, + }; + } + return state; } diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 4aed1c09a..1ca862fcc 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -37,7 +37,7 @@ export const getStoriesState = (state: StateType): StoriesStateType => export const shouldShowStoriesView = createSelector( getStoriesState, - ({ isShowingStoriesView }): boolean => isShowingStoriesView + ({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp) ); export const hasSelectedStoryData = createSelector( diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index 9dc3183d5..2bfa3b3f5 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -13,7 +13,7 @@ import { getMe } from '../selectors/conversations'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredLeftPaneWidth } from '../selectors/items'; -import { getStories } from '../selectors/stories'; +import { getStories, shouldShowStoriesView } from '../selectors/stories'; import { saveAttachment } from '../../util/saveAttachment'; import { useConversationsActions } from '../ducks/conversations'; import { useGlobalModalActions } from '../ducks/globalModals'; @@ -37,7 +37,7 @@ export function SmartStories(): JSX.Element | null { const i18n = useSelector(getIntl); const isShowingStoriesView = useSelector( - (state: StateType) => state.stories.isShowingStoriesView + shouldShowStoriesView ); const preferredWidthFromStorage = useSelector( diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index f9b278b5d..7de71cfd4 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -39,7 +39,11 @@ export function SmartStoryCreator({ onClose, }: PropsType): JSX.Element | null { const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions(); - const { sendStoryMessage } = useStoriesActions(); + const { + sendStoryModalOpenStateChanged, + sendStoryMessage, + verifyStoryListMembers, + } = useStoriesActions(); const { tagGroupsAsNewGroupStory } = useConversationsActions(); const { createDistributionList } = useStoryDistributionListsActions(); @@ -70,9 +74,11 @@ export function SmartStoryCreator({ me={me} onClose={onClose} onDistributionListCreated={createDistributionList} + onSelectedStoryList={verifyStoryListMembers} onSend={sendStoryMessage} processAttachment={processAttachment} recentStickers={recentStickers} + sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged} signalConnections={signalConnections} tagGroupsAsNewGroupStory={tagGroupsAsNewGroupStory} /> diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 908adc48b..6d8d5a4ba 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -486,6 +486,7 @@ const URL_CALLS = { accountExistence: 'v1/accounts/account', attachmentId: 'v2/attachments/form/upload', attestation: 'v1/attestation', + batchIdentityCheck: 'v1/profile/identity_check/batch', boostBadges: 'v1/subscription/boost/badges', challenge: 'v1/challenge', config: 'v1/config', @@ -782,6 +783,20 @@ export type GetGroupCredentialsResultType = Readonly<{ credentials: ReadonlyArray; }>; +const verifyAciResponse = z + .object({ + elements: z.array( + z.object({ + aci: z.string(), + identityKey: z.string(), + }) + ), + }) + .passthrough(); + +export type VerifyAciRequestType = Array<{ aci: string; fingerprint: string }>; +export type VerifyAciResponseType = z.infer; + export type WebAPIType = { startRegistration(): unknown; finishRegistration(baton: unknown): void; @@ -878,6 +893,9 @@ export type WebAPIType = { inviteLinkBase64?: string ) => Promise; modifyStorageRecords: MessageSender['modifyStorageRecords']; + postBatchIdentityCheck: ( + elements: VerifyAciRequestType + ) => Promise; putAttachment: (encryptedBin: Uint8Array) => Promise; putProfile: ( jsonData: ProfileRequestDataType @@ -1272,6 +1290,7 @@ export function initialize({ makeSfuRequest, modifyGroup, modifyStorageRecords, + postBatchIdentityCheck, putAttachment, putProfile, putStickers, @@ -1559,6 +1578,28 @@ export function initialize({ }); } + async function postBatchIdentityCheck(elements: VerifyAciRequestType) { + const res = await _ajax({ + data: JSON.stringify({ elements }), + call: 'batchIdentityCheck', + httpType: 'POST', + responseType: 'json', + }); + + const result = verifyAciResponse.safeParse(res); + + if (result.success) { + return result.data; + } + + log.warn( + 'WebAPI: invalid response from postBatchIdentityCheck', + toLogFormat(result.error) + ); + + throw result.error; + } + function getProfileUrl( identifier: string, { diff --git a/ts/util/blockSendUntilConversationsAreVerified.ts b/ts/util/blockSendUntilConversationsAreVerified.ts index dd4eea0d1..fc9761104 100644 --- a/ts/util/blockSendUntilConversationsAreVerified.ts +++ b/ts/util/blockSendUntilConversationsAreVerified.ts @@ -9,7 +9,8 @@ import { getConversationIdForLogging } from './idForLogging'; export async function blockSendUntilConversationsAreVerified( conversations: Array, - source?: SafetyNumberChangeSource + source?: SafetyNumberChangeSource, + timestampThreshold?: number ): Promise { const conversationsToPause = new Map>(); @@ -33,7 +34,7 @@ export async function blockSendUntilConversationsAreVerified( }); } - const untrusted = conversation.getUntrusted(); + const untrusted = conversation.getUntrusted(timestampThreshold); if (untrusted.length) { untrusted.forEach(untrustedConversation => { const uuid = untrustedConversation.get('uuid'); diff --git a/ts/util/verifyStoryListMembers.ts b/ts/util/verifyStoryListMembers.ts new file mode 100644 index 000000000..768f27e91 --- /dev/null +++ b/ts/util/verifyStoryListMembers.ts @@ -0,0 +1,55 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { UUID } from '../types/UUID'; +import * as log from '../logging/log'; +import { isNotNil } from './isNotNil'; +import { updateIdentityKey } from '../services/profiles'; + +export async function verifyStoryListMembers( + uuids: Array +): Promise<{ untrustedUuids: Set; verifiedUuids: Set }> { + const { server } = window.textsecure; + if (!server) { + throw new Error('verifyStoryListMembers: server not available'); + } + + const verifiedUuids = new Set(); + const untrustedUuids = new Set(); + + const elements = await Promise.all( + uuids.map(async aci => { + const uuid = new UUID(aci); + const fingerprint = + await window.textsecure.storage.protocol.getFingerprint(uuid); + + if (!fingerprint) { + log.warn('verifyStoryListMembers: no fingerprint found for uuid=', aci); + untrustedUuids.add(aci); + return; + } + + verifiedUuids.add(aci); + return { aci, fingerprint }; + }) + ); + + const { elements: unverifiedACI } = await server.postBatchIdentityCheck( + elements.filter(isNotNil) + ); + + await Promise.all( + unverifiedACI.map(async ({ aci, identityKey }) => { + untrustedUuids.add(aci); + verifiedUuids.delete(aci); + + if (identityKey) { + await updateIdentityKey(identityKey, new UUID(aci)); + } else { + await window.ConversationController.get(aci)?.getProfiles(); + } + }) + ); + + return { untrustedUuids, verifiedUuids }; +}