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"
|
"fs-xattr": "0.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@signalapp/signal-client": "0.5.2",
|
"@signalapp/signal-client": "0.6.0",
|
||||||
"@sindresorhus/is": "0.8.0",
|
"@sindresorhus/is": "0.8.0",
|
||||||
"@types/pino": "6.3.6",
|
"@types/pino": "6.3.6",
|
||||||
"@types/pino-multi-stream": "5.1.0",
|
"@types/pino-multi-stream": "5.1.0",
|
||||||
|
@ -163,7 +163,7 @@
|
||||||
"uuid": "3.3.2",
|
"uuid": "3.3.2",
|
||||||
"websocket": "1.0.28",
|
"websocket": "1.0.28",
|
||||||
"zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#7ecf70be85e5a485ec870c1723b1c6247b9d549e",
|
"zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#7ecf70be85e5a485ec870c1723b1c6247b9d549e",
|
||||||
"zod": "1.11.13"
|
"zod": "3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.7.7",
|
"@babel/core": "7.7.7",
|
||||||
|
|
|
@ -12,7 +12,6 @@ message Envelope {
|
||||||
PREKEY_BUNDLE = 3;
|
PREKEY_BUNDLE = 3;
|
||||||
RECEIPT = 5;
|
RECEIPT = 5;
|
||||||
UNIDENTIFIED_SENDER = 6;
|
UNIDENTIFIED_SENDER = 6;
|
||||||
SENDERKEY = 7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
optional Type type = 1;
|
optional Type type = 1;
|
||||||
|
|
|
@ -67,6 +67,15 @@ export class Sessions extends SessionStore {
|
||||||
|
|
||||||
return record || null;
|
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 = {
|
export type IdentityKeysOptions = {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Direction,
|
Direction,
|
||||||
|
@ -32,6 +32,7 @@ import {
|
||||||
sessionStructureToArrayBuffer,
|
sessionStructureToArrayBuffer,
|
||||||
} from './util/sessionTranslation';
|
} from './util/sessionTranslation';
|
||||||
import {
|
import {
|
||||||
|
DeviceType,
|
||||||
KeyPairType,
|
KeyPairType,
|
||||||
IdentityKeyType,
|
IdentityKeyType,
|
||||||
SenderKeyType,
|
SenderKeyType,
|
||||||
|
@ -545,7 +546,7 @@ export class SignalProtocolStore extends EventsMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.hydrated) {
|
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;
|
return entry.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -555,17 +556,40 @@ export class SignalProtocolStore extends EventsMixin {
|
||||||
item,
|
item,
|
||||||
fromDB: entry.fromDB,
|
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;
|
return item;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorString = error && error.stack ? error.stack : error;
|
const errorString = error && error.stack ? error.stack : error;
|
||||||
window.log.error(
|
window.log.error(
|
||||||
`getSenderKey: failed to load senderKey ${encodedAddress}/${distributionId}: ${errorString}`
|
`getSenderKey: failed to load sender key ${encodedAddress}/${distributionId}: ${errorString}`
|
||||||
);
|
);
|
||||||
return undefined;
|
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
|
// Session Queue
|
||||||
|
|
||||||
async enqueueSessionJob<T>(
|
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(
|
private async _maybeMigrateSession(
|
||||||
session: SessionType
|
session: SessionType
|
||||||
): Promise<SessionRecord> {
|
): Promise<SessionRecord> {
|
||||||
|
@ -882,33 +921,51 @@ export class SignalProtocolStore extends EventsMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceIds(identifier: string): Promise<Array<number>> {
|
async getOpenDevices(
|
||||||
return this.withZone(GLOBAL_ZONE, 'getDeviceIds', async () => {
|
identifiers: Array<string>
|
||||||
|
): Promise<{
|
||||||
|
devices: Array<DeviceType>;
|
||||||
|
emptyIdentifiers: Array<string>;
|
||||||
|
}> {
|
||||||
|
return this.withZone(GLOBAL_ZONE, 'getOpenDevices', async () => {
|
||||||
if (!this.sessions) {
|
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) {
|
if (identifiers.length === 0) {
|
||||||
throw new Error('getDeviceIds: identifier was undefined/null');
|
throw new Error('getOpenDevices: No identifiers provided!');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = window.ConversationController.getConversationId(identifier);
|
const conversationIds = new Map<string, string>();
|
||||||
if (!id) {
|
identifiers.forEach(identifier => {
|
||||||
throw new Error(
|
if (identifier === null || identifier === undefined) {
|
||||||
`getDeviceIds: No conversationId found for identifier ${identifier}`
|
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 allSessions = this._getAllSessions();
|
||||||
const entries = allSessions.filter(
|
const entries = allSessions.filter(session =>
|
||||||
session => session.fromDB.conversationId === id
|
conversationIds.has(session.fromDB.conversationId)
|
||||||
);
|
);
|
||||||
const openIds = await Promise.all(
|
const openEntries: Array<
|
||||||
|
SessionCacheEntry | undefined
|
||||||
|
> = await Promise.all(
|
||||||
entries.map(async entry => {
|
entries.map(async entry => {
|
||||||
if (entry.hydrated) {
|
if (entry.hydrated) {
|
||||||
const record = entry.item;
|
const record = entry.item;
|
||||||
if (record.hasCurrentState()) {
|
if (record.hasCurrentState()) {
|
||||||
return entry.fromDB.deviceId;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -916,25 +973,67 @@ export class SignalProtocolStore extends EventsMixin {
|
||||||
|
|
||||||
const record = await this._maybeMigrateSession(entry.fromDB);
|
const record = await this._maybeMigrateSession(entry.fromDB);
|
||||||
if (record.hasCurrentState()) {
|
if (record.hasCurrentState()) {
|
||||||
return entry.fromDB.deviceId;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
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) {
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
`getDeviceIds: Failed to get device ids for identifier ${identifier}`,
|
'getOpenDevices: Failed to get devices',
|
||||||
error && error.stack ? error.stack : error
|
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> {
|
async removeSession(encodedAddress: string): Promise<void> {
|
||||||
return this.withZone(GLOBAL_ZONE, 'removeSession', async () => {
|
return this.withZone(GLOBAL_ZONE, 'removeSession', async () => {
|
||||||
if (!this.sessions) {
|
if (!this.sessions) {
|
||||||
|
|
|
@ -2060,6 +2060,7 @@ export async function startApp(): Promise<void> {
|
||||||
await server.registerCapabilities({
|
await server.registerCapabilities({
|
||||||
'gv2-3': true,
|
'gv2-3': true,
|
||||||
'gv1-migration': true,
|
'gv1-migration': true,
|
||||||
|
senderKey: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.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 timestamp = Date.now();
|
||||||
|
|
||||||
const promise = conversation.wrapSend(
|
const promise = conversation.wrapSend(
|
||||||
window.textsecure.messaging.sendMessageToGroup(
|
window.Signal.Util.sendToGroup(
|
||||||
{
|
{
|
||||||
groupV2: conversation.getGroupV2Info({
|
groupV2: conversation.getGroupV2Info({
|
||||||
groupChange: groupChangeBuffer,
|
groupChange: groupChangeBuffer,
|
||||||
|
@ -1271,6 +1271,7 @@ export async function modifyGroupV2({
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
|
conversation,
|
||||||
sendOptions
|
sendOptions
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -1631,13 +1632,16 @@ export async function createGroupV2({
|
||||||
|
|
||||||
await wrapWithSyncMessageSend({
|
await wrapWithSyncMessageSend({
|
||||||
conversation,
|
conversation,
|
||||||
logId: `sendMessageToGroup/${logId}`,
|
logId: `sendToGroup/${logId}`,
|
||||||
send: async sender =>
|
send: async () =>
|
||||||
sender.sendMessageToGroup({
|
window.Signal.Util.sendToGroup(
|
||||||
groupV2: groupV2Info,
|
{
|
||||||
timestamp,
|
groupV2: groupV2Info,
|
||||||
profileKey,
|
timestamp,
|
||||||
}),
|
profileKey,
|
||||||
|
},
|
||||||
|
conversation
|
||||||
|
),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2143,16 +2147,19 @@ export async function initiateMigrationToGroupV2(
|
||||||
|
|
||||||
await wrapWithSyncMessageSend({
|
await wrapWithSyncMessageSend({
|
||||||
conversation,
|
conversation,
|
||||||
logId: `sendMessageToGroup/${logId}`,
|
logId: `sendToGroup/${logId}`,
|
||||||
send: async sender =>
|
send: async () =>
|
||||||
// Minimal message to notify group members about migration
|
// Minimal message to notify group members about migration
|
||||||
sender.sendMessageToGroup({
|
window.Signal.Util.sendToGroup(
|
||||||
groupV2: conversation.getGroupV2Info({
|
{
|
||||||
includePendingMembers: true,
|
groupV2: conversation.getGroupV2Info({
|
||||||
}),
|
includePendingMembers: true,
|
||||||
timestamp,
|
}),
|
||||||
profileKey: ourProfileKey,
|
timestamp,
|
||||||
}),
|
profileKey: ourProfileKey,
|
||||||
|
},
|
||||||
|
conversation
|
||||||
|
),
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { JobQueue } from './JobQueue';
|
import { JobQueue } from './JobQueue';
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2018-2021 Signal Messenger, LLC
|
// Copyright 2018-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import { gzip } from 'zlib';
|
import { gzip } from 'zlib';
|
||||||
import pify from 'pify';
|
import pify from 'pify';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
import * as pino from 'pino';
|
import * as pino from 'pino';
|
||||||
import { redactAll } from '../../js/modules/privacy';
|
import { redactAll } from '../../js/modules/privacy';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
@ -27,7 +27,8 @@ const logEntrySchema = z.object({
|
||||||
});
|
});
|
||||||
export type LogEntryType = z.infer<typeof logEntrySchema>;
|
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 {
|
export function getLogLevelString(value: LogLevel): pino.Level {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
|
|
6
ts/model-types.d.ts
vendored
6
ts/model-types.d.ts
vendored
|
@ -12,6 +12,7 @@ import {
|
||||||
MessageType,
|
MessageType,
|
||||||
LastMessageStatus,
|
LastMessageStatus,
|
||||||
} from './state/ducks/conversations';
|
} from './state/ducks/conversations';
|
||||||
|
import { DeviceType } from './textsecure/Types';
|
||||||
import { SendOptionsType } from './textsecure/SendMessage';
|
import { SendOptionsType } from './textsecure/SendMessage';
|
||||||
import { SendMessageChallengeData } from './textsecure/Errors';
|
import { SendMessageChallengeData } from './textsecure/Errors';
|
||||||
import {
|
import {
|
||||||
|
@ -264,6 +265,11 @@ export type ConversationAttributesType = {
|
||||||
secretParams?: string;
|
secretParams?: string;
|
||||||
publicParams?: string;
|
publicParams?: string;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
|
senderKeyInfo?: {
|
||||||
|
createdAtDate: number;
|
||||||
|
distributionId: string;
|
||||||
|
memberDevices: Array<DeviceType>;
|
||||||
|
};
|
||||||
|
|
||||||
// GroupV2 other fields
|
// GroupV2 other fields
|
||||||
accessControl?: {
|
accessControl?: {
|
||||||
|
|
|
@ -1177,27 +1177,55 @@ export class ConversationModel extends window.Backbone
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientId = this.isPrivate() ? this.getSendTarget() : undefined;
|
await this.queueJob(async () => {
|
||||||
const groupId = this.getGroupIdBuffer();
|
const recipientId = this.isPrivate() ? this.getSendTarget() : undefined;
|
||||||
const groupMembers = this.getRecipients();
|
const groupId = this.getGroupIdBuffer();
|
||||||
|
const groupMembers = this.getRecipients();
|
||||||
|
|
||||||
// We don't send typing messages if our recipients list is empty
|
// We don't send typing messages if our recipients list is empty
|
||||||
if (!this.isPrivate() && !groupMembers.length) {
|
if (!this.isPrivate() && !groupMembers.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendOptions = await this.getSendOptions();
|
const timestamp = Date.now();
|
||||||
this.wrapSend(
|
const contentMessage = window.textsecure.messaging.getTypingContentMessage(
|
||||||
window.textsecure.messaging.sendTypingMessage(
|
|
||||||
{
|
{
|
||||||
isTyping,
|
|
||||||
recipientId,
|
recipientId,
|
||||||
groupId,
|
groupId,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
},
|
isTyping,
|
||||||
sendOptions
|
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> {
|
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(),
|
groupV1: this.getGroupV1Info(),
|
||||||
groupV2: this.getGroupV2Info(),
|
groupV2: this.getGroupV2Info(),
|
||||||
|
@ -3107,6 +3135,7 @@ export class ConversationModel extends window.Backbone
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
|
this,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
@ -3208,19 +3237,19 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
// Special-case the self-send case - we send only a sync message
|
// Special-case the self-send case - we send only a sync message
|
||||||
if (this.isMe()) {
|
if (this.isMe()) {
|
||||||
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
destination,
|
attachments: [],
|
||||||
undefined, // body
|
// body
|
||||||
[], // attachments
|
// deletedForEveryoneTimestamp
|
||||||
undefined, // quote
|
|
||||||
[], // preview
|
|
||||||
undefined, // sticker
|
|
||||||
outgoingReaction,
|
|
||||||
undefined, // deletedForEveryoneTimestamp
|
|
||||||
timestamp,
|
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey
|
preview: [],
|
||||||
);
|
profileKey,
|
||||||
|
// quote
|
||||||
|
reaction: outgoingReaction,
|
||||||
|
recipients: [destination],
|
||||||
|
// sticker
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
const result = await message.sendSyncMessageOnly(dataMessage);
|
const result = await message.sendSyncMessageOnly(dataMessage);
|
||||||
window.Whisper.Reactions.onReaction(reactionModel);
|
window.Whisper.Reactions.onReaction(reactionModel);
|
||||||
return result;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
groupV1: this.getGroupV1Info()!,
|
groupV1: this.getGroupV1Info()!,
|
||||||
|
@ -3257,6 +3286,7 @@ export class ConversationModel extends window.Backbone
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
|
this,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
@ -3446,19 +3476,19 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
// Special-case the self-send case - we send only a sync message
|
// Special-case the self-send case - we send only a sync message
|
||||||
if (this.isMe()) {
|
if (this.isMe()) {
|
||||||
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
destination,
|
attachments: finalAttachments,
|
||||||
messageBody,
|
body: messageBody,
|
||||||
finalAttachments,
|
// deletedForEveryoneTimestamp
|
||||||
quote,
|
|
||||||
preview,
|
|
||||||
sticker,
|
|
||||||
null, // reaction
|
|
||||||
undefined, // deletedForEveryoneTimestamp
|
|
||||||
now,
|
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey
|
preview,
|
||||||
);
|
profileKey,
|
||||||
|
quote,
|
||||||
|
// reaction
|
||||||
|
recipients: [destination],
|
||||||
|
sticker,
|
||||||
|
timestamp: now,
|
||||||
|
});
|
||||||
return message.sendSyncMessageOnly(dataMessage);
|
return message.sendSyncMessageOnly(dataMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3467,7 +3497,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
let promise;
|
let promise;
|
||||||
if (conversationType === Message.GROUP) {
|
if (conversationType === Message.GROUP) {
|
||||||
promise = window.textsecure.messaging.sendMessageToGroup(
|
promise = window.Signal.Util.sendToGroup(
|
||||||
{
|
{
|
||||||
attachments: finalAttachments,
|
attachments: finalAttachments,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
|
@ -3481,6 +3511,7 @@ export class ConversationModel extends window.Backbone
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
mentions,
|
mentions,
|
||||||
},
|
},
|
||||||
|
this,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -3904,21 +3935,21 @@ export class ConversationModel extends window.Backbone
|
||||||
if (this.isMe()) {
|
if (this.isMe()) {
|
||||||
const flags =
|
const flags =
|
||||||
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
||||||
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
attachments: [],
|
||||||
this.getSendTarget()!,
|
// body
|
||||||
undefined, // body
|
// deletedForEveryoneTimestamp
|
||||||
[], // attachments
|
|
||||||
undefined, // quote
|
|
||||||
[], // preview
|
|
||||||
undefined, // sticker
|
|
||||||
undefined, // reaction
|
|
||||||
undefined, // deletedForEveryoneTimestamp
|
|
||||||
message.get('sent_at'),
|
|
||||||
expireTimer,
|
expireTimer,
|
||||||
|
flags,
|
||||||
|
preview: [],
|
||||||
profileKey,
|
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);
|
return message.sendSyncMessageOnly(dataMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2190,22 +2190,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
recipients.length === 1 &&
|
recipients.length === 1 &&
|
||||||
(recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID)
|
(recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID)
|
||||||
) {
|
) {
|
||||||
const [identifier] = recipients;
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
||||||
identifier,
|
|
||||||
body,
|
|
||||||
attachments,
|
attachments,
|
||||||
quoteWithData,
|
body,
|
||||||
previewWithData,
|
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
|
||||||
stickerWithData,
|
expireTimer: this.get('expireTimer'),
|
||||||
null,
|
// flags
|
||||||
this.get('deletedForEveryoneTimestamp'),
|
mentions: this.get('bodyRanges'),
|
||||||
this.get('sent_at'),
|
preview: previewWithData,
|
||||||
this.get('expireTimer'),
|
|
||||||
profileKey,
|
profileKey,
|
||||||
undefined, // flags
|
quote: quoteWithData,
|
||||||
this.get('bodyRanges')
|
reaction: null,
|
||||||
);
|
recipients,
|
||||||
|
sticker: stickerWithData,
|
||||||
|
timestamp: this.get('sent_at'),
|
||||||
|
});
|
||||||
return this.sendSyncMessageOnly(dataMessage);
|
return this.sendSyncMessageOnly(dataMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2229,15 +2228,32 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Because this is a partial group send, we manually construct the request like
|
const initialGroupV2 = conversation.getGroupV2Info();
|
||||||
// sendMessageToGroup does.
|
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,
|
messageText: body,
|
||||||
body,
|
|
||||||
timestamp: this.get('sent_at'),
|
timestamp: this.get('sent_at'),
|
||||||
attachments,
|
attachments,
|
||||||
quote: quoteWithData,
|
quote: quoteWithData,
|
||||||
|
@ -2247,15 +2263,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
mentions: this.get('bodyRanges'),
|
mentions: this.get('bodyRanges'),
|
||||||
profileKey,
|
profileKey,
|
||||||
groupV2,
|
groupV2,
|
||||||
group: groupV2
|
groupV1,
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
id: this.getConversation()!.get('groupId')!,
|
|
||||||
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
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
|
// Special-case the self-send case - we send only a sync message
|
||||||
if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) {
|
if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) {
|
||||||
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
identifier,
|
|
||||||
body,
|
|
||||||
attachments,
|
attachments,
|
||||||
quoteWithData,
|
body,
|
||||||
previewWithData,
|
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
|
||||||
stickerWithData,
|
expireTimer: this.get('expireTimer'),
|
||||||
null,
|
// flags
|
||||||
this.get('deletedForEveryoneTimestamp'),
|
mentions: this.get('bodyRanges'),
|
||||||
this.get('sent_at'),
|
preview: previewWithData,
|
||||||
this.get('expireTimer'),
|
|
||||||
profileKey,
|
profileKey,
|
||||||
undefined, // flags
|
quote: quoteWithData,
|
||||||
this.get('bodyRanges')
|
reaction: null,
|
||||||
);
|
recipients: [identifier],
|
||||||
|
sticker: stickerWithData,
|
||||||
|
timestamp: this.get('sent_at'),
|
||||||
|
});
|
||||||
return this.sendSyncMessageOnly(dataMessage);
|
return this.sendSyncMessageOnly(dataMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -768,12 +768,19 @@ export class CallingClass {
|
||||||
// We "fire and forget" because sending this message is non-essential.
|
// We "fire and forget" because sending this message is non-essential.
|
||||||
wrapWithSyncMessageSend({
|
wrapWithSyncMessageSend({
|
||||||
conversation,
|
conversation,
|
||||||
logId: `sendGroupCallUpdateMessage/${conversationId}-${eraId}`,
|
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
|
||||||
send: sender =>
|
send: () =>
|
||||||
sender.sendGroupCallUpdate({ eraId, groupV2, timestamp }, sendOptions),
|
window.Signal.Util.sendToGroup(
|
||||||
|
{ groupCallUpdate: { eraId }, groupV2, timestamp },
|
||||||
|
conversation,
|
||||||
|
sendOptions
|
||||||
|
),
|
||||||
timestamp,
|
timestamp,
|
||||||
}).catch(err => {
|
}).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>;
|
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.
|
// In case your clock is different from the server's, we "fake" expire certificates early.
|
||||||
const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000;
|
const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
@ -88,10 +92,14 @@ export class SenderCertificateService {
|
||||||
);
|
);
|
||||||
|
|
||||||
const valueInStorage = storage.get(modeToStorageKey(mode));
|
const valueInStorage = storage.get(modeToStorageKey(mode));
|
||||||
return serializedCertificateSchema.check(valueInStorage) &&
|
if (
|
||||||
|
isWellFormed(valueInStorage) &&
|
||||||
isExpirationValid(valueInStorage.expires)
|
isExpirationValid(valueInStorage.expires)
|
||||||
? valueInStorage
|
) {
|
||||||
: undefined;
|
return valueInStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchCertificate(
|
private fetchCertificate(
|
||||||
|
|
|
@ -139,6 +139,7 @@ const dataInterface: ClientInterface = {
|
||||||
getSenderKeyById,
|
getSenderKeyById,
|
||||||
removeAllSenderKeys,
|
removeAllSenderKeys,
|
||||||
getAllSenderKeys,
|
getAllSenderKeys,
|
||||||
|
removeSenderKeyById,
|
||||||
|
|
||||||
createOrUpdateSession,
|
createOrUpdateSession,
|
||||||
createOrUpdateSessions,
|
createOrUpdateSessions,
|
||||||
|
@ -759,6 +760,9 @@ async function removeAllSenderKeys(): Promise<void> {
|
||||||
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
|
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
|
||||||
return channels.getAllSenderKeys();
|
return channels.getAllSenderKeys();
|
||||||
}
|
}
|
||||||
|
async function removeSenderKeyById(id: string): Promise<void> {
|
||||||
|
return channels.removeSenderKeyById(id);
|
||||||
|
}
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
|
|
||||||
|
|
|
@ -185,6 +185,7 @@ export type DataInterface = {
|
||||||
getSenderKeyById: (id: string) => Promise<SenderKeyType | undefined>;
|
getSenderKeyById: (id: string) => Promise<SenderKeyType | undefined>;
|
||||||
removeAllSenderKeys: () => Promise<void>;
|
removeAllSenderKeys: () => Promise<void>;
|
||||||
getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
|
getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
|
||||||
|
removeSenderKeyById: (id: string) => Promise<void>;
|
||||||
|
|
||||||
createOrUpdateSession: (data: SessionType) => Promise<void>;
|
createOrUpdateSession: (data: SessionType) => Promise<void>;
|
||||||
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
|
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
|
||||||
|
|
|
@ -130,6 +130,7 @@ const dataInterface: ServerInterface = {
|
||||||
getSenderKeyById,
|
getSenderKeyById,
|
||||||
removeAllSenderKeys,
|
removeAllSenderKeys,
|
||||||
getAllSenderKeys,
|
getAllSenderKeys,
|
||||||
|
removeSenderKeyById,
|
||||||
|
|
||||||
createOrUpdateSession,
|
createOrUpdateSession,
|
||||||
createOrUpdateSessions,
|
createOrUpdateSessions,
|
||||||
|
@ -2215,6 +2216,10 @@ async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
|
||||||
|
|
||||||
return rows;
|
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';
|
const SESSIONS_TABLE = 'sessions';
|
||||||
function createOrUpdateSessionSync(data: SessionType): void {
|
function createOrUpdateSessionSync(data: SessionType): void {
|
||||||
|
@ -4857,9 +4862,11 @@ async function removeAll(): Promise<void> {
|
||||||
// Anything that isn't user-visible data
|
// Anything that isn't user-visible data
|
||||||
async function removeAllConfiguration(): Promise<void> {
|
async function removeAllConfiguration(): Promise<void> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
const patch: Partial<ConversationType> = { senderKeyInfo: undefined };
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.exec(`
|
db.prepare(
|
||||||
|
`
|
||||||
DELETE FROM identityKeys;
|
DELETE FROM identityKeys;
|
||||||
DELETE FROM items;
|
DELETE FROM items;
|
||||||
DELETE FROM preKeys;
|
DELETE FROM preKeys;
|
||||||
|
@ -4868,7 +4875,11 @@ async function removeAllConfiguration(): Promise<void> {
|
||||||
DELETE FROM signedPreKeys;
|
DELETE FROM signedPreKeys;
|
||||||
DELETE FROM unprocessed;
|
DELETE FROM unprocessed;
|
||||||
DELETE FROM jobs;
|
DELETE FROM jobs;
|
||||||
`);
|
UPDATE conversations SET json = json_patch(json, $patch);
|
||||||
|
`
|
||||||
|
).run({
|
||||||
|
$patch: patch,
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -175,6 +175,14 @@ describe('SignalProtocolStore', () => {
|
||||||
assert.isTrue(
|
assert.isTrue(
|
||||||
constantTimeEqual(expected.serialize(), actual.serialize())
|
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 () => {
|
it('roundtrips through database', async () => {
|
||||||
|
@ -197,6 +205,17 @@ describe('SignalProtocolStore', () => {
|
||||||
assert.isTrue(
|
assert.isTrue(
|
||||||
constantTimeEqual(expected.serialize(), actual.serialize())
|
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', () => {
|
describe('zones', () => {
|
||||||
const zone = new Zone('zone', {
|
const zone = new Zone('zone', {
|
||||||
pendingSessions: true,
|
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 { assert } from 'chai';
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import EventEmitter, { once } from 'events';
|
import EventEmitter, { once } from 'events';
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
import { identity, noop, groupBy } from 'lodash';
|
import { identity, noop, groupBy } from 'lodash';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { JobError } from '../../jobs/JobError';
|
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 PREKEY_BUNDLE: number;
|
||||||
static RECEIPT: number;
|
static RECEIPT: number;
|
||||||
static UNIDENTIFIED_SENDER: number;
|
static UNIDENTIFIED_SENDER: number;
|
||||||
static SENDERKEY: number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,19 +10,16 @@
|
||||||
|
|
||||||
import { reject } from 'lodash';
|
import { reject } from 'lodash';
|
||||||
|
|
||||||
import * as z from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
CiphertextMessageType,
|
CiphertextMessageType,
|
||||||
PreKeyBundle,
|
|
||||||
processPreKeyBundle,
|
|
||||||
ProtocolAddress,
|
ProtocolAddress,
|
||||||
PublicKey,
|
|
||||||
sealedSenderEncryptMessage,
|
sealedSenderEncryptMessage,
|
||||||
SenderCertificate,
|
SenderCertificate,
|
||||||
signalEncrypt,
|
signalEncrypt,
|
||||||
} from '@signalapp/signal-client';
|
} from '@signalapp/signal-client';
|
||||||
|
|
||||||
import { ServerKeysType, WebAPIType } from './WebAPI';
|
import { WebAPIType } from './WebAPI';
|
||||||
import { ContentClass, DataMessageClass } from '../textsecure.d';
|
import { ContentClass, DataMessageClass } from '../textsecure.d';
|
||||||
import {
|
import {
|
||||||
CallbackResultType,
|
CallbackResultType,
|
||||||
|
@ -40,6 +37,7 @@ import {
|
||||||
import { isValidNumber } from '../types/PhoneNumber';
|
import { isValidNumber } from '../types/PhoneNumber';
|
||||||
import { Sessions, IdentityKeys } from '../LibSignalStores';
|
import { Sessions, IdentityKeys } from '../LibSignalStores';
|
||||||
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
||||||
|
import { getKeysForIdentifier } from './getKeysForIdentifier';
|
||||||
|
|
||||||
export const enum SenderCertificateMode {
|
export const enum SenderCertificateMode {
|
||||||
WithE164,
|
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 {
|
export default class OutgoingMessage {
|
||||||
server: WebAPIType;
|
server: WebAPIType;
|
||||||
|
|
||||||
|
@ -187,95 +206,26 @@ export default class OutgoingMessage {
|
||||||
identifier: string,
|
identifier: string,
|
||||||
recurse?: boolean
|
recurse?: boolean
|
||||||
): () => Promise<void> {
|
): () => Promise<void> {
|
||||||
return async () =>
|
return async () => {
|
||||||
window.textsecure.storage.protocol
|
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
|
||||||
.getDeviceIds(identifier)
|
identifier
|
||||||
.then(async deviceIds => {
|
);
|
||||||
if (deviceIds.length === 0) {
|
if (deviceIds.length === 0) {
|
||||||
this.registerError(
|
this.registerError(
|
||||||
identifier,
|
identifier,
|
||||||
'reloadDevicesAndSend: Got empty device list when loading device keys',
|
'reloadDevicesAndSend: Got empty device list when loading device keys',
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return this.doSendMessage(identifier, deviceIds, recurse);
|
return this.doSendMessage(identifier, deviceIds, recurse);
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getKeysForIdentifier(
|
async getKeysForIdentifier(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
updateDevices: Array<number> | undefined
|
updateDevices?: Array<number>
|
||||||
): Promise<void | Array<void | null>> {
|
): 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 { sendMetadata } = this;
|
||||||
const info =
|
const info =
|
||||||
sendMetadata && sendMetadata[identifier]
|
sendMetadata && sendMetadata[identifier]
|
||||||
|
@ -283,65 +233,23 @@ export default class OutgoingMessage {
|
||||||
: { accessKey: undefined };
|
: { accessKey: undefined };
|
||||||
const { accessKey } = info;
|
const { accessKey } = info;
|
||||||
|
|
||||||
if (updateDevices === undefined) {
|
try {
|
||||||
if (accessKey) {
|
const { accessKeyFailed } = await getKeysForIdentifier(
|
||||||
return this.server
|
identifier,
|
||||||
.getKeysForIdentifierUnauth(identifier, undefined, { accessKey })
|
this.server,
|
||||||
.catch(async (error: Error) => {
|
updateDevices,
|
||||||
if (error.code === 401 || error.code === 403) {
|
accessKey
|
||||||
if (this.failoverIdentifiers.indexOf(identifier) === -1) {
|
);
|
||||||
this.failoverIdentifiers.push(identifier);
|
if (accessKeyFailed && !this.failoverIdentifiers.includes(identifier)) {
|
||||||
}
|
this.failoverIdentifiers.push(identifier);
|
||||||
return this.server.getKeysForIdentifier(identifier);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
})
|
|
||||||
.then(handleResult);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
return this.server.getKeysForIdentifier(identifier).then(handleResult);
|
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(
|
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 {
|
getPlaintext(): ArrayBuffer {
|
||||||
if (!this.plaintext) {
|
if (!this.plaintext) {
|
||||||
const messageBuffer = this.message.toArrayBuffer();
|
this.plaintext = padMessage(this.message.toArrayBuffer());
|
||||||
this.plaintext = new Uint8Array(
|
|
||||||
this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
|
|
||||||
);
|
|
||||||
this.plaintext.set(new Uint8Array(messageBuffer));
|
|
||||||
this.plaintext[messageBuffer.byteLength] = 0x80;
|
|
||||||
}
|
}
|
||||||
return this.plaintext;
|
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(
|
async removeDeviceIdsForIdentifier(
|
||||||
identifier: string,
|
identifier: string,
|
||||||
deviceIdsToRemove: Array<number>
|
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
|
identifier
|
||||||
);
|
);
|
||||||
await this.getKeysForIdentifier(identifier, updateDevices);
|
if (deviceIds.length === 0) {
|
||||||
|
await this.getKeysForIdentifier(identifier);
|
||||||
|
}
|
||||||
await this.reloadDevicesAndSend(identifier, true)();
|
await this.reloadDevicesAndSend(identifier, true)();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.message?.includes('untrusted identity for address')) {
|
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,
|
UnprocessedUpdateType,
|
||||||
} from '../sql/Interface';
|
} from '../sql/Interface';
|
||||||
|
|
||||||
|
export type DeviceType = {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
};
|
||||||
|
|
||||||
// How the legacy APIs generate these types
|
// How the legacy APIs generate these types
|
||||||
|
|
||||||
export type CompatSignedPreKeyType = {
|
export type CompatSignedPreKeyType = {
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { pki } from 'node-forge';
|
||||||
import is from '@sindresorhus/is';
|
import is from '@sindresorhus/is';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Long } from '../window.d';
|
import { Long } from '../window.d';
|
||||||
import { getUserAgent } from '../util/getUserAgent';
|
import { getUserAgent } from '../util/getUserAgent';
|
||||||
|
@ -351,6 +352,49 @@ type ArrayBufferWithDetailsType = {
|
||||||
response: Response;
|
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 {
|
function isSuccess(status: number): boolean {
|
||||||
return status >= 0 && status < 400;
|
return status >= 0 && status < 400;
|
||||||
}
|
}
|
||||||
|
@ -685,6 +729,7 @@ const URL_CALLS = {
|
||||||
groupToken: 'v1/groups/token',
|
groupToken: 'v1/groups/token',
|
||||||
keys: 'v2/keys',
|
keys: 'v2/keys',
|
||||||
messages: 'v1/messages',
|
messages: 'v1/messages',
|
||||||
|
multiRecipient: 'v1/messages/multi_recipient',
|
||||||
profile: 'v1/profile',
|
profile: 'v1/profile',
|
||||||
registerCapabilities: 'v1/devices/capabilities',
|
registerCapabilities: 'v1/devices/capabilities',
|
||||||
removeSignalingKey: 'v1/accounts/signaling_key',
|
removeSignalingKey: 'v1/accounts/signaling_key',
|
||||||
|
@ -728,6 +773,7 @@ type AjaxOptionsType = {
|
||||||
call: keyof typeof URL_CALLS;
|
call: keyof typeof URL_CALLS;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
data?: ArrayBuffer | Buffer | string;
|
data?: ArrayBuffer | Buffer | string;
|
||||||
|
headers?: HeaderListType;
|
||||||
host?: string;
|
host?: string;
|
||||||
httpType: HTTPCodeType;
|
httpType: HTTPCodeType;
|
||||||
jsonData?: any;
|
jsonData?: any;
|
||||||
|
@ -749,10 +795,12 @@ export type WebAPIConnectType = {
|
||||||
export type CapabilitiesType = {
|
export type CapabilitiesType = {
|
||||||
gv2: boolean;
|
gv2: boolean;
|
||||||
'gv1-migration': boolean;
|
'gv1-migration': boolean;
|
||||||
|
senderKey: boolean;
|
||||||
};
|
};
|
||||||
export type CapabilitiesUploadType = {
|
export type CapabilitiesUploadType = {
|
||||||
'gv2-3': boolean;
|
'gv2-3': boolean;
|
||||||
'gv1-migration': boolean;
|
'gv1-migration': boolean;
|
||||||
|
senderKey: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StickerPackManifestType = any;
|
type StickerPackManifestType = any;
|
||||||
|
@ -895,6 +943,12 @@ export type WebAPIType = {
|
||||||
online?: boolean,
|
online?: boolean,
|
||||||
options?: { accessKey?: string }
|
options?: { accessKey?: string }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
sendWithSenderKey: (
|
||||||
|
payload: ArrayBuffer,
|
||||||
|
accessKeys: ArrayBuffer,
|
||||||
|
timestamp: number,
|
||||||
|
online?: boolean
|
||||||
|
) => Promise<MultiRecipient200ResponseType>;
|
||||||
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
|
||||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||||
uploadGroupAvatar: (
|
uploadGroupAvatar: (
|
||||||
|
@ -1065,6 +1119,7 @@ export function initialize({
|
||||||
requestVerificationVoice,
|
requestVerificationVoice,
|
||||||
sendMessages,
|
sendMessages,
|
||||||
sendMessagesUnauth,
|
sendMessagesUnauth,
|
||||||
|
sendWithSenderKey,
|
||||||
setSignedPreKey,
|
setSignedPreKey,
|
||||||
updateDeviceName,
|
updateDeviceName,
|
||||||
uploadGroupAvatar,
|
uploadGroupAvatar,
|
||||||
|
@ -1082,6 +1137,7 @@ export function initialize({
|
||||||
certificateAuthority,
|
certificateAuthority,
|
||||||
contentType: param.contentType || 'application/json; charset=utf-8',
|
contentType: param.contentType || 'application/json; charset=utf-8',
|
||||||
data: param.data || (param.jsonData && _jsonThing(param.jsonData)),
|
data: param.data || (param.jsonData && _jsonThing(param.jsonData)),
|
||||||
|
headers: param.headers,
|
||||||
host: param.host || url,
|
host: param.host || url,
|
||||||
password: param.password || password,
|
password: param.password || password,
|
||||||
path: URL_CALLS[param.call] + param.urlParameters,
|
path: URL_CALLS[param.call] + param.urlParameters,
|
||||||
|
@ -1375,6 +1431,7 @@ export function initialize({
|
||||||
const capabilities: CapabilitiesUploadType = {
|
const capabilities: CapabilitiesUploadType = {
|
||||||
'gv2-3': true,
|
'gv2-3': true,
|
||||||
'gv1-migration': true,
|
'gv1-migration': true,
|
||||||
|
senderKey: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { accessKey } = options;
|
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) {
|
function redactStickerUrl(stickerUrl: string) {
|
||||||
return stickerUrl.replace(
|
return stickerUrl.replace(
|
||||||
/(\/stickers\/)([^/]+)(\/)/,
|
/(\/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';
|
import { CallbackResultType } from '../textsecure/SendMessage';
|
||||||
|
|
||||||
const SEALED_SENDER = {
|
export const SEALED_SENDER = {
|
||||||
UNKNOWN: 0,
|
UNKNOWN: 0,
|
||||||
ENABLED: 1,
|
ENABLED: 1,
|
||||||
DISABLED: 2,
|
DISABLED: 2,
|
||||||
|
|
|
@ -35,6 +35,7 @@ import {
|
||||||
import * as zkgroup from './zkgroup';
|
import * as zkgroup from './zkgroup';
|
||||||
import { StartupQueue } from './StartupQueue';
|
import { StartupQueue } from './StartupQueue';
|
||||||
import { postLinkExperience } from './postLinkExperience';
|
import { postLinkExperience } from './postLinkExperience';
|
||||||
|
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
GoogleChrome,
|
GoogleChrome,
|
||||||
|
@ -62,6 +63,8 @@ export {
|
||||||
postLinkExperience,
|
postLinkExperience,
|
||||||
queueUpdateMessage,
|
queueUpdateMessage,
|
||||||
saveNewMessageBatcher,
|
saveNewMessageBatcher,
|
||||||
|
sendContentMessageToGroup,
|
||||||
|
sendToGroup,
|
||||||
setBatchingStrategy,
|
setBatchingStrategy,
|
||||||
sessionRecordToProtobuf,
|
sessionRecordToProtobuf,
|
||||||
sessionStructureToArrayBuffer,
|
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"
|
react-lifecycles-compat "^3.0.4"
|
||||||
warning "^3.0.0"
|
warning "^3.0.0"
|
||||||
|
|
||||||
"@signalapp/signal-client@0.5.2":
|
"@signalapp/signal-client@0.6.0":
|
||||||
version "0.5.2"
|
version "0.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.5.2.tgz#c618fff993e4becbaba36ac77ab818d073259ac5"
|
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a"
|
||||||
integrity sha512-gfNCKb1z38oKok+JhwX18ed99DRPXyYWOTUveINNPsSwMrvSbTDwL3yM/oYLipj7GhXO68MR9ojg72df3N2nNg==
|
integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg==
|
||||||
dependencies:
|
dependencies:
|
||||||
node-gyp-build "^4.2.3"
|
node-gyp-build "^4.2.3"
|
||||||
uuid "^8.3.0"
|
uuid "^8.3.0"
|
||||||
|
@ -18980,7 +18980,7 @@ zip-stream@^1.2.0:
|
||||||
ref-array-napi "1.2.1"
|
ref-array-napi "1.2.1"
|
||||||
ref-napi "3.0.2"
|
ref-napi "3.0.2"
|
||||||
|
|
||||||
zod@1.11.13:
|
zod@3.0.2:
|
||||||
version "1.11.13"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-1.11.13.tgz#6acb1e52b670afeb816ce2e2ddf6ab359f9ea506"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.0.2.tgz#0d8f0adbc7569e1a3c67b2cc788f81a55dc8a403"
|
||||||
integrity sha512-10+KA7eWa8g1hbKIXkOnhjJ4RKEwX85ECz3VJzP+pWkJOFKn76bHy1kG0d1JHBwmdElLcCsaB0O9HqIfT1vZnw==
|
integrity sha512-a+9VrxBi5CWBFq2LO5aNgbAaIRzPpBLbH4qGjSFeKd/ClLAXZq1dNFLTe9N1VDUBKxqXgHVkMlyp5MtSJylJww==
|
||||||
|
|
Loading…
Add table
Reference in a new issue