Fetch sender certificates on-demand

This commit is contained in:
Evan Hahn 2021-04-08 11:24:21 -05:00 committed by Josh Perez
parent 6ff55914f0
commit a82fa86176
19 changed files with 752 additions and 298 deletions

View file

@ -722,19 +722,19 @@ export class ConversationController {
return null;
}
prepareForSend(
async prepareForSend(
id: string | undefined,
options?: WhatIsThis
): {
): Promise<{
wrap: (
promise: Promise<CallbackResultType | void | null>
) => Promise<CallbackResultType | void | null>;
sendOptions: SendOptionsType | undefined;
} {
}> {
// id is any valid conversation identifier
const conversation = this.get(id);
const sendOptions = conversation
? conversation.getSendOptions(options)
? await conversation.getSendOptions(options)
: undefined;
const wrap = conversation
? conversation.wrapSend.bind(conversation)

View file

@ -7,8 +7,7 @@ import { WhatIsThis } from './window.d';
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
import { isWindowDragElement } from './util/isWindowDragElement';
import { assert } from './util/assert';
import * as refreshSenderCertificate from './refreshSenderCertificate';
import { SenderCertificateMode } from './metadata/SecretSessionCipher';
import { senderCertificateService } from './services/senderCertificate';
import { routineProfileRefresh } from './routineProfileRefresh';
import { isMoreRecentThan, isOlderThan } from './util/timestamp';
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
@ -30,6 +29,17 @@ export async function startApp(): Promise<void> {
err && err.stack ? err.stack : err
);
}
window.textsecure.protobuf.onLoad(() => {
senderCertificateService.initialize({
WebAPI: window.WebAPI,
navigator,
onlineEventTarget: window,
SenderCertificate: window.textsecure.protobuf.SenderCertificate,
storage: window.storage,
});
});
const eventHandlerQueue = new window.PQueue({
concurrency: 1,
timeout: 1000 * 60 * 2,
@ -70,7 +80,7 @@ export async function startApp(): Promise<void> {
const {
wrap,
sendOptions,
} = window.ConversationController.prepareForSend(c.get('id'));
} = await window.ConversationController.prepareForSend(c.get('id'));
// eslint-disable-next-line no-await-in-loop
await wrap(
window.textsecure.messaging.sendDeliveryReceipt(
@ -1592,11 +1602,14 @@ export async function startApp(): Promise<void> {
);
}
window.getSyncRequest = () =>
new window.textsecure.SyncRequest(
window.getSyncRequest = () => {
const syncRequest = new window.textsecure.SyncRequest(
window.textsecure.messaging,
messageReceiver
);
syncRequest.start();
return syncRequest;
};
let disconnectTimer: WhatIsThis | null = null;
let reconnectTimer: WhatIsThis | null = null;
@ -1948,10 +1961,7 @@ export async function startApp(): Promise<void> {
);
onChangeTheme();
}
const syncRequest = new window.textsecure.SyncRequest(
window.textsecure.messaging,
messageReceiver
);
const syncRequest = window.getSyncRequest();
window.Whisper.events.trigger('contactsync:begin');
syncRequest.addEventListener('success', () => {
window.log.info('sync successful');
@ -1969,7 +1979,7 @@ export async function startApp(): Promise<void> {
const {
wrap,
sendOptions,
} = window.ConversationController.prepareForSend(ourId, {
} = await window.ConversationController.prepareForSend(ourId, {
syncMessage: true,
});
@ -2090,17 +2100,6 @@ export async function startApp(): Promise<void> {
newVersion
);
[SenderCertificateMode.WithE164, SenderCertificateMode.WithoutE164].forEach(
mode => {
refreshSenderCertificate.initialize({
events: window.Whisper.events,
storage: window.storage,
mode,
navigator,
});
}
);
window.Whisper.deliveryReceiptQueue.start();
window.Whisper.Notifications.enable();

View file

@ -1241,7 +1241,7 @@ export async function modifyGroupV2({
? window.storage.get('profileKey')
: undefined;
const sendOptions = conversation.getSendOptions();
const sendOptions = await conversation.getSendOptions();
const timestamp = Date.now();
const promise = conversation.wrapSend(

View file

@ -3,6 +3,7 @@
/* eslint-disable class-methods-use-this */
import * as z from 'zod';
import * as CiphertextMessage from './CiphertextMessage';
import {
bytesFromString,
@ -44,9 +45,16 @@ export const enum SenderCertificateMode {
WithoutE164,
}
export type SerializedCertificateType = {
serialized: ArrayBuffer;
};
export const serializedCertificateSchema = z
.object({
expires: z.number().optional(),
serialized: z.instanceof(ArrayBuffer),
})
.nonstrict();
export type SerializedCertificateType = z.infer<
typeof serializedCertificateSchema
>;
type ServerCertificateType = {
id: number;

View file

@ -54,7 +54,11 @@ import {
PhoneNumberSharingMode,
parsePhoneNumberSharingMode,
} from '../util/phoneNumberSharingMode';
import { SerializedCertificateType } from '../metadata/SecretSessionCipher';
import {
SenderCertificateMode,
SerializedCertificateType,
} from '../metadata/SecretSessionCipher';
import { senderCertificateService } from '../services/senderCertificate';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -1090,7 +1094,7 @@ export class ConversationModel extends window.Backbone.Model<
return undefined;
}
sendTypingMessage(isTyping: boolean): void {
async sendTypingMessage(isTyping: boolean): Promise<void> {
if (!window.textsecure.messaging) {
return;
}
@ -1109,7 +1113,7 @@ export class ConversationModel extends window.Backbone.Model<
return;
}
const sendOptions = this.getSendOptions();
const sendOptions = await this.getSendOptions();
this.wrapSend(
window.textsecure.messaging.sendTypingMessage(
{
@ -1912,7 +1916,10 @@ export class ConversationModel extends window.Backbone.Model<
await this.applyMessageRequestResponse(response);
const { ourNumber, ourUuid } = this;
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ourNumber || ourUuid!,
{
@ -2105,12 +2112,12 @@ export class ConversationModel extends window.Backbone.Model<
// Because syncVerification sends a (null) message to the target of the verify and
// a sync message to our own devices, we need to send the accessKeys down for both
// contacts. So we merge their sendOptions.
const { sendOptions } = window.ConversationController.prepareForSend(
const { sendOptions } = await window.ConversationController.prepareForSend(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.ourNumber || this.ourUuid!,
{ syncMessage: true }
);
const contactSendOptions = this.getSendOptions();
const contactSendOptions = await this.getSendOptions();
const options = { ...sendOptions, ...contactSendOptions };
const promise = window.textsecure.storage.protocol.loadIdentityKey(e164);
@ -3107,7 +3114,7 @@ export class ConversationModel extends window.Backbone.Model<
throw new Error('Cannot send DOE while offline!');
}
const options = this.getSendOptions();
const options = await this.getSendOptions();
const promise = (() => {
if (this.isPrivate()) {
@ -3239,7 +3246,7 @@ export class ConversationModel extends window.Backbone.Model<
return message.sendSyncMessageOnly(dataMessage);
}
const options = this.getSendOptions();
const options = await this.getSendOptions();
const promise = (() => {
if (this.isPrivate()) {
@ -3302,7 +3309,7 @@ export class ConversationModel extends window.Backbone.Model<
await window.textsecure.messaging.sendProfileKeyUpdate(
profileKey,
recipients,
this.getSendOptions(),
await this.getSendOptions(),
this.get('groupId')
);
}
@ -3435,7 +3442,7 @@ export class ConversationModel extends window.Backbone.Model<
}
const conversationType = this.get('type');
const options = this.getSendOptions();
const options = await this.getSendOptions();
let promise;
if (conversationType === Message.GROUP) {
@ -3555,17 +3562,17 @@ export class ConversationModel extends window.Backbone.Model<
);
}
getSendOptions(options = {}): SendOptionsType {
const sendMetadata = this.getSendMetadata(options);
async getSendOptions(options = {}): Promise<SendOptionsType> {
const sendMetadata = await this.getSendMetadata(options);
return {
sendMetadata,
};
}
getSendMetadata(
async getSendMetadata(
options: { syncMessage?: string; disableMeCheck?: boolean } = {}
): SendMetadataType | undefined {
): Promise<SendMetadataType | undefined> {
const { syncMessage, disableMeCheck } = options;
// START: this code has an Expiration date of ~2018/11/21
@ -3580,11 +3587,19 @@ export class ConversationModel extends window.Backbone.Model<
// END
if (!this.isPrivate()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const infoArray = this.contactCollection!.map(conversation =>
conversation.getSendMetadata(options)
assert(
this.contactCollection,
'getSendMetadata: expected contactCollection to be defined'
);
return Object.assign({}, ...infoArray);
const result: SendMetadataType = {};
await Promise.all(
this.contactCollection.map(async conversation => {
const sendMetadata =
(await conversation.getSendMetadata(options)) || {};
Object.assign(result, sendMetadata);
})
);
return result;
}
const accessKey = this.get('accessKey');
@ -3598,7 +3613,7 @@ export class ConversationModel extends window.Backbone.Model<
const e164 = this.get('e164');
const uuid = this.get('uuid');
const senderCertificate = this.getSenderCertificateForDirectConversation();
const senderCertificate = await this.getSenderCertificateForDirectConversation();
// If we've never fetched user's profile, we default to what we have
if (sealedSender === SEALED_SENDER.UNKNOWN) {
@ -3630,9 +3645,9 @@ export class ConversationModel extends window.Backbone.Model<
};
}
private getSenderCertificateForDirectConversation():
| undefined
| SerializedCertificateType {
private getSenderCertificateForDirectConversation(): Promise<
undefined | SerializedCertificateType
> {
if (!this.isPrivate()) {
throw new Error(
'getSenderCertificateForDirectConversation should only be called for direct conversations'
@ -3643,33 +3658,26 @@ export class ConversationModel extends window.Backbone.Model<
window.storage.get('phoneNumberSharingMode')
);
let storageKey: 'senderCertificate' | 'senderCertificateNoE164';
let certificateMode: SenderCertificateMode;
switch (phoneNumberSharingMode) {
case PhoneNumberSharingMode.Everybody:
storageKey = 'senderCertificate';
certificateMode = SenderCertificateMode.WithE164;
break;
case PhoneNumberSharingMode.ContactsOnly: {
const isInSystemContacts = Boolean(this.get('name'));
storageKey = isInSystemContacts
? 'senderCertificate'
: 'senderCertificateNoE164';
certificateMode = isInSystemContacts
? SenderCertificateMode.WithE164
: SenderCertificateMode.WithoutE164;
break;
}
case PhoneNumberSharingMode.Nobody:
storageKey = 'senderCertificateNoE164';
certificateMode = SenderCertificateMode.WithoutE164;
break;
default:
throw missingCaseError(phoneNumberSharingMode);
}
const result = window.storage.get<SerializedCertificateType>(storageKey);
assert(
result,
`getSenderCertificateForDirectConversation: couldn't find a certificate stored in ${JSON.stringify(
storageKey
)}. Returning undefined`
);
return result;
return senderCertificateService.get(certificateMode);
}
// Is this someone who is a contact, or are we sharing our profile with them?
@ -4055,7 +4063,7 @@ export class ConversationModel extends window.Backbone.Model<
if (this.get('profileSharing')) {
profileKey = window.storage.get('profileKey');
}
const sendOptions = this.getSendOptions();
const sendOptions = await this.getSendOptions();
let promise;
if (this.isMe()) {
@ -4174,7 +4182,7 @@ export class ConversationModel extends window.Backbone.Model<
const message = window.MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
const options = await this.getSendOptions();
message.send(
this.wrapSend(
// TODO: DESKTOP-724
@ -4227,7 +4235,7 @@ export class ConversationModel extends window.Backbone.Model<
const message = window.MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
const options = await this.getSendOptions();
message.send(
this.wrapSend(
window.textsecure.messaging.leaveGroup(
@ -4299,7 +4307,7 @@ export class ConversationModel extends window.Backbone.Model<
// to a contact, we need accessKeys for both.
const {
sendOptions,
} = window.ConversationController.prepareForSend(
} = await window.ConversationController.prepareForSend(
window.ConversationController.getOurConversationId(),
{ syncMessage: true }
);
@ -4314,7 +4322,7 @@ export class ConversationModel extends window.Backbone.Model<
// Only send read receipts for accepted conversations
if (window.storage.get('read-receipt-setting') && this.getAccepted()) {
window.log.info(`Sending ${items.length} read receipts`);
const convoSendOptions = this.getSendOptions();
const convoSendOptions = await this.getSendOptions();
const receiptsBySender = window._.groupBy(items, 'senderId');
await Promise.all(
@ -4452,7 +4460,8 @@ export class ConversationModel extends window.Backbone.Model<
));
}
const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {};
const sendMetadata =
(await c.getSendMetadata({ disableMeCheck: true })) || {};
const getInfo =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};

View file

@ -1703,9 +1703,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const {
wrap,
sendOptions,
} = window.ConversationController.prepareForSend(ourNumber || ourUuid, {
syncMessage: true,
});
} = await window.ConversationController.prepareForSend(
ourNumber || ourUuid,
{
syncMessage: true,
}
);
await wrap(
window.textsecure.messaging.syncViewOnceOpen(
@ -2119,7 +2122,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
let promise;
const options = conversation.getSendOptions();
const options = await conversation.getSendOptions();
if (conversation.isPrivate()) {
const [identifier] = recipients;
@ -2312,9 +2315,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return this.sendSyncMessageOnly(dataMessage);
}
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
identifier
);
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(identifier);
const promise = window.textsecure.messaging.sendMessageToIdentifier(
identifier,
body,
@ -2533,7 +2537,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
async sendSyncMessage(): Promise<WhatIsThis> {
const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid();
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(
ourUuid || ourNumber,
{
syncMessage: true,

View file

@ -1,178 +0,0 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { once } from 'lodash';
import * as log from './logging/log';
import { missingCaseError } from './util/missingCaseError';
import { SenderCertificateMode } from './metadata/SecretSessionCipher';
const ONE_DAY = 24 * 60 * 60 * 1000; // one day
const MINIMUM_TIME_LEFT = 2 * 60 * 60 * 1000; // two hours
let timeout: null | ReturnType<typeof setTimeout> = null;
let scheduledTime: null | number = null;
const removeOldKey = once((storage: typeof window.storage) => {
const oldCertKey = 'senderCertificateWithUuid';
const oldUuidCert = storage.get(oldCertKey);
if (oldUuidCert) {
storage.remove(oldCertKey);
}
});
// We need to refresh our own profile regularly to account for newly-added devices which
// do not support unidentified delivery.
function refreshOurProfile() {
window.log.info('refreshOurProfile');
const ourId = window.ConversationController.getOurConversationIdOrThrow();
const conversation = window.ConversationController.get(ourId);
conversation?.getProfiles();
}
export function initialize({
events,
storage,
mode,
navigator,
}: Readonly<{
events: {
on: (name: string, callback: () => void) => void;
};
storage: typeof window.storage;
mode: SenderCertificateMode;
navigator: Navigator;
}>): void {
let storageKey: 'senderCertificate' | 'senderCertificateNoE164';
let logString: string;
switch (mode) {
case SenderCertificateMode.WithE164:
storageKey = 'senderCertificate';
logString = 'sender certificate WITH E164';
break;
case SenderCertificateMode.WithoutE164:
storageKey = 'senderCertificateNoE164';
logString = 'sender certificate WITHOUT E164';
break;
default:
throw missingCaseError(mode);
}
runWhenOnline();
removeOldKey(storage);
events.on('timetravel', scheduleNextRotation);
function scheduleNextRotation() {
const now = Date.now();
const certificate = storage.get(storageKey);
if (!certificate || !certificate.expires) {
setTimeoutForNextRun(scheduledTime || now);
return;
}
// If we have a time in place and it's already before the safety zone before expire,
// we keep it
if (
scheduledTime &&
scheduledTime <= certificate.expires - MINIMUM_TIME_LEFT
) {
setTimeoutForNextRun(scheduledTime);
return;
}
// Otherwise, we reset every day, or earlier if the safety zone requires it
const time = Math.min(
now + ONE_DAY,
certificate.expires - MINIMUM_TIME_LEFT
);
setTimeoutForNextRun(time);
}
async function saveCert(certificate: string): Promise<void> {
const arrayBuffer = window.Signal.Crypto.base64ToArrayBuffer(certificate);
const decodedContainer = window.textsecure.protobuf.SenderCertificate.decode(
arrayBuffer
);
const decodedCert = window.textsecure.protobuf.SenderCertificate.Certificate.decode(
decodedContainer.certificate
);
// We don't want to send a protobuf-generated object across IPC, so we make
// our own object.
const toSave = {
expires: decodedCert.expires.toNumber(),
serialized: arrayBuffer,
};
await storage.put(storageKey, toSave);
}
async function run(): Promise<void> {
log.info(`refreshSenderCertificate: Getting new ${logString}...`);
try {
const OLD_USERNAME = storage.get('number_id');
const USERNAME = storage.get('uuid_id');
const PASSWORD = storage.get('password');
const server = window.WebAPI.connect({
username: USERNAME || OLD_USERNAME,
password: PASSWORD,
});
const omitE164 = mode === SenderCertificateMode.WithoutE164;
const { certificate } = await server.getSenderCertificate(omitE164);
await saveCert(certificate);
scheduledTime = null;
scheduleNextRotation();
} catch (error) {
log.error(
`refreshSenderCertificate: Get failed for ${logString}. Trying again in five minutes...`,
error && error.stack ? error.stack : error
);
scheduledTime = Date.now() + 5 * 60 * 1000;
scheduleNextRotation();
}
refreshOurProfile();
}
function runWhenOnline() {
if (navigator.onLine) {
run();
} else {
log.info(
'refreshSenderCertificate: Offline. Will update certificate when online...'
);
const listener = () => {
log.info(
'refreshSenderCertificate: Online. Now updating certificate...'
);
window.removeEventListener('online', listener);
run();
};
window.addEventListener('online', listener);
}
}
function setTimeoutForNextRun(time = Date.now()) {
const now = Date.now();
if (scheduledTime !== time || !timeout) {
log.info(
`refreshSenderCertificate: Next ${logString} refresh scheduled for`,
new Date(time).toISOString()
);
}
scheduledTime = time;
const waitTime = Math.max(0, time - now);
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(runWhenOnline, waitTime);
}
}

View file

@ -730,10 +730,10 @@ export class CallingClass {
});
}
private sendGroupCallUpdateMessage(
private async sendGroupCallUpdateMessage(
conversationId: string,
eraId: string
): void {
): Promise<void> {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.error(
@ -743,7 +743,7 @@ export class CallingClass {
}
const groupV2 = conversation.getGroupV2Info();
const sendOptions = conversation.getSendOptions();
const sendOptions = await conversation.getSendOptions();
if (!groupV2) {
window.log.error(
'Unable to send group call update message for conversation that lacks groupV2 info'
@ -1258,7 +1258,7 @@ export class CallingClass {
): Promise<boolean> {
const conversation = window.ConversationController.get(remoteUserId);
const sendOptions = conversation
? conversation.getSendOptions()
? await conversation.getSendOptions()
: undefined;
if (!window.textsecure.messaging) {

View file

@ -0,0 +1,253 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
SenderCertificateMode,
serializedCertificateSchema,
SerializedCertificateType,
} from '../metadata/SecretSessionCipher';
import { SenderCertificateClass } from '../textsecure';
import { base64ToArrayBuffer } from '../Crypto';
import { assert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { waitForOnline } from '../util/waitForOnline';
import * as log from '../logging/log';
// We define a stricter storage here that returns `unknown` instead of `any`.
type Storage = {
get(key: string): unknown;
put(key: string, value: unknown): Promise<void>;
remove(key: string): Promise<void>;
};
// In case your clock is different from the server's, we "fake" expire certificates early.
const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000;
// This is exported for testing.
export class SenderCertificateService {
private WebAPI?: typeof window.WebAPI;
private SenderCertificate?: typeof SenderCertificateClass;
private fetchPromises: Map<
SenderCertificateMode,
Promise<undefined | SerializedCertificateType>
> = new Map();
private navigator?: { onLine: boolean };
private onlineEventTarget?: EventTarget;
private storage?: Storage;
initialize({
SenderCertificate,
WebAPI,
navigator,
onlineEventTarget,
storage,
}: {
WebAPI: typeof window.WebAPI;
navigator: Readonly<{ onLine: boolean }>;
onlineEventTarget: EventTarget;
SenderCertificate: typeof SenderCertificateClass;
storage: Storage;
}): void {
log.info('Sender certificate service initialized');
this.SenderCertificate = SenderCertificate;
this.WebAPI = WebAPI;
this.navigator = navigator;
this.onlineEventTarget = onlineEventTarget;
this.storage = storage;
removeOldKey(storage);
}
async get(
mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> {
const storedCertificate = this.getStoredCertificate(mode);
if (storedCertificate) {
log.info(
`Sender certificate service found a valid ${modeToLogString(
mode
)} certificate in storage; skipping fetch`
);
return storedCertificate;
}
return this.fetchCertificate(mode);
}
private getStoredCertificate(
mode: SenderCertificateMode
): undefined | SerializedCertificateType {
const { storage } = this;
assert(
storage,
'Sender certificate service method was called before it was initialized'
);
const valueInStorage = storage.get(modeToStorageKey(mode));
return serializedCertificateSchema.check(valueInStorage) &&
isExpirationValid(valueInStorage.expires)
? valueInStorage
: undefined;
}
private fetchCertificate(
mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> {
// This prevents multiple concurrent fetches.
const existingPromise = this.fetchPromises.get(mode);
if (existingPromise) {
log.info(
`Sender certificate service was already fetching a ${modeToLogString(
mode
)} certificate; piggybacking off of that`
);
return existingPromise;
}
let promise: Promise<undefined | SerializedCertificateType>;
const doFetch = async () => {
const result = await this.fetchAndSaveCertificate(mode);
assert(
this.fetchPromises.get(mode) === promise,
'Sender certificate service was deleting a different promise than expected'
);
this.fetchPromises.delete(mode);
return result;
};
promise = doFetch();
assert(
!this.fetchPromises.has(mode),
'Sender certificate service somehow already had a promise for this mode'
);
this.fetchPromises.set(mode, promise);
return promise;
}
private async fetchAndSaveCertificate(
mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> {
const { SenderCertificate, storage, navigator, onlineEventTarget } = this;
assert(
SenderCertificate && storage && navigator && onlineEventTarget,
'Sender certificate service method was called before it was initialized'
);
log.info(
`Sender certificate service: fetching and saving a ${modeToLogString(
mode
)} certificate`
);
await waitForOnline(navigator, onlineEventTarget);
let certificateString: string;
try {
certificateString = await this.requestSenderCertificate(mode);
} catch (err) {
log.warn(
`Sender certificate service could not fetch a ${modeToLogString(
mode
)} certificate. Returning undefined`,
err && err.stack ? err.stack : err
);
return undefined;
}
const certificate = base64ToArrayBuffer(certificateString);
const decodedContainer = SenderCertificate.decode(certificate);
const decodedCert = decodedContainer.certificate
? SenderCertificate.Certificate.decode(decodedContainer.certificate)
: undefined;
const expires = decodedCert?.expires?.toNumber();
if (!isExpirationValid(expires)) {
log.warn(
`Sender certificate service fetched a ${modeToLogString(
mode
)} certificate from the server that was already expired (or was invalid). Is your system clock off?`
);
return undefined;
}
const serializedCertificate = {
expires: expires - CLOCK_SKEW_THRESHOLD,
serialized: certificate,
};
await storage.put(modeToStorageKey(mode), serializedCertificate);
return serializedCertificate;
}
private async requestSenderCertificate(
mode: SenderCertificateMode
): Promise<string> {
const { storage, WebAPI } = this;
assert(
storage && WebAPI,
'Sender certificate service method was called before it was initialized'
);
const username = storage.get('uuid_id') || storage.get('number_id');
const password = storage.get('password');
if (typeof username !== 'string') {
throw new Error(
'Sender certificate service: username in storage was not a string. Cannot connect'
);
}
if (typeof password !== 'string') {
throw new Error(
'Sender certificate service: password in storage was not a string. Cannot connect'
);
}
const server = WebAPI.connect({ username, password });
const omitE164 = mode === SenderCertificateMode.WithoutE164;
const { certificate } = await server.getSenderCertificate(omitE164);
return certificate;
}
}
function modeToStorageKey(
mode: SenderCertificateMode
): 'senderCertificate' | 'senderCertificateNoE164' {
switch (mode) {
case SenderCertificateMode.WithE164:
return 'senderCertificate';
case SenderCertificateMode.WithoutE164:
return 'senderCertificateNoE164';
default:
throw missingCaseError(mode);
}
}
function modeToLogString(mode: SenderCertificateMode): string {
switch (mode) {
case SenderCertificateMode.WithE164:
return 'yes-E164';
case SenderCertificateMode.WithoutE164:
return 'no-E164';
default:
throw missingCaseError(mode);
}
}
function isExpirationValid(expiration: unknown): expiration is number {
return typeof expiration === 'number' && expiration > Date.now();
}
function removeOldKey(storage: Readonly<Storage>) {
const oldCertKey = 'senderCertificateWithUuid';
const oldUuidCert = storage.get(oldCertKey);
if (oldUuidCert) {
storage.remove(oldCertKey);
}
}
export const senderCertificateService = new SenderCertificateService();

View file

@ -1,16 +1,18 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function sendStickerPackSync(
export async function sendStickerPackSync(
packId: string,
packKey: string,
installed: boolean
): void {
): Promise<void> {
const { ConversationController, textsecure, log } = window;
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
const { wrap, sendOptions } = await ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
{
syncMessage: true,
}
);
if (!textsecure.messaging) {

View file

@ -0,0 +1,257 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// We allow `any`s because it's arduous to set up "real" WebAPIs and storages.
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as fs from 'fs';
import * as path from 'path';
import { assert } from 'chai';
import * as sinon from 'sinon';
import { v4 as uuid } from 'uuid';
import { arrayBufferToBase64 } from '../../Crypto';
import { SenderCertificateClass } from '../../textsecure';
import { SenderCertificateMode } from '../../metadata/SecretSessionCipher';
import { SenderCertificateService } from '../../services/senderCertificate';
describe('SenderCertificateService', () => {
const FIFTEEN_MINUTES = 15 * 60 * 1000;
let fakeValidCertificate: SenderCertificateClass;
let fakeValidCertificateExpiry: number;
let fakeServer: any;
let fakeWebApi: typeof window.WebAPI;
let fakeNavigator: { onLine: boolean };
let fakeWindow: EventTarget;
let fakeStorage: any;
let SenderCertificate: typeof SenderCertificateClass;
function initializeTestService(): SenderCertificateService {
const result = new SenderCertificateService();
result.initialize({
SenderCertificate,
WebAPI: fakeWebApi,
navigator: fakeNavigator,
onlineEventTarget: fakeWindow,
storage: fakeStorage,
});
return result;
}
before(done => {
const protoPath = path.join(
__dirname,
'..',
'..',
'..',
'protos',
'UnidentifiedDelivery.proto'
);
fs.readFile(protoPath, 'utf8', (err, proto) => {
if (err) {
done(err);
return;
}
({ SenderCertificate } = global.window.dcodeIO.ProtoBuf.loadProto(
proto
).build('signalservice'));
done();
});
});
beforeEach(() => {
fakeValidCertificate = new SenderCertificate();
fakeValidCertificateExpiry = Date.now() + 604800000;
const certificate = new SenderCertificate.Certificate();
certificate.expires = global.window.dcodeIO.Long.fromNumber(
fakeValidCertificateExpiry
);
fakeValidCertificate.certificate = certificate.toArrayBuffer();
fakeServer = {
getSenderCertificate: sinon.stub().resolves({
certificate: arrayBufferToBase64(fakeValidCertificate.toArrayBuffer()),
}),
};
fakeWebApi = { connect: sinon.stub().returns(fakeServer) };
fakeNavigator = { onLine: true };
fakeWindow = {
addEventListener: sinon.stub(),
dispatchEvent: sinon.stub(),
removeEventListener: sinon.stub(),
};
fakeStorage = {
get: sinon.stub(),
put: sinon.stub().resolves(),
remove: sinon.stub().resolves(),
};
fakeStorage.get.withArgs('uuid_id').returns(`${uuid()}.2`);
fakeStorage.get.withArgs('password').returns('abc123');
});
describe('initialize', () => {
it('removes an old storage service key if it was present', () => {
fakeStorage.get
.withArgs('senderCertificateWithUuid')
.returns('some value');
initializeTestService();
sinon.assert.calledWith(fakeStorage.remove, 'senderCertificateWithUuid');
});
it("doesn't remove anything from storage if it wasn't there", () => {
initializeTestService();
sinon.assert.notCalled(fakeStorage.put);
});
});
describe('get', () => {
it('returns valid yes-E164 certificates from storage if they exist', async () => {
const cert = {
expires: Date.now() + 123456,
serialized: new ArrayBuffer(2),
};
fakeStorage.get.withArgs('senderCertificate').returns(cert);
const service = initializeTestService();
assert.strictEqual(
await service.get(SenderCertificateMode.WithE164),
cert
);
sinon.assert.notCalled(fakeStorage.put);
});
it('returns valid no-E164 certificates from storage if they exist', async () => {
const cert = {
expires: Date.now() + 123456,
serialized: new ArrayBuffer(2),
};
fakeStorage.get.withArgs('senderCertificateNoE164').returns(cert);
const service = initializeTestService();
assert.strictEqual(
await service.get(SenderCertificateMode.WithoutE164),
cert
);
sinon.assert.notCalled(fakeStorage.put);
});
it('returns and stores a newly-fetched yes-E164 certificate if none was in storage', async () => {
const service = initializeTestService();
assert.deepEqual(await service.get(SenderCertificateMode.WithE164), {
expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES,
serialized: fakeValidCertificate.toArrayBuffer(),
});
sinon.assert.calledWithMatch(fakeStorage.put, 'senderCertificate', {
expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES,
serialized: fakeValidCertificate.toArrayBuffer(),
});
sinon.assert.calledWith(fakeServer.getSenderCertificate, false);
});
it('returns and stores a newly-fetched no-E164 certificate if none was in storage', async () => {
const service = initializeTestService();
assert.deepEqual(await service.get(SenderCertificateMode.WithoutE164), {
expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES,
serialized: fakeValidCertificate.toArrayBuffer(),
});
sinon.assert.calledWithMatch(fakeStorage.put, 'senderCertificateNoE164', {
expires: fakeValidCertificateExpiry - FIFTEEN_MINUTES,
serialized: fakeValidCertificate.toArrayBuffer(),
});
sinon.assert.calledWith(fakeServer.getSenderCertificate, true);
});
it('fetches new certificates if the value in storage has already expired', async () => {
const service = initializeTestService();
fakeStorage.get.withArgs('senderCertificate').returns({
expires: Date.now() - 1000,
serialized: new ArrayBuffer(2),
});
await service.get(SenderCertificateMode.WithE164);
sinon.assert.called(fakeServer.getSenderCertificate);
});
it('fetches new certificates if the value in storage is invalid', async () => {
const service = initializeTestService();
fakeStorage.get.withArgs('senderCertificate').returns({
serialized: 'not an arraybuffer',
});
await service.get(SenderCertificateMode.WithE164);
sinon.assert.called(fakeServer.getSenderCertificate);
});
it('only hits the server once per certificate type when requesting many times', async () => {
const service = initializeTestService();
await Promise.all([
service.get(SenderCertificateMode.WithE164),
service.get(SenderCertificateMode.WithoutE164),
service.get(SenderCertificateMode.WithE164),
service.get(SenderCertificateMode.WithoutE164),
service.get(SenderCertificateMode.WithE164),
service.get(SenderCertificateMode.WithoutE164),
service.get(SenderCertificateMode.WithE164),
service.get(SenderCertificateMode.WithoutE164),
]);
sinon.assert.calledTwice(fakeServer.getSenderCertificate);
});
it('hits the server again after a request has completed', async () => {
const service = initializeTestService();
await service.get(SenderCertificateMode.WithE164);
sinon.assert.calledOnce(fakeServer.getSenderCertificate);
await service.get(SenderCertificateMode.WithE164);
sinon.assert.calledTwice(fakeServer.getSenderCertificate);
});
it('returns undefined if the request to the server fails', async () => {
const service = initializeTestService();
fakeServer.getSenderCertificate.rejects(new Error('uh oh'));
assert.isUndefined(await service.get(SenderCertificateMode.WithE164));
});
it('returns undefined if the server returns an already-expired certificate', async () => {
const service = initializeTestService();
const expiredCertificate = new SenderCertificate();
const certificate = new SenderCertificate.Certificate();
certificate.expires = global.window.dcodeIO.Long.fromNumber(
Date.now() - 1000
);
expiredCertificate.certificate = certificate.toArrayBuffer();
fakeServer.getSenderCertificate.resolves({
certificate: arrayBufferToBase64(expiredCertificate.toArrayBuffer()),
});
assert.isUndefined(await service.get(SenderCertificateMode.WithE164));
});
});
});

View file

@ -0,0 +1,51 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { waitForOnline } from '../../util/waitForOnline';
describe('waitForOnline', () => {
function getFakeWindow(): EventTarget {
const result = new EventTarget();
sinon.stub(result, 'addEventListener');
sinon.stub(result, 'removeEventListener');
return result;
}
it("resolves immediately if you're online", async () => {
const fakeNavigator = { onLine: true };
const fakeWindow = getFakeWindow();
await waitForOnline(fakeNavigator, fakeWindow);
sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub);
sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub);
});
it("if you're offline, resolves as soon as you're online", async () => {
const fakeNavigator = { onLine: false };
const fakeWindow = getFakeWindow();
(fakeWindow.addEventListener as sinon.SinonStub)
.withArgs('online')
.callsFake((_eventName: string, callback: () => void) => {
setTimeout(callback, 0);
});
let done = false;
const promise = (async () => {
await waitForOnline(fakeNavigator, fakeWindow);
done = true;
})();
assert.isFalse(done);
await promise;
assert.isTrue(done);
sinon.assert.calledOnce(fakeWindow.addEventListener as sinon.SinonStub);
sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub);
});
});

3
ts/textsecure.d.ts vendored
View file

@ -10,6 +10,7 @@ import {
import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import MessageSender from './textsecure/SendMessage';
import SyncRequest from './textsecure/SyncRequest';
import EventTarget from './textsecure/EventTarget';
import { ByteBufferClass } from './window.d';
import SendMessage, { SendOptionsType } from './textsecure/SendMessage';
@ -90,7 +91,7 @@ export type TextSecureType = {
MessageReceiver: typeof MessageReceiver;
AccountManager: WhatIsThis;
MessageSender: WhatIsThis;
SyncRequest: WhatIsThis;
SyncRequest: typeof SyncRequest;
};
type StoredSignedPreKeyType = SignedPreKeyType & {

View file

@ -1255,7 +1255,7 @@ class MessageReceiverInner extends EventTarget {
await sessionCipher.closeOpenSessionForDevice();
// Send a null message with newly-created session
const sendOptions = conversation.getSendOptions();
const sendOptions = await conversation.getSendOptions();
await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions);
}

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable more/no-then */
@ -9,9 +9,10 @@
import EventTarget from './EventTarget';
import MessageReceiver from './MessageReceiver';
import MessageSender from './SendMessage';
import { assert } from '../util/assert';
class SyncRequestInner extends EventTarget {
receiver: MessageReceiver;
private started = false;
contactSync?: boolean;
@ -23,7 +24,10 @@ class SyncRequestInner extends EventTarget {
ongroup: Function;
constructor(sender: MessageSender, receiver: MessageReceiver) {
constructor(
private sender: MessageSender,
private receiver: MessageReceiver
) {
super();
if (
@ -34,21 +38,30 @@ class SyncRequestInner extends EventTarget {
'Tried to construct a SyncRequest without MessageSender and MessageReceiver'
);
}
this.receiver = receiver;
this.oncontact = this.onContactSyncComplete.bind(this);
receiver.addEventListener('contactsync', this.oncontact);
this.ongroup = this.onGroupSyncComplete.bind(this);
receiver.addEventListener('groupsync', this.ongroup);
}
async start(): Promise<void> {
if (this.started) {
assert(false, 'SyncRequestInner: started more than once. Doing nothing');
return;
}
this.started = true;
const { sender } = this;
const ourNumber = window.textsecure.storage.user.getNumber();
const { wrap, sendOptions } = window.ConversationController.prepareForSend(
ourNumber,
{
syncMessage: true,
}
);
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(ourNumber, {
syncMessage: true,
});
window.log.info('SyncRequest created. Sending config sync request...');
wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));
@ -106,13 +119,20 @@ class SyncRequestInner extends EventTarget {
}
export default class SyncRequest {
constructor(sender: MessageSender, receiver: MessageReceiver) {
const inner = new SyncRequestInner(sender, receiver);
this.addEventListener = inner.addEventListener.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner);
}
private inner: SyncRequestInner;
addEventListener: (name: string, handler: Function) => void;
removeEventListener: (name: string, handler: Function) => void;
constructor(sender: MessageSender, receiver: MessageReceiver) {
const inner = new SyncRequestInner(sender, receiver);
this.inner = inner;
this.addEventListener = inner.addEventListener.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner);
}
start(): void {
this.inner.start();
}
}

View file

@ -804,7 +804,9 @@ export type WebAPIType = {
}
) => Promise<any>;
getProvisioningSocket: () => WebSocket;
getSenderCertificate: (withUuid?: boolean) => Promise<any>;
getSenderCertificate: (
withUuid?: boolean
) => Promise<{ certificate: string }>;
getSticker: (packId: string, stickerId: number) => Promise<any>;
getStickerPackManifest: (packId: string) => Promise<StickerPackManifestType>;
getStorageCredentials: MessageSender['getStorageCredentials'];

View file

@ -17020,7 +17020,7 @@
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.js",
"line": " wrap(textsecure.messaging.sendStickerPackSync([",
"lineNumber": 14,
"lineNumber": 16,
"reasonCategory": "falseMatch",
"updated": "2020-02-07T19:52:28.522Z"
},
@ -17028,7 +17028,7 @@
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.ts",
"line": " wrap(",
"lineNumber": 24,
"lineNumber": 26,
"reasonCategory": "falseMatch",
"updated": "2020-02-07T19:52:28.522Z"
},
@ -17100,7 +17100,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.js",
"line": " wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));",
"lineNumber": 32,
"lineNumber": 43,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17108,7 +17108,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.js",
"line": " wrap(sender.sendRequestBlockSyncMessage(sendOptions));",
"lineNumber": 34,
"lineNumber": 45,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17116,7 +17116,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.js",
"line": " wrap(sender.sendRequestContactSyncMessage(sendOptions))",
"lineNumber": 36,
"lineNumber": 47,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17124,7 +17124,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.js",
"line": " return wrap(sender.sendRequestGroupSyncMessage(sendOptions));",
"lineNumber": 39,
"lineNumber": 50,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17132,7 +17132,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.ts",
"line": " wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));",
"lineNumber": 54,
"lineNumber": 67,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17140,7 +17140,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.ts",
"line": " wrap(sender.sendRequestBlockSyncMessage(sendOptions));",
"lineNumber": 57,
"lineNumber": 70,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17148,7 +17148,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.ts",
"line": " wrap(sender.sendRequestContactSyncMessage(sendOptions))",
"lineNumber": 60,
"lineNumber": 73,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17156,7 +17156,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.ts",
"line": " return wrap(sender.sendRequestGroupSyncMessage(sendOptions));",
"lineNumber": 63,
"lineNumber": 76,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
@ -17172,7 +17172,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2232,
"lineNumber": 2234,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
@ -17284,4 +17284,4 @@
"reasonCategory": "falseMatch",
"updated": "2021-04-06T23:11:04.431Z"
}
]
]

21
ts/util/waitForOnline.ts Normal file
View file

@ -0,0 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function waitForOnline(
navigator: Readonly<{ onLine: boolean }>,
onlineEventTarget: EventTarget
): Promise<void> {
return new Promise(resolve => {
if (navigator.onLine) {
resolve();
return;
}
const listener = () => {
onlineEventTarget.removeEventListener('online', listener);
resolve();
};
onlineEventTarget.addEventListener('online', listener);
});
}

6
ts/window.d.ts vendored
View file

@ -70,8 +70,8 @@ import * as searchDuck from './state/ducks/search';
import * as stickersDuck from './state/ducks/stickers';
import * as conversationsSelectors from './state/selectors/conversations';
import * as searchSelectors from './state/selectors/search';
import { SendOptionsType } from './textsecure/SendMessage';
import AccountManager from './textsecure/AccountManager';
import { SendOptionsType } from './textsecure/SendMessage';
import Data from './sql/Client';
import { UserMessage } from './types/Message';
import { PhoneNumberFormat } from 'google-libphonenumber';
@ -97,6 +97,7 @@ import { ElectronLocaleType } from './util/mapToSupportLocale';
import { SignalProtocolStore } from './LibSignalStore';
import { StartupQueue } from './util/StartupQueue';
import * as synchronousCrypto from './util/synchronousCrypto';
import SyncRequest from './textsecure/SyncRequest';
export { Long } from 'long';
@ -169,7 +170,7 @@ declare global {
getServerPublicParams: () => string;
getSfuUrl: () => string;
getSocketStatus: () => number;
getSyncRequest: () => WhatIsThis;
getSyncRequest: () => SyncRequest;
getTitle: () => string;
waitForEmptyEventQueue: () => Promise<void>;
getVersion: () => string;
@ -578,6 +579,7 @@ export type DCodeIOType = {
fromString: (str: string | null) => Long;
isLong: (obj: unknown) => obj is Long;
};
ProtoBuf: WhatIsThis;
};
type MessageControllerType = {