From ee513a19658b4e28db6d18d0d1379c5ca747b877 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 28 May 2021 12:11:19 -0700 Subject: [PATCH] Support for message retry requests --- _locales/en/messages.json | 32 ++ images/delivery-issue.svg | 13 + libtextsecure/test/message_receiver_test.js | 97 ++--- package.json | 2 +- protos/SignalService.proto | 6 + protos/UnidentifiedDelivery.proto | 14 +- stylesheets/_modules.scss | 95 ++++- test/setup-test-node.js | 11 + ts/background.ts | 333 +++++++++++++++++- .../ChatSessionRefreshedDialog.tsx | 73 ++-- .../ChatSessionRefreshedNotification.tsx | 13 +- .../DeliveryIssueDialog.stories.tsx | 27 ++ .../conversation/DeliveryIssueDialog.tsx | 57 +++ .../DeliveryIssueNotification.stories.tsx | 23 ++ .../DeliveryIssueNotification.tsx | 68 ++++ .../conversation/TimelineItem.stories.tsx | 16 +- ts/components/conversation/TimelineItem.tsx | 11 + ts/groups.ts | 21 +- ts/model-types.d.ts | 15 +- ts/models/conversations.ts | 79 ++++- ts/models/messages.ts | 135 ++++++- ts/services/calling.ts | 4 + ts/state/ducks/conversations.ts | 15 +- ts/test-both/util/retryPlaceholders_test.ts | 285 +++++++++++++++ ts/test-electron/background_test.ts | 57 +++ ts/textsecure.d.ts | 10 +- ts/textsecure/Errors.ts | 4 +- ts/textsecure/MessageReceiver.ts | 168 ++++++--- ts/textsecure/OutgoingMessage.ts | 98 ++++-- ts/textsecure/SendMessage.ts | 269 ++++++++++---- ts/textsecure/WebAPI.ts | 12 +- ts/util/index.ts | 2 + ts/util/retryPlaceholders.ts | 196 +++++++++++ ts/util/sendToGroup.ts | 51 ++- ts/views/conversation_view.ts | 34 +- ts/window.d.ts | 1 + yarn.lock | 8 +- 37 files changed, 1996 insertions(+), 359 deletions(-) create mode 100644 images/delivery-issue.svg create mode 100644 ts/components/conversation/DeliveryIssueDialog.stories.tsx create mode 100644 ts/components/conversation/DeliveryIssueDialog.tsx create mode 100644 ts/components/conversation/DeliveryIssueNotification.stories.tsx create mode 100644 ts/components/conversation/DeliveryIssueNotification.tsx create mode 100644 ts/test-both/util/retryPlaceholders_test.ts create mode 100644 ts/test-electron/background_test.ts create mode 100644 ts/util/retryPlaceholders.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 08ada9069..8989eb29e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1117,6 +1117,38 @@ "message": "Contact Support", "description": "Shown on explainer dialog available from chat session refreshed timeline events" }, + "DeliveryIssue--preview": { + "message": "Delivery issue", + "description": "Shown in left pane preview when message delivery issue happens" + }, + "DeliveryIssue--notification": { + "message": "A message from $sender$ couldn’t be delivered", + "description": "Shown in timeline when message delivery issue happens", + "placeholders": { + "name": { + "content": "$1", + "example": "Alice" + } + } + }, + "DeliveryIssue--learnMore": { + "message": "Learn More", + "description": "Shown in timeline when message delivery issue happens, to provide access to a popup info dialog" + }, + "DeliveryIssue--title": { + "message": "Delivery Issue", + "description": "Shown on explainer dialog available from delivery issue timeline events" + }, + "DeliveryIssue--summary": { + "message": "A message, sticker, reaction, read receipt or media couldn’t be delivered to you from $sender$. They may have tried sending it to you directly, or in a group.", + "description": "Shown on explainer dialog available from delivery issue timeline events", + "placeholders": { + "name": { + "content": "$1", + "example": "Alice" + } + } + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" diff --git a/images/delivery-issue.svg b/images/delivery-issue.svg new file mode 100644 index 000000000..64edbf090 --- /dev/null +++ b/images/delivery-issue.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index ca1b0b071..5e6aa4f90 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -44,7 +44,7 @@ describe('MessageReceiver', () => { }); }); - it('generates light-session-reset event when it cannot decrypt', done => { + it('generates decryption-error event when it cannot decrypt', done => { const mockServer = new MockServer('ws://localhost:8081/'); mockServer.on('connection', server => { @@ -63,82 +63,33 @@ describe('MessageReceiver', () => { } ); - messageReceiver.addEventListener('light-session-reset', done()); + messageReceiver.addEventListener('decrytion-error', done()); }); }); - describe('methods', () => { - let messageReceiver; - let mockServer; + // For when we start testing individual MessageReceiver methods - beforeEach(() => { - // Necessary to populate the server property inside of MockSocket. Without it, we - // crash when doing any number of things to a MockSocket instance. - mockServer = new MockServer('ws://localhost:8081'); + // describe('methods', () => { + // let messageReceiver; + // let mockServer; - messageReceiver = new textsecure.MessageReceiver( - 'oldUsername.3', - 'username.3', - 'password', - 'signalingKey', - { - serverTrustRoot: 'AAAAAAAA', - } - ); - }); - afterEach(() => { - mockServer.close(); - }); + // beforeEach(() => { + // // Necessary to populate the server property inside of MockSocket. Without it, we + // // crash when doing any number of things to a MockSocket instance. + // mockServer = new MockServer('ws://localhost:8081'); - describe('#isOverHourIntoPast', () => { - it('returns false for now', () => { - assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now())); - }); - it('returns false for 5 minutes ago', () => { - const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; - assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo)); - }); - it('returns true for 65 minutes ago', () => { - const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000; - assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo)); - }); - }); - - describe('#cleanupSessionResets', () => { - it('leaves empty object alone', () => { - window.storage.put('sessionResets', {}); - messageReceiver.cleanupSessionResets(); - const actual = window.storage.get('sessionResets'); - - const expected = {}; - assert.deepEqual(actual, expected); - }); - it('filters out any timestamp older than one hour', () => { - const startValue = { - one: Date.now() - 1, - two: Date.now(), - three: Date.now() - 65 * 60 * 1000, - }; - window.storage.put('sessionResets', startValue); - messageReceiver.cleanupSessionResets(); - const actual = window.storage.get('sessionResets'); - - const expected = window._.pick(startValue, ['one', 'two']); - assert.deepEqual(actual, expected); - }); - it('filters out falsey items', () => { - const startValue = { - one: 0, - two: false, - three: Date.now(), - }; - window.storage.put('sessionResets', startValue); - messageReceiver.cleanupSessionResets(); - const actual = window.storage.get('sessionResets'); - - const expected = window._.pick(startValue, ['three']); - assert.deepEqual(actual, expected); - }); - }); - }); + // messageReceiver = new textsecure.MessageReceiver( + // 'oldUsername.3', + // 'username.3', + // 'password', + // 'signalingKey', + // { + // serverTrustRoot: 'AAAAAAAA', + // } + // ); + // }); + // afterEach(() => { + // mockServer.close(); + // }); + // }); }); diff --git a/package.json b/package.json index 056bbeb08..61edc4cbd 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "fs-xattr": "0.3.0" }, "dependencies": { - "@signalapp/signal-client": "0.6.0", + "@signalapp/signal-client": "0.8.0", "@sindresorhus/is": "0.8.0", "@types/pino": "6.3.6", "@types/pino-multi-stream": "5.1.0", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 37587c352..feb4c371a 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -12,6 +12,11 @@ message Envelope { PREKEY_BUNDLE = 3; RECEIPT = 5; UNIDENTIFIED_SENDER = 6; + + // Our parser does not handle reserved in enums: DESKTOP-1569 + // reserved 7; + + PLAINTEXT_CONTENT = 8; } optional Type type = 1; @@ -34,6 +39,7 @@ message Content { optional ReceiptMessage receiptMessage = 5; optional TypingMessage typingMessage = 6; optional bytes senderKeyDistributionMessage = 7; + optional bytes decryptionErrorMessage = 8; } // Everything in CallingMessage must be kept in sync with RingRTC (ringrtc-node). diff --git a/protos/UnidentifiedDelivery.proto b/protos/UnidentifiedDelivery.proto index 2b9339eae..d17f614b0 100644 --- a/protos/UnidentifiedDelivery.proto +++ b/protos/UnidentifiedDelivery.proto @@ -39,16 +39,18 @@ message UnidentifiedSenderMessage { // reserved 3 to 6; SENDERKEY_MESSAGE = 7; + PLAINTEXT_CONTENT = 8; } enum ContentHint { - // Commented out here, even though it is correct syntax. Our parser cannot handle it. - // Our parser does not handle reserved in enums: DESKTOP-1569 // reserved 0; // A content hint of "default" should never be encoded. - - SUPPLEMENTARY = 1; - RETRY = 2; + + // Do not insert an error. + SUPPLEMENTARY = 1; + + // Put an invisible placeholder in the chat (using the groupId from the sealed sender envelope if available) and delay showing an error until later. + RESENDABLE = 2; } optional Type type = 1; @@ -61,4 +63,4 @@ message UnidentifiedSenderMessage { optional bytes ephemeralPublic = 1; optional bytes encryptedStatic = 2; optional bytes encryptedMessage = 3; -} \ No newline at end of file +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index aeafe8055..c08f2e405 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -10385,16 +10385,62 @@ $contact-modal-padding: 18px; padding: 5px 12px; } -// Module: Chat Session Refreshed Dialog +// Module: Delivery Issue Notification + +.module-delivery-issue-notification { + @include font-body-2; + display: flex; + flex-direction: column; + align-items: center; +} + +.module-delivery-issue-notification__first-line { + margin-bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; -.module-chat-session-refreshed-dialog { - width: 360px; - padding: 16px; - padding-top: 28px; - border-radius: 8px; margin-left: auto; margin-right: auto; + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} +.module-delivery-issue-notification__icon { + height: 14px; + width: 14px; + display: inline-block; + margin-right: 8px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/error-outline-12.svg', + $color-gray-25 + ); + } +} +.module-delivery-issue-notification__button { + @include button-reset; + @include button-light-blue-text; + @include button-small; + + @include font-body-2; + padding: 5px 12px; +} + +// Module: Chat Session Refreshed Dialog + +.module-chat-session-refreshed-dialog { @include light-theme { background-color: $color-white; } @@ -10413,6 +10459,7 @@ $contact-modal-padding: 18px; .module-chat-session-refreshed-dialog__buttons { text-align: right; margin-top: 20px; + padding: 3px; } .module-chat-session-refreshed-dialog__button { @include font-body-1-bold; @@ -10427,6 +10474,42 @@ $contact-modal-padding: 18px; @include button-secondary; } +// Module: Delivery Issue Dialog + +.module-delivery-issue-dialog { + // margin-left: auto; + // margin-right: auto; + + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-95; + } +} +.module-delivery-issue-dialog__image { + text-align: center; +} +.module-delivery-issue-dialog__title { + @include font-body-1-bold; + margin-top: 10px; + margin-bottom: 3px; +} +.module-delivery-issue-dialog__buttons { + text-align: right; + margin-top: 20px; + padding: 3px; +} +.module-delivery-issue-dialog__button { + @include font-body-1-bold; + @include button-reset; + @include button-primary; + + border-radius: 4px; + padding: 7px 14px; + margin-left: 12px; +} + /* Third-party module: react-contextmenu*/ .react-contextmenu { diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 984de53b7..21384c4fe 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -3,12 +3,19 @@ /* eslint-disable no-console */ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js'); const Long = require('../components/long/dist/Long.js'); const { setEnvironment, Environment } = require('../ts/environment'); +chai.use(chaiAsPromised); + setEnvironment(Environment.Test); +const storageMap = new Map(); + // To replicate logic we have on the client side global.window = { log: { @@ -21,6 +28,10 @@ global.window = { ByteBuffer, Long, }, + storage: { + get: key => storageMap.get(key), + put: async (key, value) => storageMap.set(key, value), + }, }; // For ducks/network.getEmptyState() diff --git a/ts/background.ts b/ts/background.ts index 2b477e790..9697e0ae7 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1,6 +1,12 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isNumber } from 'lodash'; +import { + DecryptionErrorMessage, + PlaintextContent, +} from '@signalapp/signal-client'; + import { DataMessageClass } from './textsecure.d'; import { MessageAttributesType } from './model-types.d'; import { WhatIsThis } from './window.d'; @@ -22,10 +28,38 @@ import { ourProfileKeyService } from './services/ourProfileKey'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { setToExpire } from './services/MessageUpdater'; import { LatestQueue } from './util/LatestQueue'; +import { parseIntOrThrow } from './util/parseIntOrThrow'; +import { + DecryptionErrorType, + RetryRequestType, +} from './textsecure/MessageReceiver'; import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; +export function isOverHourIntoPast(timestamp: number): boolean { + const HOUR = 1000 * 60 * 60; + return isNumber(timestamp) && isOlderThan(timestamp, HOUR); +} + +type SessionResetsType = Record; +export async function cleanupSessionResets(): Promise { + const sessionResets = window.storage.get( + 'sessionResets', + {} + ); + + const keys = Object.keys(sessionResets); + keys.forEach(key => { + const timestamp = sessionResets[key]; + if (!timestamp || isOverHourIntoPast(timestamp)) { + delete sessionResets[key]; + } + }); + + await window.storage.put('sessionResets', sessionResets); +} + export async function startApp(): Promise { window.startupProcessingQueue = new window.Signal.Util.StartupQueue(); window.attachmentDownloadQueue = []; @@ -377,6 +411,27 @@ export async function startApp(): Promise { } first = false; + cleanupSessionResets(); + const retryPlaceholders = new window.Signal.Util.RetryPlaceholders(); + window.Signal.Services.retryPlaceholders = retryPlaceholders; + + setInterval(async () => { + const expired = await retryPlaceholders.getExpiredAndRemove(); + window.log.info( + `retryPlaceholders/interval: Found ${expired.length} expired items` + ); + expired.forEach(item => { + const { conversationId, senderUuid } = item; + const conversation = window.ConversationController.get(conversationId); + if (conversation) { + const now = Date.now(); + conversation.queueJob(() => + conversation.addDeliveryIssue(now, senderUuid) + ); + } + }); + }, 5 * 60 * 1000); + // These make key operations available to IPC handlers created in preload.js window.Events = { getDeviceName: () => window.textsecure.storage.user.getDeviceName(), @@ -1949,7 +2004,8 @@ export async function startApp(): Promise { addQueuedEventListener('read', onReadReceipt); addQueuedEventListener('verified', onVerified); addQueuedEventListener('error', onError); - addQueuedEventListener('light-session-reset', onLightSessionReset); + addQueuedEventListener('decryption-error', onDecryptionError); + addQueuedEventListener('retry-request', onRetryRequest); addQueuedEventListener('empty', onEmpty); addQueuedEventListener('reconnect', onReconnect); addQueuedEventListener('configuration', onConfiguration); @@ -2061,7 +2117,7 @@ export async function startApp(): Promise { await server.registerCapabilities({ 'gv2-3': true, 'gv1-migration': true, - senderKey: false, + senderKey: true, }); } catch (error) { window.log.error( @@ -3287,18 +3343,271 @@ export async function startApp(): Promise { window.log.warn('background onError: Doing nothing with incoming error'); } - type LightSessionResetEventType = Event & { - senderUuid: string; - senderDevice: number; + type RetryRequestEventType = Event & { + retryRequest: RetryRequestType; }; - function onLightSessionReset(event: LightSessionResetEventType) { - const { senderUuid, senderDevice } = event; + function isInList( + conversation: ConversationModel, + list: Array | undefined + ): boolean { + const uuid = conversation.get('uuid'); + const e164 = conversation.get('e164'); + const id = conversation.get('id'); - if (event.confirm) { - event.confirm(); + if (!list) { + return false; } + if (list.includes(id)) { + return true; + } + + if (uuid && list.includes(uuid)) { + return true; + } + + if (e164 && list.includes(e164)) { + return true; + } + + return false; + } + + async function onRetryRequest(event: RetryRequestEventType) { + const { retryRequest } = event; + const { + requesterUuid, + requesterDevice, + sentAt, + senderDevice, + } = retryRequest; + + window.log.info('onRetryRequest:', { + requesterUuid, + requesterDevice, + sentAt, + senderDevice, + }); + + const requesterConversation = window.ConversationController.getOrCreate( + requesterUuid, + 'private' + ); + + const messages = await window.Signal.Data.getMessagesBySentAt(sentAt, { + MessageCollection: window.Whisper.MessageCollection, + }); + + const targetMessage = messages.find(message => { + if (message.get('sent_at') !== sentAt) { + return false; + } + + if (message.get('type') !== 'outgoing') { + return false; + } + + if (!isInList(requesterConversation, message.get('sent_to'))) { + return false; + } + + return true; + }); + + if (!targetMessage) { + window.log.info( + `onRetryRequest: Did not find message sent at ${sentAt}, sent to ${requesterUuid}` + ); + return; + } + + if (targetMessage.isErased()) { + window.log.info( + `onRetryRequest: Message sent at ${sentAt} is erased, refusing to send again.` + ); + return; + } + + const HOUR = 60 * 60 * 1000; + const ONE_DAY = 24 * HOUR; + if (isOlderThan(sentAt, ONE_DAY)) { + window.log.info( + `onRetryRequest: Message sent at ${sentAt} is too old, refusing to send again.` + ); + return; + } + + const sentUnidentified = isInList( + requesterConversation, + targetMessage.get('unidentifiedDeliveries') + ); + const wasDelivered = isInList( + requesterConversation, + targetMessage.get('delivered_to') + ); + if (sentUnidentified && wasDelivered) { + window.log.info( + `onRetryRequest: Message sent at ${sentAt} was sent sealed sender and was delivered, refusing to send again.` + ); + return; + } + + window.log.info( + `onRetryRequest: Resending message ${sentAt} to user ${requesterUuid}` + ); + + const ourDeviceId = parseIntOrThrow( + window.textsecure.storage.user.getDeviceId(), + 'onRetryRequest/getDeviceId' + ); + if (ourDeviceId === senderDevice) { + const address = `${requesterUuid}.${requesterDevice}`; + window.log.info( + `onRetryRequest: Devices match, archiving session with ${address}` + ); + await window.textsecure.storage.protocol.archiveSession(address); + } + + targetMessage.resend(requesterUuid); + } + + type DecryptionErrorEventType = Event & { + decryptionError: DecryptionErrorType; + }; + + async function onDecryptionError(event: DecryptionErrorEventType) { + const { decryptionError } = event; + const { senderUuid, senderDevice } = decryptionError; + + window.log.info(`onDecryptionError: ${senderUuid}.${senderDevice}`); + + const conversation = window.ConversationController.getOrCreate( + senderUuid, + 'private' + ); + const capabilities = conversation.get('capabilities'); + if (!capabilities) { + await conversation.getProfiles(); + } + + if (conversation.get('capabilities')?.senderKey) { + requestResend(decryptionError); + return; + } + + await startAutomaticSessionReset(decryptionError); + } + + async function requestResend(decryptionError: DecryptionErrorType) { + const { + cipherTextBytes, + cipherTextType, + contentHint, + groupId, + receivedAtCounter, + receivedAtDate, + senderDevice, + senderUuid, + timestamp, + } = decryptionError; + + window.log.info(`requestResend: ${senderUuid}.${senderDevice}`, { + cipherTextBytesLength: cipherTextBytes?.byteLength, + cipherTextType, + contentHint, + groupId: groupId ? `groupv2(${groupId})` : undefined, + timestamp, + }); + + // 1. Find the target conversation + + const group = groupId + ? window.ConversationController.get(groupId) + : undefined; + const sender = window.ConversationController.getOrCreate( + senderUuid, + 'private' + ); + const conversation = group || sender; + + function immediatelyAddError() { + const receivedAt = Date.now(); + conversation.queueJob(async () => { + conversation.addDeliveryIssue(receivedAt, senderUuid); + }); + } + + // 2. Send resend request + + if (!cipherTextBytes || !isNumber(cipherTextType)) { + window.log.warn( + 'requestResend: Missing cipherText information, failing over to automatic reset' + ); + startAutomaticSessionReset(decryptionError); + return; + } + + try { + const message = DecryptionErrorMessage.forOriginal( + Buffer.from(cipherTextBytes), + cipherTextType, + timestamp, + senderDevice + ); + + const plaintext = PlaintextContent.from(message); + const options = await conversation.getSendOptions(); + const result = await window.textsecure.messaging.sendRetryRequest({ + plaintext, + options, + uuid: senderUuid, + }); + if (result.errors && result.errors.length > 0) { + throw result.errors[0]; + } + } catch (error) { + window.log.error( + 'requestResend: Failed to send retry request, failing over to automatic reset', + error && error.stack ? error.stack : error + ); + startAutomaticSessionReset(decryptionError); + return; + } + + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + + // 3. Determine how to represent this to the user. Three different options. + + // This is a sync message of some kind that cannot be resent. Don't do anything. + if (contentHint === ContentHint.SUPPLEMENTARY) { + scheduleSessionReset(senderUuid, senderDevice); + return; + } + + // If we request a re-send, it might just work out for us! + if (contentHint === ContentHint.RESENDABLE) { + const { retryPlaceholders } = window.Signal.Services; + assert(retryPlaceholders, 'requestResend: adding placeholder'); + + window.log.warn('requestResend: Adding placeholder'); + await retryPlaceholders.add({ + conversationId: conversation.get('id'), + receivedAt: receivedAtDate, + receivedAtCounter, + sentAt: timestamp, + senderUuid, + }); + + return; + } + + immediatelyAddError(); + } + + function scheduleSessionReset(senderUuid: string, senderDevice: number) { // Postpone sending light session resets until the queue is empty lightSessionResetQueue.add(() => { window.textsecure.storage.protocol.lightSessionReset( @@ -3306,6 +3615,12 @@ export async function startApp(): Promise { senderDevice ); }); + } + + function startAutomaticSessionReset(decryptionError: DecryptionErrorType) { + const { senderUuid, senderDevice } = decryptionError; + + scheduleSessionReset(senderUuid, senderDevice); const conversationId = window.ConversationController.ensureContactIds({ uuid: senderUuid, diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.tsx index f6591d1a3..0bcda908f 100644 --- a/ts/components/conversation/ChatSessionRefreshedDialog.tsx +++ b/ts/components/conversation/ChatSessionRefreshedDialog.tsx @@ -4,6 +4,8 @@ import * as React from 'react'; import classNames from 'classnames'; +import { Modal } from '../Modal'; + import { LocalizerType } from '../../types/Util'; export type PropsType = { @@ -12,47 +14,48 @@ export type PropsType = { onClose: () => unknown; }; -// TODO: This should use . See DESKTOP-1038. export function ChatSessionRefreshedDialog( props: PropsType ): React.ReactElement { const { i18n, contactSupport, onClose } = props; return ( -
-
- + +
+
+ +
+
+ {i18n('ChatRefresh--notification')} +
+
+ {i18n('ChatRefresh--summary')} +
+
+ + +
-
- {i18n('ChatRefresh--notification')} -
-
- {i18n('ChatRefresh--summary')} -
-
- - -
-
+ ); } diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.tsx index cf5e31674..d0530c537 100644 --- a/ts/components/conversation/ChatSessionRefreshedNotification.tsx +++ b/ts/components/conversation/ChatSessionRefreshedNotification.tsx @@ -5,7 +5,6 @@ import React, { useCallback, useState, ReactElement } from 'react'; import { LocalizerType } from '../../types/Util'; -import { ModalHost } from '../ModalHost'; import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; type PropsHousekeepingType = { @@ -50,13 +49,11 @@ export function ChatSessionRefreshedNotification( {i18n('ChatRefresh--learnMore')} {isDialogOpen ? ( - - - + ) : null}
); diff --git a/ts/components/conversation/DeliveryIssueDialog.stories.tsx b/ts/components/conversation/DeliveryIssueDialog.stories.tsx new file mode 100644 index 000000000..d77aea9f6 --- /dev/null +++ b/ts/components/conversation/DeliveryIssueDialog.stories.tsx @@ -0,0 +1,27 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { DeliveryIssueDialog } from './DeliveryIssueDialog'; +import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); +const sender = getDefaultConversation(); + +storiesOf('Components/Conversation/DeliveryIssueDialog', module).add( + 'Default', + () => { + return ( + + ); + } +); diff --git a/ts/components/conversation/DeliveryIssueDialog.tsx b/ts/components/conversation/DeliveryIssueDialog.tsx new file mode 100644 index 000000000..009d4edf8 --- /dev/null +++ b/ts/components/conversation/DeliveryIssueDialog.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { ConversationType } from '../../state/ducks/conversations'; +import { Modal } from '../Modal'; +import { Intl } from '../Intl'; +import { Emojify } from './Emojify'; + +import { LocalizerType } from '../../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + sender: ConversationType; + onClose: () => unknown; +}; + +export function DeliveryIssueDialog(props: PropsType): React.ReactElement { + const { i18n, sender, onClose } = props; + + return ( + +
+
+ +
+
+ {i18n('DeliveryIssue--title')} +
+
+ , + }} + i18n={i18n} + /> +
+
+ +
+
+
+ ); +} diff --git a/ts/components/conversation/DeliveryIssueNotification.stories.tsx b/ts/components/conversation/DeliveryIssueNotification.stories.tsx new file mode 100644 index 000000000..46dad49e5 --- /dev/null +++ b/ts/components/conversation/DeliveryIssueNotification.stories.tsx @@ -0,0 +1,23 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { DeliveryIssueNotification } from './DeliveryIssueNotification'; +import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; + +const i18n = setupI18n('en', enMessages); +const sender = getDefaultConversation(); + +storiesOf('Components/Conversation/DeliveryIssueNotification', module).add( + 'Default', + () => { + return ; + } +); diff --git a/ts/components/conversation/DeliveryIssueNotification.tsx b/ts/components/conversation/DeliveryIssueNotification.tsx new file mode 100644 index 000000000..24ebdf40c --- /dev/null +++ b/ts/components/conversation/DeliveryIssueNotification.tsx @@ -0,0 +1,68 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState, ReactElement } from 'react'; + +import { ConversationType } from '../../state/ducks/conversations'; +import { LocalizerType } from '../../types/Util'; +import { Intl } from '../Intl'; +import { Emojify } from './Emojify'; + +import { DeliveryIssueDialog } from './DeliveryIssueDialog'; + +export type PropsDataType = { + sender?: ConversationType; +}; + +type PropsHousekeepingType = { + i18n: LocalizerType; +}; + +export type PropsType = PropsDataType & PropsHousekeepingType; + +export function DeliveryIssueNotification( + props: PropsType +): ReactElement | null { + const { i18n, sender } = props; + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsDialogOpen(true); + }, [setIsDialogOpen]); + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + }, [setIsDialogOpen]); + + if (!sender) { + return null; + } + + return ( +
+
+ + , + }} + i18n={i18n} + /> +
+ + {isDialogOpen ? ( + + ) : null} +
+ ); +} diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 2d8c21156..f51c65123 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -11,6 +11,7 @@ import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem'; import { CallMode } from '../../types/Calling'; +import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; const i18n = setupI18n('en', enMessages); @@ -99,9 +100,19 @@ storiesOf('Components/Conversation/TimelineItem', module) { type: 'timerNotification', data: { - type: 'fromOther', phoneNumber: '(202) 555-0000', - timespan: '1 hour', + expireTimer: 60, + ...getDefaultConversation(), + type: 'fromOther', + }, + }, + { + type: 'chatSessionRefreshed', + }, + { + type: 'deliveryIssue', + data: { + sender: getDefaultConversation(), }, }, { @@ -367,7 +378,6 @@ storiesOf('Components/Conversation/TimelineItem', module) item={item as TimelineItemProps['item']} i18n={i18n} /> -
))} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 57a5e323a..a78eba935 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -19,6 +19,10 @@ import { ChatSessionRefreshedNotification, PropsActionsType as PropsChatSessionRefreshedActionsType, } from './ChatSessionRefreshedNotification'; +import { + DeliveryIssueNotification, + PropsDataType as DeliveryIssueProps, +} from './DeliveryIssueNotification'; import { CallingNotificationType } from '../../util/callingNotification'; import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import { @@ -66,6 +70,10 @@ type ChatSessionRefreshedType = { type: 'chatSessionRefreshed'; data: null; }; +type DeliveryIssueType = { + type: 'deliveryIssue'; + data: DeliveryIssueProps; +}; type LinkNotificationType = { type: 'linkNotification'; data: null; @@ -114,6 +122,7 @@ type ProfileChangeNotificationType = { export type TimelineItemType = | CallHistoryType | ChatSessionRefreshedType + | DeliveryIssueType | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType @@ -203,6 +212,8 @@ export class TimelineItem extends React.PureComponent { i18n={i18n} /> ); + } else if (item.type === 'deliveryIssue') { + notification = ; } else if (item.type === 'linkNotification') { notification = (
diff --git a/ts/groups.ts b/ts/groups.ts index 2fc480299..aa92d76b6 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1259,6 +1259,9 @@ export async function modifyGroupV2({ const sendOptions = await conversation.getSendOptions(); const timestamp = Date.now(); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; const promise = conversation.wrapSend( window.Signal.Util.sendToGroup( @@ -1272,6 +1275,7 @@ export async function modifyGroupV2({ profileKey, }, conversation, + ContentHint.SUPPLEMENTARY, sendOptions ) ); @@ -1629,6 +1633,10 @@ export async function createGroupV2({ const groupV2Info = conversation.getGroupV2Info({ includePendingMembers: true, }); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + const sendOptions = await conversation.getSendOptions(); await wrapWithSyncMessageSend({ conversation, @@ -1640,7 +1648,9 @@ export async function createGroupV2({ timestamp, profileKey, }, - conversation + conversation, + ContentHint.SUPPLEMENTARY, + sendOptions ), timestamp, }); @@ -2145,6 +2155,11 @@ export async function initiateMigrationToGroupV2( | ArrayBuffer | undefined = await ourProfileKeyService.get(); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + const sendOptions = await conversation.getSendOptions(); + await wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/${logId}`, @@ -2158,7 +2173,9 @@ export async function initiateMigrationToGroupV2( timestamp, profileKey: ourProfileKey, }, - conversation + conversation, + ContentHint.SUPPLEMENTARY, + sendOptions ), timestamp, }); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index ee503765d..bcf8af7d4 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -129,18 +129,19 @@ export type MessageAttributesType = { id: string; type?: - | 'incoming' - | 'outgoing' - | 'group' - | 'keychange' - | 'verified-change' - | 'message-history-unsynced' | 'call-history' | 'chat-session-refreshed' + | 'delivery-issue' + | 'group' | 'group-v1-migration' | 'group-v2-change' + | 'incoming' + | 'keychange' + | 'message-history-unsynced' + | 'outgoing' | 'profile-change' - | 'timer-notification'; + | 'timer-notification' + | 'verified-change'; body: string; attachments: Array; preview: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 4dfabbbf0..3fc9f5944 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -139,6 +139,8 @@ export class ConversationModel extends window.Backbone throttledFetchSMSOnlyUUID?: () => Promise | void; + throttledMaybeMigrateV1Group?: () => Promise | void; + typingRefreshTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null; @@ -304,7 +306,11 @@ export class ConversationModel extends window.Backbone this.isFetchingUUID = this.isSMSOnly(); this.throttledFetchSMSOnlyUUID = window._.throttle( - this.fetchSMSOnlyUUID, + this.fetchSMSOnlyUUID.bind(this), + FIVE_MINUTES + ); + this.throttledMaybeMigrateV1Group = window._.throttle( + this.maybeMigrateV1Group.bind(this), FIVE_MINUTES ); @@ -811,6 +817,10 @@ export class ConversationModel extends window.Backbone } setRegistered(): void { + if (this.get('discoveredUnregisteredAt') === undefined) { + return; + } + window.log.info( `Conversation ${this.idForLogging()} is registered once again` ); @@ -1193,15 +1203,18 @@ export class ConversationModel extends window.Backbone } ); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; const sendOptions = await this.getSendOptions(); if (this.isPrivate()) { - const silent = true; this.wrapSend( window.textsecure.messaging.sendMessageProtoAndWait( timestamp, groupMembers, contentMessage, - silent, + ContentHint.SUPPLEMENTARY, + undefined, { ...sendOptions, online: true, @@ -1211,6 +1224,7 @@ export class ConversationModel extends window.Backbone } else { this.wrapSend( window.Signal.Util.sendContentMessageToGroup({ + contentHint: ContentHint.SUPPLEMENTARY, contentMessage, conversation: this, online: true, @@ -2438,7 +2452,8 @@ export class ConversationModel extends window.Backbone async addChatSessionRefreshed(receivedAt: number): Promise { window.log.info( - `addChatSessionRefreshed: adding for ${this.idForLogging()}` + `addChatSessionRefreshed: adding for ${this.idForLogging()}`, + { receivedAt } ); const message = ({ @@ -2466,6 +2481,43 @@ export class ConversationModel extends window.Backbone this.trigger('newmessage', model); } + async addDeliveryIssue( + receivedAt: number, + senderUuid: string + ): Promise { + window.log.info(`addDeliveryIssue: adding for ${this.idForLogging()}`, { + receivedAt, + senderUuid, + }); + + const message = ({ + conversationId: this.id, + type: 'delivery-issue', + sourceUuid: senderUuid, + sent_at: receivedAt, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: receivedAt, + unread: 1, + // TODO: DESKTOP-722 + // this type does not fully implement the interface it is expected to + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + + await this.notify(model); + } + async addKeyChange(keyChangedId: string): Promise { window.log.info( 'adding key change advisory for', @@ -3108,6 +3160,10 @@ export class ConversationModel extends window.Backbone profileKey = await ourProfileKeyService.get(); } + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + if (this.isPrivate()) { return window.textsecure.messaging.sendMessageToIdentifier( destination, @@ -3120,6 +3176,8 @@ export class ConversationModel extends window.Backbone targetTimestamp, timestamp, undefined, // expireTimer + ContentHint.SUPPLEMENTARY, + undefined, // groupId profileKey, options ); @@ -3134,6 +3192,7 @@ export class ConversationModel extends window.Backbone profileKey, }, this, + ContentHint.SUPPLEMENTARY, options ); })(); @@ -3254,6 +3313,9 @@ export class ConversationModel extends window.Backbone } const options = await this.getSendOptions(); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; const promise = (() => { if (this.isPrivate()) { @@ -3268,6 +3330,8 @@ export class ConversationModel extends window.Backbone undefined, // deletedForEveryoneTimestamp timestamp, expireTimer, + ContentHint.SUPPLEMENTARY, + undefined, // groupId profileKey, options ); @@ -3285,6 +3349,7 @@ export class ConversationModel extends window.Backbone profileKey, }, this, + ContentHint.SUPPLEMENTARY, options ); })(); @@ -3492,6 +3557,9 @@ export class ConversationModel extends window.Backbone const conversationType = this.get('type'); const options = await this.getSendOptions(); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; let promise; if (conversationType === Message.GROUP) { @@ -3510,6 +3578,7 @@ export class ConversationModel extends window.Backbone mentions, }, this, + ContentHint.RESENDABLE, options ); } else { @@ -3524,6 +3593,8 @@ export class ConversationModel extends window.Backbone undefined, // deletedForEveryoneTimestamp now, expireTimer, + ContentHint.RESENDABLE, + undefined, // groupId profileKey, options ); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index b527711d0..818117cb1 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -41,6 +41,7 @@ import { import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification'; import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification'; import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration'; +import { PropsDataType as DeliveryIssuePropsType } from '../components/conversation/DeliveryIssueNotification'; import { PropsData as GroupNotificationProps, ChangeType, @@ -132,6 +133,10 @@ type MessageBubbleProps = type: 'chatSessionRefreshed'; data: null; } + | { + type: 'deliveryIssue'; + data: DeliveryIssuePropsType; + } | { type: 'message'; data: PropsForMessage; @@ -407,6 +412,12 @@ export class MessageModel extends window.Backbone.Model { data: null, }; } + if (this.isDeliveryIssue()) { + return { + type: 'deliveryIssue', + data: this.getPropsForDeliveryIssue(), + }; + } return { type: 'message', @@ -581,6 +592,10 @@ export class MessageModel extends window.Backbone.Model { return this.get('type') === 'chat-session-refreshed'; } + isDeliveryIssue(): boolean { + return this.get('type') === 'delivery-issue'; + } + isProfileChange(): boolean { return this.get('type') === 'profile-change'; } @@ -874,6 +889,14 @@ export class MessageModel extends window.Backbone.Model { } } + getPropsForDeliveryIssue(): DeliveryIssuePropsType { + const sender = this.getContact()?.format(); + + return { + sender, + }; + } + getPropsForProfileChange(): ProfileChangeNotificationPropsType { const change = this.get('profileChange'); const changedId = this.get('changedId'); @@ -1359,6 +1382,13 @@ export class MessageModel extends window.Backbone.Model { } getNotificationData(): { emoji?: string; text: string } { + if (this.isDeliveryIssue()) { + return { + emoji: '⚠️', + text: window.i18n('DeliveryIssue--preview'), + }; + } + if (this.isChatSessionRefreshed()) { return { emoji: '🔁', @@ -1893,6 +1923,7 @@ export class MessageModel extends window.Backbone.Model { // Rendered sync messages const isCallHistory = this.isCallHistory(); const isChatSessionRefreshed = this.isChatSessionRefreshed(); + const isDeliveryIssue = this.isDeliveryIssue(); const isGroupUpdate = this.isGroupUpdate(); const isGroupV2Change = this.isGroupV2Change(); const isEndSession = this.isEndSession(); @@ -1922,6 +1953,7 @@ export class MessageModel extends window.Backbone.Model { // Rendered sync messages isCallHistory || isChatSessionRefreshed || + isDeliveryIssue || isGroupUpdate || isGroupV2Change || isEndSession || @@ -2216,6 +2248,10 @@ export class MessageModel extends window.Backbone.Model { let promise; const options = await conversation.getSendOptions(); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + if (conversation.isPrivate()) { const [identifier] = recipients; promise = window.textsecure.messaging.sendMessageToIdentifier( @@ -2229,6 +2265,8 @@ export class MessageModel extends window.Backbone.Model { this.get('deletedForEveryoneTimestamp'), this.get('sent_at'), this.get('expireTimer'), + ContentHint.RESENDABLE, + undefined, // groupId profileKey, options ); @@ -2271,6 +2309,7 @@ export class MessageModel extends window.Backbone.Model { groupV1, }, conversation, + ContentHint.RESENDABLE, options, partialSend ); @@ -2403,7 +2442,13 @@ export class MessageModel extends window.Backbone.Model { async resend(identifier: string): Promise> { const error = this.removeOutgoingErrors(identifier); if (!error) { - window.log.warn('resend: requested number was not present in errors'); + window.log.warn( + 'resend: requested number was not present in errors. continuing.' + ); + } + + if (this.isErased()) { + window.log.warn('resend: message is erased; refusing to resend'); return null; } @@ -2431,7 +2476,6 @@ export class MessageModel extends window.Backbone.Model { body, deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), expireTimer: this.get('expireTimer'), - // flags mentions: this.get('bodyRanges'), preview: previewWithData, profileKey, @@ -2444,22 +2488,59 @@ export class MessageModel extends window.Backbone.Model { return this.sendSyncMessageOnly(dataMessage); } + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + const parentConversation = this.getConversation(); + const groupId = parentConversation?.get('groupId'); const { wrap, sendOptions, } = await window.ConversationController.prepareForSend(identifier); - const promise = window.textsecure.messaging.sendMessageToIdentifier( - identifier, - body, + const group = + groupId && parentConversation?.isGroupV1() + ? { + id: groupId, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + } + : undefined; + + const timestamp = this.get('sent_at'); + const contentMessage = await window.textsecure.messaging.getContentMessage({ attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('deletedForEveryoneTimestamp'), - this.get('sent_at'), - this.get('expireTimer'), - profileKey, + body, + expireTimer: this.get('expireTimer'), + group, + groupV2: parentConversation?.getGroupV2Info(), + preview: previewWithData, + quote: quoteWithData, + mentions: this.get('bodyRanges'), + recipients: [identifier], + sticker: stickerWithData, + timestamp, + }); + + if (parentConversation) { + const senderKeyInfo = parentConversation.get('senderKeyInfo'); + if (senderKeyInfo && senderKeyInfo.distributionId) { + const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage( + senderKeyInfo.distributionId + ); + + window.dcodeIO.ByteBuffer.wrap( + window.Signal.Crypto.typedArrayToArrayBuffer( + senderKeyDistributionMessage.serialize() + ) + ); + } + } + + const promise = window.textsecure.messaging.sendMessageProtoAndWait( + timestamp, + [identifier], + contentMessage, + ContentHint.RESENDABLE, + groupId && parentConversation?.isGroupV2() ? groupId : undefined, sendOptions ); @@ -2506,7 +2587,10 @@ export class MessageModel extends window.Backbone.Model { sent_to: _.union(sentTo, result.successfulIdentifiers), sent: true, expirationStartTimestamp: Date.now(), - unidentifiedDeliveries: result.unidentifiedDeliveries, + unidentifiedDeliveries: _.union( + this.get('unidentifiedDeliveries') || [], + result.unidentifiedDeliveries + ), }); if (!this.doNotSave) { @@ -2595,7 +2679,10 @@ export class MessageModel extends window.Backbone.Model { sent_to: _.union(sentTo, result.successfulIdentifiers), sent: true, expirationStartTimestamp, - unidentifiedDeliveries: result.unidentifiedDeliveries, + unidentifiedDeliveries: _.union( + this.get('unidentifiedDeliveries') || [], + result.unidentifiedDeliveries + ), }); promises.push(this.sendSyncMessage()); } else if (result.errors) { @@ -3452,6 +3539,24 @@ export class MessageModel extends window.Backbone.Model { } } + // Now check for decryption error placeholders + const { retryPlaceholders } = window.Signal.Services; + if (retryPlaceholders) { + const item = await retryPlaceholders.findByMessageAndRemove( + conversationId, + message.get('sent_at') + ); + if (item) { + window.log.info( + `handleDataMessage: found retry placeholder. Updating ${message.idForLogging()} received_at/received_at_ms` + ); + message.set({ + received_at: item.receivedAtCounter, + received_at_ms: item.receivedAt, + }); + } + } + // GroupV2 if (initialMessage.groupV2) { diff --git a/ts/services/calling.ts b/ts/services/calling.ts index d286facea..540e84302 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -766,6 +766,9 @@ export class CallingClass { const timestamp = Date.now(); // We "fire and forget" because sending this message is non-essential. + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`, @@ -773,6 +776,7 @@ export class CallingClass { window.Signal.Util.sendToGroup( { groupCallUpdate: { eraId }, groupV2, timestamp }, conversation, + ContentHint.SUPPLEMENTARY, sendOptions ), timestamp, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index a45b76899..30a5710c4 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -159,18 +159,19 @@ export type MessageType = { source?: string; sourceUuid?: string; type?: - | 'incoming' - | 'outgoing' - | 'group' - | 'keychange' - | 'verified-change' - | 'message-history-unsynced' | 'call-history' | 'chat-session-refreshed' + | 'delivery-issue' + | 'group' | 'group-v1-migration' | 'group-v2-change' + | 'incoming' + | 'keychange' + | 'message-history-unsynced' + | 'outgoing' | 'profile-change' - | 'timer-notification'; + | 'timer-notification' + | 'verified-change'; quote?: { author?: string; authorUuid?: string }; received_at: number; sent_at?: number; diff --git a/ts/test-both/util/retryPlaceholders_test.ts b/ts/test-both/util/retryPlaceholders_test.ts new file mode 100644 index 000000000..1b7f71218 --- /dev/null +++ b/ts/test-both/util/retryPlaceholders_test.ts @@ -0,0 +1,285 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { + getOneHourAgo, + RetryItemType, + RetryPlaceholders, + STORAGE_KEY, +} from '../../util/retryPlaceholders'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +describe('RetryPlaceholders', () => { + beforeEach(() => { + window.storage.put(STORAGE_KEY, null); + }); + + function getDefaultItem(): RetryItemType { + return { + conversationId: 'conversation-id', + sentAt: Date.now() - 10, + receivedAt: Date.now() - 5, + receivedAtCounter: 4, + senderUuid: 'sender-uuid', + }; + } + + describe('constructor', () => { + it('loads previously-saved data on creation', () => { + const items: Array = [ + getDefaultItem(), + { ...getDefaultItem(), conversationId: 'conversation-id-2' }, + ]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + + assert.strictEqual(2, placeholders.getCount()); + }); + it('starts with no data if provided data fails to parse', () => { + window.storage.put(STORAGE_KEY, [ + { item: 'is wrong shape!' }, + { bad: 'is not good!' }, + ]); + + const placeholders = new RetryPlaceholders(); + + assert.strictEqual(0, placeholders.getCount()); + }); + }); + + describe('#add', () => { + it('adds one item', async () => { + const placeholders = new RetryPlaceholders(); + await placeholders.add(getDefaultItem()); + assert.strictEqual(1, placeholders.getCount()); + }); + + it('throws if provided data fails to parse', () => { + const placeholders = new RetryPlaceholders(); + assert.isRejected( + placeholders.add({ + item: 'is wrong shape!', + } as any), + 'Item did not match schema' + ); + }); + }); + + describe('#getNextToExpire', () => { + it('returns nothing if no items', () => { + const placeholders = new RetryPlaceholders(); + assert.strictEqual(0, placeholders.getCount()); + assert.isUndefined(placeholders.getNextToExpire()); + }); + it('returns only item if just one item', () => { + const item = getDefaultItem(); + const items: Array = [item]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(1, placeholders.getCount()); + assert.deepEqual(item, placeholders.getNextToExpire()); + }); + it('returns soonest expiration given a list, and after add', async () => { + const older = { + ...getDefaultItem(), + receivedAt: Date.now(), + }; + const newer = { + ...getDefaultItem(), + receivedAt: Date.now() + 10, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual(older, placeholders.getNextToExpire()); + + const oldest = { + ...getDefaultItem(), + receivedAt: Date.now() - 5, + }; + + await placeholders.add(oldest); + assert.strictEqual(3, placeholders.getCount()); + assert.deepEqual(oldest, placeholders.getNextToExpire()); + }); + }); + + describe('#getExpiredAndRemove', () => { + it('does nothing if no item expired', async () => { + const older = { + ...getDefaultItem(), + receivedAt: Date.now() + 10, + }; + const newer = { + ...getDefaultItem(), + receivedAt: Date.now() + 15, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual([], await placeholders.getExpiredAndRemove()); + assert.strictEqual(2, placeholders.getCount()); + }); + it('removes just one if expired', async () => { + const older = { + ...getDefaultItem(), + receivedAt: getOneHourAgo() - 1000, + }; + const newer = { + ...getDefaultItem(), + receivedAt: Date.now() + 15, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual([older], await placeholders.getExpiredAndRemove()); + assert.strictEqual(1, placeholders.getCount()); + assert.deepEqual(newer, placeholders.getNextToExpire()); + }); + it('removes all if expired', async () => { + const older = { + ...getDefaultItem(), + receivedAt: getOneHourAgo() - 1000, + }; + const newer = { + ...getDefaultItem(), + receivedAt: getOneHourAgo() - 900, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual( + [older, newer], + await placeholders.getExpiredAndRemove() + ); + assert.strictEqual(0, placeholders.getCount()); + }); + }); + + describe('#findByConversationAndRemove', () => { + it('does nothing if no items found matching conversation', async () => { + const older = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + }; + const newer = { + ...getDefaultItem(), + conversationId: 'conversation-id-2', + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual( + [], + await placeholders.findByConversationAndRemove('conversation-id-3') + ); + assert.strictEqual(2, placeholders.getCount()); + }); + it('removes all items matching conversation', async () => { + const convo1a = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + receivedAt: Date.now() - 5, + }; + const convo1b = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + receivedAt: Date.now() - 4, + }; + const convo2a = { + ...getDefaultItem(), + conversationId: 'conversation-id-2', + receivedAt: Date.now() + 15, + }; + const items: Array = [convo1a, convo1b, convo2a]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(3, placeholders.getCount()); + assert.deepEqual( + [convo1a, convo1b], + await placeholders.findByConversationAndRemove('conversation-id-1') + ); + assert.strictEqual(1, placeholders.getCount()); + + const convo2b = { + ...getDefaultItem(), + conversationId: 'conversation-id-2', + receivedAt: Date.now() + 16, + }; + + await placeholders.add(convo2b); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual( + [convo2a, convo2b], + await placeholders.findByConversationAndRemove('conversation-id-2') + ); + assert.strictEqual(0, placeholders.getCount()); + }); + }); + + describe('#findByMessageAndRemove', () => { + it('does nothing if no item matching message found', async () => { + const sentAt = Date.now() - 20; + + const older = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + sentAt: Date.now() - 10, + }; + const newer = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + sentAt: Date.now() - 11, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.isUndefined( + await placeholders.findByMessageAndRemove('conversation-id-1', sentAt) + ); + assert.strictEqual(2, placeholders.getCount()); + }); + it('removes the item matching message', async () => { + const sentAt = Date.now() - 20; + + const older = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + sentAt: Date.now() - 10, + }; + const newer = { + ...getDefaultItem(), + conversationId: 'conversation-id-1', + sentAt, + }; + const items: Array = [older, newer]; + window.storage.put(STORAGE_KEY, items); + + const placeholders = new RetryPlaceholders(); + assert.strictEqual(2, placeholders.getCount()); + assert.deepEqual( + newer, + await placeholders.findByMessageAndRemove('conversation-id-1', sentAt) + ); + assert.strictEqual(1, placeholders.getCount()); + }); + }); +}); diff --git a/ts/test-electron/background_test.ts b/ts/test-electron/background_test.ts new file mode 100644 index 000000000..753997ca7 --- /dev/null +++ b/ts/test-electron/background_test.ts @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isOverHourIntoPast, cleanupSessionResets } from '../background'; + +describe('#isOverHourIntoPast', () => { + it('returns false for now', () => { + assert.isFalse(isOverHourIntoPast(Date.now())); + }); + it('returns false for 5 minutes ago', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + assert.isFalse(isOverHourIntoPast(fiveMinutesAgo)); + }); + it('returns true for 65 minutes ago', () => { + const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000; + assert.isTrue(isOverHourIntoPast(sixtyFiveMinutesAgo)); + }); +}); + +describe('#cleanupSessionResets', () => { + it('leaves empty object alone', () => { + window.storage.put('sessionResets', {}); + cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = {}; + assert.deepEqual(actual, expected); + }); + it('filters out any timestamp older than one hour', () => { + const startValue = { + one: Date.now() - 1, + two: Date.now(), + three: Date.now() - 65 * 60 * 1000, + }; + window.storage.put('sessionResets', startValue); + cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['one', 'two']); + assert.deepEqual(actual, expected); + }); + it('filters out falsey items', () => { + const startValue = { + one: 0, + two: false, + three: Date.now(), + }; + window.storage.put('sessionResets', startValue); + cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['three']); + assert.deepEqual(actual, expected); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 838dd3d44..ed67741dc 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -1,6 +1,8 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client'; + import Crypto from './textsecure/Crypto'; import MessageReceiver from './textsecure/MessageReceiver'; import MessageSender from './textsecure/SendMessage'; @@ -571,6 +573,7 @@ export declare class ContentClass { receiptMessage?: ReceiptMessageClass; typingMessage?: TypingMessageClass; senderKeyDistributionMessage?: ByteBufferClass; + decryptionErrorMessage?: ByteBufferClass; } export declare class DataMessageClass { @@ -722,6 +725,9 @@ export declare class EnvelopeClass { receivedAtDate: number; unidentifiedDeliveryReceived?: boolean; messageAgeSec?: number; + contentHint?: number; + groupId?: string; + usmc?: UnidentifiedSenderMessageContent; } // Note: we need to use namespaces to express nested classes in Typescript @@ -731,6 +737,7 @@ export declare namespace EnvelopeClass { static PREKEY_BUNDLE: number; static RECEIPT: number; static UNIDENTIFIED_SENDER: number; + static PLAINTEXT_CONTENT: number; } } @@ -1386,10 +1393,11 @@ export declare namespace UnidentifiedSenderMessageClass.Message { static PREKEY_MESSAGE: number; static MESSAGE: number; static SENDERKEY_MESSAGE: number; + static PLAINTEXT_CONTENT: number; } class ContentHint { static SUPPLEMENTARY: number; - static RETRY: number; + static RESENDABLE: number; } } diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts index 6b1ad21b3..11da58f2f 100644 --- a/ts/textsecure/Errors.ts +++ b/ts/textsecure/Errors.ts @@ -70,8 +70,8 @@ export class OutgoingMessageError extends ReplayableError { // Note: Data to resend message is no longer captured constructor( incomingIdentifier: string, - _m: ArrayBuffer, - _t: number, + _m: unknown, + _t: unknown, httpError?: Error ) { const identifier = incomingIdentifier.split('.')[0]; diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 9cfe43afe..d0c50cc95 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -13,9 +13,12 @@ import { isNumber, map, omit, noop } from 'lodash'; import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; +import { z } from 'zod'; import { + DecryptionErrorMessage, groupDecrypt, + PlaintextContent, PreKeySignalMessage, processSenderKeyDistributionMessage, ProtocolAddress, @@ -73,7 +76,30 @@ const GROUPV1_ID_LENGTH = 16; const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; -type SessionResetsType = Record; +const decryptionErrorTypeSchema = z + .object({ + cipherTextBytes: z.instanceof(ArrayBuffer).optional(), + cipherTextType: z.number().optional(), + contentHint: z.number().optional(), + groupId: z.string().optional(), + receivedAtCounter: z.number(), + receivedAtDate: z.number(), + senderDevice: z.number(), + senderUuid: z.string(), + timestamp: z.number(), + }) + .passthrough(); +export type DecryptionErrorType = z.infer; + +const retryRequestTypeSchema = z + .object({ + requesterUuid: z.string(), + requesterDevice: z.number(), + senderDevice: z.number(), + sentAt: z.number(), + }) + .passthrough(); +export type RetryRequestType = z.infer; declare global { // We want to extend `Event`, so we need an interface. @@ -107,6 +133,8 @@ declare global { timestamp?: any; typing?: any; verified?: any; + retryRequest?: RetryRequestType; + decryptionError?: DecryptionErrorType; } // We want to extend `Error`, so we need an interface. // eslint-disable-next-line no-restricted-syntax @@ -261,8 +289,6 @@ class MessageReceiverInner extends EventTarget { maxSize: 30, processBatch: this.cacheRemoveBatch.bind(this), }); - - this.cleanupSessionResets(); } static stringToArrayBuffer = (string: string): ArrayBuffer => @@ -1122,7 +1148,14 @@ class MessageReceiverInner extends EventTarget { ArrayBuffer | { isMe: boolean } | { isBlocked: boolean } | undefined >; - if (envelope.type === envelopeTypeEnum.CIPHERTEXT) { + if (envelope.type === envelopeTypeEnum.PLAINTEXT_CONTENT) { + const buffer = Buffer.from(ciphertext.toArrayBuffer()); + const plaintextContent = PlaintextContent.deserialize(buffer); + + promise = Promise.resolve( + this.unpad(typedArrayToArrayBuffer(plaintextContent.body())) + ); + } else if (envelope.type === envelopeTypeEnum.CIPHERTEXT) { window.log.info('message from', this.getEnvelopeId(envelope)); if (!identifier) { throw new Error( @@ -1215,6 +1248,13 @@ class MessageReceiverInner extends EventTarget { originalSource || originalSourceUuid ); + // eslint-disable-next-line no-param-reassign + envelope.contentHint = messageContent.contentHint(); + // eslint-disable-next-line no-param-reassign + envelope.groupId = messageContent.groupId()?.toString('base64'); + // eslint-disable-next-line no-param-reassign + envelope.usmc = messageContent; + if ( (envelope.source && this.isBlocked(envelope.source)) || (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) @@ -1231,6 +1271,17 @@ class MessageReceiverInner extends EventTarget { ); } + if ( + messageContent.msgType() === + unidentifiedSenderTypeEnum.PLAINTEXT_CONTENT + ) { + const plaintextContent = PlaintextContent.deserialize( + messageContent.contents() + ); + + return plaintextContent.body(); + } + if ( messageContent.msgType() === unidentifiedSenderTypeEnum.SENDERKEY_MESSAGE @@ -1345,10 +1396,26 @@ class MessageReceiverInner extends EventTarget { } if (uuid && deviceId) { - // It is safe (from deadlocks) to await this call because the session - // reset is going to be scheduled on a separate p-queue in - // ts/background.ts - await this.lightSessionReset(uuid, deviceId); + const event = new Event('decryption-error'); + event.decryptionError = { + cipherTextBytes: envelope.usmc + ? typedArrayToArrayBuffer(envelope.usmc.contents()) + : undefined, + cipherTextType: envelope.usmc ? envelope.usmc.msgType() : undefined, + contentHint: envelope.contentHint, + groupId: envelope.groupId, + receivedAtCounter: envelope.receivedAtCounter, + receivedAtDate: envelope.receivedAtDate, + senderDevice: deviceId, + senderUuid: uuid, + timestamp: envelope.timestamp.toNumber(), + }; + + // Avoid deadlocks by scheduling processing on decrypted queue + this.addToQueue( + () => this.dispatchAndWait(event), + TaskType.Decrypted + ); } else { const envelopeId = this.getEnvelopeId(envelope); window.log.error( @@ -1360,40 +1427,6 @@ class MessageReceiverInner extends EventTarget { }); } - isOverHourIntoPast(timestamp: number): boolean { - const HOUR = 1000 * 60 * 60; - const now = Date.now(); - const oneHourIntoPast = now - HOUR; - - return isNumber(timestamp) && timestamp <= oneHourIntoPast; - } - - // We don't lose anything if we delete keys over an hour into the past, because we only - // change our behavior if the timestamps stored are less than an hour ago. - cleanupSessionResets(): void { - const sessionResets = window.storage.get( - 'sessionResets', - {} - ) as SessionResetsType; - - const keys = Object.keys(sessionResets); - keys.forEach(key => { - const timestamp = sessionResets[key]; - if (!timestamp || this.isOverHourIntoPast(timestamp)) { - delete sessionResets[key]; - } - }); - - window.storage.put('sessionResets', sessionResets); - } - - async lightSessionReset(uuid: string, deviceId: number): Promise { - const event = new Event('light-session-reset'); - event.senderUuid = uuid; - event.senderDevice = deviceId; - await this.dispatchAndWait(event); - } - async handleSentMessage( envelope: EnvelopeClass, sentContainer: SyncMessageClass.Sent @@ -1630,7 +1663,10 @@ class MessageReceiverInner extends EventTarget { // make sure to process it first. If that fails, we still try to process // the rest of the message. try { - if (content.senderKeyDistributionMessage) { + if ( + content.senderKeyDistributionMessage && + !isByteBufferEmpty(content.senderKeyDistributionMessage) + ) { await this.handleSenderKeyDistributionMessage( envelope, content.senderKeyDistributionMessage @@ -1643,6 +1679,16 @@ class MessageReceiverInner extends EventTarget { ); } + if ( + content.decryptionErrorMessage && + !isByteBufferEmpty(content.decryptionErrorMessage) + ) { + await this.handleDecryptionError( + envelope, + content.decryptionErrorMessage + ); + return; + } if (content.syncMessage) { await this.handleSyncMessage(envelope, content.syncMessage); return; @@ -1675,6 +1721,34 @@ class MessageReceiverInner extends EventTarget { } } + async handleDecryptionError( + envelope: EnvelopeClass, + decryptionError: ByteBufferClass + ) { + const envelopeId = this.getEnvelopeId(envelope); + window.log.info(`handleDecryptionError: ${envelopeId}`); + + const buffer = Buffer.from(decryptionError.toArrayBuffer()); + const request = DecryptionErrorMessage.deserialize(buffer); + + this.removeFromCache(envelope); + + const { sourceUuid, sourceDevice } = envelope; + if (!sourceUuid || !sourceDevice) { + window.log.error('handleDecryptionError: Missing uuid or device!'); + return; + } + + const event = new Event('retry-request'); + event.retryRequest = { + sentAt: request.timestamp(), + requesterUuid: sourceUuid, + requesterDevice: sourceDevice, + senderDevice: request.deviceId(), + }; + await this.dispatchAndWait(event); + } + async handleSenderKeyDistributionMessage( envelope: EnvelopeClass, distributionMessage: ByteBufferClass @@ -2603,10 +2677,6 @@ export default class MessageReceiver { this.stopProcessing = inner.stopProcessing.bind(inner); this.unregisterBatchers = inner.unregisterBatchers.bind(inner); - // For tests - this.isOverHourIntoPast = inner.isOverHourIntoPast.bind(inner); - this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner); - inner.connect(); this.getProcessedCount = () => inner.processedCount; } @@ -2629,10 +2699,6 @@ export default class MessageReceiver { unregisterBatchers: () => void; - isOverHourIntoPast: (timestamp: number) => boolean; - - cleanupSessionResets: () => void; - getProcessedCount: () => number; static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer; diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 47f37093f..ea7cf8831 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -13,10 +13,13 @@ import { reject } from 'lodash'; import { z } from 'zod'; import { CiphertextMessageType, + CiphertextMessage, + PlaintextContent, ProtocolAddress, - sealedSenderEncryptMessage, + sealedSenderEncrypt, SenderCertificate, signalEncrypt, + UnidentifiedSenderMessageContent, } from '@signalapp/signal-client'; import { WebAPIType } from './WebAPI'; @@ -73,6 +76,9 @@ function ciphertextMessageTypeToEnvelopeType(type: number) { if (type === CiphertextMessageType.Whisper) { return window.textsecure.protobuf.Envelope.Type.CIPHERTEXT; } + if (type === CiphertextMessageType.Plaintext) { + return window.textsecure.protobuf.Envelope.Type.PLAINTEXT_CONTENT; + } throw new Error( `ciphertextMessageTypeToEnvelopeType: Unrecognized type ${type}` ); @@ -106,12 +112,10 @@ export default class OutgoingMessage { identifiers: Array; - message: ContentClass; + message: ContentClass | PlaintextContent; callback: (result: CallbackResultType) => void; - silent?: boolean; - plaintext?: Uint8Array; identifiersCompleted: number; @@ -128,12 +132,17 @@ export default class OutgoingMessage { online?: boolean; + groupId?: string; + + contentHint: number; + constructor( server: WebAPIType, timestamp: number, identifiers: Array, - message: ContentClass | DataMessageClass, - silent: boolean | undefined, + message: ContentClass | DataMessageClass | PlaintextContent, + contentHint: number, + groupId: string | undefined, callback: (result: CallbackResultType) => void, options: OutgoingMessageOptionsType = {} ) { @@ -149,8 +158,9 @@ export default class OutgoingMessage { this.server = server; this.timestamp = timestamp; this.identifiers = identifiers; + this.contentHint = contentHint; + this.groupId = groupId; this.callback = callback; - this.silent = silent; this.identifiersCompleted = 0; this.errors = []; @@ -186,12 +196,7 @@ export default class OutgoingMessage { if (error && error.code === 428) { error = new SendMessageChallengeError(identifier, error); } else { - error = new OutgoingMessageError( - identifier, - this.message.toArrayBuffer(), - this.timestamp, - error - ); + error = new OutgoingMessageError(identifier, null, null, error); } } @@ -246,7 +251,6 @@ export default class OutgoingMessage { } catch (error) { if (error?.message?.includes('untrusted identity for address')) { error.timestamp = this.timestamp; - error.originalMessage = this.message.toArrayBuffer(); } throw error; } @@ -265,7 +269,6 @@ export default class OutgoingMessage { identifier, jsonData, timestamp, - this.silent, this.online, { accessKey } ); @@ -274,7 +277,6 @@ export default class OutgoingMessage { identifier, jsonData, timestamp, - this.silent, this.online ); } @@ -299,18 +301,45 @@ export default class OutgoingMessage { getPlaintext(): ArrayBuffer { if (!this.plaintext) { - this.plaintext = padMessage(this.message.toArrayBuffer()); + const { message } = this; + + if (message instanceof window.textsecure.protobuf.Content) { + this.plaintext = padMessage(message.toArrayBuffer()); + } else { + this.plaintext = message.serialize(); + } } return this.plaintext; } + async getCiphertextMessage({ + identityKeyStore, + protocolAddress, + sessionStore, + }: { + identityKeyStore: IdentityKeys; + protocolAddress: ProtocolAddress; + sessionStore: Sessions; + }): Promise { + const { message } = this; + + if (message instanceof window.textsecure.protobuf.Content) { + return signalEncrypt( + Buffer.from(this.getPlaintext()), + protocolAddress, + sessionStore, + identityKeyStore + ); + } + + return message.asCiphertextMessage(); + } + async doSendMessage( identifier: string, deviceIds: Array, recurse?: boolean ): Promise { - const plaintext = this.getPlaintext(); - const { sendMetadata } = this; const { accessKey, senderCertificate } = sendMetadata?.[identifier] || {}; @@ -364,15 +393,29 @@ export default class OutgoingMessage { const destinationRegistrationId = activeSession.remoteRegistrationId(); if (sealedSender && senderCertificate) { + const ciphertextMessage = await this.getCiphertextMessage({ + identityKeyStore, + protocolAddress, + sessionStore, + }); + const certificate = SenderCertificate.deserialize( Buffer.from(senderCertificate.serialized) ); + const groupIdBuffer = this.groupId + ? Buffer.from(this.groupId, 'base64') + : null; - const buffer = await sealedSenderEncryptMessage( - Buffer.from(plaintext), - protocolAddress, + const content = UnidentifiedSenderMessageContent.new( + ciphertextMessage, certificate, - sessionStore, + this.contentHint, + groupIdBuffer + ); + + const buffer = await sealedSenderEncrypt( + content, + protocolAddress, identityKeyStore ); @@ -385,12 +428,11 @@ export default class OutgoingMessage { }; } - const ciphertextMessage = await signalEncrypt( - Buffer.from(plaintext), + const ciphertextMessage = await this.getCiphertextMessage({ + identityKeyStore, protocolAddress, sessionStore, - identityKeyStore - ); + }); const type = ciphertextMessageTypeToEnvelopeType( ciphertextMessage.type() ); @@ -487,8 +529,6 @@ export default class OutgoingMessage { if (error?.message?.includes('untrusted identity for address')) { // eslint-disable-next-line no-param-reassign error.timestamp = this.timestamp; - // eslint-disable-next-line no-param-reassign - error.originalMessage = this.message.toArrayBuffer(); window.log.error( 'Got "key changed" error from encrypt - no identityKey for application layer', identifier, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 41368b9d7..1e7ac3004 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -12,6 +12,7 @@ import { Dictionary } from 'lodash'; import PQueue from 'p-queue'; import { AbortSignal } from 'abort-controller'; import { + PlaintextContent, ProtocolAddress, SenderKeyDistributionMessage, } from '@signalapp/signal-client'; @@ -795,10 +796,11 @@ export default class MessageSender { async sendMessage( attrs: MessageOptionsType, + contentHint: number, + groupId: string | undefined, options?: SendOptionsType ): Promise { const message = new Message(attrs); - const silent = false; return Promise.all([ this.uploadAttachments(message), @@ -812,6 +814,8 @@ export default class MessageSender { message.timestamp, message.recipients || [], message.toProto(), + contentHint, + groupId, (res: CallbackResultType) => { res.dataMessage = message.toArrayBuffer(); if (res.errors && res.errors.length > 0) { @@ -820,7 +824,6 @@ export default class MessageSender { resolve(res); } }, - silent, options ); }) @@ -830,9 +833,10 @@ export default class MessageSender { sendMessageProto( timestamp: number, recipients: Array, - messageProto: ContentClass | DataMessageClass, + messageProto: ContentClass | DataMessageClass | PlaintextContent, + contentHint: number, + groupId: string | undefined, callback: (result: CallbackResultType) => void, - silent?: boolean, options?: SendOptionsType ): void { const rejections = window.textsecure.storage.get( @@ -848,7 +852,8 @@ export default class MessageSender { timestamp, recipients, messageProto, - silent, + contentHint, + groupId, callback, options ); @@ -863,8 +868,9 @@ export default class MessageSender { async sendMessageProtoAndWait( timestamp: number, identifiers: Array, - messageProto: DataMessageClass, - silent?: boolean, + messageProto: ContentClass | DataMessageClass | PlaintextContent, + contentHint: number, + groupId: string | undefined, options?: SendOptionsType ): Promise { return new Promise((resolve, reject) => { @@ -881,8 +887,9 @@ export default class MessageSender { timestamp, identifiers, messageProto, + contentHint, + groupId, callback, - silent, options ); }); @@ -890,9 +897,9 @@ export default class MessageSender { async sendIndividualProto( identifier: string, - proto: DataMessageClass | ContentClass, + proto: DataMessageClass | ContentClass | PlaintextContent, timestamp: number, - silent?: boolean, + contentHint: number, options?: SendOptionsType ): Promise { return new Promise((resolve, reject) => { @@ -907,13 +914,16 @@ export default class MessageSender { timestamp, [identifier], proto, + contentHint, + undefined, // groupId callback, - silent, options ); }); } + // You might wonder why this takes a groupId. models/messages.resend() can send a group + // message to just one person. async sendMessageToIdentifier( identifier: string, messageText: string | undefined, @@ -925,6 +935,8 @@ export default class MessageSender { deletedForEveryoneTimestamp: number | undefined, timestamp: number, expireTimer: number | undefined, + contentHint: number, + groupId: string | undefined, profileKey?: ArrayBuffer, options?: SendOptionsType ): Promise { @@ -942,6 +954,8 @@ export default class MessageSender { expireTimer, profileKey, }, + contentHint, + groupId, options ); } @@ -1018,12 +1032,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, timestamp, - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1043,12 +1060,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1071,12 +1091,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1098,12 +1121,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1127,12 +1153,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1160,12 +1189,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + await this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1189,12 +1221,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + await this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1224,12 +1259,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1261,12 +1299,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1299,12 +1340,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, sendOptions ); } @@ -1344,12 +1388,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1397,12 +1444,15 @@ export default class MessageSender { const secondMessage = new window.textsecure.protobuf.Content(); secondMessage.syncMessage = syncMessage; - const innerSilent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( myUuid || myNumber, secondMessage, now, - innerSilent, + ContentHint.SUPPLEMENTARY, options ); }); @@ -1416,6 +1466,10 @@ export default class MessageSender { sendOptions: SendOptionsType, groupId?: string ): Promise { + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendMessage( { recipients, @@ -1431,6 +1485,8 @@ export default class MessageSender { } : {}), }, + ContentHint.SUPPLEMENTARY, + undefined, // groupId sendOptions ); } @@ -1446,13 +1502,16 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.callingMessage = callingMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; await this.sendMessageProtoAndWait( finalTimestamp, recipients, contentMessage, - silent, + ContentHint.SUPPLEMENTARY, + undefined, // groupId sendOptions ); } @@ -1481,12 +1540,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.receiptMessage = receiptMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( recipientUuid || recipientE164, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1504,12 +1566,15 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.receiptMessage = receiptMessage; - const silent = true; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendIndividualProto( senderUuid || senderE164, contentMessage, Date.now(), - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1534,14 +1599,17 @@ export default class MessageSender { const contentMessage = new window.textsecure.protobuf.Content(); contentMessage.nullMessage = nullMessage; - // We want the NullMessage to look like a normal outgoing message; not silent - const silent = false; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + + // We want the NullMessage to look like a normal outgoing message const timestamp = Date.now(); return this.sendIndividualProto( identifier, contentMessage, timestamp, - silent, + ContentHint.SUPPLEMENTARY, options ); } @@ -1555,7 +1623,6 @@ export default class MessageSender { CallbackResultType | void | Array> > { window.log.info('resetSession: start'); - const silent = false; const proto = new window.textsecure.protobuf.DataMessage(); proto.body = 'TERMINATE'; proto.flags = window.textsecure.protobuf.DataMessage.Flags.END_SESSION; @@ -1568,6 +1635,10 @@ export default class MessageSender { throw error; }; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + const sendToContactPromise = window.textsecure.storage.protocol .archiveAllSessions(identifier) .catch(logError('resetSession/archiveAllSessions1 error:')) @@ -1579,7 +1650,7 @@ export default class MessageSender { identifier, proto, timestamp, - silent, + ContentHint.SUPPLEMENTARY, options ).catch(logError('resetSession/sendToContact error:')); }) @@ -1619,6 +1690,10 @@ export default class MessageSender { profileKey?: ArrayBuffer, options?: SendOptionsType ): Promise { + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendMessage( { recipients: [identifier], @@ -1628,6 +1703,31 @@ export default class MessageSender { flags: window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, }, + ContentHint.SUPPLEMENTARY, + undefined, // groupId + options + ); + } + + async sendRetryRequest({ + options, + plaintext, + uuid, + }: { + options?: SendOptionsType; + plaintext: PlaintextContent; + uuid: string; + }): Promise { + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + + return this.sendMessageProtoAndWait( + Date.now(), + [uuid], + plaintext, + ContentHint.SUPPLEMENTARY, + undefined, // groupId options ); } @@ -1639,6 +1739,8 @@ export default class MessageSender { providedIdentifiers: Array, proto: ContentClass, timestamp = Date.now(), + contentHint: number, + groupId: string | undefined, options?: SendOptionsType ): Promise { const myE164 = window.textsecure.storage.user.getNumber(); @@ -1658,7 +1760,6 @@ export default class MessageSender { } return new Promise((resolve, reject) => { - const silent = true; const callback = (res: CallbackResultType) => { res.dataMessage = proto.dataMessage?.toArrayBuffer(); if (res.errors && res.errors.length > 0) { @@ -1672,21 +1773,17 @@ export default class MessageSender { timestamp, providedIdentifiers, proto, + contentHint, + groupId, callback, - silent, options ); }); } - // The one group send exception - a message that should never be sent via sender key - async sendSenderKeyDistributionMessage( - { - distributionId, - identifiers, - }: { distributionId: string; identifiers: Array }, - options?: SendOptionsType - ): Promise { + async getSenderKeyDistributionMessage( + distributionId: string + ): Promise { const ourUuid = window.textsecure.storage.user.getUuid(); if (!ourUuid) { throw new Error( @@ -1702,7 +1799,7 @@ export default class MessageSender { const address = `${ourUuid}.${ourDeviceId}`; const senderKeyStore = new SenderKeys(); - const message = await window.textsecure.storage.protocol.enqueueSenderKeyJob( + return window.textsecure.storage.protocol.enqueueSenderKeyJob( address, async () => SenderKeyDistributionMessage.create( @@ -1711,13 +1808,40 @@ export default class MessageSender { senderKeyStore ) ); + } - const proto = new window.textsecure.protobuf.Content(); - proto.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap( - typedArrayToArrayBuffer(message.serialize()) + // The one group send exception - a message that should never be sent via sender key + async sendSenderKeyDistributionMessage( + { + contentHint, + distributionId, + groupId, + identifiers, + }: { + contentHint: number; + distributionId: string; + groupId: string | undefined; + identifiers: Array; + }, + options?: SendOptionsType + ): Promise { + const contentMessage = new window.textsecure.protobuf.Content(); + + const senderKeyDistributionMessage = await this.getSenderKeyDistributionMessage( + distributionId + ); + contentMessage.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap( + typedArrayToArrayBuffer(senderKeyDistributionMessage.serialize()) ); - return this.sendGroupProto(identifiers, proto, Date.now(), options); + return this.sendGroupProto( + identifiers, + contentMessage, + Date.now(), + contentHint, + groupId, + options + ); } // GroupV1-only functions; not to be used in the future @@ -1731,7 +1855,18 @@ export default class MessageSender { proto.group = new window.textsecure.protobuf.GroupContext(); proto.group.id = stringToArrayBuffer(groupId); proto.group.type = window.textsecure.protobuf.GroupContext.Type.QUIT; - return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options); + + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendGroupProto( + groupIdentifiers, + proto, + Date.now(), + ContentHint.SUPPLEMENTARY, + undefined, // only for GV2 ids + options + ); } async sendExpirationTimerUpdateToGroup( @@ -1770,7 +1905,15 @@ export default class MessageSender { }); } - return this.sendMessage(attrs, options); + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; + return this.sendMessage( + attrs, + ContentHint.SUPPLEMENTARY, + undefined, // only for GV2 ids + options + ); } // Simple pass-throughs diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 070f0f6e5..6fbed700a 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -934,14 +934,12 @@ export type WebAPIType = { destination: string, messageArray: Array, timestamp: number, - silent?: boolean, online?: boolean ) => Promise; sendMessagesUnauth: ( destination: string, messageArray: Array, timestamp: number, - silent?: boolean, online?: boolean, options?: { accessKey?: string } ) => Promise; @@ -1446,7 +1444,7 @@ export function initialize({ const capabilities: CapabilitiesUploadType = { 'gv2-3': true, 'gv1-migration': true, - senderKey: false, + senderKey: true, }; const { accessKey } = options; @@ -1684,15 +1682,11 @@ export function initialize({ destination: string, messageArray: Array, timestamp: number, - silent?: boolean, online?: boolean, { accessKey }: { accessKey?: string } = {} ) { const jsonData: any = { messages: messageArray, timestamp }; - if (silent) { - jsonData.silent = true; - } if (online) { jsonData.online = true; } @@ -1712,14 +1706,10 @@ export function initialize({ destination: string, messageArray: Array, timestamp: number, - silent?: boolean, online?: boolean ) { const jsonData: any = { messages: messageArray, timestamp }; - if (silent) { - jsonData.silent = true; - } if (online) { jsonData.online = true; } diff --git a/ts/util/index.ts b/ts/util/index.ts index b2ffaf9d7..937b4a692 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -36,6 +36,7 @@ import * as zkgroup from './zkgroup'; import { StartupQueue } from './StartupQueue'; import { postLinkExperience } from './postLinkExperience'; import { sendToGroup, sendContentMessageToGroup } from './sendToGroup'; +import { RetryPlaceholders } from './retryPlaceholders'; export { GoogleChrome, @@ -62,6 +63,7 @@ export { parseRemoteClientExpiration, postLinkExperience, queueUpdateMessage, + RetryPlaceholders, saveNewMessageBatcher, sendContentMessageToGroup, sendToGroup, diff --git a/ts/util/retryPlaceholders.ts b/ts/util/retryPlaceholders.ts new file mode 100644 index 000000000..5f8b3f364 --- /dev/null +++ b/ts/util/retryPlaceholders.ts @@ -0,0 +1,196 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { z } from 'zod'; +import { groupBy } from 'lodash'; + +const retryItemSchema = z + .object({ + conversationId: z.string(), + sentAt: z.number(), + receivedAt: z.number(), + receivedAtCounter: z.number(), + senderUuid: z.string(), + }) + .passthrough(); +export type RetryItemType = z.infer; + +const retryItemListSchema = z.array(retryItemSchema); +export type RetryItemListType = z.infer; + +export type ByConversationLookupType = { + [key: string]: Array; +}; +export type ByMessageLookupType = Map; + +export function getItemId(conversationId: string, sentAt: number): string { + return `${conversationId}--${sentAt}`; +} + +const HOUR = 60 * 60 * 1000; +export const STORAGE_KEY = 'retryPlaceholders'; + +export function getOneHourAgo(): number { + return Date.now() - HOUR; +} + +export class RetryPlaceholders { + private items: Array; + + private byConversation: ByConversationLookupType; + + private byMessage: ByMessageLookupType; + + constructor() { + if (!window.storage) { + throw new Error( + 'RetryPlaceholders.constructor: window.storage not available!' + ); + } + + const parsed = retryItemListSchema.safeParse( + window.storage.get(STORAGE_KEY) || [] + ); + if (!parsed.success) { + window.log.warn( + `RetryPlaceholders.constructor: Data fetched from storage did not match schema: ${JSON.stringify( + parsed.error.flatten() + )}` + ); + } + + this.items = parsed.success ? parsed.data : []; + window.log.info( + `RetryPlaceholders.constructor: Started with ${this.items.length} items` + ); + + this.sortByExpiresAtAsc(); + this.byConversation = this.makeByConversationLookup(); + this.byMessage = this.makeByMessageLookup(); + } + + // Arranging local data for efficiency + + sortByExpiresAtAsc(): void { + this.items.sort( + (left: RetryItemType, right: RetryItemType) => + left.receivedAt - right.receivedAt + ); + } + + makeByConversationLookup(): ByConversationLookupType { + return groupBy(this.items, item => item.conversationId); + } + + makeByMessageLookup(): ByMessageLookupType { + const lookup = new Map(); + this.items.forEach(item => { + lookup.set(getItemId(item.conversationId, item.sentAt), item); + }); + return lookup; + } + + makeLookups(): void { + this.byConversation = this.makeByConversationLookup(); + this.byMessage = this.makeByMessageLookup(); + } + + // Basic data management + + async add(item: RetryItemType): Promise { + const parsed = retryItemSchema.safeParse(item); + if (!parsed.success) { + throw new Error( + `RetryPlaceholders.add: Item did not match schema ${JSON.stringify( + parsed.error.flatten() + )}` + ); + } + + this.items.push(item); + this.sortByExpiresAtAsc(); + this.makeLookups(); + await this.save(); + } + + async save(): Promise { + await window.storage.put(STORAGE_KEY, this.items); + } + + // Finding items in different ways + + getCount(): number { + return this.items.length; + } + + getNextToExpire(): RetryItemType | undefined { + return this.items[0]; + } + + async getExpiredAndRemove(): Promise> { + const expiration = getOneHourAgo(); + const max = this.items.length; + const result: Array = []; + + for (let i = 0; i < max; i += 1) { + const item = this.items[i]; + if (item.receivedAt <= expiration) { + result.push(item); + } else { + break; + } + } + + window.log.info( + `RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items` + ); + + this.items.splice(0, result.length); + this.makeLookups(); + await this.save(); + + return result; + } + + async findByConversationAndRemove( + conversationId: string + ): Promise> { + const result = this.byConversation[conversationId]; + if (!result) { + return []; + } + + const items = this.items.filter( + item => item.conversationId !== conversationId + ); + + window.log.info( + `RetryPlaceholders.findByConversationAndRemove: Found ${result.length} expired items` + ); + + this.items = items; + this.sortByExpiresAtAsc(); + this.makeLookups(); + await this.save(); + + return result; + } + + async findByMessageAndRemove( + conversationId: string, + sentAt: number + ): Promise { + const result = this.byMessage.get(getItemId(conversationId, sentAt)); + if (!result) { + return undefined; + } + + const index = this.items.findIndex(item => item === result); + + this.items.splice(index, 1); + this.makeLookups(); + await this.save(); + + return result; + } +} diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 693c83ea8..c3eac7368 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -55,6 +55,7 @@ const MAX_RECURSION = 5; export async function sendToGroup( groupSendOptions: GroupSendOptionsType, conversation: ConversationModel, + contentHint: number, sendOptions?: SendOptionsType, isPartialSend?: boolean ): Promise { @@ -75,6 +76,7 @@ export async function sendToGroup( ); return sendContentMessageToGroup({ + contentHint, contentMessage, conversation, isPartialSend, @@ -85,6 +87,7 @@ export async function sendToGroup( } export async function sendContentMessageToGroup({ + contentHint, contentMessage, conversation, isPartialSend, @@ -93,6 +96,7 @@ export async function sendContentMessageToGroup({ sendOptions, timestamp, }: { + contentHint: number; contentMessage: ContentClass; conversation: ConversationModel; isPartialSend?: boolean; @@ -110,6 +114,7 @@ export async function sendContentMessageToGroup({ if (conversation.isGroupV2()) { try { return await sendToGroupViaSenderKey({ + contentHint, contentMessage, conversation, isPartialSend, @@ -127,10 +132,15 @@ export async function sendContentMessageToGroup({ } } + const groupId = conversation.isGroupV2() + ? conversation.get('groupId') + : undefined; return window.textsecure.messaging.sendGroupProto( recipients, contentMessage, timestamp, + contentHint, + groupId, sendOptions ); } @@ -138,6 +148,7 @@ export async function sendContentMessageToGroup({ // The Primary Sender Key workflow export async function sendToGroupViaSenderKey(options: { + contentHint: number; contentMessage: ContentClass; conversation: ConversationModel; isPartialSend?: boolean; @@ -148,6 +159,7 @@ export async function sendToGroupViaSenderKey(options: { timestamp: number; }): Promise { const { + contentHint, contentMessage, conversation, isPartialSend, @@ -157,6 +169,9 @@ export async function sendToGroupViaSenderKey(options: { sendOptions, timestamp, } = options; + const { + ContentHint, + } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message; const logId = conversation.idForLogging(); window.log.info( @@ -176,6 +191,15 @@ export async function sendToGroupViaSenderKey(options: { ); } + if ( + contentHint !== ContentHint.RESENDABLE && + contentHint !== ContentHint.SUPPLEMENTARY + ) { + throw new Error( + `sendToGroupViaSenderKey/${logId}: Invalid contentHint ${contentHint}` + ); + } + assert( window.textsecure.messaging, 'sendToGroupViaSenderKey: textsecure.messaging not available!' @@ -293,10 +317,15 @@ export async function sendToGroupViaSenderKey(options: { newToMemberUuids.length } members: ${JSON.stringify(newToMemberUuids)}` ); - await window.textsecure.messaging.sendSenderKeyDistributionMessage({ - distributionId, - identifiers: newToMemberUuids, - }); + await window.textsecure.messaging.sendSenderKeyDistributionMessage( + { + contentHint: ContentHint.SUPPLEMENTARY, + distributionId, + groupId, + identifiers: newToMemberUuids, + }, + sendOptions + ); } // 9. Update memberDevices with both adds and the removals which didn't require a reset. @@ -323,6 +352,7 @@ export async function sendToGroupViaSenderKey(options: { // 10. Send the Sender Key message! try { const messageBuffer = await encryptForSenderKey({ + contentHint, devices: devicesForSenderKey, distributionId, contentMessage: contentMessage.toArrayBuffer(), @@ -396,6 +426,8 @@ export async function sendToGroupViaSenderKey(options: { normalRecipients, contentMessage, timestamp, + contentHint, + groupId, sendOptions ); @@ -594,14 +626,16 @@ function getXorOfAccessKeys(devices: Array): Buffer { } async function encryptForSenderKey({ + contentHint, + contentMessage, devices, distributionId, - contentMessage, groupId, }: { + contentHint: number; + contentMessage: ArrayBuffer; devices: Array; distributionId: string; - contentMessage: ArrayBuffer; groupId: string; }): Promise { const ourUuid = window.textsecure.storage.user.getUuid(); @@ -625,7 +659,6 @@ async function encryptForSenderKey({ () => groupEncrypt(sender, distributionId, senderKeyStore, message) ); - const contentHint = 1; const groupIdBuffer = Buffer.from(groupId, 'base64'); const senderCertificateObject = await senderCertificateService.get( SenderCertificateMode.WithoutE164 @@ -676,8 +709,8 @@ function isValidSenderKeyRecipient( return false; } - const { capabilities } = memberConversation.attributes; - if (!capabilities.senderKey) { + const capabilities = memberConversation.get('capabilities'); + if (!capabilities?.senderKey) { window.log.info( `isValidSenderKeyRecipient: Missing senderKey capability for member ${uuid}` ); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 2576661dd..3c7592561 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -394,12 +394,6 @@ Whisper.ConversationView = Whisper.View.extend({ this.model.throttledGetProfiles = this.model.throttledGetProfiles || window._.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); - this.model.throttledMaybeMigrateV1Group = - this.model.throttledMaybeMigrateV1Group || - window._.throttle( - this.model.maybeMigrateV1Group.bind(this.model), - FIVE_MINUTES - ); this.debouncedMaybeGrabLinkPreview = window._.debounce( this.maybeGrabLinkPreview.bind(this), @@ -2171,6 +2165,8 @@ Whisper.ConversationView = Whisper.View.extend({ }, async onOpened(messageId: any) { + const { model }: { model: ConversationModel } = this; + if (messageId) { const message = await getMessageById(messageId, { Message: Whisper.Message, @@ -2184,29 +2180,41 @@ Whisper.ConversationView = Whisper.View.extend({ window.log.warn(`onOpened: Did not find message ${messageId}`); } + const { retryPlaceholders } = window.Signal.Services; + if (retryPlaceholders) { + const placeholders = await retryPlaceholders.findByConversationAndRemove( + model.id + ); + window.log.info(`onOpened: Found ${placeholders.length} placeholders`); + } + this.loadNewestMessages(); - this.model.updateLastMessage(); + model.updateLastMessage(); this.focusMessageField(); - const quotedMessageId = this.model.get('quotedMessageId'); + const quotedMessageId = model.get('quotedMessageId'); if (quotedMessageId) { this.setQuoteMessage(quotedMessageId); } - this.model.fetchLatestGroupV2Data(); - this.model.throttledMaybeMigrateV1Group(); + model.fetchLatestGroupV2Data(); assert( - this.model.throttledFetchSMSOnlyUUID !== undefined, + model.throttledMaybeMigrateV1Group !== undefined, 'Conversation model should be initialized' ); - this.model.throttledFetchSMSOnlyUUID(); + model.throttledMaybeMigrateV1Group(); + assert( + model.throttledFetchSMSOnlyUUID !== undefined, + 'Conversation model should be initialized' + ); + model.throttledFetchSMSOnlyUUID(); const statusPromise = this.model.throttledGetProfiles(); // eslint-disable-next-line more/no-then this.statusFetch = statusPromise.then(() => // eslint-disable-next-line more/no-then - this.model.updateVerified().then(() => { + model.updateVerified().then(() => { this.onVerifiedChange(); this.statusFetch = null; }) diff --git a/ts/window.d.ts b/ts/window.d.ts index 0cfb65d21..1d9c07c8e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -315,6 +315,7 @@ declare global { ) => void; onTimeout: (timestamp: number, cb: () => void, id?: string) => string; removeTimeout: (uuid: string) => void; + retryPlaceholders?: Util.RetryPlaceholders; runStorageServiceSyncJob: () => Promise; storageServiceUploadJob: () => void; }; diff --git a/yarn.lock b/yarn.lock index 2d20ff122..be35ace0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1634,10 +1634,10 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" -"@signalapp/signal-client@0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a" - integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg== +"@signalapp/signal-client@0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.8.0.tgz#30c3bfafbd32680c8dd7e5417e53b928b1ccdd65" + integrity sha512-pchM+cwWdJZSCIceUvq/2lNZr6qJO7qGpQMfxbm9CGrcQaU7t7vtrkR5F0AsHnYO+lfL/3mMOVbBb0Rgl5/IVw== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0"