Send support for Sender Key
This commit is contained in:
parent
d8417e562b
commit
e6f1ec2b6b
30 changed files with 2290 additions and 911 deletions
|
@ -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",
|
||||
|
|
|
@ -12,7 +12,6 @@ message Envelope {
|
|||
PREKEY_BUNDLE = 3;
|
||||
RECEIPT = 5;
|
||||
UNIDENTIFIED_SENDER = 6;
|
||||
SENDERKEY = 7;
|
||||
}
|
||||
|
||||
optional Type type = 1;
|
||||
|
|
|
@ -67,6 +67,15 @@ export class Sessions extends SessionStore {
|
|||
|
||||
return record || null;
|
||||
}
|
||||
|
||||
async getExistingSessions(
|
||||
addresses: Array<ProtocolAddress>
|
||||
): Promise<Array<SessionRecord>> {
|
||||
const encodedAddresses = addresses.map(encodedNameFromAddress);
|
||||
return window.textsecure.storage.protocol.loadSessions(encodedAddresses, {
|
||||
zone: this.zone,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type IdentityKeysOptions = {
|
||||
|
|
|
@ -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<void> {
|
||||
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<T>(
|
||||
|
@ -792,6 +816,21 @@ export class SignalProtocolStore extends EventsMixin {
|
|||
});
|
||||
}
|
||||
|
||||
async loadSessions(
|
||||
encodedAddresses: Array<string>,
|
||||
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
|
||||
): Promise<Array<SessionRecord>> {
|
||||
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<SessionRecord> {
|
||||
|
@ -882,33 +921,51 @@ export class SignalProtocolStore extends EventsMixin {
|
|||
});
|
||||
}
|
||||
|
||||
async getDeviceIds(identifier: string): Promise<Array<number>> {
|
||||
return this.withZone(GLOBAL_ZONE, 'getDeviceIds', async () => {
|
||||
async getOpenDevices(
|
||||
identifiers: Array<string>
|
||||
): Promise<{
|
||||
devices: Array<DeviceType>;
|
||||
emptyIdentifiers: Array<string>;
|
||||
}> {
|
||||
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<string, string>();
|
||||
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<Array<number>> {
|
||||
const { devices } = await this.getOpenDevices([identifier]);
|
||||
return devices.map((device: DeviceType) => device.id);
|
||||
}
|
||||
|
||||
async removeSession(encodedAddress: string): Promise<void> {
|
||||
return this.withZone(GLOBAL_ZONE, 'removeSession', async () => {
|
||||
if (!this.sessions) {
|
||||
|
|
|
@ -2060,6 +2060,7 @@ export async function startApp(): Promise<void> {
|
|||
await server.registerCapabilities({
|
||||
'gv2-3': true,
|
||||
'gv1-migration': true,
|
||||
senderKey: false,
|
||||
});
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
|
|
41
ts/groups.ts
41
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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<typeof logEntrySchema>;
|
||||
|
||||
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) {
|
||||
|
|
6
ts/model-types.d.ts
vendored
6
ts/model-types.d.ts
vendored
|
@ -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<DeviceType>;
|
||||
};
|
||||
|
||||
// GroupV2 other fields
|
||||
accessControl?: {
|
||||
|
|
|
@ -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<void> {
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -2190,22 +2190,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,10 @@ type Storage = {
|
|||
remove(key: string): Promise<void>;
|
||||
};
|
||||
|
||||
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(
|
||||
|
|
|
@ -139,6 +139,7 @@ const dataInterface: ClientInterface = {
|
|||
getSenderKeyById,
|
||||
removeAllSenderKeys,
|
||||
getAllSenderKeys,
|
||||
removeSenderKeyById,
|
||||
|
||||
createOrUpdateSession,
|
||||
createOrUpdateSessions,
|
||||
|
@ -759,6 +760,9 @@ async function removeAllSenderKeys(): Promise<void> {
|
|||
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
|
||||
return channels.getAllSenderKeys();
|
||||
}
|
||||
async function removeSenderKeyById(id: string): Promise<void> {
|
||||
return channels.removeSenderKeyById(id);
|
||||
}
|
||||
|
||||
// Sessions
|
||||
|
||||
|
|
|
@ -185,6 +185,7 @@ export type DataInterface = {
|
|||
getSenderKeyById: (id: string) => Promise<SenderKeyType | undefined>;
|
||||
removeAllSenderKeys: () => Promise<void>;
|
||||
getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
|
||||
removeSenderKeyById: (id: string) => Promise<void>;
|
||||
|
||||
createOrUpdateSession: (data: SessionType) => Promise<void>;
|
||||
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
|
||||
|
|
|
@ -130,6 +130,7 @@ const dataInterface: ServerInterface = {
|
|||
getSenderKeyById,
|
||||
removeAllSenderKeys,
|
||||
getAllSenderKeys,
|
||||
removeSenderKeyById,
|
||||
|
||||
createOrUpdateSession,
|
||||
createOrUpdateSessions,
|
||||
|
@ -2215,6 +2216,10 @@ async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
|
|||
|
||||
return rows;
|
||||
}
|
||||
async function removeSenderKeyById(id: string): Promise<void> {
|
||||
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<void> {
|
|||
// Anything that isn't user-visible data
|
||||
async function removeAllConfiguration(): Promise<void> {
|
||||
const db = getInstance();
|
||||
const patch: Partial<ConversationType> = { 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<void> {
|
|||
DELETE FROM signedPreKeys;
|
||||
DELETE FROM unprocessed;
|
||||
DELETE FROM jobs;
|
||||
`);
|
||||
UPDATE conversations SET json = json_patch(json, $patch);
|
||||
`
|
||||
).run({
|
||||
$patch: patch,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
144
ts/test-electron/util/sendToGroup_test.ts
Normal file
144
ts/test-electron/util/sendToGroup_test.ts
Normal file
|
@ -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<DeviceType> {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
1
ts/textsecure.d.ts
vendored
1
ts/textsecure.d.ts
vendored
|
@ -730,7 +730,6 @@ export declare namespace EnvelopeClass {
|
|||
static PREKEY_BUNDLE: number;
|
||||
static RECEIPT: number;
|
||||
static UNIDENTIFIED_SENDER: number;
|
||||
static SENDERKEY: number;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<void> {
|
||||
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<number> | undefined
|
||||
updateDevices?: Array<number>
|
||||
): Promise<void | Array<void | null>> {
|
||||
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<void | Array<void | null>> = 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<Array<number> | undefined> {
|
||||
const sessionStore = new Sessions();
|
||||
|
||||
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
|
||||
identifier
|
||||
);
|
||||
if (deviceIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateDevices: Array<number> = [];
|
||||
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<number>
|
||||
|
@ -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')) {
|
||||
|
|
File diff suppressed because it is too large
Load diff
5
ts/textsecure/Types.d.ts
vendored
5
ts/textsecure/Types.d.ts
vendored
|
@ -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 = {
|
||||
|
|
|
@ -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<void>;
|
||||
sendWithSenderKey: (
|
||||
payload: ArrayBuffer,
|
||||
accessKeys: ArrayBuffer,
|
||||
timestamp: number,
|
||||
online?: boolean
|
||||
) => Promise<MultiRecipient200ResponseType>;
|
||||
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||
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<MultiRecipient200ResponseType> {
|
||||
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\/)([^/]+)(\/)/,
|
||||
|
|
140
ts/textsecure/getKeysForIdentifier.ts
Normal file
140
ts/textsecure/getKeysForIdentifier.ts
Normal file
|
@ -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<number>,
|
||||
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<number>
|
||||
): Promise<void> {
|
||||
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;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { CallbackResultType } from '../textsecure/SendMessage';
|
||||
|
||||
const SEALED_SENDER = {
|
||||
export const SEALED_SENDER = {
|
||||
UNKNOWN: 0,
|
||||
ENABLED: 1,
|
||||
DISABLED: 2,
|
||||
|
|
|
@ -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,
|
||||
|
|
885
ts/util/sendToGroup.ts
Normal file
885
ts/util/sendToGroup.ts
Normal file
|
@ -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<CallbackResultType> {
|
||||
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<string>;
|
||||
sendOptions?: SendOptionsType;
|
||||
timestamp: number;
|
||||
}): Promise<CallbackResultType> {
|
||||
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<string>;
|
||||
recursionCount: number;
|
||||
sendOptions?: SendOptionsType;
|
||||
timestamp: number;
|
||||
}): Promise<CallbackResultType> {
|
||||
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<DeviceType, DeviceType>(
|
||||
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<T>({
|
||||
tasks,
|
||||
maxConcurrency = MAX_CONCURRENCY,
|
||||
}: {
|
||||
tasks: Array<() => Promise<T>>;
|
||||
maxConcurrency?: number;
|
||||
}): Promise<Array<T>> {
|
||||
const queue = new PQueue({
|
||||
concurrency: maxConcurrency,
|
||||
timeout: 2 * 60 * 1000,
|
||||
});
|
||||
return queue.addAll(tasks);
|
||||
}
|
||||
|
||||
function getRecipients(options: GroupSendOptionsType): Array<string> {
|
||||
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<DeviceType> = 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<DeviceType>): 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<DeviceType>;
|
||||
distributionId: string;
|
||||
contentMessage: ArrayBuffer;
|
||||
groupId: string;
|
||||
}): Promise<Buffer> {
|
||||
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<DeviceType>): Array<string> {
|
||||
const uuids = new Set<string>();
|
||||
devices.forEach(device => {
|
||||
uuids.add(device.identifier);
|
||||
});
|
||||
|
||||
return Array.from(uuids);
|
||||
}
|
||||
|
||||
export function _analyzeSenderKeyDevices(
|
||||
memberDevices: Array<DeviceType>,
|
||||
devicesForSend: Array<DeviceType>,
|
||||
isPartialSend?: boolean
|
||||
): {
|
||||
newToMemberDevices: Array<DeviceType>;
|
||||
newToMemberUuids: Array<string>;
|
||||
removedFromMemberDevices: Array<DeviceType>;
|
||||
removedFromMemberUuids: Array<string>;
|
||||
} {
|
||||
const newToMemberDevices = differenceWith<DeviceType, DeviceType>(
|
||||
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<DeviceType, DeviceType>(
|
||||
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<void> {
|
||||
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<string>
|
||||
): Promise<void> {
|
||||
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<number>
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
16
yarn.lock
16
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==
|
||||
|
|
Loading…
Add table
Reference in a new issue