diff --git a/package.json b/package.json index 3082934a4..f47fcc489 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "fs-xattr": "0.3.0" }, "dependencies": { - "@signalapp/signal-client": "0.5.2", + "@signalapp/signal-client": "0.6.0", "@sindresorhus/is": "0.8.0", "@types/pino": "6.3.6", "@types/pino-multi-stream": "5.1.0", @@ -163,7 +163,7 @@ "uuid": "3.3.2", "websocket": "1.0.28", "zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#7ecf70be85e5a485ec870c1723b1c6247b9d549e", - "zod": "1.11.13" + "zod": "3.0.2" }, "devDependencies": { "@babel/core": "7.7.7", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index d7c664cfd..37587c352 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -12,7 +12,6 @@ message Envelope { PREKEY_BUNDLE = 3; RECEIPT = 5; UNIDENTIFIED_SENDER = 6; - SENDERKEY = 7; } optional Type type = 1; diff --git a/ts/LibSignalStores.ts b/ts/LibSignalStores.ts index 0088c3418..d69e767b2 100644 --- a/ts/LibSignalStores.ts +++ b/ts/LibSignalStores.ts @@ -67,6 +67,15 @@ export class Sessions extends SessionStore { return record || null; } + + async getExistingSessions( + addresses: Array + ): Promise> { + const encodedAddresses = addresses.map(encodedNameFromAddress); + return window.textsecure.storage.protocol.loadSessions(encodedAddresses, { + zone: this.zone, + }); + } } export type IdentityKeysOptions = { diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index ec2486a38..79b176bda 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -6,7 +6,7 @@ import PQueue from 'p-queue'; import { isNumber } from 'lodash'; -import * as z from 'zod'; +import { z } from 'zod'; import { Direction, @@ -32,6 +32,7 @@ import { sessionStructureToArrayBuffer, } from './util/sessionTranslation'; import { + DeviceType, KeyPairType, IdentityKeyType, SenderKeyType, @@ -545,7 +546,7 @@ export class SignalProtocolStore extends EventsMixin { } if (entry.hydrated) { - window.log.info('Successfully fetched signed prekey (cache hit):', id); + window.log.info('Successfully fetched sender key (cache hit):', id); return entry.item; } @@ -555,17 +556,40 @@ export class SignalProtocolStore extends EventsMixin { item, fromDB: entry.fromDB, }); - window.log.info('Successfully fetched signed prekey (cache miss):', id); + window.log.info('Successfully fetched sender key(cache miss):', id); return item; } catch (error) { const errorString = error && error.stack ? error.stack : error; window.log.error( - `getSenderKey: failed to load senderKey ${encodedAddress}/${distributionId}: ${errorString}` + `getSenderKey: failed to load sender key ${encodedAddress}/${distributionId}: ${errorString}` ); return undefined; } } + async removeSenderKey( + encodedAddress: string, + distributionId: string + ): Promise { + if (!this.senderKeys) { + throw new Error('getSenderKey: this.senderKeys not yet cached!'); + } + + try { + const senderId = await normalizeEncodedAddress(encodedAddress); + const id = this.getSenderKeyId(senderId, distributionId); + + await window.Signal.Data.removeSenderKeyById(id); + + this.senderKeys.delete(id); + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `removeSenderKey: failed to remove senderKey ${encodedAddress}/${distributionId}: ${errorString}` + ); + } + } + // Session Queue async enqueueSessionJob( @@ -792,6 +816,21 @@ export class SignalProtocolStore extends EventsMixin { }); } + async loadSessions( + encodedAddresses: Array, + { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} + ): Promise> { + return this.withZone(zone, 'loadSession', async () => { + const sessions = await Promise.all( + encodedAddresses.map(async address => + this.loadSession(address, { zone }) + ) + ); + + return sessions.filter(isNotNil); + }); + } + private async _maybeMigrateSession( session: SessionType ): Promise { @@ -882,33 +921,51 @@ export class SignalProtocolStore extends EventsMixin { }); } - async getDeviceIds(identifier: string): Promise> { - return this.withZone(GLOBAL_ZONE, 'getDeviceIds', async () => { + async getOpenDevices( + identifiers: Array + ): Promise<{ + devices: Array; + emptyIdentifiers: Array; + }> { + return this.withZone(GLOBAL_ZONE, 'getOpenDevices', async () => { if (!this.sessions) { - throw new Error('getDeviceIds: this.sessions not yet cached!'); + throw new Error('getOpenDevices: this.sessions not yet cached!'); } - if (identifier === null || identifier === undefined) { - throw new Error('getDeviceIds: identifier was undefined/null'); + if (identifiers.length === 0) { + throw new Error('getOpenDevices: No identifiers provided!'); } try { - const id = window.ConversationController.getConversationId(identifier); - if (!id) { - throw new Error( - `getDeviceIds: No conversationId found for identifier ${identifier}` + const conversationIds = new Map(); + identifiers.forEach(identifier => { + if (identifier === null || identifier === undefined) { + throw new Error('getOpenDevices: identifier was undefined/null'); + } + + const conversation = window.ConversationController.getOrCreate( + identifier, + 'private' ); - } + if (!conversation) { + throw new Error( + `getOpenDevices: No conversationId found for identifier ${identifier}` + ); + } + conversationIds.set(conversation.get('id'), identifier); + }); const allSessions = this._getAllSessions(); - const entries = allSessions.filter( - session => session.fromDB.conversationId === id + const entries = allSessions.filter(session => + conversationIds.has(session.fromDB.conversationId) ); - const openIds = await Promise.all( + const openEntries: Array< + SessionCacheEntry | undefined + > = await Promise.all( entries.map(async entry => { if (entry.hydrated) { const record = entry.item; if (record.hasCurrentState()) { - return entry.fromDB.deviceId; + return entry; } return undefined; @@ -916,25 +973,67 @@ export class SignalProtocolStore extends EventsMixin { const record = await this._maybeMigrateSession(entry.fromDB); if (record.hasCurrentState()) { - return entry.fromDB.deviceId; + return entry; } return undefined; }) ); - return openIds.filter(isNotNil); + const devices = openEntries + .map(entry => { + if (!entry) { + return undefined; + } + + const { conversationId } = entry.fromDB; + conversationIds.delete(conversationId); + + const id = entry.fromDB.deviceId; + const conversation = window.ConversationController.get( + conversationId + ); + if (!conversation) { + throw new Error( + `getOpenDevices: Unable to find matching conversation for ${conversationId}` + ); + } + + const identifier = + conversation.get('uuid') || conversation.get('e164'); + if (!identifier) { + throw new Error( + `getOpenDevices: No identifier for conversation ${conversationId}` + ); + } + + return { + identifier, + id, + }; + }) + .filter(isNotNil); + const emptyIdentifiers = Array.from(conversationIds.values()); + + return { + devices, + emptyIdentifiers, + }; } catch (error) { window.log.error( - `getDeviceIds: Failed to get device ids for identifier ${identifier}`, + 'getOpenDevices: Failed to get devices', error && error.stack ? error.stack : error ); + throw error; } - - return []; }); } + async getDeviceIds(identifier: string): Promise> { + const { devices } = await this.getOpenDevices([identifier]); + return devices.map((device: DeviceType) => device.id); + } + async removeSession(encodedAddress: string): Promise { return this.withZone(GLOBAL_ZONE, 'removeSession', async () => { if (!this.sessions) { diff --git a/ts/background.ts b/ts/background.ts index 760ea605e..b1f0439c9 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2060,6 +2060,7 @@ export async function startApp(): Promise { await server.registerCapabilities({ 'gv2-3': true, 'gv1-migration': true, + senderKey: false, }); } catch (error) { window.log.error( diff --git a/ts/groups.ts b/ts/groups.ts index b31bdf258..2fc480299 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1261,7 +1261,7 @@ export async function modifyGroupV2({ const timestamp = Date.now(); const promise = conversation.wrapSend( - window.textsecure.messaging.sendMessageToGroup( + window.Signal.Util.sendToGroup( { groupV2: conversation.getGroupV2Info({ groupChange: groupChangeBuffer, @@ -1271,6 +1271,7 @@ export async function modifyGroupV2({ timestamp, profileKey, }, + conversation, sendOptions ) ); @@ -1631,13 +1632,16 @@ export async function createGroupV2({ await wrapWithSyncMessageSend({ conversation, - logId: `sendMessageToGroup/${logId}`, - send: async sender => - sender.sendMessageToGroup({ - groupV2: groupV2Info, - timestamp, - profileKey, - }), + logId: `sendToGroup/${logId}`, + send: async () => + window.Signal.Util.sendToGroup( + { + groupV2: groupV2Info, + timestamp, + profileKey, + }, + conversation + ), timestamp, }); @@ -2143,16 +2147,19 @@ export async function initiateMigrationToGroupV2( await wrapWithSyncMessageSend({ conversation, - logId: `sendMessageToGroup/${logId}`, - send: async sender => + logId: `sendToGroup/${logId}`, + send: async () => // Minimal message to notify group members about migration - sender.sendMessageToGroup({ - groupV2: conversation.getGroupV2Info({ - includePendingMembers: true, - }), - timestamp, - profileKey: ourProfileKey, - }), + window.Signal.Util.sendToGroup( + { + groupV2: conversation.getGroupV2Info({ + includePendingMembers: true, + }), + timestamp, + profileKey: ourProfileKey, + }, + conversation + ), timestamp, }); } diff --git a/ts/jobs/removeStorageKeyJobQueue.ts b/ts/jobs/removeStorageKeyJobQueue.ts index 50228273f..d385c52c5 100644 --- a/ts/jobs/removeStorageKeyJobQueue.ts +++ b/ts/jobs/removeStorageKeyJobQueue.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as z from 'zod'; +import { z } from 'zod'; import { JobQueue } from './JobQueue'; import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; diff --git a/ts/logging/debuglogs.ts b/ts/logging/debuglogs.ts index 25a6df612..6b503242f 100644 --- a/ts/logging/debuglogs.ts +++ b/ts/logging/debuglogs.ts @@ -1,7 +1,7 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as z from 'zod'; +import { z } from 'zod'; import FormData from 'form-data'; import { gzip } from 'zlib'; import pify from 'pify'; diff --git a/ts/logging/shared.ts b/ts/logging/shared.ts index d78b28d6b..2641bb274 100644 --- a/ts/logging/shared.ts +++ b/ts/logging/shared.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as z from 'zod'; +import { z } from 'zod'; import * as pino from 'pino'; import { redactAll } from '../../js/modules/privacy'; import { missingCaseError } from '../util/missingCaseError'; @@ -27,7 +27,8 @@ const logEntrySchema = z.object({ }); export type LogEntryType = z.infer; -export const isLogEntry = logEntrySchema.check.bind(logEntrySchema); +export const isLogEntry = (data: unknown): data is LogEntryType => + logEntrySchema.safeParse(data).success; export function getLogLevelString(value: LogLevel): pino.Level { switch (value) { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 246e05de2..0a1a83b27 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -12,6 +12,7 @@ import { MessageType, LastMessageStatus, } from './state/ducks/conversations'; +import { DeviceType } from './textsecure/Types'; import { SendOptionsType } from './textsecure/SendMessage'; import { SendMessageChallengeData } from './textsecure/Errors'; import { @@ -264,6 +265,11 @@ export type ConversationAttributesType = { secretParams?: string; publicParams?: string; revision?: number; + senderKeyInfo?: { + createdAtDate: number; + distributionId: string; + memberDevices: Array; + }; // GroupV2 other fields accessControl?: { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ac4a7334e..5a1e801c7 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1177,27 +1177,55 @@ export class ConversationModel extends window.Backbone return; } - const recipientId = this.isPrivate() ? this.getSendTarget() : undefined; - const groupId = this.getGroupIdBuffer(); - const groupMembers = this.getRecipients(); + await this.queueJob(async () => { + const recipientId = this.isPrivate() ? this.getSendTarget() : undefined; + const groupId = this.getGroupIdBuffer(); + const groupMembers = this.getRecipients(); - // We don't send typing messages if our recipients list is empty - if (!this.isPrivate() && !groupMembers.length) { - return; - } + // We don't send typing messages if our recipients list is empty + if (!this.isPrivate() && !groupMembers.length) { + return; + } - const sendOptions = await this.getSendOptions(); - this.wrapSend( - window.textsecure.messaging.sendTypingMessage( + const timestamp = Date.now(); + const contentMessage = window.textsecure.messaging.getTypingContentMessage( { - isTyping, recipientId, groupId, groupMembers, - }, - sendOptions - ) - ); + isTyping, + timestamp, + } + ); + + const sendOptions = await this.getSendOptions(); + if (this.isPrivate()) { + const silent = true; + this.wrapSend( + window.textsecure.messaging.sendMessageProtoAndWait( + timestamp, + groupMembers, + contentMessage, + silent, + { + ...sendOptions, + online: true, + } + ) + ); + } else { + this.wrapSend( + window.Signal.Util.sendContentMessageToGroup({ + contentMessage, + conversation: this, + online: true, + recipients: groupMembers, + sendOptions, + timestamp, + }) + ); + } + }); } async cleanup(): Promise { @@ -3099,7 +3127,7 @@ export class ConversationModel extends window.Backbone ); } - return window.textsecure.messaging.sendMessageToGroup( + return window.Signal.Util.sendToGroup( { groupV1: this.getGroupV1Info(), groupV2: this.getGroupV2Info(), @@ -3107,6 +3135,7 @@ export class ConversationModel extends window.Backbone timestamp, profileKey, }, + this, options ); })(); @@ -3208,19 +3237,19 @@ export class ConversationModel extends window.Backbone // Special-case the self-send case - we send only a sync message if (this.isMe()) { - const dataMessage = await window.textsecure.messaging.getMessageProto( - destination, - undefined, // body - [], // attachments - undefined, // quote - [], // preview - undefined, // sticker - outgoingReaction, - undefined, // deletedForEveryoneTimestamp - timestamp, + const dataMessage = await window.textsecure.messaging.getDataMessage({ + attachments: [], + // body + // deletedForEveryoneTimestamp expireTimer, - profileKey - ); + preview: [], + profileKey, + // quote + reaction: outgoingReaction, + recipients: [destination], + // sticker + timestamp, + }); const result = await message.sendSyncMessageOnly(dataMessage); window.Whisper.Reactions.onReaction(reactionModel); return result; @@ -3246,7 +3275,7 @@ export class ConversationModel extends window.Backbone ); } - return window.textsecure.messaging.sendMessageToGroup( + return window.Signal.Util.sendToGroup( { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion groupV1: this.getGroupV1Info()!, @@ -3257,6 +3286,7 @@ export class ConversationModel extends window.Backbone expireTimer, profileKey, }, + this, options ); })(); @@ -3446,19 +3476,19 @@ export class ConversationModel extends window.Backbone // Special-case the self-send case - we send only a sync message if (this.isMe()) { - const dataMessage = await window.textsecure.messaging.getMessageProto( - destination, - messageBody, - finalAttachments, - quote, - preview, - sticker, - null, // reaction - undefined, // deletedForEveryoneTimestamp - now, + const dataMessage = await window.textsecure.messaging.getDataMessage({ + attachments: finalAttachments, + body: messageBody, + // deletedForEveryoneTimestamp expireTimer, - profileKey - ); + preview, + profileKey, + quote, + // reaction + recipients: [destination], + sticker, + timestamp: now, + }); return message.sendSyncMessageOnly(dataMessage); } @@ -3467,7 +3497,7 @@ export class ConversationModel extends window.Backbone let promise; if (conversationType === Message.GROUP) { - promise = window.textsecure.messaging.sendMessageToGroup( + promise = window.Signal.Util.sendToGroup( { attachments: finalAttachments, expireTimer, @@ -3481,6 +3511,7 @@ export class ConversationModel extends window.Backbone timestamp: now, mentions, }, + this, options ); } else { @@ -3904,21 +3935,21 @@ export class ConversationModel extends window.Backbone if (this.isMe()) { const flags = window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; - const dataMessage = await window.textsecure.messaging.getMessageProto( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.getSendTarget()!, - undefined, // body - [], // attachments - undefined, // quote - [], // preview - undefined, // sticker - undefined, // reaction - undefined, // deletedForEveryoneTimestamp - message.get('sent_at'), + const dataMessage = await window.textsecure.messaging.getDataMessage({ + attachments: [], + // body + // deletedForEveryoneTimestamp expireTimer, + flags, + preview: [], profileKey, - flags - ); + // quote + // reaction + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + recipients: [this.getSendTarget()!], + // sticker + timestamp: message.get('sent_at'), + }); return message.sendSyncMessageOnly(dataMessage); } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4145e255f..de46c46f3 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2190,22 +2190,21 @@ export class MessageModel extends window.Backbone.Model { recipients.length === 1 && (recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID) ) { - const [identifier] = recipients; - const dataMessage = await window.textsecure.messaging.getMessageProto( - identifier, - body, + const dataMessage = await window.textsecure.messaging.getDataMessage({ attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('deletedForEveryoneTimestamp'), - this.get('sent_at'), - this.get('expireTimer'), + body, + deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), + expireTimer: this.get('expireTimer'), + // flags + mentions: this.get('bodyRanges'), + preview: previewWithData, profileKey, - undefined, // flags - this.get('bodyRanges') - ); + quote: quoteWithData, + reaction: null, + recipients, + sticker: stickerWithData, + timestamp: this.get('sent_at'), + }); return this.sendSyncMessageOnly(dataMessage); } @@ -2229,15 +2228,32 @@ export class MessageModel extends window.Backbone.Model { options ); } else { - // Because this is a partial group send, we manually construct the request like - // sendMessageToGroup does. + const initialGroupV2 = conversation.getGroupV2Info(); + const groupId = conversation.get('groupId'); + if (!groupId) { + throw new Error("retrySend: Conversation didn't have groupId"); + } - const groupV2 = conversation.getGroupV2Info(); + const groupV2 = initialGroupV2 + ? { + ...initialGroupV2, + members: recipients, + } + : undefined; + const groupV1 = groupV2 + ? undefined + : { + id: groupId, + members: recipients, + }; - promise = window.textsecure.messaging.sendMessage( + // Important to ensure that we don't consider this receipient list to be the entire + // member list. + const partialSend = true; + + promise = window.Signal.Util.sendToGroup( { - recipients, - body, + messageText: body, timestamp: this.get('sent_at'), attachments, quote: quoteWithData, @@ -2247,15 +2263,11 @@ export class MessageModel extends window.Backbone.Model { mentions: this.get('bodyRanges'), profileKey, groupV2, - group: groupV2 - ? undefined - : { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: this.getConversation()!.get('groupId')!, - type: window.textsecure.protobuf.GroupContext.Type.DELIVER, - }, + groupV1, }, - options + conversation, + options, + partialSend ); } @@ -2409,21 +2421,21 @@ export class MessageModel extends window.Backbone.Model { // Special-case the self-send case - we send only a sync message if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) { - const dataMessage = await window.textsecure.messaging.getMessageProto( - identifier, - body, + const dataMessage = await window.textsecure.messaging.getDataMessage({ attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('deletedForEveryoneTimestamp'), - this.get('sent_at'), - this.get('expireTimer'), + body, + deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'), + expireTimer: this.get('expireTimer'), + // flags + mentions: this.get('bodyRanges'), + preview: previewWithData, profileKey, - undefined, // flags - this.get('bodyRanges') - ); + quote: quoteWithData, + reaction: null, + recipients: [identifier], + sticker: stickerWithData, + timestamp: this.get('sent_at'), + }); return this.sendSyncMessageOnly(dataMessage); } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 2aacd5bec..d286facea 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -768,12 +768,19 @@ export class CallingClass { // We "fire and forget" because sending this message is non-essential. wrapWithSyncMessageSend({ conversation, - logId: `sendGroupCallUpdateMessage/${conversationId}-${eraId}`, - send: sender => - sender.sendGroupCallUpdate({ eraId, groupV2, timestamp }, sendOptions), + logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`, + send: () => + window.Signal.Util.sendToGroup( + { groupCallUpdate: { eraId }, groupV2, timestamp }, + conversation, + sendOptions + ), timestamp, }).catch(err => { - window.log.error('Failed to send group call update', err); + window.log.error( + 'Failed to send group call update:', + err && err.stack ? err.stack : err + ); }); } diff --git a/ts/services/senderCertificate.ts b/ts/services/senderCertificate.ts index 4958e49c1..63a7e9c80 100644 --- a/ts/services/senderCertificate.ts +++ b/ts/services/senderCertificate.ts @@ -20,6 +20,10 @@ type Storage = { remove(key: string): Promise; }; +function isWellFormed(data: unknown): data is SerializedCertificateType { + return serializedCertificateSchema.safeParse(data).success; +} + // In case your clock is different from the server's, we "fake" expire certificates early. const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000; @@ -88,10 +92,14 @@ export class SenderCertificateService { ); const valueInStorage = storage.get(modeToStorageKey(mode)); - return serializedCertificateSchema.check(valueInStorage) && + if ( + isWellFormed(valueInStorage) && isExpirationValid(valueInStorage.expires) - ? valueInStorage - : undefined; + ) { + return valueInStorage; + } + + return undefined; } private fetchCertificate( diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 2e280a0d0..35906a85f 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -139,6 +139,7 @@ const dataInterface: ClientInterface = { getSenderKeyById, removeAllSenderKeys, getAllSenderKeys, + removeSenderKeyById, createOrUpdateSession, createOrUpdateSessions, @@ -759,6 +760,9 @@ async function removeAllSenderKeys(): Promise { async function getAllSenderKeys(): Promise> { return channels.getAllSenderKeys(); } +async function removeSenderKeyById(id: string): Promise { + return channels.removeSenderKeyById(id); +} // Sessions diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 54363a374..9fee4f266 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -185,6 +185,7 @@ export type DataInterface = { getSenderKeyById: (id: string) => Promise; removeAllSenderKeys: () => Promise; getAllSenderKeys: () => Promise>; + removeSenderKeyById: (id: string) => Promise; createOrUpdateSession: (data: SessionType) => Promise; createOrUpdateSessions: (array: Array) => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 12cfe34e6..56048abdc 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -130,6 +130,7 @@ const dataInterface: ServerInterface = { getSenderKeyById, removeAllSenderKeys, getAllSenderKeys, + removeSenderKeyById, createOrUpdateSession, createOrUpdateSessions, @@ -2215,6 +2216,10 @@ async function getAllSenderKeys(): Promise> { return rows; } +async function removeSenderKeyById(id: string): Promise { + const db = getInstance(); + prepare(db, 'DELETE FROM senderKeys WHERE id = $id').run({ id }); +} const SESSIONS_TABLE = 'sessions'; function createOrUpdateSessionSync(data: SessionType): void { @@ -4857,9 +4862,11 @@ async function removeAll(): Promise { // Anything that isn't user-visible data async function removeAllConfiguration(): Promise { const db = getInstance(); + const patch: Partial = { senderKeyInfo: undefined }; db.transaction(() => { - db.exec(` + db.prepare( + ` DELETE FROM identityKeys; DELETE FROM items; DELETE FROM preKeys; @@ -4868,7 +4875,11 @@ async function removeAllConfiguration(): Promise { DELETE FROM signedPreKeys; DELETE FROM unprocessed; DELETE FROM jobs; - `); + UPDATE conversations SET json = json_patch(json, $patch); + ` + ).run({ + $patch: patch, + }); })(); } diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index d5507ac93..03ac9e36c 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -175,6 +175,14 @@ describe('SignalProtocolStore', () => { assert.isTrue( constantTimeEqual(expected.serialize(), actual.serialize()) ); + + await store.removeSenderKey(encodedAddress, distributionId); + + const postDeleteGet = await store.getSenderKey( + encodedAddress, + distributionId + ); + assert.isUndefined(postDeleteGet); }); it('roundtrips through database', async () => { @@ -197,6 +205,17 @@ describe('SignalProtocolStore', () => { assert.isTrue( constantTimeEqual(expected.serialize(), actual.serialize()) ); + + await store.removeSenderKey(encodedAddress, distributionId); + + // Re-fetch from the database to ensure we get the latest database value + await store.hydrateCaches(); + + const postDeleteGet = await store.getSenderKey( + encodedAddress, + distributionId + ); + assert.isUndefined(postDeleteGet); }); }); @@ -1280,6 +1299,54 @@ describe('SignalProtocolStore', () => { }); }); + describe('getOpenDevices', () => { + it('returns all open devices for a number', async () => { + const openRecord = getSessionRecord(true); + const openDevices = [1, 2, 3, 10].map(deviceId => { + return [number, deviceId].join('.'); + }); + await Promise.all( + openDevices.map(async encodedNumber => { + await store.storeSession(encodedNumber, openRecord); + }) + ); + + const closedRecord = getSessionRecord(false); + await store.storeSession([number, 11].join('.'), closedRecord); + + const result = await store.getOpenDevices([number, 'blah', 'blah2']); + assert.deepEqual(result, { + devices: [ + { + id: 1, + identifier: number, + }, + { + id: 2, + identifier: number, + }, + { + id: 3, + identifier: number, + }, + { + id: 10, + identifier: number, + }, + ], + emptyIdentifiers: ['blah', 'blah2'], + }); + }); + + it('returns empty array for a number with no device ids', async () => { + const result = await store.getOpenDevices(['foo']); + assert.deepEqual(result, { + devices: [], + emptyIdentifiers: ['foo'], + }); + }); + }); + describe('zones', () => { const zone = new Zone('zone', { pendingSessions: true, diff --git a/ts/test-electron/util/sendToGroup_test.ts b/ts/test-electron/util/sendToGroup_test.ts new file mode 100644 index 000000000..bb695d93f --- /dev/null +++ b/ts/test-electron/util/sendToGroup_test.ts @@ -0,0 +1,144 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { _analyzeSenderKeyDevices, _waitForAll } from '../../util/sendToGroup'; + +import { DeviceType } from '../../textsecure/Types.d'; + +describe('sendToGroup', () => { + describe('#_analyzeSenderKeyDevices', () => { + function getDefaultDeviceList(): Array { + return [ + { + identifier: 'ident-guid-one', + id: 1, + }, + { + identifier: 'ident-guid-one', + id: 2, + }, + { + identifier: 'ident-guid-two', + id: 2, + }, + ]; + } + + it('returns nothing if new and previous lists are the same', () => { + const memberDevices = getDefaultDeviceList(); + const devicesForSend = getDefaultDeviceList(); + + const { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + } = _analyzeSenderKeyDevices(memberDevices, devicesForSend); + + assert.isEmpty(newToMemberDevices); + assert.isEmpty(newToMemberUuids); + assert.isEmpty(removedFromMemberDevices); + assert.isEmpty(removedFromMemberUuids); + }); + it('returns set of new devices', () => { + const memberDevices = getDefaultDeviceList(); + const devicesForSend = getDefaultDeviceList(); + + memberDevices.pop(); + memberDevices.pop(); + + const { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + } = _analyzeSenderKeyDevices(memberDevices, devicesForSend); + + assert.deepEqual(newToMemberDevices, [ + { + identifier: 'ident-guid-one', + id: 2, + }, + { + identifier: 'ident-guid-two', + id: 2, + }, + ]); + assert.deepEqual(newToMemberUuids, ['ident-guid-one', 'ident-guid-two']); + assert.isEmpty(removedFromMemberDevices); + assert.isEmpty(removedFromMemberUuids); + }); + it('returns set of removed devices', () => { + const memberDevices = getDefaultDeviceList(); + const devicesForSend = getDefaultDeviceList(); + + devicesForSend.pop(); + devicesForSend.pop(); + + const { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + } = _analyzeSenderKeyDevices(memberDevices, devicesForSend); + + assert.isEmpty(newToMemberDevices); + assert.isEmpty(newToMemberUuids); + assert.deepEqual(removedFromMemberDevices, [ + { + identifier: 'ident-guid-one', + id: 2, + }, + { + identifier: 'ident-guid-two', + id: 2, + }, + ]); + assert.deepEqual(removedFromMemberUuids, [ + 'ident-guid-one', + 'ident-guid-two', + ]); + }); + it('returns empty removals if partial send', () => { + const memberDevices = getDefaultDeviceList(); + const devicesForSend = getDefaultDeviceList(); + + devicesForSend.pop(); + devicesForSend.pop(); + + const isPartialSend = true; + const { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + } = _analyzeSenderKeyDevices( + memberDevices, + devicesForSend, + isPartialSend + ); + + assert.isEmpty(newToMemberDevices); + assert.isEmpty(newToMemberUuids); + assert.isEmpty(removedFromMemberDevices); + assert.isEmpty(removedFromMemberUuids); + }); + }); + + describe('#_waitForAll', () => { + it('returns nothing if new and previous lists are the same', async () => { + const task1 = () => Promise.resolve(1); + const task2 = () => Promise.resolve(2); + const task3 = () => Promise.resolve(3); + + const result = await _waitForAll({ + tasks: [task1, task2, task3], + maxConcurrency: 1, + }); + + assert.deepEqual(result, [1, 2, 3]); + }); + }); +}); diff --git a/ts/test-node/jobs/JobQueue_test.ts b/ts/test-node/jobs/JobQueue_test.ts index 5ee0a459f..a296240ad 100644 --- a/ts/test-node/jobs/JobQueue_test.ts +++ b/ts/test-node/jobs/JobQueue_test.ts @@ -4,7 +4,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import EventEmitter, { once } from 'events'; -import * as z from 'zod'; +import { z } from 'zod'; import { identity, noop, groupBy } from 'lodash'; import { v4 as uuid } from 'uuid'; import { JobError } from '../../jobs/JobError'; diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 5cbac4280..d71be78ab 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -730,7 +730,6 @@ export declare namespace EnvelopeClass { static PREKEY_BUNDLE: number; static RECEIPT: number; static UNIDENTIFIED_SENDER: number; - static SENDERKEY: number; } } diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 0b3d02325..47f37093f 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -10,19 +10,16 @@ import { reject } from 'lodash'; -import * as z from 'zod'; +import { z } from 'zod'; import { CiphertextMessageType, - PreKeyBundle, - processPreKeyBundle, ProtocolAddress, - PublicKey, sealedSenderEncryptMessage, SenderCertificate, signalEncrypt, } from '@signalapp/signal-client'; -import { ServerKeysType, WebAPIType } from './WebAPI'; +import { WebAPIType } from './WebAPI'; import { ContentClass, DataMessageClass } from '../textsecure.d'; import { CallbackResultType, @@ -40,6 +37,7 @@ import { import { isValidNumber } from '../types/PhoneNumber'; import { Sessions, IdentityKeys } from '../LibSignalStores'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; +import { getKeysForIdentifier } from './getKeysForIdentifier'; export const enum SenderCertificateMode { WithE164, @@ -80,6 +78,27 @@ function ciphertextMessageTypeToEnvelopeType(type: number) { ); } +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: ArrayBuffer): Uint8Array { + const plaintext = new Uint8Array( + getPaddedMessageLength(messageBuffer.byteLength + 1) - 1 + ); + plaintext.set(new Uint8Array(messageBuffer)); + plaintext[messageBuffer.byteLength] = 0x80; + + return plaintext; +} + export default class OutgoingMessage { server: WebAPIType; @@ -187,95 +206,26 @@ export default class OutgoingMessage { identifier: string, recurse?: boolean ): () => Promise { - return async () => - window.textsecure.storage.protocol - .getDeviceIds(identifier) - .then(async deviceIds => { - 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); - }); + return async () => { + const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( + 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 | undefined + updateDevices?: Array ): Promise> { - const handleResult = async (response: ServerKeysType) => { - const sessionStore = new Sessions(); - const identityKeyStore = new IdentityKeys(); - - return Promise.all( - response.devices.map(async device => { - const { deviceId, registrationId, preKey, signedPreKey } = device; - if ( - updateDevices === undefined || - updateDevices.indexOf(deviceId) > -1 - ) { - if (device.registrationId === 0) { - window.log.info('device registrationId 0!'); - } - if (!signedPreKey) { - throw new Error( - `getKeysForIdentifier/${identifier}: Missing signed prekey for deviceId ${deviceId}` - ); - } - const protocolAddress = ProtocolAddress.new(identifier, deviceId); - const preKeyId = preKey?.keyId || null; - const preKeyObject = preKey - ? PublicKey.deserialize(Buffer.from(preKey.publicKey)) - : null; - const signedPreKeyObject = PublicKey.deserialize( - Buffer.from(signedPreKey.publicKey) - ); - const identityKey = PublicKey.deserialize( - Buffer.from(response.identityKey) - ); - - const preKeyBundle = PreKeyBundle.new( - registrationId, - deviceId, - preKeyId, - preKeyObject, - signedPreKey.keyId, - signedPreKeyObject, - Buffer.from(signedPreKey.signature), - identityKey - ); - - const address = `${identifier}.${deviceId}`; - await window.textsecure.storage.protocol - .enqueueSessionJob(address, () => - processPreKeyBundle( - preKeyBundle, - protocolAddress, - sessionStore, - identityKeyStore - ) - ) - .catch(error => { - if ( - error?.message?.includes('untrusted identity for address') - ) { - error.timestamp = this.timestamp; - error.originalMessage = this.message.toArrayBuffer(); - error.identityKey = response.identityKey; - } - throw error; - }); - } - - return null; - }) - ); - }; - const { sendMetadata } = this; const info = sendMetadata && sendMetadata[identifier] @@ -283,65 +233,23 @@ export default class OutgoingMessage { : { accessKey: undefined }; const { accessKey } = info; - if (updateDevices === undefined) { - if (accessKey) { - return this.server - .getKeysForIdentifierUnauth(identifier, undefined, { accessKey }) - .catch(async (error: Error) => { - if (error.code === 401 || error.code === 403) { - if (this.failoverIdentifiers.indexOf(identifier) === -1) { - this.failoverIdentifiers.push(identifier); - } - return this.server.getKeysForIdentifier(identifier); - } - throw error; - }) - .then(handleResult); + try { + const { accessKeyFailed } = await getKeysForIdentifier( + identifier, + this.server, + updateDevices, + accessKey + ); + if (accessKeyFailed && !this.failoverIdentifiers.includes(identifier)) { + this.failoverIdentifiers.push(identifier); } - - return this.server.getKeysForIdentifier(identifier).then(handleResult); + } catch (error) { + if (error?.message?.includes('untrusted identity for address')) { + error.timestamp = this.timestamp; + error.originalMessage = this.message.toArrayBuffer(); + } + throw error; } - - let promise: Promise> = Promise.resolve(); - updateDevices.forEach(deviceId => { - promise = promise.then(async () => { - let innerPromise; - - if (accessKey) { - innerPromise = this.server - .getKeysForIdentifierUnauth(identifier, deviceId, { accessKey }) - .then(handleResult) - .catch(async error => { - if (error.code === 401 || error.code === 403) { - if (this.failoverIdentifiers.indexOf(identifier) === -1) { - this.failoverIdentifiers.push(identifier); - } - return this.server - .getKeysForIdentifier(identifier, deviceId) - .then(handleResult); - } - throw error; - }); - } else { - innerPromise = this.server - .getKeysForIdentifier(identifier, deviceId) - .then(handleResult); - } - - return innerPromise.catch(async e => { - if (e.name === 'HTTPError' && e.code === 404) { - if (deviceId !== 1) { - return this.removeDeviceIdsForIdentifier(identifier, [deviceId]); - } - throw new UnregisteredUserError(identifier, e); - } else { - throw e; - } - }); - }); - }); - - return promise; } async transmitMessage( @@ -389,25 +297,9 @@ export default class OutgoingMessage { }); } - getPaddedMessageLength(messageLength: number): number { - const messageLengthWithTerminator = messageLength + 1; - let messagePartCount = Math.floor(messageLengthWithTerminator / 160); - - if (messageLengthWithTerminator % 160 !== 0) { - messagePartCount += 1; - } - - return messagePartCount * 160; - } - getPlaintext(): ArrayBuffer { if (!this.plaintext) { - const messageBuffer = this.message.toArrayBuffer(); - this.plaintext = new Uint8Array( - this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1 - ); - this.plaintext.set(new Uint8Array(messageBuffer)); - this.plaintext[messageBuffer.byteLength] = 0x80; + this.plaintext = padMessage(this.message.toArrayBuffer()); } return this.plaintext; } @@ -629,34 +521,6 @@ export default class OutgoingMessage { }); } - async getStaleDeviceIdsForIdentifier( - identifier: string - ): Promise | undefined> { - const sessionStore = new Sessions(); - - const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( - identifier - ); - if (deviceIds.length === 0) { - return undefined; - } - - const updateDevices: Array = []; - await Promise.all( - deviceIds.map(async deviceId => { - const record = await sessionStore.getSession( - ProtocolAddress.new(identifier, deviceId) - ); - - if (!record || !record.hasCurrentState()) { - updateDevices.push(deviceId); - } - }) - ); - - return updateDevices; - } - async removeDeviceIdsForIdentifier( identifier: string, deviceIdsToRemove: Array @@ -713,10 +577,12 @@ export default class OutgoingMessage { ); } - const updateDevices = await this.getStaleDeviceIdsForIdentifier( + const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( identifier ); - await this.getKeysForIdentifier(identifier, updateDevices); + if (deviceIds.length === 0) { + await this.getKeysForIdentifier(identifier); + } await this.reloadDevicesAndSend(identifier, true)(); } catch (error) { if (error?.message?.includes('untrusted identity for address')) { diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index df53d70d6..2071656ae 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -8,16 +8,23 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-classes-per-file */ -import { Dictionary, without } from 'lodash'; +import { Dictionary } from 'lodash'; import PQueue from 'p-queue'; import { AbortSignal } from 'abort-controller'; +import { + ProtocolAddress, + SenderKeyDistributionMessage, +} from '@signalapp/signal-client'; +import { parseIntOrThrow } from '../util/parseIntOrThrow'; +import { SenderKeys } from '../LibSignalStores'; import { GroupCredentialsType, GroupLogResponseType, ProxiedRequestOptionsType, ChallengeType, WebAPIType, + MultiRecipient200ResponseType, } from './WebAPI'; import createTaskWithTimeout from './TaskWithTimeout'; import OutgoingMessage, { SerializedCertificateType } from './OutgoingMessage'; @@ -28,6 +35,7 @@ import { getRandomBytes, getZeroes, hexToArrayBuffer, + typedArrayToArrayBuffer, } from '../Crypto'; import { AttachmentPointerClass, @@ -113,7 +121,23 @@ type GroupCallUpdateType = { eraId: string; }; -type MessageOptionsType = { +export type AttachmentType = { + size: number; + data: ArrayBuffer; + contentType: string; + + fileName: string; + flags: number; + width: number; + height: number; + caption: string; + + attachmentPointer?: AttachmentPointerClass; + + blurHash?: string; +}; + +export type MessageOptionsType = { attachments?: Array | null; body?: string; expireTimer?: number; @@ -135,6 +159,22 @@ type MessageOptionsType = { mentions?: BodyRangesType; groupCallUpdate?: GroupCallUpdateType; }; +export type GroupSendOptionsType = { + attachments?: Array; + expireTimer?: number; + groupV2?: GroupV2InfoType; + groupV1?: GroupV1InfoType; + messageText?: string; + preview?: any; + profileKey?: ArrayBuffer; + quote?: any; + reaction?: any; + sticker?: any; + deletedForEveryoneTimestamp?: number; + timestamp: number; + mentions?: BodyRangesType; + groupCallUpdate?: GroupCallUpdateType; +}; class Message { attachments: Array; @@ -412,22 +452,6 @@ class Message { } } -export type AttachmentType = { - size: number; - data: ArrayBuffer; - contentType: string; - - fileName: string; - flags: number; - width: number; - height: number; - caption: string; - - attachmentPointer?: AttachmentPointerClass; - - blurHash?: string; -}; - export default class MessageSender { server: WebAPIType; @@ -440,6 +464,29 @@ export default class MessageSender { this.pendingMessages = {}; } + async queueJobForIdentifier( + identifier: string, + runJob: () => Promise + ): Promise { + const { id } = await window.ConversationController.getOrCreateAndWait( + identifier, + 'private' + ); + this.pendingMessages[id] = + this.pendingMessages[id] || new PQueue({ concurrency: 1 }); + + const queue = this.pendingMessages[id]; + + const taskWithTimeout = createTaskWithTimeout( + runJob, + `queueJobForIdentifier ${identifier} ${id}` + ); + + return queue.add(taskWithTimeout); + } + + // Attachment upload functions + _getAttachmentSizeBucket(size: number): number { return Math.max( 541, @@ -447,6 +494,15 @@ export default class MessageSender { ); } + getRandomPadding(): ArrayBuffer { + // Generate a random int from 1 and 512 + const buffer = getRandomBytes(2); + const paddingLength = (new Uint16Array(buffer)[0] & 0x1ff) + 1; + + // Generate a random padding buffer of the chosen size + return getRandomBytes(paddingLength); + } + getPaddedAttachment(data: ArrayBuffer): ArrayBuffer { const size = data.byteLength; const paddedSize = this._getAttachmentSizeBucket(size); @@ -510,27 +566,6 @@ export default class MessageSender { return proto; } - async queueJobForIdentifier( - identifier: string, - runJob: () => Promise - ): Promise { - const { id } = await window.ConversationController.getOrCreateAndWait( - identifier, - 'private' - ); - this.pendingMessages[id] = - this.pendingMessages[id] || new PQueue({ concurrency: 1 }); - - const queue = this.pendingMessages[id]; - - const taskWithTimeout = createTaskWithTimeout( - runJob, - `queueJobForIdentifier ${identifier} ${id}` - ); - - return queue.add(taskWithTimeout); - } - async uploadAttachments(message: Message): Promise { return Promise.all( message.attachments.map(this.makeAttachmentPointer.bind(this)) @@ -617,6 +652,147 @@ export default class MessageSender { }); } + // Proto assembly + + async getDataMessage(options: MessageOptionsType): Promise { + const message = await this.getHydratedMessage(options); + return message.toArrayBuffer(); + } + + async getContentMessage(options: MessageOptionsType): Promise { + const message = await this.getHydratedMessage(options); + const dataMessage = message.toProto(); + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.dataMessage = dataMessage; + + return contentMessage; + } + + async getHydratedMessage(attributes: MessageOptionsType): Promise { + const message = new Message(attributes); + await Promise.all([ + this.uploadAttachments(message), + this.uploadThumbnails(message), + this.uploadLinkPreviews(message), + this.uploadSticker(message), + ]); + + return message; + } + + getTypingContentMessage(options: { + recipientId?: string; + groupId?: ArrayBuffer; + groupMembers: Array; + isTyping: boolean; + timestamp?: number; + }): ContentClass { + const ACTION_ENUM = window.textsecure.protobuf.TypingMessage.Action; + const { recipientId, groupId, isTyping, timestamp } = options; + + if (!recipientId && !groupId) { + throw new Error( + 'getTypingContentMessage: Need to provide either recipientId or groupId!' + ); + } + + const finalTimestamp = timestamp || Date.now(); + const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED; + + const typingMessage = new window.textsecure.protobuf.TypingMessage(); + typingMessage.groupId = groupId || null; + typingMessage.action = action; + typingMessage.timestamp = finalTimestamp; + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.typingMessage = typingMessage; + + return contentMessage; + } + + getAttrsFromGroupOptions(options: GroupSendOptionsType): MessageOptionsType { + const { + messageText, + timestamp, + attachments, + quote, + preview, + sticker, + reaction, + expireTimer, + profileKey, + deletedForEveryoneTimestamp, + groupV2, + groupV1, + mentions, + groupCallUpdate, + } = options; + + if (!groupV1 && !groupV2) { + throw new Error( + 'getAttrsFromGroupOptions: Neither group1 nor groupv2 information provided!' + ); + } + + const myE164 = window.textsecure.storage.user.getNumber(); + const myUuid = window.textsecure.storage.user.getUuid(); + + const groupMembers = groupV2?.members || groupV1?.members || []; + + // We should always have a UUID but have this check just in case we don't. + let isNotMe: (recipient: string) => boolean; + if (myUuid) { + isNotMe = r => r !== myE164 && r !== myUuid; + } else { + isNotMe = r => r !== myE164; + } + + const blockedIdentifiers = new Set( + concat( + window.storage.getBlockedUuids(), + window.storage.getBlockedNumbers() + ) + ); + + const recipients = groupMembers.filter( + recipient => isNotMe(recipient) && !blockedIdentifiers.has(recipient) + ); + + return { + attachments, + body: messageText, + deletedForEveryoneTimestamp, + expireTimer, + groupCallUpdate, + groupV2, + group: groupV1 + ? { + id: groupV1.id, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + } + : undefined, + mentions, + preview, + profileKey, + quote, + reaction, + recipients, + sticker, + timestamp, + }; + } + + createSyncMessage(): SyncMessageClass { + const syncMessage = new window.textsecure.protobuf.SyncMessage(); + + syncMessage.padding = this.getRandomPadding(); + + return syncMessage; + } + + // Low-level sends + async sendMessage( attrs: MessageOptionsType, options?: SendOptionsType @@ -654,7 +830,7 @@ export default class MessageSender { sendMessageProto( timestamp: number, recipients: Array, - messageProto: DataMessageClass, + messageProto: ContentClass | DataMessageClass, callback: (result: CallbackResultType) => void, silent?: boolean, options?: SendOptionsType @@ -738,14 +914,40 @@ export default class MessageSender { }); } - createSyncMessage(): SyncMessageClass { - const syncMessage = new window.textsecure.protobuf.SyncMessage(); - - syncMessage.padding = this.getRandomPadding(); - - return syncMessage; + async sendMessageToIdentifier( + identifier: string, + messageText: string | undefined, + attachments: Array | undefined, + quote: unknown, + preview: Array | undefined, + sticker: unknown, + reaction: unknown, + deletedForEveryoneTimestamp: number | undefined, + timestamp: number, + expireTimer: number | undefined, + profileKey?: ArrayBuffer, + options?: SendOptionsType + ): Promise { + return this.sendMessage( + { + recipients: [identifier], + body: messageText, + timestamp, + attachments, + quote, + preview, + sticker, + reaction, + deletedForEveryoneTimestamp, + expireTimer, + profileKey, + }, + options + ); } + // Support for sync messages + async sendSyncMessage( encodedDataMessage: ArrayBuffer, timestamp: number, @@ -826,45 +1028,6 @@ export default class MessageSender { ); } - async getProfile( - number: string, - options: { - accessKey?: string; - profileKeyVersion?: string; - profileKeyCredentialRequest?: string; - } = {} - ): Promise { - const { accessKey } = options; - - if (accessKey) { - const unauthOptions = { - ...options, - accessKey, - }; - return this.server.getProfileUnauth(number, unauthOptions); - } - - return this.server.getProfile(number, options); - } - - async getUuidsForE164s( - numbers: Array - ): Promise> { - return this.server.getUuidsForE164s(numbers); - } - - async getAvatar(path: string): Promise { - return this.server.getAvatar(path); - } - - async getSticker(packId: string, stickerId: number): Promise { - return this.server.getSticker(packId, stickerId); - } - - async getStickerPackManifest(packId: string): Promise { - return this.server.getStickerPackManifest(packId); - } - async sendRequestBlockSyncMessage( options?: SendOptionsType ): Promise { @@ -1036,183 +1199,6 @@ export default class MessageSender { ); } - async sendTypingMessage( - options: { - recipientId?: string; - groupId?: ArrayBuffer; - groupMembers: Array; - isTyping: boolean; - timestamp?: number; - }, - sendOptions: SendOptionsType = {} - ): Promise { - const ACTION_ENUM = window.textsecure.protobuf.TypingMessage.Action; - const { recipientId, groupId, groupMembers, isTyping, timestamp } = options; - - // We don't want to send typing messages to our other devices, but we will - // in the group case. - const myNumber = window.textsecure.storage.user.getNumber(); - const myUuid = window.textsecure.storage.user.getUuid(); - if (recipientId && (myNumber === recipientId || myUuid === recipientId)) { - return null; - } - - if (!recipientId && !groupId) { - throw new Error('Need to provide either recipientId or groupId!'); - } - - const recipients = (groupId - ? without(groupMembers, myNumber, myUuid) - : [recipientId]) as Array; - - const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED; - const finalTimestamp = timestamp || Date.now(); - - const typingMessage = new window.textsecure.protobuf.TypingMessage(); - typingMessage.groupId = groupId || null; - typingMessage.action = action; - typingMessage.timestamp = finalTimestamp; - - const contentMessage = new window.textsecure.protobuf.Content(); - contentMessage.typingMessage = typingMessage; - - const silent = true; - const online = true; - - return this.sendMessageProtoAndWait( - finalTimestamp, - recipients, - contentMessage, - silent, - { - ...sendOptions, - online, - } - ); - } - - async sendProfileKeyUpdate( - profileKey: ArrayBuffer, - recipients: Array, - sendOptions: SendOptionsType, - groupId?: string - ): Promise { - return this.sendMessage( - { - recipients, - timestamp: Date.now(), - profileKey, - flags: window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE, - ...(groupId - ? { - group: { - id: groupId, - type: window.textsecure.protobuf.GroupContext.Type.DELIVER, - }, - } - : {}), - }, - sendOptions - ); - } - - async sendCallingMessage( - recipientId: string, - callingMessage: CallingMessageClass, - sendOptions?: SendOptionsType - ): Promise { - const recipients = [recipientId]; - const finalTimestamp = Date.now(); - - const contentMessage = new window.textsecure.protobuf.Content(); - contentMessage.callingMessage = callingMessage; - - const silent = true; - - await this.sendMessageProtoAndWait( - finalTimestamp, - recipients, - contentMessage, - silent, - sendOptions - ); - } - - sendGroupCallUpdate( - { - groupV2, - eraId, - timestamp, - }: { groupV2: GroupV2InfoType; eraId: string; timestamp: number }, - options?: SendOptionsType - ): Promise { - return this.sendMessageToGroup( - { - groupV2, - groupCallUpdate: { eraId }, - timestamp, - }, - options - ); - } - - async sendDeliveryReceipt( - recipientE164: string, - recipientUuid: string, - timestamps: Array, - options?: SendOptionsType - ): Promise { - const myNumber = window.textsecure.storage.user.getNumber(); - const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if ( - (myNumber === recipientE164 || myUuid === recipientUuid) && - (myDevice === 1 || myDevice === '1') - ) { - return Promise.resolve(); - } - - const receiptMessage = new window.textsecure.protobuf.ReceiptMessage(); - receiptMessage.type = - window.textsecure.protobuf.ReceiptMessage.Type.DELIVERY; - receiptMessage.timestamp = timestamps; - - const contentMessage = new window.textsecure.protobuf.Content(); - contentMessage.receiptMessage = receiptMessage; - - const silent = true; - return this.sendIndividualProto( - recipientUuid || recipientE164, - contentMessage, - Date.now(), - silent, - options - ); - } - - async sendReadReceipts( - senderE164: string, - senderUuid: string, - timestamps: Array, - options?: SendOptionsType - ): Promise { - const receiptMessage = new window.textsecure.protobuf.ReceiptMessage(); - receiptMessage.type = window.textsecure.protobuf.ReceiptMessage.Type.READ; - receiptMessage.timestamp = timestamps; - - const contentMessage = new window.textsecure.protobuf.Content(); - contentMessage.receiptMessage = receiptMessage; - - const silent = true; - return this.sendIndividualProto( - senderUuid || senderE164, - contentMessage, - Date.now(), - silent, - options - ); - } - async syncReadMessages( reads: Array<{ senderUuid?: string; @@ -1368,47 +1354,6 @@ export default class MessageSender { ); } - getRandomPadding(): ArrayBuffer { - // Generate a random int from 1 and 512 - const buffer = getRandomBytes(2); - const paddingLength = (new Uint16Array(buffer)[0] & 0x1ff) + 1; - - // Generate a random padding buffer of the chosen size - return getRandomBytes(paddingLength); - } - - async sendNullMessage( - { - uuid, - e164, - padding, - }: { uuid?: string; e164?: string; padding?: ArrayBuffer }, - options?: SendOptionsType - ): Promise { - const nullMessage = new window.textsecure.protobuf.NullMessage(); - - const identifier = uuid || e164; - if (!identifier) { - throw new Error('sendNullMessage: Got neither uuid nor e164!'); - } - - nullMessage.padding = padding || this.getRandomPadding(); - - 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 timestamp = Date.now(); - return this.sendIndividualProto( - identifier, - contentMessage, - timestamp, - silent, - options - ); - } - async syncVerification( destinationE164: string, destinationUuid: string, @@ -1463,127 +1408,140 @@ export default class MessageSender { }); } - async sendGroupProto( - providedIdentifiers: Array, - proto: DataMessageClass, - timestamp = Date.now(), - options = {} - ): Promise { - const myE164 = window.textsecure.storage.user.getNumber(); - const myUuid = window.textsecure.storage.user.getUuid(); - const identifiers = providedIdentifiers.filter( - id => id !== myE164 && id !== myUuid - ); + // Sending messages to contacts - if (identifiers.length === 0) { - return Promise.resolve({ - dataMessage: proto.toArrayBuffer(), - errors: [], - failoverIdentifiers: [], - successfulIdentifiers: [], - unidentifiedDeliveries: [], - }); - } - - return new Promise((resolve, reject) => { - const silent = true; - const callback = (res: CallbackResultType) => { - res.dataMessage = proto.toArrayBuffer(); - if (res.errors && res.errors.length > 0) { - reject(res); - } else { - resolve(res); - } - }; - - this.sendMessageProto( - timestamp, - providedIdentifiers, - proto, - callback, - silent, - options - ); - }); - } - - async getMessageProto( - destination: string, - body: string | undefined, - attachments: Array, - quote: unknown, - preview: Array, - sticker: unknown, - reaction: unknown, - deletedForEveryoneTimestamp: number | undefined, - timestamp: number, - expireTimer: number | undefined, - profileKey?: ArrayBuffer, - flags?: number, - mentions?: BodyRangesType - ): Promise { - const attributes = { - recipients: [destination], - destination, - body, - timestamp, - attachments, - quote, - preview, - sticker, - reaction, - deletedForEveryoneTimestamp, - expireTimer, - profileKey, - flags, - mentions, - }; - - return this.getMessageProtoObj(attributes); - } - - async getMessageProtoObj( - attributes: MessageOptionsType - ): Promise { - const message = new Message(attributes); - await Promise.all([ - this.uploadAttachments(message), - this.uploadThumbnails(message), - this.uploadLinkPreviews(message), - this.uploadSticker(message), - ]); - - return message.toArrayBuffer(); - } - - async sendMessageToIdentifier( - identifier: string, - messageText: string | undefined, - attachments: Array | undefined, - quote: unknown, - preview: Array | undefined, - sticker: unknown, - reaction: unknown, - deletedForEveryoneTimestamp: number | undefined, - timestamp: number, - expireTimer: number | undefined, - profileKey?: ArrayBuffer, - options?: SendOptionsType + async sendProfileKeyUpdate( + profileKey: ArrayBuffer, + recipients: Array, + sendOptions: SendOptionsType, + groupId?: string ): Promise { return this.sendMessage( { - recipients: [identifier], - body: messageText, - timestamp, - attachments, - quote, - preview, - sticker, - reaction, - deletedForEveryoneTimestamp, - expireTimer, + recipients, + timestamp: Date.now(), profileKey, + flags: window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE, + ...(groupId + ? { + group: { + id: groupId, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + }, + } + : {}), }, + sendOptions + ); + } + + async sendCallingMessage( + recipientId: string, + callingMessage: CallingMessageClass, + sendOptions?: SendOptionsType + ): Promise { + const recipients = [recipientId]; + const finalTimestamp = Date.now(); + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.callingMessage = callingMessage; + + const silent = true; + + await this.sendMessageProtoAndWait( + finalTimestamp, + recipients, + contentMessage, + silent, + sendOptions + ); + } + + async sendDeliveryReceipt( + recipientE164: string, + recipientUuid: string, + timestamps: Array, + options?: SendOptionsType + ): Promise { + const myNumber = window.textsecure.storage.user.getNumber(); + const myUuid = window.textsecure.storage.user.getUuid(); + const myDevice = window.textsecure.storage.user.getDeviceId(); + if ( + (myNumber === recipientE164 || myUuid === recipientUuid) && + (myDevice === 1 || myDevice === '1') + ) { + return Promise.resolve(); + } + + const receiptMessage = new window.textsecure.protobuf.ReceiptMessage(); + receiptMessage.type = + window.textsecure.protobuf.ReceiptMessage.Type.DELIVERY; + receiptMessage.timestamp = timestamps; + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.receiptMessage = receiptMessage; + + const silent = true; + return this.sendIndividualProto( + recipientUuid || recipientE164, + contentMessage, + Date.now(), + silent, + options + ); + } + + async sendReadReceipts( + senderE164: string, + senderUuid: string, + timestamps: Array, + options?: SendOptionsType + ): Promise { + const receiptMessage = new window.textsecure.protobuf.ReceiptMessage(); + receiptMessage.type = window.textsecure.protobuf.ReceiptMessage.Type.READ; + receiptMessage.timestamp = timestamps; + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.receiptMessage = receiptMessage; + + const silent = true; + return this.sendIndividualProto( + senderUuid || senderE164, + contentMessage, + Date.now(), + silent, + options + ); + } + + async sendNullMessage( + { + uuid, + e164, + padding, + }: { uuid?: string; e164?: string; padding?: ArrayBuffer }, + options?: SendOptionsType + ): Promise { + const nullMessage = new window.textsecure.protobuf.NullMessage(); + + const identifier = uuid || e164; + if (!identifier) { + throw new Error('sendNullMessage: Got neither uuid nor e164!'); + } + + nullMessage.padding = padding || this.getRandomPadding(); + + 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 timestamp = Date.now(); + return this.sendIndividualProto( + identifier, + contentMessage, + timestamp, + silent, options ); } @@ -1654,91 +1612,152 @@ export default class MessageSender { return Promise.all([sendToContactPromise, sendSyncPromise]); } - async sendMessageToGroup( - { - attachments, - expireTimer, - groupV2, - groupV1, - messageText, - preview, - profileKey, - quote, - reaction, - sticker, - deletedForEveryoneTimestamp, - timestamp, - mentions, - groupCallUpdate, - }: { - attachments?: Array; - expireTimer?: number; - groupV2?: GroupV2InfoType; - groupV1?: GroupV1InfoType; - messageText?: string; - preview?: any; - profileKey?: ArrayBuffer; - quote?: any; - reaction?: any; - sticker?: any; - deletedForEveryoneTimestamp?: number; - timestamp: number; - mentions?: BodyRangesType; - groupCallUpdate?: GroupCallUpdateType; - }, + async sendExpirationTimerUpdateToIdentifier( + identifier: string, + expireTimer: number | undefined, + timestamp: number, + profileKey?: ArrayBuffer, options?: SendOptionsType ): Promise { - if (!groupV1 && !groupV2) { - throw new Error( - 'sendMessageToGroup: Neither group1 nor groupv2 information provided!' - ); - } + return this.sendMessage( + { + recipients: [identifier], + timestamp, + expireTimer, + profileKey, + flags: + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + }, + options + ); + } + // Group sends + + // No functions should really call this; since most group sends are now via Sender Key + async sendGroupProto( + providedIdentifiers: Array, + proto: ContentClass | DataMessageClass, + timestamp = Date.now(), + options?: SendOptionsType + ): Promise { const myE164 = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); + const identifiers = providedIdentifiers.filter( + id => id !== myE164 && id !== myUuid + ); - const groupMembers = groupV2?.members || groupV1?.members || []; - - // We should always have a UUID but have this check just in case we don't. - let isNotMe: (recipient: string) => boolean; - if (myUuid) { - isNotMe = r => r !== myE164 && r !== myUuid; - } else { - isNotMe = r => r !== myE164; + if (identifiers.length === 0) { + return Promise.resolve({ + dataMessage: proto.toArrayBuffer(), + errors: [], + failoverIdentifiers: [], + successfulIdentifiers: [], + unidentifiedDeliveries: [], + }); } - const blockedIdentifiers = new Set( - concat( - window.storage.getBlockedUuids(), - window.storage.getBlockedNumbers() - ) + return new Promise((resolve, reject) => { + const silent = true; + const callback = (res: CallbackResultType) => { + res.dataMessage = proto.toArrayBuffer(); + if (res.errors && res.errors.length > 0) { + reject(res); + } else { + resolve(res); + } + }; + + this.sendMessageProto( + timestamp, + providedIdentifiers, + proto, + 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 }, + options?: SendOptionsType + ): Promise { + const ourUuid = window.textsecure.storage.user.getUuid(); + if (!ourUuid) { + throw new Error( + 'sendSenderKeyDistributionMessage: Failed to fetch our UUID!' + ); + } + const ourDeviceId = parseIntOrThrow( + window.textsecure.storage.user.getDeviceId(), + 'sendSenderKeyDistributionMessage' ); - const recipients = groupMembers.filter( - recipient => isNotMe(recipient) && !blockedIdentifiers.has(recipient) + const protocolAddress = ProtocolAddress.new(ourUuid, ourDeviceId); + const address = `${ourUuid}.${ourDeviceId}`; + const senderKeyStore = new SenderKeys(); + + const message = await window.textsecure.storage.protocol.enqueueSenderKeyJob( + address, + async () => + SenderKeyDistributionMessage.create( + protocolAddress, + distributionId, + senderKeyStore + ) ); + const proto = new window.textsecure.protobuf.Content(); + proto.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap( + typedArrayToArrayBuffer(message.serialize()) + ); + + return this.sendGroupProto(identifiers, proto, Date.now(), options); + } + + // GroupV1-only functions; not to be used in the future + + async leaveGroup( + groupId: string, + groupIdentifiers: Array, + options?: SendOptionsType + ): Promise { + const proto = new window.textsecure.protobuf.DataMessage(); + 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); + } + + async sendExpirationTimerUpdateToGroup( + groupId: string, + groupIdentifiers: Array, + expireTimer: number | undefined, + timestamp: number, + profileKey?: ArrayBuffer, + options?: SendOptionsType + ): Promise { + const myNumber = window.textsecure.storage.user.getNumber(); + const myUuid = window.textsecure.storage.user.getUuid(); + const recipients = groupIdentifiers.filter( + identifier => identifier !== myNumber && identifier !== myUuid + ); const attrs = { recipients, - body: messageText, timestamp, - attachments, - quote, - preview, - sticker, - reaction, expireTimer, profileKey, - deletedForEveryoneTimestamp, - groupV2, - group: groupV1 - ? { - id: groupV1.id, - type: window.textsecure.protobuf.GroupContext.Type.DELIVER, - } - : undefined, - mentions, - groupCallUpdate, + flags: + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + group: { + id: groupId, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + }, }; if (recipients.length === 0) { @@ -1747,13 +1766,54 @@ export default class MessageSender { failoverIdentifiers: [], errors: [], unidentifiedDeliveries: [], - dataMessage: await this.getMessageProtoObj(attrs), + dataMessage: await this.getDataMessage(attrs), }); } return this.sendMessage(attrs, options); } + // Simple pass-throughs + + async getProfile( + number: string, + options: { + accessKey?: string; + profileKeyVersion?: string; + profileKeyCredentialRequest?: string; + } = {} + ): Promise { + const { accessKey } = options; + + if (accessKey) { + const unauthOptions = { + ...options, + accessKey, + }; + return this.server.getProfileUnauth(number, unauthOptions); + } + + return this.server.getProfile(number, options); + } + + async getUuidsForE164s( + numbers: Array + ): Promise> { + return this.server.getUuidsForE164s(numbers); + } + + async getAvatar(path: string): Promise { + return this.server.getAvatar(path); + } + + async getSticker(packId: string, stickerId: number): Promise { + return this.server.getSticker(packId, stickerId); + } + + async getStickerPackManifest(packId: string): Promise { + return this.server.getStickerPackManifest(packId); + } + async createGroup( group: GroupClass, options: GroupCredentialsType @@ -1798,75 +1858,13 @@ export default class MessageSender { return this.server.modifyGroup(changes, options, inviteLinkBase64); } - async leaveGroup( - groupId: string, - groupIdentifiers: Array, - options?: SendOptionsType - ): Promise { - const proto = new window.textsecure.protobuf.DataMessage(); - 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); - } - - async sendExpirationTimerUpdateToGroup( - groupId: string, - groupIdentifiers: Array, - expireTimer: number | undefined, + async sendWithSenderKey( + data: ArrayBuffer, + accessKeys: ArrayBuffer, timestamp: number, - profileKey?: ArrayBuffer, - options?: SendOptionsType - ): Promise { - const myNumber = window.textsecure.storage.user.getNumber(); - const myUuid = window.textsecure.storage.user.getUuid(); - const recipients = groupIdentifiers.filter( - identifier => identifier !== myNumber && identifier !== myUuid - ); - const attrs = { - recipients, - timestamp, - expireTimer, - profileKey, - flags: - window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - group: { - id: groupId, - type: window.textsecure.protobuf.GroupContext.Type.DELIVER, - }, - }; - - if (recipients.length === 0) { - return Promise.resolve({ - successfulIdentifiers: [], - failoverIdentifiers: [], - errors: [], - unidentifiedDeliveries: [], - dataMessage: await this.getMessageProtoObj(attrs), - }); - } - - return this.sendMessage(attrs, options); - } - - async sendExpirationTimerUpdateToIdentifier( - identifier: string, - expireTimer: number | undefined, - timestamp: number, - profileKey?: ArrayBuffer, - options?: SendOptionsType - ): Promise { - return this.sendMessage( - { - recipients: [identifier], - timestamp, - expireTimer, - profileKey, - flags: - window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - }, - options - ); + online?: boolean + ): Promise { + return this.server.sendWithSenderKey(data, accessKeys, timestamp, online); } async fetchLinkPreviewMetadata( diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index baded98f2..cff966f72 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -11,6 +11,11 @@ export { UnprocessedUpdateType, } from '../sql/Interface'; +export type DeviceType = { + id: number; + identifier: string; +}; + // How the legacy APIs generate these types export type CompatSignedPreKeyType = { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 229314817..118ff1d3b 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -26,6 +26,7 @@ import { pki } from 'node-forge'; import is from '@sindresorhus/is'; import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; +import { z } from 'zod'; import { Long } from '../window.d'; import { getUserAgent } from '../util/getUserAgent'; @@ -351,6 +352,49 @@ type ArrayBufferWithDetailsType = { response: Response; }; +export const multiRecipient200ResponseSchema = z + .object({ + uuids404: z.array(z.string()).optional(), + needsSync: z.boolean().optional(), + }) + .passthrough(); +export type MultiRecipient200ResponseType = z.infer< + typeof multiRecipient200ResponseSchema +>; + +export const multiRecipient409ResponseSchema = z.array( + z + .object({ + uuid: z.string(), + devices: z + .object({ + missingDevices: z.array(z.number()).optional(), + extraDevices: z.array(z.number()).optional(), + }) + .passthrough(), + }) + .passthrough() +); +export type MultiRecipient409ResponseType = z.infer< + typeof multiRecipient409ResponseSchema +>; + +export const multiRecipient410ResponseSchema = z.array( + z + .object({ + uuid: z.string(), + devices: z + .object({ + staleDevices: z.array(z.number()).optional(), + }) + .passthrough(), + }) + .passthrough() +); +export type MultiRecipient410ResponseType = z.infer< + typeof multiRecipient410ResponseSchema +>; + function isSuccess(status: number): boolean { return status >= 0 && status < 400; } @@ -685,6 +729,7 @@ const URL_CALLS = { groupToken: 'v1/groups/token', keys: 'v2/keys', messages: 'v1/messages', + multiRecipient: 'v1/messages/multi_recipient', profile: 'v1/profile', registerCapabilities: 'v1/devices/capabilities', removeSignalingKey: 'v1/accounts/signaling_key', @@ -728,6 +773,7 @@ type AjaxOptionsType = { call: keyof typeof URL_CALLS; contentType?: string; data?: ArrayBuffer | Buffer | string; + headers?: HeaderListType; host?: string; httpType: HTTPCodeType; jsonData?: any; @@ -749,10 +795,12 @@ export type WebAPIConnectType = { export type CapabilitiesType = { gv2: boolean; 'gv1-migration': boolean; + senderKey: boolean; }; export type CapabilitiesUploadType = { 'gv2-3': boolean; 'gv1-migration': boolean; + senderKey: boolean; }; type StickerPackManifestType = any; @@ -895,6 +943,12 @@ export type WebAPIType = { online?: boolean, options?: { accessKey?: string } ) => Promise; + sendWithSenderKey: ( + payload: ArrayBuffer, + accessKeys: ArrayBuffer, + timestamp: number, + online?: boolean + ) => Promise; setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise; updateDeviceName: (deviceName: string) => Promise; uploadGroupAvatar: ( @@ -1065,6 +1119,7 @@ export function initialize({ requestVerificationVoice, sendMessages, sendMessagesUnauth, + sendWithSenderKey, setSignedPreKey, updateDeviceName, uploadGroupAvatar, @@ -1082,6 +1137,7 @@ export function initialize({ certificateAuthority, contentType: param.contentType || 'application/json; charset=utf-8', data: param.data || (param.jsonData && _jsonThing(param.jsonData)), + headers: param.headers, host: param.host || url, password: param.password || password, path: URL_CALLS[param.call] + param.urlParameters, @@ -1375,6 +1431,7 @@ export function initialize({ const capabilities: CapabilitiesUploadType = { 'gv2-3': true, 'gv1-migration': true, + senderKey: false, }; const { accessKey } = options; @@ -1661,6 +1718,25 @@ export function initialize({ }); } + async function sendWithSenderKey( + data: ArrayBuffer, + accessKeys: ArrayBuffer, + timestamp: number, + online?: boolean + ): Promise { + return _ajax({ + call: 'multiRecipient', + httpType: 'PUT', + contentType: 'application/vnd.signal-messenger.mrm', + data, + urlParameters: `?ts=${timestamp}&online=${online ? 'true' : 'false'}`, + responseType: 'json', + headers: { + 'Unidentified-Access-Key': arrayBufferToBase64(accessKeys), + }, + }); + } + function redactStickerUrl(stickerUrl: string) { return stickerUrl.replace( /(\/stickers\/)([^/]+)(\/)/, diff --git a/ts/textsecure/getKeysForIdentifier.ts b/ts/textsecure/getKeysForIdentifier.ts new file mode 100644 index 000000000..3887b32a2 --- /dev/null +++ b/ts/textsecure/getKeysForIdentifier.ts @@ -0,0 +1,140 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + PreKeyBundle, + processPreKeyBundle, + ProtocolAddress, + PublicKey, +} from '@signalapp/signal-client'; + +import { UnregisteredUserError } from './Errors'; +import { Sessions, IdentityKeys } from '../LibSignalStores'; +import { ServerKeysType, WebAPIType } from './WebAPI'; + +export async function getKeysForIdentifier( + identifier: string, + server: WebAPIType, + devicesToUpdate?: Array, + accessKey?: string +): Promise<{ accessKeyFailed?: boolean }> { + try { + const { keys, accessKeyFailed } = await getServerKeys( + identifier, + server, + accessKey + ); + + await handleServerKeys(identifier, keys, devicesToUpdate); + + return { + accessKeyFailed, + }; + } catch (error) { + if (error.name === 'HTTPError' && error.code === 404) { + await window.textsecure.storage.protocol.archiveAllSessions(identifier); + } + throw new UnregisteredUserError(identifier, error); + } +} + +async function getServerKeys( + identifier: string, + server: WebAPIType, + accessKey?: string +): Promise<{ accessKeyFailed?: boolean; keys: ServerKeysType }> { + if (!accessKey) { + return { + keys: await server.getKeysForIdentifier(identifier), + }; + } + + try { + return { + keys: await server.getKeysForIdentifierUnauth(identifier, undefined, { + accessKey, + }), + }; + } catch (error) { + if (error.code === 401 || error.code === 403) { + return { + accessKeyFailed: true, + keys: await server.getKeysForIdentifier(identifier), + }; + } + + throw error; + } +} + +async function handleServerKeys( + identifier: string, + response: ServerKeysType, + devicesToUpdate?: Array +): Promise { + const sessionStore = new Sessions(); + const identityKeyStore = new IdentityKeys(); + + await Promise.all( + response.devices.map(async device => { + const { deviceId, registrationId, preKey, signedPreKey } = device; + if ( + devicesToUpdate !== undefined && + !devicesToUpdate.includes(deviceId) + ) { + return; + } + + if (device.registrationId === 0) { + window.log.info( + `handleServerKeys/${identifier}: Got device registrationId zero!` + ); + } + if (!signedPreKey) { + throw new Error( + `getKeysForIdentifier/${identifier}: Missing signed prekey for deviceId ${deviceId}` + ); + } + const protocolAddress = ProtocolAddress.new(identifier, deviceId); + const preKeyId = preKey?.keyId || null; + const preKeyObject = preKey + ? PublicKey.deserialize(Buffer.from(preKey.publicKey)) + : null; + const signedPreKeyObject = PublicKey.deserialize( + Buffer.from(signedPreKey.publicKey) + ); + const identityKey = PublicKey.deserialize( + Buffer.from(response.identityKey) + ); + + const preKeyBundle = PreKeyBundle.new( + registrationId, + deviceId, + preKeyId, + preKeyObject, + signedPreKey.keyId, + signedPreKeyObject, + Buffer.from(signedPreKey.signature), + identityKey + ); + + const address = `${identifier}.${deviceId}`; + await window.textsecure.storage.protocol + .enqueueSessionJob(address, () => + processPreKeyBundle( + preKeyBundle, + protocolAddress, + sessionStore, + identityKeyStore + ) + ) + .catch(error => { + if (error?.message?.includes('untrusted identity for address')) { + // eslint-disable-next-line no-param-reassign + error.identityKey = response.identityKey; + } + throw error; + }); + }) + ); +} diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index 939a5cd97..a0f21bf76 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -3,7 +3,7 @@ import { CallbackResultType } from '../textsecure/SendMessage'; -const SEALED_SENDER = { +export const SEALED_SENDER = { UNKNOWN: 0, ENABLED: 1, DISABLED: 2, diff --git a/ts/util/index.ts b/ts/util/index.ts index 8027b7115..b2ffaf9d7 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -35,6 +35,7 @@ import { import * as zkgroup from './zkgroup'; import { StartupQueue } from './StartupQueue'; import { postLinkExperience } from './postLinkExperience'; +import { sendToGroup, sendContentMessageToGroup } from './sendToGroup'; export { GoogleChrome, @@ -62,6 +63,8 @@ export { postLinkExperience, queueUpdateMessage, saveNewMessageBatcher, + sendContentMessageToGroup, + sendToGroup, setBatchingStrategy, sessionRecordToProtobuf, sessionStructureToArrayBuffer, diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts new file mode 100644 index 000000000..8aa335271 --- /dev/null +++ b/ts/util/sendToGroup.ts @@ -0,0 +1,885 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { differenceWith, partition } from 'lodash'; +import PQueue from 'p-queue'; + +import { + groupEncrypt, + ProtocolAddress, + sealedSenderMultiRecipientEncrypt, + SenderCertificate, + UnidentifiedSenderMessageContent, +} from '@signalapp/signal-client'; +import { senderCertificateService } from '../services/senderCertificate'; +import { + padMessage, + SenderCertificateMode, +} from '../textsecure/OutgoingMessage'; + +import { isOlderThan } from './timestamp'; +import { + CallbackResultType, + GroupSendOptionsType, + SendOptionsType, +} from '../textsecure/SendMessage'; +import { IdentityKeys, SenderKeys, Sessions } from '../LibSignalStores'; +import { ConversationModel } from '../models/conversations'; +import { DeviceType } from '../textsecure/Types.d'; +import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier'; +import { ConversationAttributesType } from '../model-types.d'; +import { SEALED_SENDER } from './handleMessageSend'; +import { parseIntOrThrow } from './parseIntOrThrow'; +import { + multiRecipient200ResponseSchema, + multiRecipient409ResponseSchema, + multiRecipient410ResponseSchema, +} from '../textsecure/WebAPI'; +import { ContentClass } from '../textsecure.d'; + +import { assert } from './assert'; + +const ERROR_EXPIRED_OR_MISSING_DEVICES = 409; +const ERROR_STALE_DEVICES = 410; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const MAX_CONCURRENCY = 5; + +// sendWithSenderKey is recursive, but we don't want to loop back too many times. +const MAX_RECURSION = 5; + +// Public API: + +export async function sendToGroup( + groupSendOptions: GroupSendOptionsType, + conversation: ConversationModel, + sendOptions?: SendOptionsType, + isPartialSend?: boolean +): Promise { + assert( + window.textsecure.messaging, + 'sendToGroup: textsecure.messaging not available!' + ); + + const { timestamp } = groupSendOptions; + const recipients = getRecipients(groupSendOptions); + + // First, do the attachment upload and prepare the proto we'll be sending + const protoAttributes = window.textsecure.messaging.getAttrsFromGroupOptions( + groupSendOptions + ); + const contentMessage = await window.textsecure.messaging.getContentMessage( + protoAttributes + ); + + return sendContentMessageToGroup({ + contentMessage, + conversation, + isPartialSend, + recipients, + sendOptions, + timestamp, + }); +} + +export async function sendContentMessageToGroup({ + contentMessage, + conversation, + isPartialSend, + online, + recipients, + sendOptions, + timestamp, +}: { + contentMessage: ContentClass; + conversation: ConversationModel; + isPartialSend?: boolean; + online?: boolean; + recipients: Array; + sendOptions?: SendOptionsType; + timestamp: number; +}): Promise { + const logId = conversation.idForLogging(); + assert( + window.textsecure.messaging, + 'sendContentMessageToGroup: textsecure.messaging not available!' + ); + + if (conversation.isGroupV2()) { + try { + return await sendToGroupViaSenderKey({ + contentMessage, + conversation, + isPartialSend, + online, + recipients, + recursionCount: 0, + sendOptions, + timestamp, + }); + } catch (error) { + window.log.error( + `sendToGroup/${logId}: Sender Key send failed, logging, proceeding to normal send`, + error && error.stack ? error.stack : error + ); + } + } + + return window.textsecure.messaging.sendGroupProto( + recipients, + contentMessage, + timestamp, + sendOptions + ); +} + +// The Primary Sender Key workflow + +export async function sendToGroupViaSenderKey(options: { + contentMessage: ContentClass; + conversation: ConversationModel; + isPartialSend?: boolean; + online?: boolean; + recipients: Array; + recursionCount: number; + sendOptions?: SendOptionsType; + timestamp: number; +}): Promise { + const { + contentMessage, + conversation, + isPartialSend, + online, + recursionCount, + recipients, + sendOptions, + timestamp, + } = options; + + const logId = conversation.idForLogging(); + window.log.info( + `sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}...` + ); + + if (recursionCount > MAX_RECURSION) { + throw new Error( + `sendToGroupViaSenderKey/${logId}: Too much recursion! Count is at ${recursionCount}` + ); + } + + const groupId = conversation.get('groupId'); + if (!groupId || !conversation.isGroupV2()) { + throw new Error( + `sendToGroupViaSenderKey/${logId}: Missing groupId or group is not GV2` + ); + } + + assert( + window.textsecure.messaging, + 'sendToGroupViaSenderKey: textsecure.messaging not available!' + ); + + const { + attributes, + }: { attributes: ConversationAttributesType } = conversation; + + // 1. Add sender key info if we have none, or clear out if it's too old + const THIRTY_DAYS = 30 * DAY; + if (!attributes.senderKeyInfo) { + window.log.info( + `sendToGroupViaSenderKey/${logId}: Adding initial sender key info` + ); + conversation.set({ + senderKeyInfo: { + createdAtDate: Date.now(), + distributionId: window.getGuid(), + memberDevices: [], + }, + }); + await window.Signal.Data.updateConversation(attributes); + } else if (isOlderThan(attributes.senderKeyInfo.createdAtDate, THIRTY_DAYS)) { + const { createdAtDate } = attributes.senderKeyInfo; + window.log.info( + `sendToGroupViaSenderKey/${logId}: Resetting sender key; ${createdAtDate} is too old` + ); + await resetSenderKey(conversation); + } + + // 2. Fetch all devices we believe we'll be sending to + const { + devices: currentDevices, + emptyIdentifiers, + } = await window.textsecure.storage.protocol.getOpenDevices(recipients); + + // 3. If we have no open sessions with people we believe we are sending to, and we + // believe that any have signal accounts, fetch their prekey bundle and start + // sessions with them. + if ( + emptyIdentifiers.length > 0 && + emptyIdentifiers.some(isIdentifierRegistered) + ) { + await fetchKeysForIdentifiers(emptyIdentifiers); + + // Restart here to capture devices for accounts we just started sesions with + return sendToGroupViaSenderKey({ + ...options, + recursionCount: recursionCount + 1, + }); + } + + assert( + attributes.senderKeyInfo, + `sendToGroupViaSenderKey/${logId}: expect senderKeyInfo` + ); + // Note: From here on, we will need to recurse if we change senderKeyInfo + const { + memberDevices, + distributionId, + createdAtDate, + } = attributes.senderKeyInfo; + + // 4. Partition devices into sender key and non-sender key groups + const [devicesForSenderKey, devicesForNormalSend] = partition( + currentDevices, + device => isValidSenderKeyRecipient(conversation, device.identifier) + ); + window.log.info( + `sendToGroupViaSenderKey/${logId}: ${devicesForSenderKey.length} devices for sender key, ${devicesForNormalSend.length} devices for normal send` + ); + + // 5. Analyze target devices for sender key, determine which have been added or removed + const { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + } = _analyzeSenderKeyDevices( + memberDevices, + devicesForSenderKey, + isPartialSend + ); + + // 6. If members have been removed from the group, we need to reset our sender key, then + // start over to get a fresh set of target devices. + const keyNeedsReset = Array.from(removedFromMemberUuids).some( + uuid => !conversation.hasMember(uuid) + ); + if (keyNeedsReset) { + await resetSenderKey(conversation); + + // Restart here to start over; empty memberDevices means we'll send distribution + // message to everyone. + return sendToGroupViaSenderKey({ + ...options, + recursionCount: recursionCount + 1, + }); + } + + // 7. If there are new members or new devices in the group, we need to ensure that they + // have our sender key before we send sender key messages to them. + if (newToMemberUuids.length > 0) { + window.log.info( + `sendToGroupViaSenderKey/${logId}: Sending sender key to ${ + newToMemberUuids.length + } members: ${JSON.stringify(newToMemberUuids)}` + ); + await window.textsecure.messaging.sendSenderKeyDistributionMessage({ + distributionId, + identifiers: newToMemberUuids, + }); + } + + // 8. Update memberDevices with both adds and the removals which didn't require a reset. + if (removedFromMemberDevices.length > 0 || newToMemberDevices.length > 0) { + const updatedMemberDevices = [ + ...differenceWith( + memberDevices, + removedFromMemberDevices, + deviceComparator + ), + ...newToMemberDevices, + ]; + + conversation.set({ + senderKeyInfo: { + createdAtDate, + distributionId, + memberDevices: updatedMemberDevices, + }, + }); + await window.Signal.Data.updateConversation(conversation.attributes); + } + + // 9. Ensure we have enough recipients + const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey); + if (senderKeyRecipients.length < 2) { + throw new Error( + `sendToGroupViaSenderKey/${logId}: Not enough recipients for Sender Key message. Failing over.` + ); + } + + // 10. Send the Sender Key message! + try { + const messageBuffer = await encryptForSenderKey({ + devices: devicesForSenderKey, + distributionId, + contentMessage: contentMessage.toArrayBuffer(), + groupId, + }); + const accessKeys = getXorOfAccessKeys(devicesForSenderKey); + + const result = await window.textsecure.messaging.sendWithSenderKey( + messageBuffer, + accessKeys, + timestamp, + online + ); + + const parsed = multiRecipient200ResponseSchema.safeParse(result); + if (parsed.success) { + const { uuids404 } = parsed.data; + if (uuids404 && uuids404.length > 0) { + await _waitForAll({ + tasks: uuids404.map(uuid => async () => + markIdentifierUnregistered(uuid) + ), + }); + } + } else { + window.log.error( + `sendToGroupViaSenderKey/${logId}: Server returned unexpected 200 response ${JSON.stringify( + parsed.error.flatten() + )}` + ); + } + } catch (error) { + if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) { + await handle409Response(logId, error); + + // Restart here to capture the right set of devices for our next send. + return sendToGroupViaSenderKey({ + ...options, + recursionCount: recursionCount + 1, + }); + } + if (error.code === ERROR_STALE_DEVICES) { + await handle410Response(conversation, error); + + // Restart here to use the right registrationIds for devices we already knew about, + // as well as send our sender key to these re-registered or re-linked devices. + return sendToGroupViaSenderKey({ + ...options, + recursionCount: recursionCount + 1, + }); + } + + throw new Error( + `sendToGroupViaSenderKey/${logId}: Returned unexpected error ${error.code}. Failing over.` + ); + } + + // 11. Return early if there are no normal send recipients + const normalRecipients = getUuidsFromDevices(devicesForNormalSend); + if (normalRecipients.length === 0) { + return { + dataMessage: contentMessage.dataMessage?.toArrayBuffer(), + successfulIdentifiers: senderKeyRecipients, + unidentifiedDeliveries: senderKeyRecipients, + }; + } + + // 12. Send normal message to the leftover normal recipients. Then combine normal send + // result with result from sender key send for final return value. + const normalSendResult = await window.textsecure.messaging.sendGroupProto( + normalRecipients, + contentMessage, + timestamp, + sendOptions + ); + + return { + dataMessage: contentMessage.dataMessage?.toArrayBuffer(), + errors: normalSendResult.errors, + failoverIdentifiers: normalSendResult.failoverIdentifiers, + successfulIdentifiers: [ + ...(normalSendResult.successfulIdentifiers || []), + ...senderKeyRecipients, + ], + unidentifiedDeliveries: [ + ...(normalSendResult.unidentifiedDeliveries || []), + ...senderKeyRecipients, + ], + }; +} + +// Utility Methods + +export async function _waitForAll({ + tasks, + maxConcurrency = MAX_CONCURRENCY, +}: { + tasks: Array<() => Promise>; + maxConcurrency?: number; +}): Promise> { + const queue = new PQueue({ + concurrency: maxConcurrency, + timeout: 2 * 60 * 1000, + }); + return queue.addAll(tasks); +} + +function getRecipients(options: GroupSendOptionsType): Array { + if (options.groupV2) { + return options.groupV2.members; + } + if (options.groupV1) { + return options.groupV1.members; + } + + throw new Error('getRecipients: Unable to extract recipients!'); +} + +async function markIdentifierUnregistered(identifier: string) { + const conversation = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + + conversation.setUnregistered(); + await window.Signal.Data.saveConversation(conversation.attributes); + + await window.textsecure.storage.protocol.archiveAllSessions(identifier); +} + +function isIdentifierRegistered(identifier: string) { + const conversation = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + const isUnregistered = conversation.isUnregistered(); + + return !isUnregistered; +} + +async function handle409Response(logId: string, error: Error) { + const parsed = multiRecipient409ResponseSchema.safeParse(error.response); + if (parsed.success) { + await _waitForAll({ + tasks: parsed.data.map(item => async () => { + const { uuid, devices } = item; + // Start new sessions with devices we didn't know about before + if (devices.missingDevices && devices.missingDevices.length > 0) { + await fetchKeysForIdentifier(uuid, devices.extraDevices); + } + + // Archive sessions with devices that have been removed + if (devices.extraDevices && devices.extraDevices.length > 0) { + await _waitForAll({ + tasks: devices.extraDevices.map(deviceId => async () => { + const address = `${uuid}.${deviceId}`; + await window.textsecure.storage.protocol.archiveSession(address); + }), + }); + } + }), + maxConcurrency: 2, + }); + } else { + window.log.error( + `handle409Response/${logId}: Server returned unexpected 409 response ${JSON.stringify( + parsed.error.flatten() + )}` + ); + throw error; + } +} + +async function handle410Response( + conversation: ConversationModel, + error: Error +) { + const logId = conversation.idForLogging(); + + const parsed = multiRecipient410ResponseSchema.safeParse(error.response); + if (parsed.success) { + await _waitForAll({ + tasks: parsed.data.map(item => async () => { + const { uuid, devices } = item; + if (devices.staleDevices && devices.staleDevices.length > 0) { + // First, archive our existing sessions with these devices + await _waitForAll({ + tasks: devices.staleDevices.map(deviceId => async () => { + const address = `${uuid}.${deviceId}`; + await window.textsecure.storage.protocol.archiveSession(address); + }), + }); + + // Start new sessions with these devices + await fetchKeysForIdentifier(uuid, devices.staleDevices); + + // Forget that we've sent our sender key to these devices, since they've + // been re-registered or re-linked. + const senderKeyInfo = conversation.get('senderKeyInfo'); + if (senderKeyInfo) { + const devicesToRemove: Array = devices.staleDevices.map( + id => ({ id, identifier: uuid }) + ); + conversation.set({ + senderKeyInfo: { + ...senderKeyInfo, + memberDevices: differenceWith( + senderKeyInfo.memberDevices, + devicesToRemove, + deviceComparator + ), + }, + }); + await window.Signal.Data.updateConversation( + conversation.attributes + ); + } + } + }), + maxConcurrency: 2, + }); + } else { + window.log.error( + `handle410Response/${logId}: Server returned unexpected 410 response ${JSON.stringify( + parsed.error.flatten() + )}` + ); + throw error; + } +} + +function getXorOfAccessKeys(devices: Array): Buffer { + const ACCESS_KEY_LENGTH = 16; + const uuids = getUuidsFromDevices(devices); + + const result = Buffer.alloc(ACCESS_KEY_LENGTH); + assert( + result.length === ACCESS_KEY_LENGTH, + 'getXorOfAccessKeys starting value' + ); + + uuids.forEach(uuid => { + const conversation = window.ConversationController.get(uuid); + if (!conversation) { + throw new Error( + `getXorOfAccessKeys: Unable to fetch conversation for UUID ${uuid}` + ); + } + + const accessKey = getAccessKey(conversation.attributes); + if (!accessKey) { + throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`); + } + + const accessKeyBuffer = Buffer.from(accessKey, 'base64'); + if (accessKeyBuffer.length !== ACCESS_KEY_LENGTH) { + throw new Error( + `getXorOfAccessKeys: Access key for ${uuid} had length ${accessKeyBuffer.length}` + ); + } + + for (let i = 0; i < ACCESS_KEY_LENGTH; i += 1) { + // eslint-disable-next-line no-bitwise + result[i] ^= accessKeyBuffer[i]; + } + }); + + return result; +} + +async function encryptForSenderKey({ + devices, + distributionId, + contentMessage, + groupId, +}: { + devices: Array; + distributionId: string; + contentMessage: ArrayBuffer; + groupId: string; +}): Promise { + const ourUuid = window.textsecure.storage.user.getUuid(); + const ourDeviceId = window.textsecure.storage.user.getDeviceId(); + if (!ourUuid || !ourDeviceId) { + throw new Error( + 'encryptForSenderKey: Unable to fetch our uuid or deviceId' + ); + } + + const sender = ProtocolAddress.new( + ourUuid, + parseIntOrThrow(ourDeviceId, 'encryptForSenderKey, ourDeviceId') + ); + const ourAddress = getOurAddress(); + const senderKeyStore = new SenderKeys(); + const message = Buffer.from(padMessage(contentMessage)); + + const ciphertextMessage = await window.textsecure.storage.protocol.enqueueSenderKeyJob( + ourAddress, + () => groupEncrypt(sender, distributionId, senderKeyStore, message) + ); + + const contentHint = 1; + const groupIdBuffer = Buffer.from(groupId, 'base64'); + const senderCertificateObject = await senderCertificateService.get( + SenderCertificateMode.WithoutE164 + ); + if (!senderCertificateObject) { + throw new Error('encryptForSenderKey: Unable to fetch sender certifiate!'); + } + + const senderCertificate = SenderCertificate.deserialize( + Buffer.from(senderCertificateObject.serialized) + ); + const content = UnidentifiedSenderMessageContent.new( + ciphertextMessage, + senderCertificate, + contentHint, + groupIdBuffer + ); + + const recipients = devices.map(device => + ProtocolAddress.new(device.identifier, device.id) + ); + const identityKeyStore = new IdentityKeys(); + const sessionStore = new Sessions(); + return sealedSenderMultiRecipientEncrypt( + content, + recipients, + identityKeyStore, + sessionStore + ); +} + +function isValidSenderKeyRecipient( + conversation: ConversationModel, + uuid: string +): boolean { + if (!conversation.hasMember(uuid)) { + window.log.info( + `isValidSenderKeyRecipient: Sending to ${uuid}, not a group member` + ); + return false; + } + + const memberConversation = window.ConversationController.get(uuid); + if (!memberConversation) { + window.log.warn( + `isValidSenderKeyRecipient: Missing conversation model for member ${uuid}` + ); + return false; + } + + const { capabilities } = memberConversation.attributes; + if (!capabilities.senderKey) { + window.log.info( + `isValidSenderKeyRecipient: Missing senderKey capability for member ${uuid}` + ); + return false; + } + + if (!getAccessKey(memberConversation.attributes)) { + window.log.warn( + `isValidSenderKeyRecipient: Missing accessKey for member ${uuid}` + ); + return false; + } + + if (memberConversation.isUnregistered()) { + window.log.warn( + `isValidSenderKeyRecipient: Member ${uuid} is unregistered` + ); + return false; + } + + return true; +} + +function deviceComparator(left?: DeviceType, right?: DeviceType): boolean { + return Boolean( + left && + right && + left.id === right.id && + left.identifier === right.identifier + ); +} + +function getUuidsFromDevices(devices: Array): Array { + const uuids = new Set(); + devices.forEach(device => { + uuids.add(device.identifier); + }); + + return Array.from(uuids); +} + +export function _analyzeSenderKeyDevices( + memberDevices: Array, + devicesForSend: Array, + isPartialSend?: boolean +): { + newToMemberDevices: Array; + newToMemberUuids: Array; + removedFromMemberDevices: Array; + removedFromMemberUuids: Array; +} { + const newToMemberDevices = differenceWith( + devicesForSend, + memberDevices, + deviceComparator + ); + const newToMemberUuids = getUuidsFromDevices(newToMemberDevices); + + // If this is a partial send, we won't do anything with device removals + if (isPartialSend) { + return { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices: [], + removedFromMemberUuids: [], + }; + } + + const removedFromMemberDevices = differenceWith( + memberDevices, + devicesForSend, + deviceComparator + ); + const removedFromMemberUuids = getUuidsFromDevices(removedFromMemberDevices); + + return { + newToMemberDevices, + newToMemberUuids, + removedFromMemberDevices, + removedFromMemberUuids, + }; +} + +function getOurAddress(): string { + const ourUuid = window.textsecure.storage.user.getUuid(); + const ourDeviceId = window.textsecure.storage.user.getDeviceId(); + if (!ourUuid || !ourDeviceId) { + throw new Error('getOurAddress: Unable to fetch our uuid or deviceId'); + } + return `${ourUuid}.${ourDeviceId}`; +} + +async function resetSenderKey(conversation: ConversationModel): Promise { + const logId = conversation.idForLogging(); + + window.log.info( + `resetSenderKey/${logId}: Sender key needs reset. Clearing data...` + ); + const { + attributes, + }: { attributes: ConversationAttributesType } = conversation; + const { senderKeyInfo } = attributes; + if (!senderKeyInfo) { + window.log.warn(`resetSenderKey/${logId}: No sender key info`); + return; + } + + const { distributionId } = senderKeyInfo; + const address = getOurAddress(); + + await window.textsecure.storage.protocol.removeSenderKey( + address, + distributionId + ); + + // Note: We preserve existing distributionId to minimize space for sender key storage + conversation.set({ + senderKeyInfo: { + createdAtDate: Date.now(), + distributionId, + memberDevices: [], + }, + }); + await window.Signal.Data.saveConversation(conversation.attributes); +} + +function getAccessKey( + attributes: ConversationAttributesType +): string | undefined { + const { sealedSender, accessKey } = attributes; + + if ( + sealedSender === SEALED_SENDER.ENABLED || + sealedSender === SEALED_SENDER.UNKNOWN + ) { + return accessKey || undefined; + } + + return undefined; +} + +async function fetchKeysForIdentifiers( + identifiers: Array +): Promise { + window.log.info( + `fetchKeysForIdentifiers: Fetching keys for ${identifiers.length} identifiers` + ); + + try { + await _waitForAll({ + tasks: identifiers.map(identifier => async () => + fetchKeysForIdentifier(identifier) + ), + }); + } catch (error) { + window.log.error( + 'fetchKeysForIdentifiers: Failed to fetch keys:', + error && error.stack ? error.stack : error + ); + } +} + +async function fetchKeysForIdentifier( + identifier: string, + devices?: Array +): Promise { + window.log.info( + `fetchKeysForIdentifier: Fetching ${ + devices || 'all' + } devices for ${identifier}` + ); + + if (!window.textsecure?.messaging?.server) { + throw new Error('fetchKeysForIdentifier: No server available!'); + } + + const emptyConversation = window.ConversationController.getOrCreate( + identifier, + 'private' + ); + + try { + const { accessKeyFailed } = await getKeysForIdentifier( + identifier, + window.textsecure?.messaging?.server, + devices, + getAccessKey(emptyConversation.attributes) + ); + if (accessKeyFailed) { + window.log.info( + `fetchKeysForIdentifiers: Setting sealedSender to DISABLED for conversation ${emptyConversation.idForLogging()}` + ); + emptyConversation.set({ + sealedSender: SEALED_SENDER.DISABLED, + }); + await window.Signal.Data.saveConversation(emptyConversation.attributes); + } + } catch (error) { + if (error.name === 'UnregisteredUserError') { + await markIdentifierUnregistered(identifier); + return; + } + throw error; + } +} diff --git a/yarn.lock b/yarn.lock index 04af50355..5d345ef5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1467,10 +1467,10 @@ react-lifecycles-compat "^3.0.4" warning "^3.0.0" -"@signalapp/signal-client@0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.5.2.tgz#c618fff993e4becbaba36ac77ab818d073259ac5" - integrity sha512-gfNCKb1z38oKok+JhwX18ed99DRPXyYWOTUveINNPsSwMrvSbTDwL3yM/oYLipj7GhXO68MR9ojg72df3N2nNg== +"@signalapp/signal-client@0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a" + integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" @@ -18980,7 +18980,7 @@ zip-stream@^1.2.0: ref-array-napi "1.2.1" ref-napi "3.0.2" -zod@1.11.13: - version "1.11.13" - resolved "https://registry.yarnpkg.com/zod/-/zod-1.11.13.tgz#6acb1e52b670afeb816ce2e2ddf6ab359f9ea506" - integrity sha512-10+KA7eWa8g1hbKIXkOnhjJ4RKEwX85ECz3VJzP+pWkJOFKn76bHy1kG0d1JHBwmdElLcCsaB0O9HqIfT1vZnw== +zod@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.0.2.tgz#0d8f0adbc7569e1a3c67b2cc788f81a55dc8a403" + integrity sha512-a+9VrxBi5CWBFq2LO5aNgbAaIRzPpBLbH4qGjSFeKd/ClLAXZq1dNFLTe9N1VDUBKxqXgHVkMlyp5MtSJylJww==