// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* 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/libsignal-client'; import { ErrorCode, LibSignalErrorBase, CiphertextMessageType, ProtocolAddress, sealedSenderEncrypt, SenderCertificate, signalEncrypt, UnidentifiedSenderMessageContent, } from '@signalapp/libsignal-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 { Address } from '../types/Address'; import * as Errors from '../types/errors'; import { QualifiedAddress } from '../types/QualifiedAddress'; import type { ServiceIdString } from '../types/ServiceId'; import { Sessions, IdentityKeys } from '../LibSignalStores'; import { getKeysForServiceId } from './getKeysForServiceId'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; export const enum SenderCertificateMode { WithE164, WithoutE164, } export type SendLogCallbackType = (options: { serviceId: ServiceIdString; deviceIds: Array; }) => Promise; export const serializedCertificateSchema = z.object({ expires: z.number().optional(), serialized: z.instanceof(Uint8Array), }); 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; serviceIds: ReadonlyArray; message: Proto.Content | PlaintextContent; callback: (result: CallbackResultType) => void; plaintext?: Uint8Array; serviceIdsCompleted: number; errors: Array; successfulServiceIds: Array; failoverServiceIds: Array; unidentifiedDeliveries: Array; sendMetadata?: SendMetadataType; online?: boolean; groupId?: string; contentHint: number; urgent: boolean; story?: boolean; recipients: Record>; sendLogCallback?: SendLogCallbackType; constructor({ callback, contentHint, groupId, serviceIds, message, options, sendLogCallback, server, story, timestamp, urgent, }: { callback: (result: CallbackResultType) => void; contentHint: number; groupId: string | undefined; serviceIds: ReadonlyArray; message: Proto.Content | Proto.DataMessage | PlaintextContent; options?: OutgoingMessageOptionsType; sendLogCallback?: SendLogCallbackType; server: WebAPIType; story?: boolean; timestamp: number; urgent: boolean; }) { if (message instanceof Proto.DataMessage) { const content = new Proto.Content(); content.dataMessage = message; this.message = content; } else { this.message = message; } this.server = server; this.timestamp = timestamp; this.serviceIds = serviceIds; this.contentHint = contentHint; this.groupId = groupId; this.callback = callback; this.story = story; this.urgent = urgent; this.serviceIdsCompleted = 0; this.errors = []; this.successfulServiceIds = []; this.failoverServiceIds = []; this.unidentifiedDeliveries = []; this.recipients = {}; this.sendLogCallback = sendLogCallback; this.sendMetadata = options?.sendMetadata; this.online = options?.online; } numberCompleted(): void { this.serviceIdsCompleted += 1; if (this.serviceIdsCompleted >= this.serviceIds.length) { const proto = this.message; const contentProto = this.getContentProtoBytes(); const { timestamp, contentHint, recipients, urgent } = this; let dataMessage: Uint8Array | undefined; let editMessage: Uint8Array | undefined; let hasPniSignatureMessage = false; if (proto instanceof Proto.Content) { if (proto.dataMessage) { dataMessage = Proto.DataMessage.encode(proto.dataMessage).finish(); } else if (proto.editMessage) { editMessage = Proto.EditMessage.encode(proto.editMessage).finish(); } hasPniSignatureMessage = Boolean(proto.pniSignatureMessage); } else if (proto instanceof Proto.DataMessage) { dataMessage = Proto.DataMessage.encode(proto).finish(); } else if (proto instanceof Proto.EditMessage) { editMessage = Proto.EditMessage.encode(proto).finish(); } this.callback({ successfulServiceIds: this.successfulServiceIds, failoverServiceIds: this.failoverServiceIds, errors: this.errors, unidentifiedDeliveries: this.unidentifiedDeliveries, contentHint, dataMessage, editMessage, recipients, contentProto, timestamp, urgent, hasPniSignatureMessage, }); } } registerError( serviceId: ServiceIdString, reason: string, providedError?: Error ): void { let error = providedError; if (!error || (error instanceof HTTPError && error.code !== 404)) { if (error && error.code === 428) { error = new SendMessageChallengeError(serviceId, error); } else { error = new OutgoingMessageError(serviceId, null, null, error); } } error.cause = reason; this.errors[this.errors.length] = error; this.numberCompleted(); } reloadDevicesAndSend( serviceId: ServiceIdString, recurse?: boolean ): () => Promise { return async () => { const ourAci = window.textsecure.storage.user.getCheckedAci(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ ourServiceId: ourAci, serviceId, }); if (deviceIds.length === 0) { this.registerError( serviceId, 'reloadDevicesAndSend: Got empty device list when loading device keys', undefined ); return undefined; } return this.doSendMessage(serviceId, deviceIds, recurse); }; } async getKeysForServiceId( serviceId: ServiceIdString, updateDevices: Array | null ): Promise { const { sendMetadata } = this; const info = sendMetadata && sendMetadata[serviceId] ? sendMetadata[serviceId] : { accessKey: null }; const { accessKey } = info; const { accessKeyFailed } = await getKeysForServiceId( serviceId, this.server, updateDevices ?? null, accessKey, null ); if (accessKeyFailed && !this.failoverServiceIds.includes(serviceId)) { this.failoverServiceIds.push(serviceId); } } async transmitMessage( serviceId: ServiceIdString, jsonData: ReadonlyArray, timestamp: number, { accessKey }: { accessKey?: string } = {} ): Promise { let promise; if (accessKey) { promise = this.server.sendMessagesUnauth(serviceId, jsonData, timestamp, { accessKey, online: this.online, story: this.story, urgent: this.urgent, }); } else { promise = this.server.sendMessages(serviceId, jsonData, timestamp, { online: this.online, story: this.story, urgent: this.urgent, }); } 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(serviceId, e); } if (e.code === 428) { throw new SendMessageChallengeError(serviceId, e); } throw new SendMessageNetworkError(serviceId, 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( serviceId: ServiceIdString, deviceIds: Array, recurse?: boolean ): Promise { const { sendMetadata } = this; const { accessKey, senderCertificate } = sendMetadata?.[serviceId] || {}; 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 ourAci = window.textsecure.storage.user.getCheckedAci(); const ourDeviceId = window.textsecure.storage.user.getDeviceId(); if ((serviceId === ourNumber || serviceId === ourAci) && !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({ ourServiceId: ourAci }); const identityKeyStore = new IdentityKeys({ ourServiceId: ourAci }); return Promise.all( deviceIds.map(async destinationDeviceId => { const address = new QualifiedAddress( ourAci, new Address(serviceId, destinationDeviceId) ); return window.textsecure.storage.protocol.enqueueSessionJob( address, `doSendMessage(${address.toString()}, ${this.timestamp})`, async () => { const protocolAddress = ProtocolAddress.new( serviceId, destinationDeviceId ); const activeSession = await sessionStore.getSession(protocolAddress); if (!activeSession) { throw new Error( 'OutgoingMessage.doSendMessage: No active session!' ); } 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(serviceId, jsonData, this.timestamp, { accessKey, }).then( () => { this.recipients[serviceId] = deviceIds; this.unidentifiedDeliveries.push(serviceId); this.successfulServiceIds.push(serviceId); this.numberCompleted(); if (this.sendLogCallback) { void this.sendLogCallback({ serviceId, deviceIds, }); } else if (this.successfulServiceIds.length > 1) { log.warn( `OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` ); } }, async (error: Error) => { if ( error instanceof SendMessageNetworkError && (error.code === 401 || error.code === 403) ) { log.warn( `OutgoingMessage.doSendMessage: Failing over to unsealed send for serviceId ${serviceId}` ); if (this.failoverServiceIds.indexOf(serviceId) === -1) { this.failoverServiceIds.push(serviceId); } // This ensures that we don't hit this codepath the next time through if (sendMetadata) { delete sendMetadata[serviceId]; } return this.doSendMessage(serviceId, deviceIds, recurse); } throw error; } ); } return this.transmitMessage(serviceId, jsonData, this.timestamp).then( () => { this.successfulServiceIds.push(serviceId); this.recipients[serviceId] = deviceIds; this.numberCompleted(); if (this.sendLogCallback) { void this.sendLogCallback({ serviceId, deviceIds, }); } else if (this.successfulServiceIds.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( serviceId, '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.removeDeviceIdsForServiceId( serviceId, response.extraDevices || [] ); } else { p = Promise.all( (response.staleDevices || []).map(async (deviceId: number) => { await window.textsecure.storage.protocol.archiveSession( new QualifiedAddress(ourAci, new Address(serviceId, deviceId)) ); }) ); } return p.then(async () => { const resetDevices = error.code === 410 ? (response.staleDevices ?? null) : (response.missingDevices ?? null); return this.getKeysForServiceId(serviceId, 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(serviceId, error.code === 409) ); }); } let newError = error; if ( error instanceof LibSignalErrorBase && error.code === ErrorCode.UntrustedIdentity ) { newError = new OutgoingIdentityKeyError(serviceId, error); log.error( 'Got "key changed" error from encrypt - no identityKey for application layer', serviceId, deviceIds ); log.info('closing all sessions for', serviceId); window.textsecure.storage.protocol.archiveAllSessions(serviceId).then( () => { throw error; }, innerError => { log.error( 'doSendMessage: Error closing sessions: ' + `${Errors.toLogFormat(innerError)}` ); throw error; } ); } this.registerError( serviceId, 'Failed to create or send message', newError ); return undefined; }); } async removeDeviceIdsForServiceId( serviceId: ServiceIdString, deviceIdsToRemove: Array ): Promise { const ourAci = window.textsecure.storage.user.getCheckedAci(); await Promise.all( deviceIdsToRemove.map(async deviceId => { await window.textsecure.storage.protocol.archiveSession( new QualifiedAddress(ourAci, new Address(serviceId, deviceId)) ); }) ); } async sendToServiceId(serviceId: ServiceIdString): Promise { try { const ourAci = window.textsecure.storage.user.getCheckedAci(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ ourServiceId: ourAci, serviceId, }); if (deviceIds.length === 0) { await this.getKeysForServiceId(serviceId, null); } await this.reloadDevicesAndSend(serviceId, true)(); } catch (error) { if ( error instanceof LibSignalErrorBase && error.code === ErrorCode.UntrustedIdentity ) { const newError = new OutgoingIdentityKeyError(serviceId, error); this.registerError(serviceId, 'Untrusted identity', newError); } else { this.registerError( serviceId, `Failed to retrieve new device keys for serviceId ${serviceId}`, error ); } } } }