// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import lodash from 'lodash'; import { z } from 'zod'; import type { CiphertextMessage } from '@signalapp/libsignal-client'; import { ErrorCode, LibSignalErrorBase, CiphertextMessageType, PlaintextContent, ProtocolAddress, sealedSenderEncrypt, SenderCertificate, signalEncrypt, UnidentifiedSenderMessageContent, } from '@signalapp/libsignal-client'; import { GroupSendFullToken } from '@signalapp/libsignal-client/zkgroup.js'; import { sendMessagesLegacy, sendMessagesUnauthLegacy, getKeysForServiceId as doGetKeysForServiceId, getKeysForServiceIdUnauth, sendUnsealedMessage, sendSealedSenderMessage, type SealedSenderAuthType, } from './WebAPI.preload.ts'; import type { SendMetadataType, SendOptionsType, } from './SendMessage.preload.ts'; import { OutgoingIdentityKeyError, MismatchedDevicesError, UnauthorizedMessageSendError, OutgoingMessageError, } from './Errors.std.ts'; import type { CallbackResultType, CustomError } from './Types.d.ts'; import { Address } from '../types/Address.std.ts'; import { QualifiedAddress } from '../types/QualifiedAddress.std.ts'; import type { ServiceIdString } from '../types/ServiceId.std.ts'; import { Sessions, IdentityKeys } from '../LibSignalStores.node.ts'; import { getKeysForServiceId } from './getKeysForServiceId.preload.ts'; import { SignalService as Proto } from '../protobuf/index.std.ts'; import { createLogger } from '../logging/log.std.ts'; import type { GroupSendToken } from '../types/GroupSendEndorsements.std.ts'; import { isSignalServiceId } from '../util/isSignalConversation.dom.ts'; import * as Bytes from '../Bytes.std.ts'; import { signalProtocolStore } from '../SignalProtocolStore.preload.ts'; import { itemStorage } from './Storage.preload.ts'; import { isFeaturedEnabledNoRedux } from '../util/isFeatureEnabled.dom.ts'; import { type SingleOutboundSealedSenderMessage, type SingleOutboundUnsealedMessage, } from '@signalapp/libsignal-client/dist/net/chat/SingleOutboundMessage'; import { handleMismatchedDevicesError } from '../util/handleMismatchedDevicesError.preload.ts'; import { strictAssert } from '../util/assert.std.ts'; import { toLogFormat } from '../types/errors.std.ts'; import { ZERO_ACCESS_KEY } from '../types/SealedSender.std.ts'; const { reject } = lodash; const log = createLogger('OutgoingMessage'); 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 >; // TODO: DESKTOP-10214 type can be excluded type OutgoingUnsealedMessageType = { type: Exclude; } & SingleOutboundUnsealedMessage; type OutgoingSealedSenderMessageType = { type: Proto.Envelope.Type.UNIDENTIFIED_SENDER; } & SingleOutboundSealedSenderMessage; export type OutgoingMessageType = | OutgoingUnsealedMessageType | OutgoingSealedSenderMessageType; type OutgoingMessageOptionsType = SendOptionsType & { online?: boolean; }; function ciphertextMessageTypeToEnvelopeType(type: number) { if (type === CiphertextMessageType.PreKey) { return Proto.Envelope.Type.PREKEY_MESSAGE; } if (type === CiphertextMessageType.Whisper) { return Proto.Envelope.Type.DOUBLE_RATCHET; } if (type === CiphertextMessageType.Plaintext) { return Proto.Envelope.Type.PLAINTEXT_CONTENT; } throw new Error( `ciphertextMessageTypeToEnvelopeType: Unrecognized type ${type}` ); } const PADDING_BLOCK = 80; function getPaddedMessageLength(messageLength: number): number { const messageLengthWithTerminator = messageLength + 1; let messagePartCount = Math.floor( messageLengthWithTerminator / PADDING_BLOCK ); if (messageLengthWithTerminator % PADDING_BLOCK !== 0) { messagePartCount += 1; } return messagePartCount * PADDING_BLOCK; } 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 { timestamp: number; serviceIds: ReadonlyArray; message: Proto.Content.Params | 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, story, timestamp, urgent, }: { callback: (result: CallbackResultType) => void; contentHint: number; groupId: string | undefined; serviceIds: ReadonlyArray; message: Proto.Content.Params | PlaintextContent; options?: OutgoingMessageOptionsType; sendLogCallback?: SendLogCallbackType; story?: boolean; timestamp: number; urgent: boolean; }) { this.message = message; 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 PlaintextContent)) { if (proto.content?.dataMessage) { dataMessage = Proto.DataMessage.encode(proto.content.dataMessage); } else if (proto.content?.editMessage) { editMessage = Proto.EditMessage.encode(proto.content.editMessage); } hasPniSignatureMessage = Boolean(proto.pniSignatureMessage); } 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 { const error = providedError ?? new OutgoingMessageError(serviceId, providedError); error.cause = reason; this.errors[this.errors.length] = error; this.numberCompleted(); } reloadDevicesAndSend( serviceId: ServiceIdString, recurse?: boolean ): () => Promise { return async () => { const ourAci = itemStorage.user.getCheckedAci(); const deviceIds = await signalProtocolStore.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, { getKeysForServiceId: doGetKeysForServiceId, getKeysForServiceIdUnauth }, updateDevices ?? null, accessKey, null ); if (accessKeyFailed && !this.failoverServiceIds.includes(serviceId)) { this.failoverServiceIds.push(serviceId); } } async transmitSealedSenderMessage( serviceId: ServiceIdString, messages: ReadonlyArray, timestamp: number, { accessKey, groupSendToken, }: { accessKey: string | null; groupSendToken: GroupSendToken | null; } = { accessKey: null, groupSendToken: null } ): Promise { const useLibsignal = isFeaturedEnabledNoRedux({ betaKey: 'desktop.sendMessageViaLibsignal.beta', prodKey: 'desktop.sendMessageViaLibsignal.prod', }); if (useLibsignal) { let auth: SealedSenderAuthType; if (this.story) { auth = 'story'; } else if (groupSendToken) { auth = new GroupSendFullToken(groupSendToken); } else if (accessKey) { if (accessKey === ZERO_ACCESS_KEY) { auth = 'unrestricted'; } else { auth = { accessKey: Bytes.fromBase64(accessKey) }; } } else { auth = 'unrestricted'; } return sendSealedSenderMessage(serviceId, messages, timestamp, auth, { online: this.online, urgent: this.urgent, }); } return sendMessagesUnauthLegacy(serviceId, messages, timestamp, { accessKey, groupSendToken, online: this.online, story: this.story, urgent: this.urgent, }); } async transmitUnsealedMessage( serviceId: ServiceIdString, messages: ReadonlyArray, timestamp: number ): Promise { const useLibsignal = isFeaturedEnabledNoRedux({ betaKey: 'desktop.sendMessageViaLibsignal.beta', prodKey: 'desktop.sendMessageViaLibsignal.prod', }); if (useLibsignal) { return sendUnsealedMessage(serviceId, messages, timestamp, { online: this.online, urgent: this.urgent, ourAci: itemStorage.user.getCheckedAci(), }); } return sendMessagesLegacy(serviceId, messages, timestamp, { online: this.online, story: this.story, urgent: this.urgent, }); } getPlaintext(): Uint8Array { if (!this.plaintext) { const { message } = this; if (message instanceof PlaintextContent) { this.plaintext = message.serialize(); } else { this.plaintext = padMessage(Proto.Content.encode(message)); } } return this.plaintext; } getContentProtoBytes(): Uint8Array | undefined { if (this.message instanceof PlaintextContent) { return undefined; } return Proto.Content.encode(this.message); } async getCiphertextMessage({ identityKeyStore, destinationAddress, localAddress, sessionStore, }: { identityKeyStore: IdentityKeys; destinationAddress: ProtocolAddress; localAddress: ProtocolAddress; sessionStore: Sessions; }): Promise { const { message } = this; if (message instanceof PlaintextContent) { return message.asCiphertextMessage(); } return signalEncrypt( this.getPlaintext(), destinationAddress, localAddress, sessionStore, identityKeyStore ); } async doSendMessage( serviceId: ServiceIdString, deviceIds: Array, recurse?: boolean ): Promise { const { sendMetadata } = this; const { accessKey = null, groupSendToken = null, senderCertificate, } = sendMetadata?.[serviceId] || {}; if (accessKey && !senderCertificate) { log.warn( 'doSendMessage: accessKey was provided, but senderCertificate was not' ); } const sealedSender = (accessKey != null || groupSendToken != null) && senderCertificate != null; // We don't send to ourselves unless sealedSender is enabled const ourNumber = itemStorage.user.getNumber(); const ourAci = itemStorage.user.getCheckedAci(); const ourDeviceId = itemStorage.user.getCheckedDeviceId(); if ((serviceId === ourNumber || serviceId === ourAci) && !sealedSender) { // oxlint-disable-next-line no-param-reassign 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({ signalProtocolStore, ourServiceId: ourAci, }); const identityKeyStore = new IdentityKeys({ signalProtocolStore, ourServiceId: ourAci, }); const localAddress = ProtocolAddress.new(ourAci, ourDeviceId); return ( Promise.all( deviceIds.map(async deviceId => { const address = new QualifiedAddress( ourAci, new Address(serviceId, deviceId) ); return signalProtocolStore.enqueueSessionJob( address, async () => { const destinationAddress = ProtocolAddress.new( serviceId, deviceId ); const activeSession = await sessionStore.getSession(destinationAddress); if (!activeSession) { throw new Error( 'OutgoingMessage.doSendMessage: No active session!' ); } const registrationId = activeSession.remoteRegistrationId(); if (sealedSender && senderCertificate) { const ciphertextMessage = await this.getCiphertextMessage({ identityKeyStore, destinationAddress, localAddress, sessionStore, }); const certificate = SenderCertificate.deserialize( senderCertificate.serialized ); const groupIdBuffer = this.groupId ? Bytes.fromBase64(this.groupId) : null; const content = UnidentifiedSenderMessageContent.new( ciphertextMessage, certificate, this.contentHint, groupIdBuffer ); const buffer = await sealedSenderEncrypt( content, destinationAddress, identityKeyStore ); return { type: Proto.Envelope.Type.UNIDENTIFIED_SENDER, deviceId, registrationId, contents: buffer, }; } const ciphertextMessage = await this.getCiphertextMessage({ identityKeyStore, destinationAddress, localAddress, sessionStore, }); const type = ciphertextMessageTypeToEnvelopeType( ciphertextMessage.type() ); return { type, deviceId, registrationId, contents: ciphertextMessage, }; } ); }) ) // oxlint-disable-next-line promise/prefer-await-to-then, signal-desktop/no-then .then(async (ciphertextMessages: Array) => { if (sealedSender) { strictAssert( ciphertextMessages.every( message => message.type === Proto.Envelope.Type.UNIDENTIFIED_SENDER ), 'must be sealed sender envelopes' ); return this.transmitSealedSenderMessage( serviceId, ciphertextMessages, this.timestamp, { accessKey, groupSendToken, } // oxlint-disable-next-line signal-desktop/no-then ).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( `doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` ); } }, async (error: Error) => { if (error instanceof UnauthorizedMessageSendError) { log.warn( `doSendMessage: Failing over to unsealed send for serviceId ${serviceId}` ); if (!this.failoverServiceIds.includes(serviceId)) { 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; } ); } strictAssert( ciphertextMessages.every( message => message.type !== Proto.Envelope.Type.UNIDENTIFIED_SENDER ), 'cannot be sealed sender' ); return this.transmitUnsealedMessage( serviceId, ciphertextMessages, this.timestamp // oxlint-disable-next-line signal-desktop/no-then ).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( `doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` ); } }); }) // oxlint-disable-next-line promise/prefer-await-to-then .catch(async error => { if (error instanceof MismatchedDevicesError) { if (!recurse) { this.registerError( serviceId, 'Hit retry limit attempting to reload device list', error ); return undefined; } await handleMismatchedDevicesError(error, { fetchKeysForServiceId: this.getKeysForServiceId.bind(this), log, ourAci, }); const entry = error.entries.at(0); // if we only have stale devices, we try only once more const shouldRecurse = Boolean(entry?.extraDevices?.length) || Boolean(entry?.missingDevices?.length); return this.reloadDevicesAndSend(serviceId, shouldRecurse)(); } let newError = error; if ( error instanceof LibSignalErrorBase && error.is(ErrorCode.UntrustedIdentity) ) { newError = new OutgoingIdentityKeyError(serviceId, error); log.error( 'UntrustedIdentityKeyError from decrypt!', serviceId, deviceIds ); log.info('closing all sessions for', serviceId); // oxlint-disable-next-line promise/prefer-await-to-then, signal-desktop/no-then signalProtocolStore.archiveAllSessions(serviceId).then( () => { throw error; }, innerError => { log.error( `doSendMessage: Error closing sessions: ${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 = itemStorage.user.getCheckedAci(); await Promise.all( deviceIdsToRemove.map(async deviceId => { await signalProtocolStore.archiveSession( new QualifiedAddress(ourAci, new Address(serviceId, deviceId)) ); }) ); } async sendToServiceId(serviceId: ServiceIdString): Promise { if (isSignalServiceId(serviceId)) { this.registerError( serviceId, 'Failed to send to Signal serviceId', new Error("Can't send to Signal serviceId") ); return; } try { const ourAci = itemStorage.user.getCheckedAci(); const deviceIds = await signalProtocolStore.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.is(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 ); } } } }