diff --git a/package.json b/package.json index 9603024728d..1780df67530 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "@babel/preset-typescript": "7.17.12", "@electron/fuses": "1.5.0", "@mixer/parallel-prettier": "2.0.1", - "@signalapp/mock-server": "2.10.0", + "@signalapp/mock-server": "2.11.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 81adba4cb8a..b7b9f4bbbc4 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -35,7 +35,8 @@ message Envelope { optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline optional bool urgent = 14 [default=true]; // indicates that the content is considered timely by the sender; defaults to true so senders have to opt-out to say something isn't time critical optional string updated_pni = 15; - // next: 16 + optional bool story = 16; // indicates that the content is a story. + // next: 17 } message Content { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 68bc3325b64..b5b9c5fb9f2 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -131,32 +131,33 @@ message AccountRecord { } } - optional bytes profileKey = 1; - optional string givenName = 2; - optional string familyName = 3; - optional string avatarUrl = 4; - optional bool noteToSelfArchived = 5; - optional bool readReceipts = 6; - optional bool sealedSenderIndicators = 7; - optional bool typingIndicators = 8; - optional bool proxiedLinkPreviews = 9; - optional bool noteToSelfMarkedUnread = 10; - optional bool linkPreviews = 11; - optional PhoneNumberSharingMode phoneNumberSharingMode = 12; - optional bool notDiscoverableByPhoneNumber = 13; - repeated PinnedConversation pinnedConversations = 14; - optional bool preferContactAvatars = 15; - optional uint32 universalExpireTimer = 17; - optional bool primarySendsSms = 18; - optional string e164 = 19; - repeated string preferredReactionEmoji = 20; - optional bytes subscriberId = 21; - optional string subscriberCurrencyCode = 22; - optional bool displayBadgesOnProfile = 23; - optional bool keepMutedChatsArchived = 25; - optional bool hasSetMyStoriesPrivacy = 26; - reserved /* hasViewedOnboardingStory */ 27; - optional bool storiesDisabled = 28; + optional bytes profileKey = 1; + optional string givenName = 2; + optional string familyName = 3; + optional string avatarUrl = 4; + optional bool noteToSelfArchived = 5; + optional bool readReceipts = 6; + optional bool sealedSenderIndicators = 7; + optional bool typingIndicators = 8; + optional bool proxiedLinkPreviews = 9; + optional bool noteToSelfMarkedUnread = 10; + optional bool linkPreviews = 11; + optional PhoneNumberSharingMode phoneNumberSharingMode = 12; + optional bool notDiscoverableByPhoneNumber = 13; + repeated PinnedConversation pinnedConversations = 14; + optional bool preferContactAvatars = 15; + optional uint32 universalExpireTimer = 17; + optional bool primarySendsSms = 18; + optional string e164 = 19; + repeated string preferredReactionEmoji = 20; + optional bytes subscriberId = 21; + optional string subscriberCurrencyCode = 22; + optional bool displayBadgesOnProfile = 23; + optional bool keepMutedChatsArchived = 25; + optional bool hasSetMyStoriesPrivacy = 26; + reserved /* hasViewedOnboardingStory */ 27; + reserved 28; // deprecatedStoriesDisabled + optional bool storiesDisabled = 29; } message StoryDistributionListRecord { diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 84531c168a0..4d8e77a2295 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -183,6 +183,7 @@ export async function sendNormalMessage( quote, recipients: allRecipientIdentifiers, sticker, + // No storyContext; you can't reply to your own stories timestamp: messageTimestamp, }); messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors); @@ -234,6 +235,7 @@ export async function sendNormalMessage( sendOptions, sendTarget: conversation.toSenderKeyTarget(), sendType: 'message', + story: Boolean(storyContext), urgent: true, }) ); @@ -282,6 +284,7 @@ export async function sendNormalMessage( sticker, storyContext, timestamp: messageTimestamp, + // Note: 1:1 story replies should not set story=true - they aren't group sends urgent: true, includePniSignatureMessage: true, }); diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 361f7da8999..98512ec543a 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -264,7 +264,8 @@ export async function sendStory( const recipientsSet = new Set(pendingSendRecipientIds); const sendOptions = await getSendOptionsForRecipients( - pendingSendRecipientIds + pendingSendRecipientIds, + { story: true } ); log.info( diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index fc84dcc8365..24a839a102e 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -179,11 +179,13 @@ export class Reactions extends Collection { storyReactionEmoji: reaction.get('emoji'), }); - const [generatedMessageId] = await Promise.all([ + // Note: generatedMessage comes with an id, so we have to force this save + await Promise.all([ window.Signal.Data.saveMessage(generatedMessage.attributes, { ourUuid: window.textsecure.storage.user .getCheckedUuid() .toString(), + forceSave: true, }), generatedMessage.hydrateStoryContext(message), ]); @@ -197,10 +199,8 @@ export class Reactions extends Collection { timestamp: reaction.get('timestamp'), }); - generatedMessage.set({ id: generatedMessageId }); - const messageToAdd = window.MessageController.register( - generatedMessageId, + generatedMessage.id, generatedMessage ); targetConversation.addSingleMessage(messageToAdd); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index ea00de93cd8..68720309ccc 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -159,7 +159,6 @@ import { getMessageIdForLogging } from '../util/idForLogging'; import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { findStoryMessage } from '../util/findStoryMessage'; -import { isConversationAccepted } from '../util/isConversationAccepted'; import { getStoryDataFromMessageAttributes } from '../services/storyLoader'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import { getMessageById } from '../messages/getMessageById'; @@ -2097,20 +2096,6 @@ export class MessageModel extends window.Backbone.Model { await conversation.queueJob('handleDataMessage', async () => { log.info(`${idLog}: starting processing in queue`); - if ( - isStory(message.attributes) && - !isConversationAccepted(conversation.attributes, { - ignoreEmptyConvo: true, - }) - ) { - log.info( - `${idLog}: dropping story from !accepted`, - this.getSenderIdentifier() - ); - confirm(); - return; - } - // First, check for duplicates. If we find one, stop processing here. const inMemoryMessage = window.MessageController.findBySender( this.getSenderIdentifier() @@ -2387,8 +2372,8 @@ export class MessageModel extends window.Backbone.Model { const messageId = message.get('id') || UUID.generate().toString(); - // Send delivery receipts, but only for incoming sealed sender messages - // and not for messages from unaccepted conversations + // Send delivery receipts, but only for non-story sealed sender messages + // and not for messages from unaccepted conversations if ( type === 'incoming' && this.get('unidentifiedDeliveryReceived') && diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 965926e4d35..1fa00808062 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -265,6 +265,7 @@ export type UnprocessedType = { serverTimestamp?: number; decrypted?: string; urgent?: boolean; + story?: boolean; }; export type UnprocessedUpdateType = { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 3c12e86f219..0fb158a7815 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3178,6 +3178,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { serverTimestamp, decrypted, urgent, + story, } = data; if (!id) { throw new Error('saveUnprocessedSync: id was falsey'); @@ -3204,7 +3205,8 @@ function saveUnprocessedSync(data: UnprocessedType): string { serverGuid, serverTimestamp, decrypted, - urgent + urgent, + story ) values ( $id, $timestamp, @@ -3218,7 +3220,8 @@ function saveUnprocessedSync(data: UnprocessedType): string { $serverGuid, $serverTimestamp, $decrypted, - $urgent + $urgent, + $story ); ` ).run({ @@ -3235,6 +3238,7 @@ function saveUnprocessedSync(data: UnprocessedType): string { serverTimestamp: serverTimestamp || null, decrypted: decrypted || null, urgent: urgent || !isBoolean(urgent) ? 1 : 0, + story: story ? 1 : 0, }); return id; @@ -3309,6 +3313,7 @@ async function getUnprocessedById( return { ...row, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, + story: Boolean(row.story), }; } @@ -3370,6 +3375,7 @@ async function getAllUnprocessedAndIncrementAttempts(): Promise< .map(row => ({ ...row, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, + story: Boolean(row.story), })); })(); } diff --git a/ts/sql/migrations/67-add-story-to-unprocessed.ts b/ts/sql/migrations/67-add-story-to-unprocessed.ts new file mode 100644 index 00000000000..cd85bfa00f9 --- /dev/null +++ b/ts/sql/migrations/67-add-story-to-unprocessed.ts @@ -0,0 +1,28 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion67( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 67) { + return; + } + + db.transaction(() => { + db.exec( + ` + ALTER TABLE unprocessed ADD COLUMN story INTEGER; + ` + ); + + db.pragma('user_version = 67'); + })(); + + logger.info('updateToSchemaVersion67: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 38546aa13d3..da78f12e056 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -42,6 +42,7 @@ import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed'; import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys'; import updateToSchemaVersion65 from './65-add-storage-id-to-stickers'; import updateToSchemaVersion66 from './66-add-pni-signature-to-sent-protos'; +import updateToSchemaVersion67 from './67-add-story-to-unprocessed'; function updateToSchemaVersion1( currentVersion: number, @@ -1947,6 +1948,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion64, updateToSchemaVersion65, updateToSchemaVersion66, + updateToSchemaVersion67, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/test-mock/rate-limit/story_test.ts b/ts/test-mock/rate-limit/story_test.ts index f046ab5b13c..e8874c4aa72 100644 --- a/ts/test-mock/rate-limit/story_test.ts +++ b/ts/test-mock/rate-limit/story_test.ts @@ -15,7 +15,7 @@ export const debug = createDebug('mock:test:rate-limit'); const IdentifierType = Proto.ManifestRecord.Identifier.Type; -describe('rate-limit/story', function needsName() { +describe('story/no-sender-key', function needsName() { this.timeout(durations.MINUTE); let bootstrap: Bootstrap; @@ -65,7 +65,7 @@ describe('rate-limit/story', function needsName() { await bootstrap.teardown(); }); - it('should request challenge and accept solution', async () => { + it('should successfully send story', async () => { const { server, contactsWithoutProfileKey: contacts, @@ -115,29 +115,6 @@ describe('rate-limit/story', function needsName() { await window.locator('button.SendStoryModal__send').click(); } - debug('Waiting for challenge'); - const request = await app.waitForChallenge(); - - debug('Checking for presence of captcha modal'); - await window - .locator('.module-Modal__title >> "Verify to continue messaging"') - .waitFor(); - - debug('Removing rate-limiting'); - for (const contact of contacts) { - const failedMessages = server.stopRateLimiting({ - source: desktop.uuid, - target: contact.device.uuid, - }); - assert.isAtMost(failedMessages ?? 0, 1); - } - - debug('Solving challenge'); - app.solveChallenge({ - seq: request.seq, - data: { captcha: 'anything' }, - }); - debug('Verifying that all contacts received story'); await Promise.all( contacts.map(async contact => { diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 50dd9af3613..e58084e6d99 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -52,7 +52,6 @@ import { QualifiedAddress } from '../types/QualifiedAddress'; import type { UUIDStringType } from '../types/UUID'; import { UUID, UUIDKind } from '../types/UUID'; import * as Errors from '../types/errors'; -import { isEnabled } from '../RemoteConfig'; import { SignalService as Proto } from '../protobuf'; import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; @@ -115,6 +114,8 @@ import { areArraysMatchingSets } from '../util/areArraysMatchingSets'; import { generateBlurHash } from '../util/generateBlurHash'; import { TEXT_ATTACHMENT } from '../types/MIME'; import type { SendTypesType } from '../util/handleMessageSend'; +import { isConversationAccepted } from '../util/isConversationAccepted'; +import { getStoriesBlocked } from '../types/Stories'; const GROUPV1_ID_LENGTH = 16; const GROUPV2_ID_LENGTH = 32; @@ -394,6 +395,7 @@ export default class MessageReceiver serverGuid: decoded.serverGuid, serverTimestamp, urgent: isBoolean(decoded.urgent) ? decoded.urgent : true, + story: decoded.story, }; // After this point, decoding errors are not the server's @@ -777,6 +779,7 @@ export default class MessageReceiver serverTimestamp: item.serverTimestamp || decoded.serverTimestamp?.toNumber(), urgent: isBoolean(item.urgent) ? item.urgent : true, + story: Boolean(item.story), }; const { decrypted } = item; @@ -1043,6 +1046,7 @@ export default class MessageReceiver receivedAtCounter: envelope.receivedAtCounter, timestamp: envelope.timestamp, urgent: envelope.urgent, + story: envelope.story, }; this.decryptAndCacheBatcher.add({ request, @@ -1271,10 +1275,10 @@ export default class MessageReceiver envelope: UnsealedEnvelope, uuidKind: UUIDKind ): Promise { - const logId = getEnvelopeId(envelope); + const logId = `MessageReceiver.decryptEnvelope(${getEnvelopeId(envelope)})`; if (this.stoppingProcessing) { - log.warn(`MessageReceiver.decryptEnvelope(${logId}): dropping unsealed`); + log.warn(`${logId}: dropping unsealed`); throw new Error('Unsealed envelope dropped due to stopping processing'); } @@ -1298,7 +1302,7 @@ export default class MessageReceiver ); } - log.info(`MessageReceiver.decryptEnvelope(${logId})`); + log.info(logId); const plaintext = await this.decrypt( stores, envelope, @@ -1307,7 +1311,7 @@ export default class MessageReceiver ); if (!plaintext) { - log.warn('MessageReceiver.decryptEnvelope: plaintext was falsey'); + log.warn(`${logId}: plaintext was falsey`); return { plaintext, envelope }; } @@ -1331,6 +1335,53 @@ export default class MessageReceiver envelope, content.senderKeyDistributionMessage ); + } else { + // Note: `story = true` can be set for sender key distribution messages + + const isStoryReply = Boolean(content.dataMessage?.storyContext); + const isGroupStoryReply = Boolean( + isStoryReply && content.dataMessage?.groupV2 + ); + const isStory = Boolean(content.storyMessage); + const isGroupStorySend = isGroupStoryReply || isStory; + const isDeleteForEveryone = Boolean(content.dataMessage?.delete); + + if (envelope.story && !isGroupStorySend && !isDeleteForEveryone) { + log.warn( + `${logId}: Dropping story message - story=true on envelope, but message was not a group story send or delete` + ); + this.removeFromCache(envelope); + return { plaintext: undefined, envelope }; + } + + if (!envelope.story && isGroupStorySend) { + log.warn( + `${logId}: Malformed story - story=false on envelope, but was a group story send` + ); + } + + const areStoriesBlocked = getStoriesBlocked(); + // Note that there are other story-related message types which aren't captured + // here. Look for other calls to getStoriesBlocked down-file. + if (areStoriesBlocked && (isStoryReply || isStory)) { + log.warn( + `${logId}: Dropping story message - stories are disabled or unavailable` + ); + this.removeFromCache(envelope); + return { plaintext: undefined, envelope }; + } + + const sender = window.ConversationController.get( + envelope.sourceUuid || envelope.source + ); + if ( + (!sender || !isConversationAccepted(sender.attributes)) && + (isStoryReply || isStory) + ) { + log.warn(`${logId}: Dropping story message - !accepted for sender`); + this.removeFromCache(envelope); + return { plaintext: undefined, envelope }; + } } if (content.pniSignatureMessage) { @@ -1359,8 +1410,7 @@ export default class MessageReceiver inProgressMessageType = ''; } catch (error) { log.error( - 'MessageReceiver.decryptEnvelope: ' + - `Failed to process ${inProgressMessageType} ` + + `${logId}: Failed to process ${inProgressMessageType} ` + `message: ${Errors.toLogFormat(error)}` ); } @@ -1371,9 +1421,7 @@ export default class MessageReceiver ((envelope.source && this.isBlocked(envelope.source)) || (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid))) ) { - log.info( - 'MessageReceiver.decryptEnvelope: Dropping non-GV2 message from blocked sender' - ); + log.info(`${logId}: Dropping non-GV2 message from blocked sender`); return { plaintext: undefined, envelope }; } @@ -1900,16 +1948,19 @@ export default class MessageReceiver sentMessage?: ProcessedSent ): Promise { const logId = getEnvelopeId(envelope); - log.info('MessageReceiver.handleStoryMessage', logId); - const attachments: Array = []; + logUnexpectedUrgentValue(envelope, 'story'); - if (window.Events.getHasStoriesDisabled()) { + if (getStoriesBlocked()) { log.info('MessageReceiver.handleStoryMessage: dropping', logId); this.removeFromCache(envelope); return; } + log.info('MessageReceiver.handleStoryMessage', logId); + + const attachments: Array = []; + if (msg.fileAttachment) { const attachment = processAttachment(msg.fileAttachment); attachments.push(attachment); @@ -2076,16 +2127,12 @@ export default class MessageReceiver const logId = getEnvelopeId(envelope); log.info('MessageReceiver.handleDataMessage', logId); - const isStoriesEnabled = - isEnabled('desktop.stories') || isEnabled('desktop.internalUser'); - if (!isStoriesEnabled && msg.storyContext) { - logUnexpectedUrgentValue(envelope, 'story'); - + if (getStoriesBlocked() && msg.storyContext) { log.info( `MessageReceiver.handleDataMessage/${logId}: Dropping incoming dataMessage with storyContext field` ); this.removeFromCache(envelope); - return undefined; + return; } let p: Promise = Promise.resolve(); @@ -2129,9 +2176,7 @@ export default class MessageReceiver let type: SendTypesType = 'message'; - if (msg.storyContext) { - type = 'story'; - } else if (msg.body) { + if (msg.storyContext || msg.body) { type = 'message'; } else if (msg.reaction) { type = 'reaction'; @@ -2294,19 +2339,8 @@ export default class MessageReceiver return; } - const isStoriesEnabled = - isEnabled('desktop.stories') || isEnabled('desktop.internalUser'); if (content.storyMessage) { - if (isStoriesEnabled) { - await this.handleStoryMessage(envelope, content.storyMessage); - return; - } - - const logId = getEnvelopeId(envelope); - log.info( - `innerHandleContentMessage/${logId}: Dropping incoming message with storyMessage field` - ); - this.removeFromCache(envelope); + await this.handleStoryMessage(envelope, content.storyMessage); return; } @@ -2689,16 +2723,17 @@ export default class MessageReceiver const sentMessage = syncMessage.sent; if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) { - if (window.Events.getHasStoriesDisabled()) { + if (getStoriesBlocked()) { log.info( - 'MessageReceiver.handleSyncMessage: dropping story recipients update' + 'MessageReceiver.handleSyncMessage: dropping story recipients update', + getEnvelopeId(envelope) ); this.removeFromCache(envelope); return; } log.info( - 'MessageReceiver.handleSyncMessage: handling storyMessageRecipients isRecipientUpdate sync message', + 'MessageReceiver.handleSyncMessage: handling story recipients update', getEnvelopeId(envelope) ); const ev = new StoryRecipientUpdateEvent( diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 34565833130..f475b2176e7 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1255,6 +1255,7 @@ export default class MessageSender { groupId, options, urgent, + story, }: Readonly<{ timestamp: number; recipients: Array; @@ -1263,6 +1264,7 @@ export default class MessageSender { groupId: string | undefined; options?: SendOptionsType; urgent: boolean; + story?: boolean; }>): Promise { return new Promise((resolve, reject) => { const callback = (result: CallbackResultType) => { @@ -1282,6 +1284,7 @@ export default class MessageSender { recipients, timestamp, urgent, + story, }); }); } diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 066edb1e68b..c62171937c2 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -94,6 +94,7 @@ export type ProcessedEnvelope = Readonly<{ serverTimestamp: number; groupId?: string; urgent?: boolean; + story?: boolean; }>; export type ProcessedAttachment = { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 109da0b61c7..daec1cf1318 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -942,6 +942,7 @@ export type WebAPIType = { timestamp: number, options: { online?: boolean; + story?: boolean; urgent?: boolean; } ) => Promise; @@ -2122,14 +2123,13 @@ export function initialize({ messages, timestamp, online: Boolean(online), - story, urgent, }; await _ajax({ call: 'messages', httpType: 'PUT', - urlParameters: `/${destination}`, + urlParameters: `/${destination}?story=${booleanToString(story)}`, jsonData, responseType: 'json', unauthenticated: true, @@ -2151,14 +2151,13 @@ export function initialize({ messages, timestamp, online: Boolean(online), - story, urgent, }; await _ajax({ call: 'messages', httpType: 'PUT', - urlParameters: `/${destination}`, + urlParameters: `/${destination}?story=${booleanToString(story)}`, jsonData, responseType: 'json', }); @@ -2175,20 +2174,23 @@ export function initialize({ { online, urgent = true, + story = false, }: { online?: boolean; + story?: boolean; urgent?: boolean; } ): Promise { const onlineParam = `&online=${booleanToString(online)}`; const urgentParam = `&urgent=${booleanToString(urgent)}`; + const storyParam = `&story=${booleanToString(story)}`; const response = await _ajax({ call: 'multiRecipient', httpType: 'PUT', contentType: 'application/vnd.signal-messenger.mrm', data, - urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}`, + urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`, responseType: 'json', unauthenticated: true, accessKey: Bytes.toBase64(accessKeys), diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index 42be8112f0d..fef9a7fdf22 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -9,6 +9,7 @@ import type { ReadStatus } from '../messages/MessageReadStatus'; import type { SendStatus } from '../messages/MessageSendState'; import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; import type { UUIDStringType } from './UUID'; +import { isEnabled } from '../RemoteConfig'; export type ReplyType = { author: Pick< @@ -142,3 +143,9 @@ export enum HasStories { Read = 'Read', Unread = 'Unread', } + +const getStoriesAvailable = () => + isEnabled('desktop.stories') || isEnabled('desktop.internalUser'); +const getStoriesDisabled = () => window.Events.getHasStoriesDisabled(); +export const getStoriesBlocked = (): boolean => + !getStoriesAvailable() || getStoriesDisabled(); diff --git a/ts/util/getSendOptions.ts b/ts/util/getSendOptions.ts index 5f0e1d667c2..3b6996ebcf0 100644 --- a/ts/util/getSendOptions.ts +++ b/ts/util/getSendOptions.ts @@ -7,7 +7,7 @@ import type { SendOptionsType, } from '../textsecure/SendMessage'; import * as Bytes from '../Bytes'; -import { getRandomBytes } from '../Crypto'; +import { getRandomBytes, getZeroes } from '../Crypto'; import { getConversationMembers } from './getConversationMembers'; import { isDirectConversation, isMe } from './whatTypeOfConversation'; import { senderCertificateService } from '../services/senderCertificate'; @@ -24,14 +24,17 @@ const SEALED_SENDER = { }; export async function getSendOptionsForRecipients( - recipients: ReadonlyArray + recipients: ReadonlyArray, + options?: { story?: boolean } ): Promise { const conversations = recipients .map(identifier => window.ConversationController.get(identifier)) .filter(isNotNil); const metadataList = await Promise.all( - conversations.map(conversation => getSendOptions(conversation.attributes)) + conversations.map(conversation => + getSendOptions(conversation.attributes, options) + ) ); return metadataList.reduce( @@ -58,9 +61,9 @@ export async function getSendOptionsForRecipients( export async function getSendOptions( conversationAttrs: ConversationAttributesType, - options: { syncMessage?: boolean } = {} + options: { syncMessage?: boolean; story?: boolean } = {} ): Promise { - const { syncMessage } = options; + const { syncMessage, story } = options; if (!isDirectConversation(conversationAttrs)) { const contactCollection = getConversationMembers(conversationAttrs); @@ -97,9 +100,13 @@ export async function getSendOptions( ); // If we've never fetched user's profile, we default to what we have - if (sealedSender === SEALED_SENDER.UNKNOWN) { + if (sealedSender === SEALED_SENDER.UNKNOWN || story) { const identifierData = { - accessKey: accessKey || Bytes.toBase64(getRandomBytes(16)), + accessKey: + accessKey || + (story + ? Bytes.toBase64(getZeroes(16)) + : Bytes.toBase64(getRandomBytes(16))), senderCertificate, }; return { diff --git a/ts/util/handleRetry.ts b/ts/util/handleRetry.ts index cd295de9000..42b81542a0d 100644 --- a/ts/util/handleRetry.ts +++ b/ts/util/handleRetry.ts @@ -38,6 +38,7 @@ import type { import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import MessageSender from '../textsecure/SendMessage'; +import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; const RETRY_LIMIT = 5; @@ -138,7 +139,8 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise { const { contentHint, messageIds, proto, timestamp, urgent } = sentProto; - const { contentProto, groupId } = await maybeAddSenderKeyDistributionMessage({ + // Only applies to sender key sends in groups. See below for story distribution lists. + const addSenderKeyResult = await maybeAddSenderKeyDistributionMessage({ contentProto: Proto.Content.decode(proto), logId, messageIds, @@ -146,44 +148,35 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise { requesterUuid, timestamp, }); + // eslint-disable-next-line prefer-destructuring + let contentProto: Proto.IContent | undefined = + addSenderKeyResult.contentProto; + const { groupId } = addSenderKeyResult; - // Assert that the requesting UUID is still part of a distribution list that - // the message was sent to. - if (contentProto.storyMessage) { - const { storyDistributionLists } = window.reduxStore.getState(); - const membersByListId = new Map>(); - storyDistributionLists.distributionLists.forEach(list => { - membersByListId.set(list.id, new Set(list.memberUuids)); + // Assert that the requesting UUID is still part of a story distribution list that + // the message was sent to, and add its sender key distribution message (SKDM). + if (contentProto.storyMessage && !groupId) { + contentProto = await checkDistributionListAndAddSKDM({ + confirm, + contentProto, + logId, + messaging, + requesterUuid, + timestamp, }); - - const messages = await dataInterface.getMessagesBySentAt(timestamp); - const isInDistributionList = messages.some(message => { - if (!message.storyDistributionListId) { - return false; - } - - const members = membersByListId.get(message.storyDistributionListId); - if (!members) { - return false; - } - - return members.has(requesterUuid); - }); - - if (!isInDistributionList) { - log.warn( - `onRetryRequest/${logId}: requesterUuid is not in distribution list` - ); - confirm(); + if (!contentProto) { return; } } + const story = Boolean(contentProto.storyMessage); const recipientConversation = window.ConversationController.getOrCreate( requesterUuid, 'private' ); - const sendOptions = await getSendOptions(recipientConversation.attributes); + const sendOptions = await getSendOptions(recipientConversation.attributes, { + story, + }); const promise = messaging.sendMessageProtoAndWait({ contentHint, groupId, @@ -192,6 +185,7 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise { recipients: [requesterUuid], timestamp, urgent, + story, }); await handleMessageSend(promise, { @@ -427,6 +421,88 @@ async function getRetryConversation({ return window.ConversationController.get(conversationId); } +async function checkDistributionListAndAddSKDM({ + contentProto, + timestamp, + confirm, + logId, + requesterUuid, + messaging, +}: { + contentProto: Proto.IContent; + timestamp: number; + confirm: () => void; + requesterUuid: string; + logId: string; + messaging: MessageSender; +}): Promise { + let distributionList: StoryDistributionListDataType | undefined; + const { storyDistributionLists } = window.reduxStore.getState(); + const membersByListId = new Map>(); + const listsById = new Map(); + storyDistributionLists.distributionLists.forEach(list => { + membersByListId.set(list.id, new Set(list.memberUuids)); + listsById.set(list.id, list); + }); + + const messages = await dataInterface.getMessagesBySentAt(timestamp); + const isInAnyDistributionList = messages.some(message => { + const listId = message.storyDistributionListId; + if (!listId) { + return false; + } + + const members = membersByListId.get(listId); + if (!members) { + return false; + } + + const isInList = members.has(requesterUuid); + + if (isInList) { + distributionList = listsById.get(listId); + } + + return isInList; + }); + + if (!isInAnyDistributionList) { + log.warn( + `checkDistributionListAndAddSKDM/${logId}: requesterUuid is not in distribution list. Dropping.` + ); + confirm(); + return undefined; + } + + strictAssert( + distributionList, + `checkDistributionListAndAddSKDM/${logId}: Should have a distribution list by this point` + ); + const distributionDetails = + await window.Signal.Data.getStoryDistributionWithMembers( + distributionList.id + ); + const distributionId = distributionDetails?.senderKeyInfo?.distributionId; + if (!distributionId) { + log.warn( + `onRetryRequest/${logId}: No sender key info for distribution list ${distributionList.id}` + ); + return contentProto; + } + + const protoWithDistributionMessage = + await messaging.getSenderKeyDistributionMessage(distributionId, { + throwIfNotInDatabase: true, + timestamp, + }); + + return { + ...contentProto, + senderKeyDistributionMessage: + protoWithDistributionMessage.senderKeyDistributionMessage, + }; +} + async function maybeAddSenderKeyDistributionMessage({ contentProto, logId, diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 355fcfbac65..0d302c2c4aa 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -8,7 +8,7 @@ import type { UUIDStringType } from '../types/UUID'; import * as log from '../logging/log'; import dataInterface from '../sql/Client'; import { DAY, SECOND } from './durations'; -import { MY_STORIES_ID } from '../types/Stories'; +import { getStoriesBlocked, MY_STORIES_ID } from '../types/Stories'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SeenStatus } from '../MessageSeenStatus'; import { SendStatus } from '../messages/MessageSendState'; @@ -28,10 +28,17 @@ export async function sendStoryMessage( conversationIds: Array, attachment: AttachmentType ): Promise { + if (getStoriesBlocked()) { + log.warn('stories.sendStoryMessage: stories disabled, returning early'); + return; + } + const { messaging } = window.textsecure; if (!messaging) { - log.warn('stories.sendStoryMessage: messaging not available'); + log.warn( + 'stories.sendStoryMessage: messaging not available, returning early' + ); return; } diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 40dc5ebf0ee..8fa81e5826e 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -99,6 +99,7 @@ export async function sendToGroup({ sendOptions, sendTarget, sendType, + story, urgent, }: { abortSignal?: AbortSignal; @@ -109,6 +110,7 @@ export async function sendToGroup({ sendOptions?: SendOptionsType; sendTarget: SenderKeyTargetType; sendType: SendTypesType; + story?: boolean; urgent: boolean; }): Promise { strictAssert( @@ -141,6 +143,7 @@ export async function sendToGroup({ sendOptions, sendTarget, sendType, + story, timestamp, urgent, }); @@ -377,7 +380,7 @@ export async function sendToGroupViaSenderKey(options: { // 4. Partition devices into sender key and non-sender key groups const [devicesForSenderKey, devicesForNormalSend] = partition( currentDevices, - device => isValidSenderKeyRecipient(memberSet, device.identifier) + device => isValidSenderKeyRecipient(memberSet, device.identifier, { story }) ); const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey); @@ -513,13 +516,13 @@ export async function sendToGroupViaSenderKey(options: { contentMessage: Proto.Content.encode(contentMessage).finish(), groupId, }); - const accessKeys = getXorOfAccessKeys(devicesForSenderKey); + const accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story }); const result = await window.textsecure.messaging.server.sendWithSenderKey( messageBuffer, accessKeys, timestamp, - { online, urgent } + { online, story, urgent } ); const parsed = multiRecipient200ResponseSchema.safeParse(result); @@ -977,7 +980,10 @@ async function handle410Response( } } -function getXorOfAccessKeys(devices: Array): Buffer { +function getXorOfAccessKeys( + devices: Array, + { story }: { story?: boolean } = {} +): Buffer { const uuids = getUuidsFromDevices(devices); const result = Buffer.alloc(ACCESS_KEY_LENGTH); @@ -994,7 +1000,7 @@ function getXorOfAccessKeys(devices: Array): Buffer { ); } - const accessKey = getAccessKey(conversation.attributes); + const accessKey = getAccessKey(conversation.attributes, { story }); if (!accessKey) { throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`); } @@ -1099,7 +1105,8 @@ async function encryptForSenderKey({ function isValidSenderKeyRecipient( members: Set, - uuid: string + uuid: string, + { story }: { story?: boolean } = {} ): boolean { const memberConversation = window.ConversationController.get(uuid); if (!memberConversation) { @@ -1121,7 +1128,7 @@ function isValidSenderKeyRecipient( return false; } - if (!getAccessKey(memberConversation.attributes)) { + if (!getAccessKey(memberConversation.attributes, { story })) { return false; } @@ -1247,10 +1254,15 @@ async function resetSenderKey(sendTarget: SenderKeyTargetType): Promise { } function getAccessKey( - attributes: ConversationAttributesType + attributes: ConversationAttributesType, + { story }: { story?: boolean } ): string | undefined { const { sealedSender, accessKey } = attributes; + if (story) { + return accessKey || ZERO_ACCESS_KEY; + } + if (sealedSender === SEALED_SENDER.ENABLED) { return accessKey || undefined; } @@ -1307,11 +1319,13 @@ async function fetchKeysForIdentifier( ); try { + // Note: we have no way to make an unrestricted unathenticated key fetch as part of a + // story send, so we hardcode story=false. const { accessKeyFailed } = await getKeysForIdentifier( identifier, window.textsecure?.messaging?.server, devices, - getAccessKey(emptyConversation.attributes) + getAccessKey(emptyConversation.attributes, { story: false }) ); if (accessKeyFailed) { log.info( diff --git a/yarn.lock b/yarn.lock index 7bb63f80637..288480c633b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1975,10 +1975,10 @@ node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@2.10.0": - version "2.10.0" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.10.0.tgz#a27246e7b912caebc0bef628303e11689bf9b74c" - integrity sha512-kHos3n8lNBhivUecEFG4g1rvYpJ6oPgzKMOsaI+vN8R1R4Pc63WXxrLsxqAI2QmAngD+nmOgbjwAvKyH4MN0+w== +"@signalapp/mock-server@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.11.0.tgz#fe5f6229c4a5c28b3591e986a1622218452c5112" + integrity sha512-m23XZ8lrBn0u+zakxkKG5SezyUg6fnWwZewFF28sHNL7fQDVPHJkFCJZgE9XJwHBDM7TYz9ca/ucReW4GIPHoQ== dependencies: "@signalapp/libsignal-client" "^0.20.0" debug "^4.3.2"