Handle Safety Number changes while sending a story

This commit is contained in:
Josh Perez 2022-08-19 14:05:31 -04:00 committed by GitHub
parent d036803df9
commit 0fb45f045d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 392 additions and 44 deletions

View file

@ -17,7 +17,7 @@ import {
} from '@signalapp/libsignal-client'; } from '@signalapp/libsignal-client';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { constantTimeEqual } from './Crypto'; import { constantTimeEqual, sha256 } from './Crypto';
import { assert, strictAssert } from './util/assert'; import { assert, strictAssert } from './util/assert';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { Zone } from './util/Zone'; import { Zone } from './util/Zone';
@ -1565,6 +1565,23 @@ export class SignalProtocolStore extends EventsMixin {
return undefined; return undefined;
} }
async getFingerprint(uuid: UUID): Promise<string | undefined> {
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<void> { private async _saveIdentityKey(data: IdentityKeyType): Promise<void> {
if (!this.identityKeys) { if (!this.identityKeys) {
throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); throw new Error('_saveIdentityKey: this.identityKeys not yet cached!');
@ -1831,7 +1848,7 @@ export class SignalProtocolStore extends EventsMixin {
return false; return false;
} }
isUntrusted(uuid: UUID): boolean { isUntrusted(uuid: UUID, timestampThreshold = TIMESTAMP_THRESHOLD): boolean {
if (uuid === null || uuid === undefined) { if (uuid === null || uuid === undefined) {
throw new Error('isUntrusted: uuid was undefined/null'); throw new Error('isUntrusted: uuid was undefined/null');
} }
@ -1842,7 +1859,7 @@ export class SignalProtocolStore extends EventsMixin {
} }
if ( if (
isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && isMoreRecentThan(identityRecord.timestamp, timestampThreshold) &&
!identityRecord.nonblockingApproval && !identityRecord.nonblockingApproval &&
!identityRecord.firstUse !identityRecord.firstUse
) { ) {

View file

@ -17,6 +17,7 @@ import { isInSystemContacts } from '../util/isInSystemContacts';
export enum SafetyNumberChangeSource { export enum SafetyNumberChangeSource {
Calling = 'Calling', Calling = 'Calling',
MessageSend = 'MessageSend', MessageSend = 'MessageSend',
Story = 'Story',
} }
export type SafetyNumberProps = { export type SafetyNumberProps = {

View file

@ -22,6 +22,7 @@ import { MY_STORIES_ID, getStoryDistributionListName } from '../types/Stories';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { StoryDistributionListName } from './StoryDistributionListName'; import { StoryDistributionListName } from './StoryDistributionListName';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { isNotNil } from '../util/isNotNil';
export type PropsType = { export type PropsType = {
candidateConversations: Array<ConversationType>; candidateConversations: Array<ConversationType>;
@ -36,6 +37,7 @@ export type PropsType = {
name: string, name: string,
viewerUuids: Array<UUIDStringType> viewerUuids: Array<UUIDStringType>
) => unknown; ) => unknown;
onSelectedStoryList: (memberUuids: Array<string>) => unknown;
onSend: ( onSend: (
listIds: Array<UUIDStringType>, listIds: Array<UUIDStringType>,
conversationIds: Array<string> conversationIds: Array<string>
@ -56,6 +58,21 @@ const Page = {
type PageType = SendStoryPage | StoriesSettingsPage; type PageType = SendStoryPage | StoriesSettingsPage;
function getListMemberUuids(
list: StoryDistributionListDataType,
signalConnections: Array<ConversationType>
): Array<string> {
if (list.id === MY_STORIES_ID && list.isBlockList) {
const excludeUuids = new Set<string>(list.memberUuids);
return signalConnections
.map(conversation => conversation.uuid)
.filter(isNotNil)
.filter(uuid => !excludeUuids.has(uuid));
}
return list.memberUuids;
}
function getListViewers( function getListViewers(
list: StoryDistributionListDataType, list: StoryDistributionListDataType,
i18n: LocalizerType, i18n: LocalizerType,
@ -85,6 +102,7 @@ export const SendStoryModal = ({
onClose, onClose,
onDistributionListCreated, onDistributionListCreated,
onSend, onSend,
onSelectedStoryList,
signalConnections, signalConnections,
tagGroupsAsNewGroupStory, tagGroupsAsNewGroupStory,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
@ -300,6 +318,11 @@ export const SendStoryModal = ({
} }
return new Set([...listIds]); return new Set([...listIds]);
}); });
if (value) {
onSelectedStoryList(
getListMemberUuids(list, signalConnections)
);
}
}} }}
> >
{({ id, checkboxNode }) => ( {({ id, checkboxNode }) => (
@ -352,6 +375,10 @@ export const SendStoryModal = ({
moduleClassName="SendStoryModal__distribution-list" moduleClassName="SendStoryModal__distribution-list"
name="SendStoryModal__distribution-list" name="SendStoryModal__distribution-list"
onChange={(value: boolean) => { onChange={(value: boolean) => {
if (!group.memberships) {
return;
}
setSelectedGroupIds(groupIds => { setSelectedGroupIds(groupIds => {
if (value) { if (value) {
groupIds.add(group.id); groupIds.add(group.id);
@ -360,6 +387,9 @@ export const SendStoryModal = ({
} }
return new Set([...groupIds]); return new Set([...groupIds]);
}); });
if (value) {
onSelectedStoryList(group.memberships.map(({ uuid }) => uuid));
}
}} }}
> >
{({ id, checkboxNode }) => ( {({ id, checkboxNode }) => (

View file

@ -43,6 +43,7 @@ export type PropsType = {
name: string, name: string,
viewerUuids: Array<UUIDStringType> viewerUuids: Array<UUIDStringType>
) => unknown; ) => unknown;
onSelectedStoryList: (memberUuids: Array<string>) => unknown;
onSend: ( onSend: (
listIds: Array<UUIDStringType>, listIds: Array<UUIDStringType>,
conversationIds: Array<string>, conversationIds: Array<string>,
@ -51,6 +52,7 @@ export type PropsType = {
processAttachment: ( processAttachment: (
file: File file: File
) => Promise<void | InMemoryAttachmentDraftType>; ) => Promise<void | InMemoryAttachmentDraftType>;
sendStoryModalOpenStateChanged: (isOpen: boolean) => unknown;
signalConnections: Array<ConversationType>; signalConnections: Array<ConversationType>;
tagGroupsAsNewGroupStory: (cids: Array<string>) => unknown; tagGroupsAsNewGroupStory: (cids: Array<string>) => unknown;
} & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>; } & Pick<StickerButtonProps, 'installedPacks' | 'recentStickers'>;
@ -69,9 +71,11 @@ export const StoryCreator = ({
me, me,
onClose, onClose,
onDistributionListCreated, onDistributionListCreated,
onSelectedStoryList,
onSend, onSend,
processAttachment, processAttachment,
recentStickers, recentStickers,
sendStoryModalOpenStateChanged,
signalConnections, signalConnections,
tagGroupsAsNewGroupStory, tagGroupsAsNewGroupStory,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
@ -112,6 +116,10 @@ export const StoryCreator = ({
}; };
}, [file, processAttachment]); }, [file, processAttachment]);
useEffect(() => {
sendStoryModalOpenStateChanged(Boolean(draftAttachment));
}, [draftAttachment, sendStoryModalOpenStateChanged]);
return ( return (
<> <>
{draftAttachment && ( {draftAttachment && (
@ -125,6 +133,7 @@ export const StoryCreator = ({
me={me} me={me}
onClose={() => setDraftAttachment(undefined)} onClose={() => setDraftAttachment(undefined)}
onDistributionListCreated={onDistributionListCreated} onDistributionListCreated={onDistributionListCreated}
onSelectedStoryList={onSelectedStoryList}
onSend={(listIds, groupIds) => { onSend={(listIds, groupIds) => {
onSend(listIds, groupIds, draftAttachment); onSend(listIds, groupIds, draftAttachment);
setDraftAttachment(undefined); setDraftAttachment(undefined);

View file

@ -2822,19 +2822,22 @@ export class ConversationModel extends window.Backbone
return window.textsecure.storage.protocol.setApproval(uuid, true); return window.textsecure.storage.protocol.setApproval(uuid, true);
} }
safeIsUntrusted(): boolean { safeIsUntrusted(timestampThreshold?: number): boolean {
try { try {
const uuid = this.getUuid(); const uuid = this.getUuid();
strictAssert(uuid, `No uuid for conversation: ${this.id}`); 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) { } catch (err) {
return false; return false;
} }
} }
isUntrusted(): boolean { isUntrusted(timestampThreshold?: number): boolean {
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
return this.safeIsUntrusted(); return this.safeIsUntrusted(timestampThreshold);
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!this.contactCollection!.length) { if (!this.contactCollection!.length) {
@ -2846,13 +2849,13 @@ export class ConversationModel extends window.Backbone
if (isMe(contact.attributes)) { if (isMe(contact.attributes)) {
return false; return false;
} }
return contact.safeIsUntrusted(); return contact.safeIsUntrusted(timestampThreshold);
}); });
} }
getUntrusted(): Array<ConversationModel> { getUntrusted(timestampThreshold?: number): Array<ConversationModel> {
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
if (this.isUntrusted()) { if (this.isUntrusted(timestampThreshold)) {
return [this]; return [this];
} }
return []; return [];
@ -2863,7 +2866,7 @@ export class ConversationModel extends window.Backbone
if (isMe(contact.attributes)) { if (isMe(contact.attributes)) {
return false; return false;
} }
return contact.isUntrusted(); return contact.isUntrusted(timestampThreshold);
}) || [] }) || []
); );
} }

View file

@ -12,6 +12,7 @@ import type {
GetProfileOptionsType, GetProfileOptionsType,
GetProfileUnauthOptionsType, GetProfileUnauthOptionsType,
} from '../textsecure/WebAPI'; } from '../textsecure/WebAPI';
import type { UUID } from '../types/UUID';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
@ -359,20 +360,7 @@ async function doGetProfile(c: ConversationModel): Promise<void> {
} }
if (profile.identityKey) { if (profile.identityKey) {
const identityKey = Bytes.fromBase64(profile.identityKey); await updateIdentityKey(profile.identityKey, uuid);
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))
);
}
} }
// Update accessKey to prevent race conditions. Since we run asynchronous // Update accessKey to prevent race conditions. Since we run asynchronous
@ -655,3 +643,27 @@ async function maybeGetPNICredential(
log.info('maybeGetPNICredential: updated PNI credential'); log.info('maybeGetPNICredential: updated PNI credential');
} }
export async function updateIdentityKey(
identityKey: string,
uuid: UUID
): Promise<void> {
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))
);
}
}

View file

@ -1,10 +1,11 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { isEqual, noop, pick } from 'lodash'; import { isEqual, noop, pick } from 'lodash';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import type { BodyRangeType } from '../../types/Util'; import type { BodyRangeType } from '../../types/Util';
import type { ConversationModel } from '../../models/conversations';
import type { MessageAttributesType } from '../../model-types.d'; import type { MessageAttributesType } from '../../model-types.d';
import type { import type {
MessageChangedActionType, MessageChangedActionType,
@ -20,9 +21,12 @@ import * as log from '../../logging/log';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { DAY } from '../../util/durations'; import { DAY } from '../../util/durations';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog';
import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories'; import { StoryViewDirectionType, StoryViewModeType } from '../../types/Stories';
import { StoryRecipientUpdateEvent } from '../../textsecure/messageReceiverEvents'; import { StoryRecipientUpdateEvent } from '../../textsecure/messageReceiverEvents';
import { ToastReactionFailed } from '../../components/ToastReactionFailed'; import { ToastReactionFailed } from '../../components/ToastReactionFailed';
import { assert } from '../../util/assert';
import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUntilConversationsAreVerified';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import { markViewed } from '../../services/MessageUpdater'; import { markViewed } from '../../services/MessageUpdater';
@ -46,6 +50,7 @@ import { isStory } from '../../messages/helpers';
import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate'; import { onStoryRecipientUpdate } from '../../util/onStoryRecipientUpdate';
import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage'; import { sendStoryMessage as doSendStoryMessage } from '../../util/sendStoryMessage';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { verifyStoryListMembers as doVerifyStoryListMembers } from '../../util/verifyStoryListMembers';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue'; import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue'; import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
@ -78,12 +83,16 @@ export type SelectedStoryDataType = {
// State // State
export type StoriesStateType = { export type StoriesStateType = {
readonly isShowingStoriesView: boolean; readonly openedAtTimestamp: number | undefined;
readonly replyState?: { readonly replyState?: {
messageId: string; messageId: string;
replies: Array<MessageAttributesType>; replies: Array<MessageAttributesType>;
}; };
readonly selectedStoryData?: SelectedStoryDataType; readonly selectedStoryData?: SelectedStoryDataType;
readonly sendStoryModalData?: {
untrustedUuids: Array<string>;
verifiedUuids: Array<string>;
};
readonly stories: Array<StoryDataType>; readonly stories: Array<StoryDataType>;
readonly storyViewMode?: StoryViewModeType; readonly storyViewMode?: StoryViewModeType;
}; };
@ -91,11 +100,14 @@ export type StoriesStateType = {
// Actions // Actions
const DOE_STORY = 'stories/DOE'; const DOE_STORY = 'stories/DOE';
const LIST_MEMBERS_VERIFIED = 'stories/LIST_MEMBERS_VERIFIED';
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES'; const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const MARK_STORY_READ = 'stories/MARK_STORY_READ';
const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD'; const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
const REPLY_TO_STORY = 'stories/REPLY_TO_STORY'; const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL'; 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 STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const VIEW_STORY = 'stories/VIEW_STORY'; const VIEW_STORY = 'stories/VIEW_STORY';
@ -105,6 +117,14 @@ type DOEStoryActionType = {
payload: string; payload: string;
}; };
type ListMembersVerified = {
type: typeof LIST_MEMBERS_VERIFIED;
payload: {
untrustedUuids: Array<string>;
verifiedUuids: Array<string>;
};
};
type LoadStoryRepliesActionType = { type LoadStoryRepliesActionType = {
type: typeof LOAD_STORY_REPLIES; type: typeof LOAD_STORY_REPLIES;
payload: { payload: {
@ -136,6 +156,11 @@ type ResolveAttachmentUrlActionType = {
}; };
}; };
type SendStoryModalOpenStateChanged = {
type: typeof SEND_STORY_MODAL_OPEN_STATE_CHANGED;
payload: number | undefined;
};
type StoryChangedActionType = { type StoryChangedActionType = {
type: typeof STORY_CHANGED; type: typeof STORY_CHANGED;
payload: StoryDataType; payload: StoryDataType;
@ -157,6 +182,7 @@ type ViewStoryActionType = {
export type StoriesActionType = export type StoriesActionType =
| DOEStoryActionType | DOEStoryActionType
| ListMembersVerified
| LoadStoryRepliesActionType | LoadStoryRepliesActionType
| MarkStoryReadActionType | MarkStoryReadActionType
| MessageChangedActionType | MessageChangedActionType
@ -165,6 +191,7 @@ export type StoriesActionType =
| QueueStoryDownloadActionType | QueueStoryDownloadActionType
| ReplyToStoryActionType | ReplyToStoryActionType
| ResolveAttachmentUrlActionType | ResolveAttachmentUrlActionType
| SendStoryModalOpenStateChanged
| StoryChangedActionType | StoryChangedActionType
| ToggleViewActionType | ToggleViewActionType
| ViewStoryActionType; | ViewStoryActionType;
@ -580,14 +607,52 @@ function sendStoryMessage(
listIds: Array<UUIDStringType>, listIds: Array<UUIDStringType>,
conversationIds: Array<string>, conversationIds: Array<string>,
attachment: AttachmentType attachment: AttachmentType
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, SendStoryModalOpenStateChanged> {
return async dispatch => { return async (dispatch, getState) => {
await doSendStoryMessage(listIds, conversationIds, attachment); 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({ dispatch({
type: 'NOOP', type: SEND_STORY_MODAL_OPEN_STATE_CHANGED,
payload: null, payload: undefined,
}); });
if (sendStoryModalData.untrustedUuids.length) {
log.info('sendStoryMessage: SN changed for some conversations');
const conversationsNeedingVerification: Array<ConversationModel> =
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<void, RootStateType, unknown, SendStoryModalOpenStateChanged> {
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 { function toggleStoriesView(): ToggleViewActionType {
return { return {
type: TOGGLE_VIEW, type: TOGGLE_VIEW,
}; };
} }
function verifyStoryListMembers(
memberUuids: Array<string>
): ThunkAction<void, RootStateType, unknown, ListMembersVerified> {
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 = ( const getSelectedStoryDataForConversationId = (
dispatch: ThunkDispatch< dispatch: ThunkDispatch<
RootStateType, RootStateType,
@ -946,8 +1068,10 @@ export const actions = {
reactToStory, reactToStory,
replyToStory, replyToStory,
sendStoryMessage, sendStoryMessage,
sendStoryModalOpenStateChanged,
storyChanged, storyChanged,
toggleStoriesView, toggleStoriesView,
verifyStoryListMembers,
viewUserStories, viewUserStories,
viewStory, viewStory,
}; };
@ -960,7 +1084,7 @@ export function getEmptyState(
overrideState: Partial<StoriesStateType> = {} overrideState: Partial<StoriesStateType> = {}
): StoriesStateType { ): StoriesStateType {
return { return {
isShowingStoriesView: false, openedAtTimestamp: undefined,
stories: [], stories: [],
...overrideState, ...overrideState,
}; };
@ -971,15 +1095,17 @@ export function reducer(
action: Readonly<StoriesActionType> action: Readonly<StoriesActionType>
): StoriesStateType { ): StoriesStateType {
if (action.type === TOGGLE_VIEW) { if (action.type === TOGGLE_VIEW) {
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
return { return {
...state, ...state,
isShowingStoriesView: !state.isShowingStoriesView, openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
selectedStoryData: state.isShowingStoriesView replyState: undefined,
sendStoryModalData: undefined,
selectedStoryData: isShowingStoriesView
? undefined ? undefined
: state.selectedStoryData, : state.selectedStoryData,
storyViewMode: state.isShowingStoriesView storyViewMode: isShowingStoriesView ? undefined : state.storyViewMode,
? 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; return state;
} }

View file

@ -37,7 +37,7 @@ export const getStoriesState = (state: StateType): StoriesStateType =>
export const shouldShowStoriesView = createSelector( export const shouldShowStoriesView = createSelector(
getStoriesState, getStoriesState,
({ isShowingStoriesView }): boolean => isShowingStoriesView ({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp)
); );
export const hasSelectedStoryData = createSelector( export const hasSelectedStoryData = createSelector(

View file

@ -13,7 +13,7 @@ import { getMe } from '../selectors/conversations';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { getPreferredLeftPaneWidth } from '../selectors/items'; import { getPreferredLeftPaneWidth } from '../selectors/items';
import { getStories } from '../selectors/stories'; import { getStories, shouldShowStoriesView } from '../selectors/stories';
import { saveAttachment } from '../../util/saveAttachment'; import { saveAttachment } from '../../util/saveAttachment';
import { useConversationsActions } from '../ducks/conversations'; import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
@ -37,7 +37,7 @@ export function SmartStories(): JSX.Element | null {
const i18n = useSelector<StateType, LocalizerType>(getIntl); const i18n = useSelector<StateType, LocalizerType>(getIntl);
const isShowingStoriesView = useSelector<StateType, boolean>( const isShowingStoriesView = useSelector<StateType, boolean>(
(state: StateType) => state.stories.isShowingStoriesView shouldShowStoriesView
); );
const preferredWidthFromStorage = useSelector<StateType, number>( const preferredWidthFromStorage = useSelector<StateType, number>(

View file

@ -39,7 +39,11 @@ export function SmartStoryCreator({
onClose, onClose,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions(); const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions();
const { sendStoryMessage } = useStoriesActions(); const {
sendStoryModalOpenStateChanged,
sendStoryMessage,
verifyStoryListMembers,
} = useStoriesActions();
const { tagGroupsAsNewGroupStory } = useConversationsActions(); const { tagGroupsAsNewGroupStory } = useConversationsActions();
const { createDistributionList } = useStoryDistributionListsActions(); const { createDistributionList } = useStoryDistributionListsActions();
@ -70,9 +74,11 @@ export function SmartStoryCreator({
me={me} me={me}
onClose={onClose} onClose={onClose}
onDistributionListCreated={createDistributionList} onDistributionListCreated={createDistributionList}
onSelectedStoryList={verifyStoryListMembers}
onSend={sendStoryMessage} onSend={sendStoryMessage}
processAttachment={processAttachment} processAttachment={processAttachment}
recentStickers={recentStickers} recentStickers={recentStickers}
sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged}
signalConnections={signalConnections} signalConnections={signalConnections}
tagGroupsAsNewGroupStory={tagGroupsAsNewGroupStory} tagGroupsAsNewGroupStory={tagGroupsAsNewGroupStory}
/> />

View file

@ -486,6 +486,7 @@ const URL_CALLS = {
accountExistence: 'v1/accounts/account', accountExistence: 'v1/accounts/account',
attachmentId: 'v2/attachments/form/upload', attachmentId: 'v2/attachments/form/upload',
attestation: 'v1/attestation', attestation: 'v1/attestation',
batchIdentityCheck: 'v1/profile/identity_check/batch',
boostBadges: 'v1/subscription/boost/badges', boostBadges: 'v1/subscription/boost/badges',
challenge: 'v1/challenge', challenge: 'v1/challenge',
config: 'v1/config', config: 'v1/config',
@ -782,6 +783,20 @@ export type GetGroupCredentialsResultType = Readonly<{
credentials: ReadonlyArray<GroupCredentialType>; credentials: ReadonlyArray<GroupCredentialType>;
}>; }>;
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<typeof verifyAciResponse>;
export type WebAPIType = { export type WebAPIType = {
startRegistration(): unknown; startRegistration(): unknown;
finishRegistration(baton: unknown): void; finishRegistration(baton: unknown): void;
@ -878,6 +893,9 @@ export type WebAPIType = {
inviteLinkBase64?: string inviteLinkBase64?: string
) => Promise<Proto.IGroupChange>; ) => Promise<Proto.IGroupChange>;
modifyStorageRecords: MessageSender['modifyStorageRecords']; modifyStorageRecords: MessageSender['modifyStorageRecords'];
postBatchIdentityCheck: (
elements: VerifyAciRequestType
) => Promise<VerifyAciResponseType>;
putAttachment: (encryptedBin: Uint8Array) => Promise<string>; putAttachment: (encryptedBin: Uint8Array) => Promise<string>;
putProfile: ( putProfile: (
jsonData: ProfileRequestDataType jsonData: ProfileRequestDataType
@ -1272,6 +1290,7 @@ export function initialize({
makeSfuRequest, makeSfuRequest,
modifyGroup, modifyGroup,
modifyStorageRecords, modifyStorageRecords,
postBatchIdentityCheck,
putAttachment, putAttachment,
putProfile, putProfile,
putStickers, 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( function getProfileUrl(
identifier: string, identifier: string,
{ {

View file

@ -9,7 +9,8 @@ import { getConversationIdForLogging } from './idForLogging';
export async function blockSendUntilConversationsAreVerified( export async function blockSendUntilConversationsAreVerified(
conversations: Array<ConversationModel>, conversations: Array<ConversationModel>,
source?: SafetyNumberChangeSource source?: SafetyNumberChangeSource,
timestampThreshold?: number
): Promise<boolean> { ): Promise<boolean> {
const conversationsToPause = new Map<string, Set<string>>(); const conversationsToPause = new Map<string, Set<string>>();
@ -33,7 +34,7 @@ export async function blockSendUntilConversationsAreVerified(
}); });
} }
const untrusted = conversation.getUntrusted(); const untrusted = conversation.getUntrusted(timestampThreshold);
if (untrusted.length) { if (untrusted.length) {
untrusted.forEach(untrustedConversation => { untrusted.forEach(untrustedConversation => {
const uuid = untrustedConversation.get('uuid'); const uuid = untrustedConversation.get('uuid');

View file

@ -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<string>
): Promise<{ untrustedUuids: Set<string>; verifiedUuids: Set<string> }> {
const { server } = window.textsecure;
if (!server) {
throw new Error('verifyStoryListMembers: server not available');
}
const verifiedUuids = new Set<string>();
const untrustedUuids = new Set<string>();
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 };
}