Send support for Sender Key

This commit is contained in:
Scott Nonnenberg 2021-05-25 15:40:04 -07:00 committed by GitHub
parent d8417e562b
commit e6f1ec2b6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2290 additions and 911 deletions

View File

@ -68,7 +68,7 @@
"fs-xattr": "0.3.0"
},
"dependencies": {
"@signalapp/signal-client": "0.5.2",
"@signalapp/signal-client": "0.6.0",
"@sindresorhus/is": "0.8.0",
"@types/pino": "6.3.6",
"@types/pino-multi-stream": "5.1.0",
@ -163,7 +163,7 @@
"uuid": "3.3.2",
"websocket": "1.0.28",
"zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#7ecf70be85e5a485ec870c1723b1c6247b9d549e",
"zod": "1.11.13"
"zod": "3.0.2"
},
"devDependencies": {
"@babel/core": "7.7.7",

View File

@ -12,7 +12,6 @@ message Envelope {
PREKEY_BUNDLE = 3;
RECEIPT = 5;
UNIDENTIFIED_SENDER = 6;
SENDERKEY = 7;
}
optional Type type = 1;

View File

@ -67,6 +67,15 @@ export class Sessions extends SessionStore {
return record || null;
}
async getExistingSessions(
addresses: Array<ProtocolAddress>
): Promise<Array<SessionRecord>> {
const encodedAddresses = addresses.map(encodedNameFromAddress);
return window.textsecure.storage.protocol.loadSessions(encodedAddresses, {
zone: this.zone,
});
}
}
export type IdentityKeysOptions = {

View File

@ -6,7 +6,7 @@
import PQueue from 'p-queue';
import { isNumber } from 'lodash';
import * as z from 'zod';
import { z } from 'zod';
import {
Direction,
@ -32,6 +32,7 @@ import {
sessionStructureToArrayBuffer,
} from './util/sessionTranslation';
import {
DeviceType,
KeyPairType,
IdentityKeyType,
SenderKeyType,
@ -545,7 +546,7 @@ export class SignalProtocolStore extends EventsMixin {
}
if (entry.hydrated) {
window.log.info('Successfully fetched signed prekey (cache hit):', id);
window.log.info('Successfully fetched sender key (cache hit):', id);
return entry.item;
}
@ -555,17 +556,40 @@ export class SignalProtocolStore extends EventsMixin {
item,
fromDB: entry.fromDB,
});
window.log.info('Successfully fetched signed prekey (cache miss):', id);
window.log.info('Successfully fetched sender key(cache miss):', id);
return item;
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`getSenderKey: failed to load senderKey ${encodedAddress}/${distributionId}: ${errorString}`
`getSenderKey: failed to load sender key ${encodedAddress}/${distributionId}: ${errorString}`
);
return undefined;
}
}
async removeSenderKey(
encodedAddress: string,
distributionId: string
): Promise<void> {
if (!this.senderKeys) {
throw new Error('getSenderKey: this.senderKeys not yet cached!');
}
try {
const senderId = await normalizeEncodedAddress(encodedAddress);
const id = this.getSenderKeyId(senderId, distributionId);
await window.Signal.Data.removeSenderKeyById(id);
this.senderKeys.delete(id);
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`removeSenderKey: failed to remove senderKey ${encodedAddress}/${distributionId}: ${errorString}`
);
}
}
// Session Queue
async enqueueSessionJob<T>(
@ -792,6 +816,21 @@ export class SignalProtocolStore extends EventsMixin {
});
}
async loadSessions(
encodedAddresses: Array<string>,
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<Array<SessionRecord>> {
return this.withZone(zone, 'loadSession', async () => {
const sessions = await Promise.all(
encodedAddresses.map(async address =>
this.loadSession(address, { zone })
)
);
return sessions.filter(isNotNil);
});
}
private async _maybeMigrateSession(
session: SessionType
): Promise<SessionRecord> {
@ -882,33 +921,51 @@ export class SignalProtocolStore extends EventsMixin {
});
}
async getDeviceIds(identifier: string): Promise<Array<number>> {
return this.withZone(GLOBAL_ZONE, 'getDeviceIds', async () => {
async getOpenDevices(
identifiers: Array<string>
): Promise<{
devices: Array<DeviceType>;
emptyIdentifiers: Array<string>;
}> {
return this.withZone(GLOBAL_ZONE, 'getOpenDevices', async () => {
if (!this.sessions) {
throw new Error('getDeviceIds: this.sessions not yet cached!');
throw new Error('getOpenDevices: this.sessions not yet cached!');
}
if (identifier === null || identifier === undefined) {
throw new Error('getDeviceIds: identifier was undefined/null');
if (identifiers.length === 0) {
throw new Error('getOpenDevices: No identifiers provided!');
}
try {
const id = window.ConversationController.getConversationId(identifier);
if (!id) {
throw new Error(
`getDeviceIds: No conversationId found for identifier ${identifier}`
const conversationIds = new Map<string, string>();
identifiers.forEach(identifier => {
if (identifier === null || identifier === undefined) {
throw new Error('getOpenDevices: identifier was undefined/null');
}
const conversation = window.ConversationController.getOrCreate(
identifier,
'private'
);
}
if (!conversation) {
throw new Error(
`getOpenDevices: No conversationId found for identifier ${identifier}`
);
}
conversationIds.set(conversation.get('id'), identifier);
});
const allSessions = this._getAllSessions();
const entries = allSessions.filter(
session => session.fromDB.conversationId === id
const entries = allSessions.filter(session =>
conversationIds.has(session.fromDB.conversationId)
);
const openIds = await Promise.all(
const openEntries: Array<
SessionCacheEntry | undefined
> = await Promise.all(
entries.map(async entry => {
if (entry.hydrated) {
const record = entry.item;
if (record.hasCurrentState()) {
return entry.fromDB.deviceId;
return entry;
}
return undefined;
@ -916,25 +973,67 @@ export class SignalProtocolStore extends EventsMixin {
const record = await this._maybeMigrateSession(entry.fromDB);
if (record.hasCurrentState()) {
return entry.fromDB.deviceId;
return entry;
}
return undefined;
})
);
return openIds.filter(isNotNil);
const devices = openEntries
.map(entry => {
if (!entry) {
return undefined;
}
const { conversationId } = entry.fromDB;
conversationIds.delete(conversationId);
const id = entry.fromDB.deviceId;
const conversation = window.ConversationController.get(
conversationId
);
if (!conversation) {
throw new Error(
`getOpenDevices: Unable to find matching conversation for ${conversationId}`
);
}
const identifier =
conversation.get('uuid') || conversation.get('e164');
if (!identifier) {
throw new Error(
`getOpenDevices: No identifier for conversation ${conversationId}`
);
}
return {
identifier,
id,
};
})
.filter(isNotNil);
const emptyIdentifiers = Array.from(conversationIds.values());
return {
devices,
emptyIdentifiers,
};
} catch (error) {
window.log.error(
`getDeviceIds: Failed to get device ids for identifier ${identifier}`,
'getOpenDevices: Failed to get devices',
error && error.stack ? error.stack : error
);
throw error;
}
return [];
});
}
async getDeviceIds(identifier: string): Promise<Array<number>> {
const { devices } = await this.getOpenDevices([identifier]);
return devices.map((device: DeviceType) => device.id);
}
async removeSession(encodedAddress: string): Promise<void> {
return this.withZone(GLOBAL_ZONE, 'removeSession', async () => {
if (!this.sessions) {

View File

@ -2060,6 +2060,7 @@ export async function startApp(): Promise<void> {
await server.registerCapabilities({
'gv2-3': true,
'gv1-migration': true,
senderKey: false,
});
} catch (error) {
window.log.error(

View File

@ -1261,7 +1261,7 @@ export async function modifyGroupV2({
const timestamp = Date.now();
const promise = conversation.wrapSend(
window.textsecure.messaging.sendMessageToGroup(
window.Signal.Util.sendToGroup(
{
groupV2: conversation.getGroupV2Info({
groupChange: groupChangeBuffer,
@ -1271,6 +1271,7 @@ export async function modifyGroupV2({
timestamp,
profileKey,
},
conversation,
sendOptions
)
);
@ -1631,13 +1632,16 @@ export async function createGroupV2({
await wrapWithSyncMessageSend({
conversation,
logId: `sendMessageToGroup/${logId}`,
send: async sender =>
sender.sendMessageToGroup({
groupV2: groupV2Info,
timestamp,
profileKey,
}),
logId: `sendToGroup/${logId}`,
send: async () =>
window.Signal.Util.sendToGroup(
{
groupV2: groupV2Info,
timestamp,
profileKey,
},
conversation
),
timestamp,
});
@ -2143,16 +2147,19 @@ export async function initiateMigrationToGroupV2(
await wrapWithSyncMessageSend({
conversation,
logId: `sendMessageToGroup/${logId}`,
send: async sender =>
logId: `sendToGroup/${logId}`,
send: async () =>
// Minimal message to notify group members about migration
sender.sendMessageToGroup({
groupV2: conversation.getGroupV2Info({
includePendingMembers: true,
}),
timestamp,
profileKey: ourProfileKey,
}),
window.Signal.Util.sendToGroup(
{
groupV2: conversation.getGroupV2Info({
includePendingMembers: true,
}),
timestamp,
profileKey: ourProfileKey,
},
conversation
),
timestamp,
});
}

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod';
import { z } from 'zod';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';

View File

@ -1,7 +1,7 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod';
import { z } from 'zod';
import FormData from 'form-data';
import { gzip } from 'zlib';
import pify from 'pify';

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod';
import { z } from 'zod';
import * as pino from 'pino';
import { redactAll } from '../../js/modules/privacy';
import { missingCaseError } from '../util/missingCaseError';
@ -27,7 +27,8 @@ const logEntrySchema = z.object({
});
export type LogEntryType = z.infer<typeof logEntrySchema>;
export const isLogEntry = logEntrySchema.check.bind(logEntrySchema);
export const isLogEntry = (data: unknown): data is LogEntryType =>
logEntrySchema.safeParse(data).success;
export function getLogLevelString(value: LogLevel): pino.Level {
switch (value) {

6
ts/model-types.d.ts vendored
View File

@ -12,6 +12,7 @@ import {
MessageType,
LastMessageStatus,
} from './state/ducks/conversations';
import { DeviceType } from './textsecure/Types';
import { SendOptionsType } from './textsecure/SendMessage';
import { SendMessageChallengeData } from './textsecure/Errors';
import {
@ -264,6 +265,11 @@ export type ConversationAttributesType = {
secretParams?: string;
publicParams?: string;
revision?: number;
senderKeyInfo?: {
createdAtDate: number;
distributionId: string;
memberDevices: Array<DeviceType>;
};
// GroupV2 other fields
accessControl?: {

View File

@ -1177,27 +1177,55 @@ export class ConversationModel extends window.Backbone
return;
}
const recipientId = this.isPrivate() ? this.getSendTarget() : undefined;
const groupId = this.getGroupIdBuffer();
const groupMembers = this.getRecipients();
await this.queueJob(async () => {
const recipientId = this.isPrivate() ? this.getSendTarget() : undefined;
const groupId = this.getGroupIdBuffer();
const groupMembers = this.getRecipients();
// We don't send typing messages if our recipients list is empty
if (!this.isPrivate() && !groupMembers.length) {
return;
}
// We don't send typing messages if our recipients list is empty
if (!this.isPrivate() && !groupMembers.length) {
return;
}
const sendOptions = await this.getSendOptions();
this.wrapSend(
window.textsecure.messaging.sendTypingMessage(
const timestamp = Date.now();
const contentMessage = window.textsecure.messaging.getTypingContentMessage(
{
isTyping,
recipientId,
groupId,
groupMembers,
},
sendOptions
)
);
isTyping,
timestamp,
}
);
const sendOptions = await this.getSendOptions();
if (this.isPrivate()) {
const silent = true;
this.wrapSend(
window.textsecure.messaging.sendMessageProtoAndWait(
timestamp,
groupMembers,
contentMessage,
silent,
{
...sendOptions,
online: true,
}
)
);
} else {
this.wrapSend(
window.Signal.Util.sendContentMessageToGroup({
contentMessage,
conversation: this,
online: true,
recipients: groupMembers,
sendOptions,
timestamp,
})
);
}
});
}
async cleanup(): Promise<void> {
@ -3099,7 +3127,7 @@ export class ConversationModel extends window.Backbone
);
}
return window.textsecure.messaging.sendMessageToGroup(
return window.Signal.Util.sendToGroup(
{
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
@ -3107,6 +3135,7 @@ export class ConversationModel extends window.Backbone
timestamp,
profileKey,
},
this,
options
);
})();
@ -3208,19 +3237,19 @@ export class ConversationModel extends window.Backbone
// Special-case the self-send case - we send only a sync message
if (this.isMe()) {
const dataMessage = await window.textsecure.messaging.getMessageProto(
destination,
undefined, // body
[], // attachments
undefined, // quote
[], // preview
undefined, // sticker
outgoingReaction,
undefined, // deletedForEveryoneTimestamp
timestamp,
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: [],
// body
// deletedForEveryoneTimestamp
expireTimer,
profileKey
);
preview: [],
profileKey,
// quote
reaction: outgoingReaction,
recipients: [destination],
// sticker
timestamp,
});
const result = await message.sendSyncMessageOnly(dataMessage);
window.Whisper.Reactions.onReaction(reactionModel);
return result;
@ -3246,7 +3275,7 @@ export class ConversationModel extends window.Backbone
);
}
return window.textsecure.messaging.sendMessageToGroup(
return window.Signal.Util.sendToGroup(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
groupV1: this.getGroupV1Info()!,
@ -3257,6 +3286,7 @@ export class ConversationModel extends window.Backbone
expireTimer,
profileKey,
},
this,
options
);
})();
@ -3446,19 +3476,19 @@ export class ConversationModel extends window.Backbone
// Special-case the self-send case - we send only a sync message
if (this.isMe()) {
const dataMessage = await window.textsecure.messaging.getMessageProto(
destination,
messageBody,
finalAttachments,
quote,
preview,
sticker,
null, // reaction
undefined, // deletedForEveryoneTimestamp
now,
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: finalAttachments,
body: messageBody,
// deletedForEveryoneTimestamp
expireTimer,
profileKey
);
preview,
profileKey,
quote,
// reaction
recipients: [destination],
sticker,
timestamp: now,
});
return message.sendSyncMessageOnly(dataMessage);
}
@ -3467,7 +3497,7 @@ export class ConversationModel extends window.Backbone
let promise;
if (conversationType === Message.GROUP) {
promise = window.textsecure.messaging.sendMessageToGroup(
promise = window.Signal.Util.sendToGroup(
{
attachments: finalAttachments,
expireTimer,
@ -3481,6 +3511,7 @@ export class ConversationModel extends window.Backbone
timestamp: now,
mentions,
},
this,
options
);
} else {
@ -3904,21 +3935,21 @@ export class ConversationModel extends window.Backbone
if (this.isMe()) {
const flags =
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
const dataMessage = await window.textsecure.messaging.getMessageProto(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.getSendTarget()!,
undefined, // body
[], // attachments
undefined, // quote
[], // preview
undefined, // sticker
undefined, // reaction
undefined, // deletedForEveryoneTimestamp
message.get('sent_at'),
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: [],
// body
// deletedForEveryoneTimestamp
expireTimer,
flags,
preview: [],
profileKey,
flags
);
// quote
// reaction
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
recipients: [this.getSendTarget()!],
// sticker
timestamp: message.get('sent_at'),
});
return message.sendSyncMessageOnly(dataMessage);
}

View File

@ -2190,22 +2190,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
recipients.length === 1 &&
(recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID)
) {
const [identifier] = recipients;
const dataMessage = await window.textsecure.messaging.getMessageProto(
identifier,
body,
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
// flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
undefined, // flags
this.get('bodyRanges')
);
quote: quoteWithData,
reaction: null,
recipients,
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}
@ -2229,15 +2228,32 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
options
);
} else {
// Because this is a partial group send, we manually construct the request like
// sendMessageToGroup does.
const initialGroupV2 = conversation.getGroupV2Info();
const groupId = conversation.get('groupId');
if (!groupId) {
throw new Error("retrySend: Conversation didn't have groupId");
}
const groupV2 = conversation.getGroupV2Info();
const groupV2 = initialGroupV2
? {
...initialGroupV2,
members: recipients,
}
: undefined;
const groupV1 = groupV2
? undefined
: {
id: groupId,
members: recipients,
};
promise = window.textsecure.messaging.sendMessage(
// Important to ensure that we don't consider this receipient list to be the entire
// member list.
const partialSend = true;
promise = window.Signal.Util.sendToGroup(
{
recipients,
body,
messageText: body,
timestamp: this.get('sent_at'),
attachments,
quote: quoteWithData,
@ -2247,15 +2263,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
mentions: this.get('bodyRanges'),
profileKey,
groupV2,
group: groupV2
? undefined
: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: this.getConversation()!.get('groupId')!,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
},
groupV1,
},
options
conversation,
options,
partialSend
);
}
@ -2409,21 +2421,21 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Special-case the self-send case - we send only a sync message
if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) {
const dataMessage = await window.textsecure.messaging.getMessageProto(
identifier,
body,
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
quoteWithData,
previewWithData,
stickerWithData,
null,
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
// flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
undefined, // flags
this.get('bodyRanges')
);
quote: quoteWithData,
reaction: null,
recipients: [identifier],
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}

View File

@ -768,12 +768,19 @@ export class CallingClass {
// We "fire and forget" because sending this message is non-essential.
wrapWithSyncMessageSend({
conversation,
logId: `sendGroupCallUpdateMessage/${conversationId}-${eraId}`,
send: sender =>
sender.sendGroupCallUpdate({ eraId, groupV2, timestamp }, sendOptions),
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
send: () =>
window.Signal.Util.sendToGroup(
{ groupCallUpdate: { eraId }, groupV2, timestamp },
conversation,
sendOptions
),
timestamp,
}).catch(err => {
window.log.error('Failed to send group call update', err);
window.log.error(
'Failed to send group call update:',
err && err.stack ? err.stack : err
);
});
}

View File

@ -20,6 +20,10 @@ type Storage = {
remove(key: string): Promise<void>;
};
function isWellFormed(data: unknown): data is SerializedCertificateType {
return serializedCertificateSchema.safeParse(data).success;
}
// In case your clock is different from the server's, we "fake" expire certificates early.
const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000;
@ -88,10 +92,14 @@ export class SenderCertificateService {
);
const valueInStorage = storage.get(modeToStorageKey(mode));
return serializedCertificateSchema.check(valueInStorage) &&
if (
isWellFormed(valueInStorage) &&
isExpirationValid(valueInStorage.expires)
? valueInStorage
: undefined;
) {
return valueInStorage;
}
return undefined;
}
private fetchCertificate(

View File

@ -139,6 +139,7 @@ const dataInterface: ClientInterface = {
getSenderKeyById,
removeAllSenderKeys,
getAllSenderKeys,
removeSenderKeyById,
createOrUpdateSession,
createOrUpdateSessions,
@ -759,6 +760,9 @@ async function removeAllSenderKeys(): Promise<void> {
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
return channels.getAllSenderKeys();
}
async function removeSenderKeyById(id: string): Promise<void> {
return channels.removeSenderKeyById(id);
}
// Sessions

View File

@ -185,6 +185,7 @@ export type DataInterface = {
getSenderKeyById: (id: string) => Promise<SenderKeyType | undefined>;
removeAllSenderKeys: () => Promise<void>;
getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
removeSenderKeyById: (id: string) => Promise<void>;
createOrUpdateSession: (data: SessionType) => Promise<void>;
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;

View File

@ -130,6 +130,7 @@ const dataInterface: ServerInterface = {
getSenderKeyById,
removeAllSenderKeys,
getAllSenderKeys,
removeSenderKeyById,
createOrUpdateSession,
createOrUpdateSessions,
@ -2215,6 +2216,10 @@ async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
return rows;
}
async function removeSenderKeyById(id: string): Promise<void> {
const db = getInstance();
prepare(db, 'DELETE FROM senderKeys WHERE id = $id').run({ id });
}
const SESSIONS_TABLE = 'sessions';
function createOrUpdateSessionSync(data: SessionType): void {
@ -4857,9 +4862,11 @@ async function removeAll(): Promise<void> {
// Anything that isn't user-visible data
async function removeAllConfiguration(): Promise<void> {
const db = getInstance();
const patch: Partial<ConversationType> = { senderKeyInfo: undefined };
db.transaction(() => {
db.exec(`
db.prepare(
`
DELETE FROM identityKeys;
DELETE FROM items;
DELETE FROM preKeys;
@ -4868,7 +4875,11 @@ async function removeAllConfiguration(): Promise<void> {
DELETE FROM signedPreKeys;
DELETE FROM unprocessed;
DELETE FROM jobs;
`);
UPDATE conversations SET json = json_patch(json, $patch);
`
).run({
$patch: patch,
});
})();
}

View File

@ -175,6 +175,14 @@ describe('SignalProtocolStore', () => {
assert.isTrue(
constantTimeEqual(expected.serialize(), actual.serialize())
);
await store.removeSenderKey(encodedAddress, distributionId);
const postDeleteGet = await store.getSenderKey(
encodedAddress,
distributionId
);
assert.isUndefined(postDeleteGet);
});
it('roundtrips through database', async () => {
@ -197,6 +205,17 @@ describe('SignalProtocolStore', () => {
assert.isTrue(
constantTimeEqual(expected.serialize(), actual.serialize())
);
await store.removeSenderKey(encodedAddress, distributionId);
// Re-fetch from the database to ensure we get the latest database value
await store.hydrateCaches();
const postDeleteGet = await store.getSenderKey(
encodedAddress,
distributionId
);
assert.isUndefined(postDeleteGet);
});
});
@ -1280,6 +1299,54 @@ describe('SignalProtocolStore', () => {
});
});
describe('getOpenDevices', () => {
it('returns all open devices for a number', async () => {
const openRecord = getSessionRecord(true);
const openDevices = [1, 2, 3, 10].map(deviceId => {
return [number, deviceId].join('.');
});
await Promise.all(
openDevices.map(async encodedNumber => {
await store.storeSession(encodedNumber, openRecord);
})
);
const closedRecord = getSessionRecord(false);
await store.storeSession([number, 11].join('.'), closedRecord);
const result = await store.getOpenDevices([number, 'blah', 'blah2']);
assert.deepEqual(result, {
devices: [
{
id: 1,
identifier: number,
},
{
id: 2,
identifier: number,
},
{
id: 3,
identifier: number,
},
{
id: 10,
identifier: number,
},
],
emptyIdentifiers: ['blah', 'blah2'],
});
});
it('returns empty array for a number with no device ids', async () => {
const result = await store.getOpenDevices(['foo']);
assert.deepEqual(result, {
devices: [],
emptyIdentifiers: ['foo'],
});
});
});
describe('zones', () => {
const zone = new Zone('zone', {
pendingSessions: true,

View 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]);
});
});
});

View File

@ -4,7 +4,7 @@
import { assert } from 'chai';
import * as sinon from 'sinon';
import EventEmitter, { once } from 'events';
import * as z from 'zod';
import { z } from 'zod';
import { identity, noop, groupBy } from 'lodash';
import { v4 as uuid } from 'uuid';
import { JobError } from '../../jobs/JobError';

1
ts/textsecure.d.ts vendored
View File

@ -730,7 +730,6 @@ export declare namespace EnvelopeClass {
static PREKEY_BUNDLE: number;
static RECEIPT: number;
static UNIDENTIFIED_SENDER: number;
static SENDERKEY: number;
}
}

View File

@ -10,19 +10,16 @@
import { reject } from 'lodash';
import * as z from 'zod';
import { z } from 'zod';
import {
CiphertextMessageType,
PreKeyBundle,
processPreKeyBundle,
ProtocolAddress,
PublicKey,
sealedSenderEncryptMessage,
SenderCertificate,
signalEncrypt,
} from '@signalapp/signal-client';
import { ServerKeysType, WebAPIType } from './WebAPI';
import { WebAPIType } from './WebAPI';
import { ContentClass, DataMessageClass } from '../textsecure.d';
import {
CallbackResultType,
@ -40,6 +37,7 @@ import {
import { isValidNumber } from '../types/PhoneNumber';
import { Sessions, IdentityKeys } from '../LibSignalStores';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { getKeysForIdentifier } from './getKeysForIdentifier';
export const enum SenderCertificateMode {
WithE164,
@ -80,6 +78,27 @@ function ciphertextMessageTypeToEnvelopeType(type: number) {
);
}
function getPaddedMessageLength(messageLength: number): number {
const messageLengthWithTerminator = messageLength + 1;
let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
if (messageLengthWithTerminator % 160 !== 0) {
messagePartCount += 1;
}
return messagePartCount * 160;
}
export function padMessage(messageBuffer: ArrayBuffer): Uint8Array {
const plaintext = new Uint8Array(
getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
);
plaintext.set(new Uint8Array(messageBuffer));
plaintext[messageBuffer.byteLength] = 0x80;
return plaintext;
}
export default class OutgoingMessage {
server: WebAPIType;
@ -187,95 +206,26 @@ export default class OutgoingMessage {
identifier: string,
recurse?: boolean
): () => Promise<void> {
return async () =>
window.textsecure.storage.protocol
.getDeviceIds(identifier)
.then(async deviceIds => {
if (deviceIds.length === 0) {
this.registerError(
identifier,
'reloadDevicesAndSend: Got empty device list when loading device keys',
undefined
);
return undefined;
}
return this.doSendMessage(identifier, deviceIds, recurse);
});
return async () => {
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
identifier
);
if (deviceIds.length === 0) {
this.registerError(
identifier,
'reloadDevicesAndSend: Got empty device list when loading device keys',
undefined
);
return undefined;
}
return this.doSendMessage(identifier, deviceIds, recurse);
};
}
async getKeysForIdentifier(
identifier: string,
updateDevices: Array<number> | undefined
updateDevices?: Array<number>
): Promise<void | Array<void | null>> {
const handleResult = async (response: ServerKeysType) => {
const sessionStore = new Sessions();
const identityKeyStore = new IdentityKeys();
return Promise.all(
response.devices.map(async device => {
const { deviceId, registrationId, preKey, signedPreKey } = device;
if (
updateDevices === undefined ||
updateDevices.indexOf(deviceId) > -1
) {
if (device.registrationId === 0) {
window.log.info('device registrationId 0!');
}
if (!signedPreKey) {
throw new Error(
`getKeysForIdentifier/${identifier}: Missing signed prekey for deviceId ${deviceId}`
);
}
const protocolAddress = ProtocolAddress.new(identifier, deviceId);
const preKeyId = preKey?.keyId || null;
const preKeyObject = preKey
? PublicKey.deserialize(Buffer.from(preKey.publicKey))
: null;
const signedPreKeyObject = PublicKey.deserialize(
Buffer.from(signedPreKey.publicKey)
);
const identityKey = PublicKey.deserialize(
Buffer.from(response.identityKey)
);
const preKeyBundle = PreKeyBundle.new(
registrationId,
deviceId,
preKeyId,
preKeyObject,
signedPreKey.keyId,
signedPreKeyObject,
Buffer.from(signedPreKey.signature),
identityKey
);
const address = `${identifier}.${deviceId}`;
await window.textsecure.storage.protocol
.enqueueSessionJob(address, () =>
processPreKeyBundle(
preKeyBundle,
protocolAddress,
sessionStore,
identityKeyStore
)
)
.catch(error => {
if (
error?.message?.includes('untrusted identity for address')
) {
error.timestamp = this.timestamp;
error.originalMessage = this.message.toArrayBuffer();
error.identityKey = response.identityKey;
}
throw error;
});
}
return null;
})
);
};
const { sendMetadata } = this;
const info =
sendMetadata && sendMetadata[identifier]
@ -283,65 +233,23 @@ export default class OutgoingMessage {
: { accessKey: undefined };
const { accessKey } = info;
if (updateDevices === undefined) {
if (accessKey) {
return this.server
.getKeysForIdentifierUnauth(identifier, undefined, { accessKey })
.catch(async (error: Error) => {
if (error.code === 401 || error.code === 403) {
if (this.failoverIdentifiers.indexOf(identifier) === -1) {
this.failoverIdentifiers.push(identifier);
}
return this.server.getKeysForIdentifier(identifier);
}
throw error;
})
.then(handleResult);
try {
const { accessKeyFailed } = await getKeysForIdentifier(
identifier,
this.server,
updateDevices,
accessKey
);
if (accessKeyFailed && !this.failoverIdentifiers.includes(identifier)) {
this.failoverIdentifiers.push(identifier);
}
return this.server.getKeysForIdentifier(identifier).then(handleResult);
} catch (error) {
if (error?.message?.includes('untrusted identity for address')) {
error.timestamp = this.timestamp;
error.originalMessage = this.message.toArrayBuffer();
}
throw error;
}
let promise: Promise<void | Array<void | null>> = Promise.resolve();
updateDevices.forEach(deviceId => {
promise = promise.then(async () => {
let innerPromise;
if (accessKey) {
innerPromise = this.server
.getKeysForIdentifierUnauth(identifier, deviceId, { accessKey })
.then(handleResult)
.catch(async error => {
if (error.code === 401 || error.code === 403) {
if (this.failoverIdentifiers.indexOf(identifier) === -1) {
this.failoverIdentifiers.push(identifier);
}
return this.server
.getKeysForIdentifier(identifier, deviceId)
.then(handleResult);
}
throw error;
});
} else {
innerPromise = this.server
.getKeysForIdentifier(identifier, deviceId)
.then(handleResult);
}
return innerPromise.catch(async e => {
if (e.name === 'HTTPError' && e.code === 404) {
if (deviceId !== 1) {
return this.removeDeviceIdsForIdentifier(identifier, [deviceId]);
}
throw new UnregisteredUserError(identifier, e);
} else {
throw e;
}
});
});
});
return promise;
}
async transmitMessage(
@ -389,25 +297,9 @@ export default class OutgoingMessage {
});
}
getPaddedMessageLength(messageLength: number): number {
const messageLengthWithTerminator = messageLength + 1;
let messagePartCount = Math.floor(messageLengthWithTerminator / 160);
if (messageLengthWithTerminator % 160 !== 0) {
messagePartCount += 1;
}
return messagePartCount * 160;
}
getPlaintext(): ArrayBuffer {
if (!this.plaintext) {
const messageBuffer = this.message.toArrayBuffer();
this.plaintext = new Uint8Array(
this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
);
this.plaintext.set(new Uint8Array(messageBuffer));
this.plaintext[messageBuffer.byteLength] = 0x80;
this.plaintext = padMessage(this.message.toArrayBuffer());
}
return this.plaintext;
}
@ -629,34 +521,6 @@ export default class OutgoingMessage {
});
}
async getStaleDeviceIdsForIdentifier(
identifier: string
): Promise<Array<number> | undefined> {
const sessionStore = new Sessions();
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
identifier
);
if (deviceIds.length === 0) {
return undefined;
}
const updateDevices: Array<number> = [];
await Promise.all(
deviceIds.map(async deviceId => {
const record = await sessionStore.getSession(
ProtocolAddress.new(identifier, deviceId)
);
if (!record || !record.hasCurrentState()) {
updateDevices.push(deviceId);
}
})
);
return updateDevices;
}
async removeDeviceIdsForIdentifier(
identifier: string,
deviceIdsToRemove: Array<number>
@ -713,10 +577,12 @@ export default class OutgoingMessage {
);
}
const updateDevices = await this.getStaleDeviceIdsForIdentifier(
const deviceIds = await window.textsecure.storage.protocol.getDeviceIds(
identifier
);
await this.getKeysForIdentifier(identifier, updateDevices);
if (deviceIds.length === 0) {
await this.getKeysForIdentifier(identifier);
}
await this.reloadDevicesAndSend(identifier, true)();
} catch (error) {
if (error?.message?.includes('untrusted identity for address')) {

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,11 @@ export {
UnprocessedUpdateType,
} from '../sql/Interface';
export type DeviceType = {
id: number;
identifier: string;
};
// How the legacy APIs generate these types
export type CompatSignedPreKeyType = {

View File

@ -26,6 +26,7 @@ import { pki } from 'node-forge';
import is from '@sindresorhus/is';
import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid';
import { z } from 'zod';
import { Long } from '../window.d';
import { getUserAgent } from '../util/getUserAgent';
@ -351,6 +352,49 @@ type ArrayBufferWithDetailsType = {
response: Response;
};
export const multiRecipient200ResponseSchema = z
.object({
uuids404: z.array(z.string()).optional(),
needsSync: z.boolean().optional(),
})
.passthrough();
export type MultiRecipient200ResponseType = z.infer<
typeof multiRecipient200ResponseSchema
>;
export const multiRecipient409ResponseSchema = z.array(
z
.object({
uuid: z.string(),
devices: z
.object({
missingDevices: z.array(z.number()).optional(),
extraDevices: z.array(z.number()).optional(),
})
.passthrough(),
})
.passthrough()
);
export type MultiRecipient409ResponseType = z.infer<
typeof multiRecipient409ResponseSchema
>;
export const multiRecipient410ResponseSchema = z.array(
z
.object({
uuid: z.string(),
devices: z
.object({
staleDevices: z.array(z.number()).optional(),
})
.passthrough(),
})
.passthrough()
);
export type MultiRecipient410ResponseType = z.infer<
typeof multiRecipient410ResponseSchema
>;
function isSuccess(status: number): boolean {
return status >= 0 && status < 400;
}
@ -685,6 +729,7 @@ const URL_CALLS = {
groupToken: 'v1/groups/token',
keys: 'v2/keys',
messages: 'v1/messages',
multiRecipient: 'v1/messages/multi_recipient',
profile: 'v1/profile',
registerCapabilities: 'v1/devices/capabilities',
removeSignalingKey: 'v1/accounts/signaling_key',
@ -728,6 +773,7 @@ type AjaxOptionsType = {
call: keyof typeof URL_CALLS;
contentType?: string;
data?: ArrayBuffer | Buffer | string;
headers?: HeaderListType;
host?: string;
httpType: HTTPCodeType;
jsonData?: any;
@ -749,10 +795,12 @@ export type WebAPIConnectType = {
export type CapabilitiesType = {
gv2: boolean;
'gv1-migration': boolean;
senderKey: boolean;
};
export type CapabilitiesUploadType = {
'gv2-3': boolean;
'gv1-migration': boolean;
senderKey: boolean;
};
type StickerPackManifestType = any;
@ -895,6 +943,12 @@ export type WebAPIType = {
online?: boolean,
options?: { accessKey?: string }
) => Promise<void>;
sendWithSenderKey: (
payload: ArrayBuffer,
accessKeys: ArrayBuffer,
timestamp: number,
online?: boolean
) => Promise<MultiRecipient200ResponseType>;
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
uploadGroupAvatar: (
@ -1065,6 +1119,7 @@ export function initialize({
requestVerificationVoice,
sendMessages,
sendMessagesUnauth,
sendWithSenderKey,
setSignedPreKey,
updateDeviceName,
uploadGroupAvatar,
@ -1082,6 +1137,7 @@ export function initialize({
certificateAuthority,
contentType: param.contentType || 'application/json; charset=utf-8',
data: param.data || (param.jsonData && _jsonThing(param.jsonData)),
headers: param.headers,
host: param.host || url,
password: param.password || password,
path: URL_CALLS[param.call] + param.urlParameters,
@ -1375,6 +1431,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = {
'gv2-3': true,
'gv1-migration': true,
senderKey: false,
};
const { accessKey } = options;
@ -1661,6 +1718,25 @@ export function initialize({
});
}
async function sendWithSenderKey(
data: ArrayBuffer,
accessKeys: ArrayBuffer,
timestamp: number,
online?: boolean
): Promise<MultiRecipient200ResponseType> {
return _ajax({
call: 'multiRecipient',
httpType: 'PUT',
contentType: 'application/vnd.signal-messenger.mrm',
data,
urlParameters: `?ts=${timestamp}&online=${online ? 'true' : 'false'}`,
responseType: 'json',
headers: {
'Unidentified-Access-Key': arrayBufferToBase64(accessKeys),
},
});
}
function redactStickerUrl(stickerUrl: string) {
return stickerUrl.replace(
/(\/stickers\/)([^/]+)(\/)/,

View 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;
});
})
);
}

View File

@ -3,7 +3,7 @@
import { CallbackResultType } from '../textsecure/SendMessage';
const SEALED_SENDER = {
export const SEALED_SENDER = {
UNKNOWN: 0,
ENABLED: 1,
DISABLED: 2,

View File

@ -35,6 +35,7 @@ import {
import * as zkgroup from './zkgroup';
import { StartupQueue } from './StartupQueue';
import { postLinkExperience } from './postLinkExperience';
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
export {
GoogleChrome,
@ -62,6 +63,8 @@ export {
postLinkExperience,
queueUpdateMessage,
saveNewMessageBatcher,
sendContentMessageToGroup,
sendToGroup,
setBatchingStrategy,
sessionRecordToProtobuf,
sessionStructureToArrayBuffer,

885
ts/util/sendToGroup.ts Normal file
View 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;
}
}

View File

@ -1467,10 +1467,10 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
"@signalapp/signal-client@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.5.2.tgz#c618fff993e4becbaba36ac77ab818d073259ac5"
integrity sha512-gfNCKb1z38oKok+JhwX18ed99DRPXyYWOTUveINNPsSwMrvSbTDwL3yM/oYLipj7GhXO68MR9ojg72df3N2nNg==
"@signalapp/signal-client@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a"
integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg==
dependencies:
node-gyp-build "^4.2.3"
uuid "^8.3.0"
@ -18980,7 +18980,7 @@ zip-stream@^1.2.0:
ref-array-napi "1.2.1"
ref-napi "3.0.2"
zod@1.11.13:
version "1.11.13"
resolved "https://registry.yarnpkg.com/zod/-/zod-1.11.13.tgz#6acb1e52b670afeb816ce2e2ddf6ab359f9ea506"
integrity sha512-10+KA7eWa8g1hbKIXkOnhjJ4RKEwX85ECz3VJzP+pWkJOFKn76bHy1kG0d1JHBwmdElLcCsaB0O9HqIfT1vZnw==
zod@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.0.2.tgz#0d8f0adbc7569e1a3c67b2cc788f81a55dc8a403"
integrity sha512-a+9VrxBi5CWBFq2LO5aNgbAaIRzPpBLbH4qGjSFeKd/ClLAXZq1dNFLTe9N1VDUBKxqXgHVkMlyp5MtSJylJww==