Support for message retry requests

This commit is contained in:
Scott Nonnenberg 2021-05-28 12:11:19 -07:00 committed by GitHub
parent 28f016ce48
commit ee513a1965
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1996 additions and 359 deletions

View file

@ -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<string, number>;
export async function cleanupSessionResets(): Promise<void> {
const sessionResets = window.storage.get<SessionResetsType>(
'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<void> {
window.startupProcessingQueue = new window.Signal.Util.StartupQueue();
window.attachmentDownloadQueue = [];
@ -377,6 +411,27 @@ export async function startApp(): Promise<void> {
}
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<void> {
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<void> {
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<void> {
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<string | undefined | null> | 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<void> {
senderDevice
);
});
}
function startAutomaticSessionReset(decryptionError: DecryptionErrorType) {
const { senderUuid, senderDevice } = decryptionError;
scheduleSessionReset(senderUuid, senderDevice);
const conversationId = window.ConversationController.ensureContactIds({
uuid: senderUuid,

View file

@ -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 <Modal>. See DESKTOP-1038.
export function ChatSessionRefreshedDialog(
props: PropsType
): React.ReactElement {
const { i18n, contactSupport, onClose } = props;
return (
<div className="module-chat-session-refreshed-dialog">
<div className="module-chat-session-refreshed-dialog__image">
<img
src="images/chat-session-refresh.svg"
height="110"
width="200"
alt=""
/>
<Modal hasXButton={false} i18n={i18n}>
<div className="module-chat-session-refreshed-dialog">
<div className="module-chat-session-refreshed-dialog__image">
<img
src="images/chat-session-refresh.svg"
height="110"
width="200"
alt=""
/>
</div>
<div className="module-chat-session-refreshed-dialog__title">
{i18n('ChatRefresh--notification')}
</div>
<div className="module-chat-session-refreshed-dialog__description">
{i18n('ChatRefresh--summary')}
</div>
<div className="module-chat-session-refreshed-dialog__buttons">
<button
type="button"
onClick={contactSupport}
className={classNames(
'module-chat-session-refreshed-dialog__button',
'module-chat-session-refreshed-dialog__button--secondary'
)}
>
{i18n('ChatRefresh--contactSupport')}
</button>
<button
type="button"
onClick={onClose}
className="module-chat-session-refreshed-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
<div className="module-chat-session-refreshed-dialog__title">
{i18n('ChatRefresh--notification')}
</div>
<div className="module-chat-session-refreshed-dialog__description">
{i18n('ChatRefresh--summary')}
</div>
<div className="module-chat-session-refreshed-dialog__buttons">
<button
type="button"
onClick={contactSupport}
className={classNames(
'module-chat-session-refreshed-dialog__button',
'module-chat-session-refreshed-dialog__button--secondary'
)}
>
{i18n('ChatRefresh--contactSupport')}
</button>
<button
type="button"
onClick={onClose}
className="module-chat-session-refreshed-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
</Modal>
);
}

View file

@ -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')}
</button>
{isDialogOpen ? (
<ModalHost onClose={closeDialog}>
<ChatSessionRefreshedDialog
onClose={closeDialog}
contactSupport={wrappedContactSupport}
i18n={i18n}
/>
</ModalHost>
<ChatSessionRefreshedDialog
onClose={closeDialog}
contactSupport={wrappedContactSupport}
i18n={i18n}
/>
) : null}
</div>
);

View file

@ -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 (
<DeliveryIssueDialog
i18n={i18n}
sender={sender}
onClose={action('onClose')}
/>
);
}
);

View file

@ -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 (
<Modal hasXButton={false} i18n={i18n}>
<div className="module-delivery-issue-dialog">
<div className="module-delivery-issue-dialog__image">
<img
src="images/delivery-issue.svg"
height="110"
width="200"
alt=""
/>
</div>
<div className="module-delivery-issue-dialog__title">
{i18n('DeliveryIssue--title')}
</div>
<div className="module-delivery-issue-dialog__description">
<Intl
id="DeliveryIssue--summary"
components={{
sender: <Emojify text={sender.title} />,
}}
i18n={i18n}
/>
</div>
<div className="module-delivery-issue-dialog__buttons">
<button
type="button"
onClick={onClose}
className="module-delivery-issue-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
</Modal>
);
}

View file

@ -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 <DeliveryIssueNotification i18n={i18n} sender={sender} />;
}
);

View file

@ -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<boolean>(false);
const openDialog = useCallback(() => {
setIsDialogOpen(true);
}, [setIsDialogOpen]);
const closeDialog = useCallback(() => {
setIsDialogOpen(false);
}, [setIsDialogOpen]);
if (!sender) {
return null;
}
return (
<div className="module-delivery-issue-notification">
<div className="module-delivery-issue-notification__first-line">
<span className="module-delivery-issue-notification__icon" />
<Intl
id="DeliveryIssue--notification"
components={{
sender: <Emojify text={sender.firstName || sender.title} />,
}}
i18n={i18n}
/>
</div>
<button
type="button"
onClick={openDialog}
className="module-delivery-issue-notification__button"
>
{i18n('DeliveryIssue--learnMore')}
</button>
{isDialogOpen ? (
<DeliveryIssueDialog
i18n={i18n}
sender={sender}
onClose={closeDialog}
/>
) : null}
</div>
);
}

View file

