// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable guard-for-in */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable more/no-then */ /* eslint-disable no-param-reassign */ import { reject } from 'lodash'; import { z } from 'zod'; import type { CiphertextMessage, PlaintextContent, } from '@signalapp/signal-client'; import { CiphertextMessageType, ProtocolAddress, sealedSenderEncrypt, SenderCertificate, signalEncrypt, UnidentifiedSenderMessageContent, } from '@signalapp/signal-client'; import type { WebAPIType, MessageType } from './WebAPI'; import type { SendMetadataType, SendOptionsType } from './SendMessage'; import { OutgoingIdentityKeyError, OutgoingMessageError, SendMessageNetworkError, SendMessageChallengeError, UnregisteredUserError, HTTPError, } from './Errors'; import type { CallbackResultType, CustomError } from './Types.d'; import { isValidNumber } from '../types/PhoneNumber'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; import { UUID, isValidUuid } from '../types/UUID'; import { Sessions, IdentityKeys } from '../LibSignalStores'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { getKeysForIdentifier } from './getKeysForIdentifier'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; export const enum SenderCertificateMode { WithE164, WithoutE164, } export type SendLogCallbackType = (options: { identifier: string; deviceIds: Array; }) => Promise; export const serializedCertificateSchema = z .object({ expires: z.number().optional(), serialized: z.instanceof(Uint8Array), }) .nonstrict(); export type SerializedCertificateType = z.infer< typeof serializedCertificateSchema >; type OutgoingMessageOptionsType = SendOptionsType & { online?: boolean; }; function ciphertextMessageTypeToEnvelopeType(type: number) { if (type === CiphertextMessageType.PreKey) { return Proto.Envelope.Type.PREKEY_BUNDLE; } if (type === CiphertextMessageType.Whisper) { return Proto.Envelope.Type.CIPHERTEXT; } if (type === CiphertextMessageType.Plaintext) { return Proto.Envelope.Type.PLAINTEXT_CONTENT; } throw new Error( `ciphertextMessageTypeToEnvelopeType: Unrecognized type ${type}` ); } function getPaddedMessageLength(messageLength: number): number { const messageLengthWithTerminator = messageLength + 1; let messagePartCount = Math.floor(messageLengthWithTerminator / 160); if (messageLengthWithTerminator % 160 !== 0) { messagePartCount += 1; } return messagePartCount * 160; } export function padMessage(messageBuffer: Uint8Array): Uint8Array { const plaintext = new Uint8Array( getPaddedMessageLength(messageBuffer.byteLength + 1) - 1 ); plaintext.set(messageBuffer); plaintext[messageBuffer.byteLength] = 0x80; return plaintext; } export default class OutgoingMessage { server: WebAPIType; timestamp: number; identifiers: ReadonlyArray; message: Proto.Content | PlaintextContent; callback: (result: CallbackResultType) => void; plaintext?: Uint8Array; identifiersCompleted: number; errors: Array; successfulIdentifiers: Array; failoverIdentifiers: Array; unidentifiedDeliveries: Array; sendMetadata?: SendMetadataType; online?: boolean; groupId?: string; contentHint: number; recipients: Record>; sendLogCallback?: SendLogCallbackType; constructor({ callback, contentHint, groupId, identifiers, message, options, sendLogCallback, server, timestamp, }: { callback: (result: CallbackResultType) => void; contentHint: number; groupId: string | undefined; identifiers: ReadonlyArray; message: Proto.Content | Proto.DataMessage | PlaintextContent; options?: OutgoingMessageOptionsType; sendLogCallback?: SendLogCallbackType; server: WebAPIType; timestamp: number; }) { if (message instanceof Proto.DataMessage) { const content = new Proto.Content(); content.dataMessage = message; // eslint-disable-next-line no-param-reassign this.message = content; } else { this.message = message; } this.server = server; this.timestamp = timestamp; this.identifiers = identifiers; this.contentHint = contentHint; this.groupId = groupId; this.callback = callback; this.identifiersCompleted = 0; this.errors = []; this.successfulIdentifiers = []; this.failoverIdentifiers = []; this.unidentifiedDeliveries = []; this.recipients = {}; this.sendLogCallback = sendLogCallback; this.sendMetadata = options?.sendMetadata; this.online = options?.online; } numberCompleted(): void { this.identifiersCompleted += 1; if (this.identifiersCompleted >= this.identifiers.length) { const contentProto = this.getContentProtoBytes(); const { timestamp, contentHint, recipients } = this; this.callback({ successfulIdentifiers: this.successfulIdentifiers, failoverIdentifiers: this.failoverIdentifiers, errors: this.errors, unidentifiedDeliveries: this.unidentifiedDeliveries, contentHint, recipients, contentProto, timestamp, }); } } registerError( identifier: string, reason: string, providedError?: Error ): void { let error = providedError; if (!error || (error instanceof HTTPError && error.code !== 404)) { if (error && error.code === 428) { error = new SendMessageChallengeError(identifier, error); } else { error = new OutgoingMessageError(identifier, null, null, error); } } error.reason = reason; error.stackForLog = providedError ? providedError.stack : undefined; this.errors[this.errors.length] = error; this.numberCompleted(); } reloadDevicesAndSend( identifier: string, recurse?: boolean ): () => Promise { return async () => { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ ourUuid, identifier, }); if (deviceIds.length === 0) { this.registerError( identifier, 'reloadDevicesAndSend: Got empty device list when loading device keys', undefined ); return undefined; } return this.doSendMessage(identifier, deviceIds, recurse); }; } async getKeysForIdentifier( identifier: string, updateDevices?: Array ): Promise> { const { sendMetadata } = this; const info = sendMetadata && sendMetadata[identifier] ? sendMetadata[identifier] : { accessKey: undefined }; const { accessKey } = info; try { const { accessKeyFailed } = await getKeysForIdentifier( identifier, this.server, updateDevices, accessKey ); if (accessKeyFailed && !this.failoverIdentifiers.includes(identifier)) { this.failoverIdentifiers.push(identifier); } } catch (error) { if (error?.message?.includes('untrusted identity for address')) { error.timestamp = this.timestamp; } throw error; } } async transmitMessage( identifier: string, jsonData: ReadonlyArray, timestamp: number, { accessKey }: { accessKey?: string } = {} ): Promise { let promise; if (accessKey) { promise = this.server.sendMessagesUnauth( identifier, jsonData, timestamp, this.online, { accessKey } ); } else { promise = this.server.sendMessages( identifier, jsonData, timestamp, this.online ); } return promise.catch(e => { if (e instanceof HTTPError && e.code !== 409 && e.code !== 410) { // 409 and 410 should bubble and be handled by doSendMessage // 404 should throw UnregisteredUserError // 428 should throw SendMessageChallengeError // all other network errors can be retried later. if (e.code === 404) { throw new UnregisteredUserError(identifier, e); } if (e.code === 428) { throw new SendMessageChallengeError(identifier, e); } throw new SendMessageNetworkError(identifier, jsonData, e); } throw e; }); } getPlaintext(): Uint8Array { if (!this.plaintext) { const { message } = this; if (message instanceof Proto.Content) { this.plaintext = padMessage(Proto.Content.encode(message).finish()); } else { this.plaintext = message.serialize(); } } return this.plaintext; } getContentProtoBytes(): Uint8Array | undefined { if (this.message instanceof Proto.Content) { return new Uint8Array(Proto.Content.encode(this.message).finish()); } return undefined; } async getCiphertextMessage({ identityKeyStore, protocolAddress, sessionStore, }: { identityKeyStore: IdentityKeys; protocolAddress: ProtocolAddress; sessionStore: Sessions; }): Promise { const { message } = this; if (message instanceof Proto.Content) { return signalEncrypt( Buffer.from(this.getPlaintext()), protocolAddress, sessionStore, identityKeyStore ); } return message.asCiphertextMessage(); } async doSendMessage( identifier: string, deviceIds: Array, recurse?: boolean ): Promise { const { sendMetadata } = this; const { accessKey, senderCertificate } = sendMetadata?.[identifier] || {}; if (accessKey && !senderCertificate) { log.warn( 'OutgoingMessage.doSendMessage: accessKey was provided, but senderCertificate was not' ); } const sealedSender = Boolean(accessKey && senderCertificate); // We don't send to ourselves unless sealedSender is enabled const ourNumber = window.textsecure.storage.user.getNumber(); const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const ourDeviceId = window.textsecure.storage.user.getDeviceId(); if ( (identifier === ourNumber || identifier === ourUuid.toString()) && !sealedSender ) { deviceIds = reject( deviceIds, deviceId => // because we store our own device ID as a string at least sometimes deviceId === ourDeviceId || (typeof ourDeviceId === 'string' && deviceId === parseInt(ourDeviceId, 10)) ); } const sessionStore = new Sessions({ ourUuid }); const identityKeyStore = new IdentityKeys({ ourUuid }); return Promise.all( deviceIds.map(async destinationDeviceId => { const theirUuid = UUID.checkedLookup(identifier); const address = new QualifiedAddress( ourUuid, new Address(theirUuid, destinationDeviceId) ); return window.textsecure.storage.protocol.enqueueSessionJob( address, async () => { const protocolAddress = ProtocolAddress.new( theirUuid.toString(), destinationDeviceId ); const activeSession = await sessionStore.getSession( protocolAddress ); if (!activeSession) { throw new Error( 'OutgoingMessage.doSendMessage: No active sesssion!' ); } 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 content = UnidentifiedSenderMessageContent.new( ciphertextMessage, certificate, this.contentHint, groupIdBuffer ); const buffer = await sealedSenderEncrypt( content, protocolAddress, identityKeyStore ); return { type: Proto.Envelope.Type.UNIDENTIFIED_SENDER, destinationDeviceId, destinationRegistrationId, content: buffer.toString('base64'), }; } const ciphertextMessage = await this.getCiphertextMessage({ identityKeyStore, protocolAddress, sessionStore, }); const type = ciphertextMessageTypeToEnvelopeType( ciphertextMessage.type() ); const content = ciphertextMessage.serialize().toString('base64'); return { type, destinationDeviceId, destinationRegistrationId, content, }; } ); }) ) .then(async (jsonData: Array) => { if (sealedSender) { return this.transmitMessage(identifier, jsonData, this.timestamp, { accessKey, }).then( () => { this.recipients[identifier] = deviceIds; this.unidentifiedDeliveries.push(identifier); this.successfulIdentifiers.push(identifier); this.numberCompleted(); if (this.sendLogCallback) { this.sendLogCallback({ identifier, deviceIds, }); } else if (this.successfulIdentifiers.length > 1) { log.warn( `OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` ); } }, async (error: Error) => { if ( error instanceof HTTPError && (error.code === 401 || error.code === 403) ) { if (this.failoverIdentifiers.indexOf(identifier) === -1) { this.failoverIdentifiers.push(identifier); } // This ensures that we don't hit this codepath the next time through if (sendMetadata) { delete sendMetadata[identifier]; } return this.doSendMessage(identifier, deviceIds, recurse); } throw error; } ); } return this.transmitMessage(identifier, jsonData, this.timestamp).then( () => { this.successfulIdentifiers.push(identifier); this.recipients[identifier] = deviceIds; this.numberCompleted(); if (this.sendLogCallback) { this.sendLogCallback({ identifier, deviceIds, }); } else if (this.successfulIdentifiers.length > 1) { log.warn( `OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` ); } } ); }) .catch(async error => { if ( error instanceof HTTPError && (error.code === 410 || error.code === 409) ) { if (!recurse) { this.registerError( identifier, 'Hit retry limit attempting to reload device list', error ); return undefined; } const response = error.response as { extraDevices?: Array; staleDevices?: Array; missingDevices?: Array; }; let p: Promise = Promise.resolve(); if (error.code === 409) { p = this.removeDeviceIdsForIdentifier( identifier, response.extraDevices || [] ); } else { p = Promise.all( (response.staleDevices || []).map(async (deviceId: number) => { await window.textsecure.storage.protocol.archiveSession( new QualifiedAddress( ourUuid, new Address(UUID.checkedLookup(identifier), deviceId) ) ); }) ); } return p.then(async () => { const resetDevices = error.code === 410 ? response.staleDevices : response.missingDevices; return this.getKeysForIdentifier(identifier, resetDevices).then( // We continue to retry as long as the error code was 409; the assumption is // that we'll request new device info and the next request will succeed. this.reloadDevicesAndSend(identifier, error.code === 409) ); }); } if (error?.message?.includes('untrusted identity for address')) { // eslint-disable-next-line no-param-reassign error.timestamp = this.timestamp; log.error( 'Got "key changed" error from encrypt - no identityKey for application layer', identifier, deviceIds ); log.info('closing all sessions for', identifier); window.textsecure.storage.protocol .archiveAllSessions(UUID.checkedLookup(identifier)) .then( () => { throw error; }, innerError => { log.error( `doSendMessage: Error closing sessions: ${innerError.stack}` ); throw error; } ); } this.registerError( identifier, 'Failed to create or send message', error ); return undefined; }); } async removeDeviceIdsForIdentifier( identifier: string, deviceIdsToRemove: Array ): Promise { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const theirUuid = UUID.checkedLookup(identifier); await Promise.all( deviceIdsToRemove.map(async deviceId => { await window.textsecure.storage.protocol.archiveSession( new QualifiedAddress(ourUuid, new Address(theirUuid, deviceId)) ); }) ); } async sendToIdentifier(providedIdentifier: string): Promise { let identifier = providedIdentifier; try { if (isValidUuid(identifier)) { // We're good! } else if (isValidNumber(identifier)) { if (!window.textsecure.messaging) { throw new Error( 'sendToIdentifier: window.textsecure.messaging is not available!' ); } try { await updateConversationsWithUuidLookup({ conversationController: window.ConversationController, conversations: [ window.ConversationController.getOrCreate(identifier, 'private'), ], messaging: window.textsecure.messaging, }); const uuid = window.ConversationController.get(identifier)?.get('uuid'); if (!uuid) { throw new UnregisteredUserError( identifier, new HTTPError('User is not registered', { code: -1, headers: {}, }) ); } identifier = uuid; } catch (error) { log.error( `sendToIdentifier: Failed to fetch UUID for identifier ${identifier}`, error && error.stack ? error.stack : error ); } } else { throw new Error( `sendToIdentifier: identifier ${identifier} was neither a UUID or E164` ); } const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ ourUuid, identifier, }); if (deviceIds.length === 0) { await this.getKeysForIdentifier(identifier); } await this.reloadDevicesAndSend(identifier, true)(); } catch (error) { if (error?.message?.includes('untrusted identity for address')) { const newError = new OutgoingIdentityKeyError( identifier, error.originalMessage, error.timestamp, error.identityKey && new Uint8Array(error.identityKey) ); this.registerError(identifier, 'Untrusted identity', newError); } else { this.registerError( identifier, `Failed to retrieve new device keys for identifier ${identifier}`, error ); } } } }