signal-desktop/ts/util/handleRetry.ts
2021-09-22 16:59:54 -04:00

542 lines
15 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
DecryptionErrorMessage,
PlaintextContent,
} from '@signalapp/signal-client';
import { isNumber } from 'lodash';
import { isProduction } from './version';
import { strictAssert } from './assert';
import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import { isGroupV2 } from './whatTypeOfConversation';
import { isOlderThan } from './timestamp';
import { parseIntOrThrow } from './parseIntOrThrow';
import * as RemoteConfig from '../RemoteConfig';
import { Address } from '../types/Address';
import { QualifiedAddress } from '../types/QualifiedAddress';
import { ToastDecryptionError } from '../components/ToastDecryptionError';
import { showToast } from './showToast';
import { ConversationModel } from '../models/conversations';
import {
DecryptionErrorEvent,
DecryptionErrorEventData,
RetryRequestEvent,
RetryRequestEventData,
} from '../textsecure/messageReceiverEvents';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
// Entrypoints
export async function onRetryRequest(event: RetryRequestEvent): Promise<void> {
const { retryRequest } = event;
const {
groupId: requestGroupId,
requesterDevice,
requesterUuid,
senderDevice,
sentAt,
} = retryRequest;
const logId = `${requesterUuid}.${requesterDevice} ${sentAt}.${senderDevice}`;
log.info(`onRetryRequest/${logId}: Starting...`);
if (!RemoteConfig.isEnabled('desktop.senderKey.retry')) {
log.warn(
`onRetryRequest/${logId}: Feature flag disabled, returning early.`
);
return;
}
if (window.RETRY_DELAY) {
log.warn(`onRetryRequest/${logId}: Delaying because RETRY_DELAY is set...`);
await new Promise(resolve => setTimeout(resolve, 5000));
}
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
let retryRespondMaxAge = 14 * DAY;
try {
retryRespondMaxAge = parseIntOrThrow(
RemoteConfig.getValue('desktop.retryRespondMaxAge'),
'retryRespondMaxAge'
);
} catch (error) {
log.warn(
`onRetryRequest/${logId}: Failed to parse integer from desktop.retryRespondMaxAge feature flag`,
error && error.stack ? error.stack : error
);
}
await archiveSessionOnMatch(retryRequest);
if (isOlderThan(sentAt, retryRespondMaxAge)) {
log.info(
`onRetryRequest/${logId}: Message is too old, refusing to send again.`
);
await sendDistributionMessageOrNullMessage(logId, retryRequest);
return;
}
const sentProto = await window.Signal.Data.getSentProtoByRecipient({
now: Date.now(),
recipientUuid: requesterUuid,
timestamp: sentAt,
});
if (!sentProto) {
log.info(`onRetryRequest/${logId}: Did not find sent proto`);
await sendDistributionMessageOrNullMessage(logId, retryRequest);
return;
}
log.info(`onRetryRequest/${logId}: Resending message`);
const { contentHint, messageIds, proto, timestamp } = sentProto;
const { contentProto, groupId } = await maybeAddSenderKeyDistributionMessage({
contentProto: Proto.Content.decode(proto),
logId,
messageIds,
requestGroupId,
requesterUuid,
});
const recipientConversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
const sendOptions = await getSendOptions(recipientConversation.attributes);
const promise = window.textsecure.messaging.sendMessageProtoAndWait({
timestamp,
recipients: [requesterUuid],
proto: new Proto.Content(contentProto),
contentHint,
groupId,
options: sendOptions,
});
await handleMessageSend(promise, {
messageIds: [],
sendType: 'resendFromLog',
});
}
function maybeShowDecryptionToast(logId: string) {
if (isProduction(window.getVersion())) {
return;
}
log.info(`maybeShowDecryptionToast/${logId}: Showing decryption error toast`);
showToast(ToastDecryptionError, {
onShowDebugLog: () => window.showDebugLog(),
});
}
export async function onDecryptionError(
event: DecryptionErrorEvent
): Promise<void> {
const { decryptionError } = event;
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
log.info(`onDecryptionError/${logId}: Starting...`);
const conversation = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
if (!conversation.get('capabilities')?.senderKey) {
await conversation.getProfiles();
}
maybeShowDecryptionToast(logId);
if (
conversation.get('capabilities')?.senderKey &&
RemoteConfig.isEnabled('desktop.senderKey.retry')
) {
await requestResend(decryptionError);
} else {
await startAutomaticSessionReset(decryptionError);
}
log.info(`onDecryptionError/${logId}: ...complete`);
}
// Helpers
async function archiveSessionOnMatch({
ratchetKey,
requesterUuid,
requesterDevice,
senderDevice,
}: RetryRequestEventData): Promise<void> {
const ourDeviceId = parseIntOrThrow(
window.textsecure.storage.user.getDeviceId(),
'archiveSessionOnMatch/getDeviceId'
);
if (ourDeviceId !== senderDevice || !ratchetKey) {
return;
}
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
const address = new QualifiedAddress(
ourUuid,
Address.create(requesterUuid, requesterDevice)
);
const session = await window.textsecure.storage.protocol.loadSession(address);
if (session && session.currentRatchetKeyMatches(ratchetKey)) {
log.info(
'archiveSessionOnMatch: Matching device and ratchetKey, archiving session'
);
await window.textsecure.storage.protocol.archiveSession(address);
}
}
async function sendDistributionMessageOrNullMessage(
logId: string,
options: RetryRequestEventData
): Promise<void> {
const { groupId, requesterUuid } = options;
let sentDistributionMessage = false;
log.info(`sendDistributionMessageOrNullMessage/${logId}: Starting...`);
const conversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
if (groupId) {
const group = window.ConversationController.get(groupId);
const distributionId = group?.get('senderKeyInfo')?.distributionId;
if (group && !group.hasMember(requesterUuid)) {
throw new Error(
`sendDistributionMessageOrNullMessage/${logId}: Requester ${requesterUuid} is not a member of ${conversation.idForLogging()}`
);
}
if (group && distributionId) {
log.info(
`sendDistributionMessageOrNullMessage/${logId}: Found matching group, sending sender key distribution message`
);
try {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const result = await handleMessageSend(
window.textsecure.messaging.sendSenderKeyDistributionMessage({
contentHint: ContentHint.RESENDABLE,
distributionId,
groupId,
identifiers: [requesterUuid],
}),
{ messageIds: [], sendType: 'senderKeyDistributionMessage' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
sentDistributionMessage = true;
} catch (error) {
log.error(
`sendDistributionMessageOrNullMessage/${logId}: Failed to send sender key distribution message`,
error && error.stack ? error.stack : error
);
}
}
}
if (!sentDistributionMessage) {
log.info(
`sendDistributionMessageOrNullMessage/${logId}: Did not send distribution message, sending null message`
);
try {
const sendOptions = await getSendOptions(conversation.attributes);
const result = await handleMessageSend(
window.textsecure.messaging.sendNullMessage(
{ uuid: requesterUuid },
sendOptions
),
{ messageIds: [], sendType: 'nullMessage' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
log.error(
`maybeSendDistributionMessage/${logId}: Failed to send null message`,
error && error.stack ? error.stack : error
);
}
}
}
async function getRetryConversation({
logId,
messageIds,
requestGroupId,
}: {
logId: string;
messageIds: Array<string>;
requestGroupId?: string;
}): Promise<ConversationModel | undefined> {
if (messageIds.length !== 1) {
// Fail over to requested groupId
return window.ConversationController.get(requestGroupId);
}
const [messageId] = messageIds;
const message = await window.Signal.Data.getMessageById(messageId, {
Message: window.Whisper.Message,
});
if (!message) {
log.warn(
`maybeAddSenderKeyDistributionMessage/${logId}: Unable to find message ${messageId}`
);
// Fail over to requested groupId
return window.ConversationController.get(requestGroupId);
}
const conversationId = message.get('conversationId');
return window.ConversationController.get(conversationId);
}
async function maybeAddSenderKeyDistributionMessage({
contentProto,
logId,
messageIds,
requestGroupId,
requesterUuid,
}: {
contentProto: Proto.IContent;
logId: string;
messageIds: Array<string>;
requestGroupId?: string;
requesterUuid: string;
}): Promise<{ contentProto: Proto.IContent; groupId?: string }> {
const conversation = await getRetryConversation({
logId,
messageIds,
requestGroupId,
});
if (!conversation) {
log.warn(
`maybeAddSenderKeyDistributionMessage/${logId}: Unable to find conversation`
);
return {
contentProto,
};
}
if (!conversation.hasMember(requesterUuid)) {
throw new Error(
`maybeAddSenderKeyDistributionMessage/${logId}: Recipient ${requesterUuid} is not a member of ${conversation.idForLogging()}`
);
}
if (!isGroupV2(conversation.attributes)) {
return {
contentProto,
};
}
const senderKeyInfo = conversation.get('senderKeyInfo');
if (senderKeyInfo && senderKeyInfo.distributionId) {
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
senderKeyInfo.distributionId
);
return {
contentProto: {
...contentProto,
senderKeyDistributionMessage: senderKeyDistributionMessage.serialize(),
},
groupId: conversation.get('groupId'),
};
}
return {
contentProto,
groupId: conversation.get('groupId'),
};
}
async function requestResend(decryptionError: DecryptionErrorEventData) {
const {
cipherTextBytes,
cipherTextType,
contentHint,
groupId,
receivedAtCounter,
receivedAtDate,
senderDevice,
senderUuid,
timestamp,
} = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
log.info(`requestResend/${logId}: Starting...`, {
cipherTextBytesLength: cipherTextBytes?.byteLength,
cipherTextType,
contentHint,
groupId: groupId ? `groupv2(${groupId})` : undefined,
});
// 1. Find the target conversation
const group = groupId
? window.ConversationController.get(groupId)
: undefined;
const sender = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const conversation = group || sender;
// 2. Send resend request
if (!cipherTextBytes || !isNumber(cipherTextType)) {
log.warn(
`requestResend/${logId}: 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 getSendOptions(conversation.attributes);
const result = await handleMessageSend(
window.textsecure.messaging.sendRetryRequest({
plaintext,
options,
groupId,
uuid: senderUuid,
}),
{ messageIds: [], sendType: 'retryRequest' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
log.error(
`requestResend/${logId}: Failed to send retry request, failing over to automatic reset`,
error && error.stack ? error.stack : error
);
startAutomaticSessionReset(decryptionError);
return;
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
// 3. Determine how to represent this to the user. Three different options.
// We believe that it could be successfully re-sent, so we'll add a placeholder.
if (contentHint === ContentHint.RESENDABLE) {
const { retryPlaceholders } = window.Signal.Services;
strictAssert(retryPlaceholders, 'requestResend: adding placeholder');
log.info(`requestResend/${logId}: Adding placeholder`);
const state = window.reduxStore.getState();
const selectedId = state.conversations.selectedConversationId;
const wasOpened = selectedId === conversation.id;
await retryPlaceholders.add({
conversationId: conversation.get('id'),
receivedAt: receivedAtDate,
receivedAtCounter,
sentAt: timestamp,
senderUuid,
wasOpened,
});
return;
}
// This message cannot be resent. We'll show no error and trust the other side to
// reset their session.
if (contentHint === ContentHint.IMPLICIT) {
log.info(`requestResend/${logId}: contentHint is IMPLICIT, doing nothing.`);
return;
}
log.warn(`requestResend/${logId}: No content hint, adding error immediately`);
conversation.queueJob('addDeliveryIssue', async () => {
conversation.addDeliveryIssue({
receivedAt: receivedAtDate,
receivedAtCounter,
senderUuid,
sentAt: timestamp,
});
});
}
function scheduleSessionReset(senderUuid: string, senderDevice: number) {
// Postpone sending light session resets until the queue is empty
const { lightSessionResetQueue } = window.Signal.Services;
if (!lightSessionResetQueue) {
throw new Error(
'scheduleSessionReset: lightSessionResetQueue is not available!'
);
}
lightSessionResetQueue.add(() => {
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
window.textsecure.storage.protocol.lightSessionReset(
new QualifiedAddress(ourUuid, Address.create(senderUuid, senderDevice))
);
});
}
function startAutomaticSessionReset(decryptionError: DecryptionErrorEventData) {
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
log.info(`startAutomaticSessionReset/${logId}: Starting...`);
scheduleSessionReset(senderUuid, senderDevice);
const conversationId = window.ConversationController.ensureContactIds({
uuid: senderUuid,
});
if (!conversationId) {
log.warn(
'onLightSessionReset: No conversation id, cannot add message to timeline'
);
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.warn(
'onLightSessionReset: No conversation, cannot add message to timeline'
);
return;
}
const receivedAt = Date.now();
const receivedAtCounter = window.Signal.Util.incrementMessageCounter();
conversation.queueJob('addChatSessionRefreshed', async () => {
conversation.addChatSessionRefreshed({ receivedAt, receivedAtCounter });
});
}