@ -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}
/>
<hr />
</React.Fragment>
))}
</>

View file

@ -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<PropsType> {
i18n={i18n}
/>
);
} else if (item.type === 'deliveryIssue') {
notification = <DeliveryIssueNotification {...item.data} i18n={i18n} />;
} else if (item.type === 'linkNotification') {
notification = (
<div className="module-message-unsynced">

View file

@ -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,
});

15
ts/model-types.d.ts vendored
View file

@ -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<WhatIsThis>;
preview: Array<WhatIsThis>;

View file

@ -139,6 +139,8 @@ export class ConversationModel extends window.Backbone
throttledFetchSMSOnlyUUID?: () => Promise<void> | void;
throttledMaybeMigrateV1Group?: () => Promise<void> | 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<void> {
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<void> {
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<void> {
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
);

View file

@ -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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
}
}
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<MessageAttributesType> {
}
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<MessageAttributesType> {
// 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<MessageAttributesType> {
// Rendered sync messages
isCallHistory ||
isChatSessionRefreshed ||
isDeliveryIssue ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||
@ -2216,6 +2248,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
groupV1,
},
conversation,
ContentHint.RESENDABLE,
options,
partialSend
);
@ -2403,7 +2442,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async resend(identifier: string): Promise<void | null | Array<void>> {
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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
}
}
// 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) {

View file

@ -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,

View file

@ -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;

View file

@ -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<RetryItemType> = [
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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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<RetryItemType> = [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());
});
});
});

View file

@ -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);
});
});

10
ts/textsecure.d.ts vendored
View file

@ -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;
}
}

View file

@ -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];

View file

@ -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<string, number>;
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<typeof decryptionErrorTypeSchema>;
const retryRequestTypeSchema = z
.object({
requesterUuid: z.string(),
requesterDevice: z.number(),
senderDevice: z.number(),
sentAt: z.number(),
})
.passthrough();
export type RetryRequestType = z.infer<typeof retryRequestTypeSchema>;
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<void> {
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;

View file

@ -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<string>;
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<string>,
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<CiphertextMessage> {
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<number>,
recurse?: boolean
): Promise<void> {
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,

View file

@ -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<CallbackResultType> {
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<string>,
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<string>,
messageProto: DataMessageClass,
silent?: boolean,
messageProto: ContentClass | DataMessageClass | PlaintextContent,
contentHint: number,
groupId: string | undefined,
options?: SendOptionsType
): Promise<CallbackResultType> {
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<CallbackResultType> {
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<CallbackResultType> {
@ -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<CallbackResultType> {
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<CallbackResultType | void | Array<void>>
> {
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<CallbackResultType> {
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<CallbackResultType> {
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<string>,
proto: ContentClass,
timestamp = Date.now(),
contentHint: number,
groupId: string | undefined,
options?: SendOptionsType
): Promise<CallbackResultType> {
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<string> },
options?: SendOptionsType
): Promise<CallbackResultType> {
async getSenderKeyDistributionMessage(
distributionId: string
): Promise<SenderKeyDistributionMessage> {
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<string>;
},
options?: SendOptionsType
): Promise<CallbackResultType> {
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

View file

@ -934,14 +934,12 @@ export type WebAPIType = {
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean
) => Promise<void>;
sendMessagesUnauth: (
destination: string,
messageArray: Array<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean,
options?: { accessKey?: string }
) => Promise<void>;
@ -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<MessageType>,
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<MessageType>,
timestamp: number,
silent?: boolean,
online?: boolean
) {
const jsonData: any = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}

View file

@ -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,

View file

@ -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<typeof retryItemSchema>;
const retryItemListSchema = z.array(retryItemSchema);
export type RetryItemListType = z.infer<typeof retryItemListSchema>;
export type ByConversationLookupType = {
[key: string]: Array<RetryItemType>;
};
export type ByMessageLookupType = Map<string, RetryItemType>;
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<RetryItemType>;
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<string, RetryItemType>();
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<void> {
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<void> {
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<Array<RetryItemType>> {
const expiration = getOneHourAgo();
const max = this.items.length;
const result: Array<RetryItemType> = [];
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<Array<RetryItemType>> {
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<RetryItemType | undefined> {
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;
}
}

View file

@ -55,6 +55,7 @@ const MAX_RECURSION = 5;
export async function sendToGroup(
groupSendOptions: GroupSendOptionsType,
conversation: ConversationModel,
contentHint: number,
sendOptions?: SendOptionsType,
isPartialSend?: boolean
): Promise<CallbackResultType> {
@ -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<CallbackResultType> {
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<DeviceType>): Buffer {
}
async function encryptForSenderKey({
contentHint,
contentMessage,
devices,
distributionId,
contentMessage,
groupId,
}: {
contentHint: number;
contentMessage: ArrayBuffer;
devices: Array<DeviceType>;
distributionId: string;
contentMessage: ArrayBuffer;
groupId: string;
}): Promise<Buffer> {
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}`
);

View file

@ -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;
})

1
ts/window.d.ts vendored
View file

@ -315,6 +315,7 @@ declare global {
) => void;
onTimeout: (timestamp: number, cb: () => void, id?: string) => string;
removeTimeout: (uuid: string) => void;
retryPlaceholders?: Util.RetryPlaceholders;
runStorageServiceSyncJob: () => Promise<void>;
storageServiceUploadJob: () => void;
};