Introduce in-memory transactions for sessions

This commit is contained in:
Fedor Indutny 2021-05-17 11:03:42 -07:00 committed by Scott Nonnenberg
parent 403b3c5fc6
commit 94d2c56ab9
12 changed files with 874 additions and 391 deletions

View file

@ -21,12 +21,6 @@
get(id) { get(id) {
return textsecure.storage.protocol.getUnprocessedById(id); return textsecure.storage.protocol.getUnprocessedById(id);
}, },
add(data) {
return textsecure.storage.protocol.addUnprocessed(data);
},
batchAdd(array) {
return textsecure.storage.protocol.addMultipleUnprocessed(array);
},
updateAttempts(id, attempts) { updateAttempts(id, attempts) {
return textsecure.storage.protocol.updateUnprocessedAttempts( return textsecure.storage.protocol.updateUnprocessedAttempts(
id, id,

View file

@ -180,7 +180,8 @@
"@types/backbone": "1.4.3", "@types/backbone": "1.4.3",
"@types/better-sqlite3": "5.4.1", "@types/better-sqlite3": "5.4.1",
"@types/blueimp-load-image": "5.14.1", "@types/blueimp-load-image": "5.14.1",
"@types/chai": "4.1.2", "@types/chai": "4.2.18",
"@types/chai-as-promised": "7.1.4",
"@types/classnames": "2.2.3", "@types/classnames": "2.2.3",
"@types/config": "0.0.34", "@types/config": "0.0.34",
"@types/dashdash": "1.14.0", "@types/dashdash": "1.14.0",
@ -232,7 +233,8 @@
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-loader": "8.0.6", "babel-loader": "8.0.6",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "4.1.2", "chai": "4.3.4",
"chai-as-promised": "7.1.1",
"core-js": "2.6.9", "core-js": "2.6.9",
"cross-env": "5.2.0", "cross-env": "5.2.0",
"css-loader": "3.2.0", "css-loader": "3.2.0",

View file

@ -24,8 +24,13 @@ import {
} from '@signalapp/signal-client'; } from '@signalapp/signal-client';
import { freezePreKey, freezeSignedPreKey } from './SignalProtocolStore'; import { freezePreKey, freezeSignedPreKey } from './SignalProtocolStore';
import { UnprocessedType } from './textsecure/Types.d';
import { typedArrayToArrayBuffer } from './Crypto'; import { typedArrayToArrayBuffer } from './Crypto';
import { assert } from './util/assert';
import { Lock } from './util/Lock';
function encodedNameFromAddress(address: ProtocolAddress): string { function encodedNameFromAddress(address: ProtocolAddress): string {
const name = address.name(); const name = address.name();
const deviceId = address.deviceId(); const deviceId = address.deviceId();
@ -33,25 +38,75 @@ function encodedNameFromAddress(address: ProtocolAddress): string {
return encodedName; return encodedName;
} }
export type SessionsOptions = {
readonly transactionOnly?: boolean;
};
export class Sessions extends SessionStore { export class Sessions extends SessionStore {
private readonly lock = new Lock();
private inTransaction = false;
constructor(private readonly options: SessionsOptions = {}) {
super();
}
public async transaction<T>(fn: () => Promise<T>): Promise<T> {
assert(!this.inTransaction, 'Already in transaction');
this.inTransaction = true;
try {
return await window.textsecure.storage.protocol.sessionTransaction(
'Sessions.transaction',
fn,
this.lock
);
} finally {
this.inTransaction = false;
}
}
public async addUnprocessed(array: Array<UnprocessedType>): Promise<void> {
await window.textsecure.storage.protocol.addMultipleUnprocessed(array, {
lock: this.lock,
});
}
// SessionStore overrides
async saveSession( async saveSession(
address: ProtocolAddress, address: ProtocolAddress,
record: SessionRecord record: SessionRecord
): Promise<void> { ): Promise<void> {
this.checkInTransaction();
await window.textsecure.storage.protocol.storeSession( await window.textsecure.storage.protocol.storeSession(
encodedNameFromAddress(address), encodedNameFromAddress(address),
record record,
{ lock: this.lock }
); );
} }
async getSession(name: ProtocolAddress): Promise<SessionRecord | null> { async getSession(name: ProtocolAddress): Promise<SessionRecord | null> {
this.checkInTransaction();
const encodedName = encodedNameFromAddress(name); const encodedName = encodedNameFromAddress(name);
const record = await window.textsecure.storage.protocol.loadSession( const record = await window.textsecure.storage.protocol.loadSession(
encodedName encodedName,
{ lock: this.lock }
); );
return record || null; return record || null;
} }
// Private
private checkInTransaction(): void {
assert(
this.inTransaction || !this.options.transactionOnly,
'Accessing session store outside of transaction'
);
}
} }
export class IdentityKeys extends IdentityKeyStore { export class IdentityKeys extends IdentityKeyStore {

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable no-restricted-syntax */
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -22,7 +23,9 @@ import {
fromEncodedBinaryToArrayBuffer, fromEncodedBinaryToArrayBuffer,
typedArrayToArrayBuffer, typedArrayToArrayBuffer,
} from './Crypto'; } from './Crypto';
import { assert } from './util/assert';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { Lock } from './util/Lock';
import { isMoreRecentThan } from './util/timestamp'; import { isMoreRecentThan } from './util/timestamp';
import { import {
sessionRecordToProtobuf, sessionRecordToProtobuf,
@ -102,9 +105,22 @@ type CacheEntryType<DBType, HydratedType> =
} }
| { hydrated: true; fromDB: DBType; item: HydratedType }; | { hydrated: true; fromDB: DBType; item: HydratedType };
type MapFields =
| 'identityKeys'
| 'preKeys'
| 'senderKeys'
| 'sessions'
| 'signedPreKeys';
export type SessionTransactionOptions = {
readonly lock?: Lock;
};
const GLOBAL_LOCK = new Lock();
async function _fillCaches<ID, T extends HasIdType<ID>, HydratedType>( async function _fillCaches<ID, T extends HasIdType<ID>, HydratedType>(
object: SignalProtocolStore, object: SignalProtocolStore,
field: keyof SignalProtocolStore, field: MapFields,
itemsPromise: Promise<Array<T>> itemsPromise: Promise<Array<T>>
): Promise<void> { ): Promise<void> {
const items = await itemsPromise; const items = await itemsPromise;
@ -182,6 +198,8 @@ const EventsMixin = (function EventsMixin(this: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any) as typeof window.Backbone.EventsMixin; } as any) as typeof window.Backbone.EventsMixin;
type SessionCacheEntry = CacheEntryType<SessionType, SessionRecord>;
export class SignalProtocolStore extends EventsMixin { export class SignalProtocolStore extends EventsMixin {
// Enums used across the app // Enums used across the app
@ -197,7 +215,15 @@ export class SignalProtocolStore extends EventsMixin {
senderKeys?: Map<string, CacheEntryType<SenderKeyType, SenderKeyRecord>>; senderKeys?: Map<string, CacheEntryType<SenderKeyType, SenderKeyRecord>>;
sessions?: Map<string, CacheEntryType<SessionType, SessionRecord>>; sessions?: Map<string, SessionCacheEntry>;
sessionLock?: Lock;
sessionLockQueue: Array<() => void> = [];
pendingSessions = new Map<string, SessionCacheEntry>();
pendingUnprocessed = new Map<string, UnprocessedType>();
preKeys?: Map<number, CacheEntryType<PreKeyType, PreKeyRecord>>; preKeys?: Map<number, CacheEntryType<PreKeyType, PreKeyRecord>>;
@ -562,43 +588,154 @@ export class SignalProtocolStore extends EventsMixin {
// Sessions // Sessions
async loadSession( // Re-entrant session transaction routine. Only one session transaction could
encodedAddress: string // be running at the same time.
): Promise<SessionRecord | undefined> { //
if (!this.sessions) { // While in transaction:
throw new Error('loadSession: this.sessions not yet cached!'); //
// - `storeSession()` adds the updated session to the `pendingSessions`
// - `loadSession()` looks up the session first in `pendingSessions` and only
// then in the main `sessions` store
//
// When transaction ends:
//
// - successfully: pending session stores are batched into the database
// - with an error: pending session stores are reverted
async sessionTransaction<T>(
name: string,
body: () => Promise<T>,
lock: Lock = GLOBAL_LOCK
): Promise<T> {
// Allow re-entering from LibSignalStores
const isNested = this.sessionLock === lock;
if (this.sessionLock && !isNested) {
window.log.info(`sessionTransaction(${name}): sessions locked, waiting`);
await new Promise<void>(resolve => this.sessionLockQueue.push(resolve));
} }
if (encodedAddress === null || encodedAddress === undefined) { if (!isNested) {
throw new Error('loadSession: encodedAddress was undefined/null'); if (lock !== GLOBAL_LOCK) {
window.log.info(`sessionTransaction(${name}): enter`);
}
this.sessionLock = lock;
} }
let result: T;
try { try {
const id = await normalizeEncodedAddress(encodedAddress); result = await body();
const entry = this.sessions.get(id);
if (!entry) {
return undefined;
}
if (entry.hydrated) {
return entry.item;
}
const item = await this._maybeMigrateSession(entry.fromDB);
this.sessions.set(id, {
hydrated: true,
item,
fromDB: entry.fromDB,
});
return item;
} catch (error) { } catch (error) {
const errorString = error && error.stack ? error.stack : error; if (!isNested) {
window.log.error( await this.revertSessions(name, error);
`loadSession: failed to load session ${encodedAddress}: ${errorString}` this.releaseSessionLock();
); }
return undefined; throw error;
} }
if (!isNested) {
await this.commitSessions(name);
this.releaseSessionLock();
}
return result;
}
private async commitSessions(name: string): Promise<void> {
const { pendingSessions, pendingUnprocessed } = this;
if (pendingSessions.size === 0 && pendingUnprocessed.size === 0) {
return;
}
window.log.info(
`commitSessions(${name}): pending sessions ${pendingSessions.size} ` +
`pending unprocessed ${pendingUnprocessed.size}`
);
this.pendingSessions = new Map();
this.pendingUnprocessed = new Map();
// Commit both unprocessed and sessions in the same database transaction
// to unroll both on error.
await window.Signal.Data.commitSessionsAndUnprocessed({
sessions: Array.from(pendingSessions.values()).map(
({ fromDB }) => fromDB
),
unprocessed: Array.from(pendingUnprocessed.values()),
});
const { sessions } = this;
assert(sessions !== undefined, "Can't commit unhydrated storage");
// Apply changes to in-memory storage after successful DB write.
pendingSessions.forEach((value, key) => {
sessions.set(key, value);
});
}
private async revertSessions(name: string, error: Error): Promise<void> {
window.log.info(
`revertSessions(${name}): pending size ${this.pendingSessions.size}`,
error && error.stack
);
this.pendingSessions.clear();
this.pendingUnprocessed.clear();
}
private releaseSessionLock(): void {
this.sessionLock = undefined;
const next = this.sessionLockQueue.shift();
if (next) {
next();
}
}
async loadSession(
encodedAddress: string,
{ lock }: SessionTransactionOptions = {}
): Promise<SessionRecord | undefined> {
return this.sessionTransaction(
'loadSession',
async () => {
if (!this.sessions) {
throw new Error('loadSession: this.sessions not yet cached!');
}
if (encodedAddress === null || encodedAddress === undefined) {
throw new Error('loadSession: encodedAddress was undefined/null');
}
try {
const id = await normalizeEncodedAddress(encodedAddress);
const map = this.pendingSessions.has(id)
? this.pendingSessions
: this.sessions;
const entry = map.get(id);
if (!entry) {
return undefined;
}
if (entry.hydrated) {
return entry.item;
}
const item = await this._maybeMigrateSession(entry.fromDB);
map.set(id, {
hydrated: true,
item,
fromDB: entry.fromDB,
});
return item;
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`loadSession: failed to load session ${encodedAddress}: ${errorString}`
);
return undefined;
}
},
lock
);
} }
private async _maybeMigrateSession( private async _maybeMigrateSession(
@ -643,139 +780,155 @@ export class SignalProtocolStore extends EventsMixin {
async storeSession( async storeSession(
encodedAddress: string, encodedAddress: string,
record: SessionRecord record: SessionRecord,
{ lock }: SessionTransactionOptions = {}
): Promise<void> { ): Promise<void> {
if (!this.sessions) { await this.sessionTransaction(
throw new Error('storeSession: this.sessions not yet cached!'); 'storeSession',
} async () => {
if (!this.sessions) {
throw new Error('storeSession: this.sessions not yet cached!');
}
if (encodedAddress === null || encodedAddress === undefined) { if (encodedAddress === null || encodedAddress === undefined) {
throw new Error('storeSession: encodedAddress was undefined/null'); throw new Error('storeSession: encodedAddress was undefined/null');
} }
const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress); const unencoded = window.textsecure.utils.unencodeNumber(
const deviceId = parseInt(unencoded[1], 10); encodedAddress
);
const deviceId = parseInt(unencoded[1], 10);
try { try {
const id = await normalizeEncodedAddress(encodedAddress); const id = await normalizeEncodedAddress(encodedAddress);
const fromDB = { const fromDB = {
id, id,
version: 2, version: 2,
conversationId: window.textsecure.utils.unencodeNumber(id)[0], conversationId: window.textsecure.utils.unencodeNumber(id)[0],
deviceId, deviceId,
record: record.serialize().toString('base64'), record: record.serialize().toString('base64'),
}; };
await window.Signal.Data.createOrUpdateSession(fromDB); const newSession = {
this.sessions.set(id, { hydrated: true,
hydrated: true, fromDB,
fromDB, item: record,
item: record, };
});
} catch (error) { this.pendingSessions.set(id, newSession);
const errorString = error && error.stack ? error.stack : error; } catch (error) {
window.log.error( const errorString = error && error.stack ? error.stack : error;
`storeSession: Save failed fo ${encodedAddress}: ${errorString}` window.log.error(
); `storeSession: Save failed fo ${encodedAddress}: ${errorString}`
throw error; );
} throw error;
}
},
lock
);
} }
async getDeviceIds(identifier: string): Promise<Array<number>> { async getDeviceIds(identifier: string): Promise<Array<number>> {
if (!this.sessions) { return this.sessionTransaction('getDeviceIds', async () => {
throw new Error('getDeviceIds: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error('getDeviceIds: this.sessions not yet cached!');
if (identifier === null || identifier === undefined) { }
throw new Error('getDeviceIds: identifier was undefined/null'); if (identifier === null || identifier === undefined) {
} throw new Error('getDeviceIds: identifier was undefined/null');
try {
const id = window.ConversationController.getConversationId(identifier);
if (!id) {
throw new Error(
`getDeviceIds: No conversationId found for identifier ${identifier}`
);
} }
const allSessions = Array.from(this.sessions.values()); try {
const entries = allSessions.filter( const id = window.ConversationController.getConversationId(identifier);
session => session.fromDB.conversationId === id if (!id) {
); throw new Error(
const openIds = await Promise.all( `getDeviceIds: No conversationId found for identifier ${identifier}`
entries.map(async entry => { );
if (entry.hydrated) { }
const record = entry.item;
const allSessions = this._getAllSessions();
const entries = allSessions.filter(
session => session.fromDB.conversationId === id
);
const openIds = await Promise.all(
entries.map(async entry => {
if (entry.hydrated) {
const record = entry.item;
if (record.hasCurrentState()) {
return entry.fromDB.deviceId;
}
return undefined;
}
const record = await this._maybeMigrateSession(entry.fromDB);
if (record.hasCurrentState()) { if (record.hasCurrentState()) {
return entry.fromDB.deviceId; return entry.fromDB.deviceId;
} }
return undefined; return undefined;
} })
);
const record = await this._maybeMigrateSession(entry.fromDB); return openIds.filter(isNotNil);
if (record.hasCurrentState()) { } catch (error) {
return entry.fromDB.deviceId; window.log.error(
} `getDeviceIds: Failed to get device ids for identifier ${identifier}`,
error && error.stack ? error.stack : error
);
}
return undefined; return [];
}) });
);
return openIds.filter(isNotNil);
} catch (error) {
window.log.error(
`getDeviceIds: Failed to get device ids for identifier ${identifier}`,
error && error.stack ? error.stack : error
);
}
return [];
} }
async removeSession(encodedAddress: string): Promise<void> { async removeSession(encodedAddress: string): Promise<void> {
if (!this.sessions) { return this.sessionTransaction('removeSession', async () => {
throw new Error('removeSession: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error('removeSession: this.sessions not yet cached!');
}
window.log.info('removeSession: deleting session for', encodedAddress); window.log.info('removeSession: deleting session for', encodedAddress);
try { try {
const id = await normalizeEncodedAddress(encodedAddress); const id = await normalizeEncodedAddress(encodedAddress);
await window.Signal.Data.removeSessionById(id); await window.Signal.Data.removeSessionById(id);
this.sessions.delete(id); this.sessions.delete(id);
} catch (e) { this.pendingSessions.delete(id);
window.log.error( } catch (e) {
`removeSession: Failed to delete session for ${encodedAddress}` window.log.error(
); `removeSession: Failed to delete session for ${encodedAddress}`
} );
}
});
} }
async removeAllSessions(identifier: string): Promise<void> { async removeAllSessions(identifier: string): Promise<void> {
if (!this.sessions) { return this.sessionTransaction('removeAllSessions', async () => {
throw new Error('removeAllSessions: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error('removeAllSessions: this.sessions not yet cached!');
if (identifier === null || identifier === undefined) {
throw new Error('removeAllSessions: identifier was undefined/null');
}
window.log.info('removeAllSessions: deleting sessions for', identifier);
const id = window.ConversationController.getConversationId(identifier);
const entries = Array.from(this.sessions.values());
for (let i = 0, max = entries.length; i < max; i += 1) {
const entry = entries[i];
if (entry.fromDB.conversationId === id) {
this.sessions.delete(entry.fromDB.id);
} }
}
await window.Signal.Data.removeSessionsByConversation(identifier); if (identifier === null || identifier === undefined) {
throw new Error('removeAllSessions: identifier was undefined/null');
}
window.log.info('removeAllSessions: deleting sessions for', identifier);
const id = window.ConversationController.getConversationId(identifier);
const entries = Array.from(this.sessions.values());
for (let i = 0, max = entries.length; i < max; i += 1) {
const entry = entries[i];
if (entry.fromDB.conversationId === id) {
this.sessions.delete(entry.fromDB.id);
this.pendingSessions.delete(entry.fromDB.id);
}
}
await window.Signal.Data.removeSessionsByConversation(identifier);
});
} }
private async _archiveSession( private async _archiveSession(entry?: SessionCacheEntry) {
entry?: CacheEntryType<SessionType, SessionRecord>
) {
if (!entry) { if (!entry) {
return; return;
} }
@ -796,74 +949,87 @@ export class SignalProtocolStore extends EventsMixin {
} }
async archiveSession(encodedAddress: string): Promise<void> { async archiveSession(encodedAddress: string): Promise<void> {
if (!this.sessions) { return this.sessionTransaction('archiveSession', async () => {
throw new Error('archiveSession: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error('archiveSession: this.sessions not yet cached!');
}
window.log.info(`archiveSession: session for ${encodedAddress}`); window.log.info(`archiveSession: session for ${encodedAddress}`);
const id = await normalizeEncodedAddress(encodedAddress); const id = await normalizeEncodedAddress(encodedAddress);
const entry = this.sessions.get(id);
await this._archiveSession(entry); const entry = this.pendingSessions.get(id) || this.sessions.get(id);
await this._archiveSession(entry);
});
} }
async archiveSiblingSessions(encodedAddress: string): Promise<void> { async archiveSiblingSessions(encodedAddress: string): Promise<void> {
if (!this.sessions) { return this.sessionTransaction('archiveSiblingSessions', async () => {
throw new Error('archiveSiblingSessions: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error(
'archiveSiblingSessions: this.sessions not yet cached!'
);
}
window.log.info( window.log.info(
'archiveSiblingSessions: archiving sibling sessions for', 'archiveSiblingSessions: archiving sibling sessions for',
encodedAddress encodedAddress
); );
const id = await normalizeEncodedAddress(encodedAddress); const id = await normalizeEncodedAddress(encodedAddress);
const [identifier, deviceId] = window.textsecure.utils.unencodeNumber(id); const [identifier, deviceId] = window.textsecure.utils.unencodeNumber(id);
const deviceIdNumber = parseInt(deviceId, 10); const deviceIdNumber = parseInt(deviceId, 10);
const allEntries = Array.from(this.sessions.values()); const allEntries = this._getAllSessions();
const entries = allEntries.filter( const entries = allEntries.filter(
entry => entry =>
entry.fromDB.conversationId === identifier && entry.fromDB.conversationId === identifier &&
entry.fromDB.deviceId !== deviceIdNumber entry.fromDB.deviceId !== deviceIdNumber
); );
await Promise.all( await Promise.all(
entries.map(async entry => { entries.map(async entry => {
await this._archiveSession(entry); await this._archiveSession(entry);
}) })
); );
});
} }
async archiveAllSessions(identifier: string): Promise<void> { async archiveAllSessions(identifier: string): Promise<void> {
if (!this.sessions) { return this.sessionTransaction('archiveAllSessions', async () => {
throw new Error('archiveAllSessions: this.sessions not yet cached!'); if (!this.sessions) {
} throw new Error('archiveAllSessions: this.sessions not yet cached!');
}
window.log.info( window.log.info(
'archiveAllSessions: archiving all sessions for', 'archiveAllSessions: archiving all sessions for',
identifier identifier
); );
const id = window.ConversationController.getConversationId(identifier); const id = window.ConversationController.getConversationId(identifier);
const allEntries = Array.from(this.sessions.values());
const entries = allEntries.filter(
entry => entry.fromDB.conversationId === id
);
await Promise.all( const allEntries = this._getAllSessions();
entries.map(async entry => { const entries = allEntries.filter(
await this._archiveSession(entry); entry => entry.fromDB.conversationId === id
}) );
);
await Promise.all(
entries.map(async entry => {
await this._archiveSession(entry);
})
);
});
} }
async clearSessionStore(): Promise<void> { async clearSessionStore(): Promise<void> {
if (this.sessions) { return this.sessionTransaction('clearSessionStore', async () => {
this.sessions.clear(); if (this.sessions) {
} this.sessions.clear();
window.Signal.Data.removeAllSessions(); }
this.pendingSessions.clear();
await window.Signal.Data.removeAllSessions();
});
} }
// Identity Keys // Identity Keys
@ -1403,56 +1569,92 @@ export class SignalProtocolStore extends EventsMixin {
// Not yet processed messages - for resiliency // Not yet processed messages - for resiliency
getUnprocessedCount(): Promise<number> { getUnprocessedCount(): Promise<number> {
return window.Signal.Data.getUnprocessedCount(); return this.sessionTransaction('getUnprocessedCount', async () => {
this._checkNoPendingUnprocessed();
return window.Signal.Data.getUnprocessedCount();
});
} }
getAllUnprocessed(): Promise<Array<UnprocessedType>> { getAllUnprocessed(): Promise<Array<UnprocessedType>> {
return window.Signal.Data.getAllUnprocessed(); return this.sessionTransaction('getAllUnprocessed', async () => {
this._checkNoPendingUnprocessed();
return window.Signal.Data.getAllUnprocessed();
});
} }
getUnprocessedById(id: string): Promise<UnprocessedType | undefined> { getUnprocessedById(id: string): Promise<UnprocessedType | undefined> {
return window.Signal.Data.getUnprocessedById(id); return this.sessionTransaction('getUnprocessedById', async () => {
} this._checkNoPendingUnprocessed();
return window.Signal.Data.getUnprocessedById(id);
addUnprocessed(data: UnprocessedType): Promise<string> {
// We need to pass forceSave because the data has an id already, which will cause
// an update instead of an insert.
return window.Signal.Data.saveUnprocessed(data, {
forceSave: true,
}); });
} }
addMultipleUnprocessed(array: Array<UnprocessedType>): Promise<void> { addUnprocessed(
// We need to pass forceSave because the data has an id already, which will cause data: UnprocessedType,
// an update instead of an insert. { lock }: SessionTransactionOptions = {}
return window.Signal.Data.saveUnprocesseds(array, { ): Promise<void> {
forceSave: true, return this.sessionTransaction(
}); 'addUnprocessed',
async () => {
this.pendingUnprocessed.set(data.id, data);
},
lock
);
}
addMultipleUnprocessed(
array: Array<UnprocessedType>,
{ lock }: SessionTransactionOptions = {}
): Promise<void> {
return this.sessionTransaction(
'addMultipleUnprocessed',
async () => {
for (const elem of array) {
this.pendingUnprocessed.set(elem.id, elem);
}
},
lock
);
} }
updateUnprocessedAttempts(id: string, attempts: number): Promise<void> { updateUnprocessedAttempts(id: string, attempts: number): Promise<void> {
return window.Signal.Data.updateUnprocessedAttempts(id, attempts); return this.sessionTransaction('updateUnprocessedAttempts', async () => {
this._checkNoPendingUnprocessed();
await window.Signal.Data.updateUnprocessedAttempts(id, attempts);
});
} }
updateUnprocessedWithData( updateUnprocessedWithData(
id: string, id: string,
data: UnprocessedUpdateType data: UnprocessedUpdateType
): Promise<void> { ): Promise<void> {
return window.Signal.Data.updateUnprocessedWithData(id, data); return this.sessionTransaction('updateUnprocessedWithData', async () => {
this._checkNoPendingUnprocessed();
await window.Signal.Data.updateUnprocessedWithData(id, data);
});
} }
updateUnprocessedsWithData( updateUnprocessedsWithData(
items: Array<{ id: string; data: UnprocessedUpdateType }> items: Array<{ id: string; data: UnprocessedUpdateType }>
): Promise<void> { ): Promise<void> {
return window.Signal.Data.updateUnprocessedsWithData(items); return this.sessionTransaction('updateUnprocessedsWithData', async () => {
this._checkNoPendingUnprocessed();
await window.Signal.Data.updateUnprocessedsWithData(items);
});
} }
removeUnprocessed(idOrArray: string | Array<string>): Promise<void> { removeUnprocessed(idOrArray: string | Array<string>): Promise<void> {
return window.Signal.Data.removeUnprocessed(idOrArray); return this.sessionTransaction('removeUnprocessed', async () => {
this._checkNoPendingUnprocessed();
await window.Signal.Data.removeUnprocessed(idOrArray);
});
} }
removeAllUnprocessed(): Promise<void> { removeAllUnprocessed(): Promise<void> {
return window.Signal.Data.removeAllUnprocessed(); return this.sessionTransaction('removeAllUnprocessed', async () => {
this._checkNoPendingUnprocessed();
await window.Signal.Data.removeAllUnprocessed();
});
} }
async removeAllData(): Promise<void> { async removeAllData(): Promise<void> {
@ -1473,6 +1675,30 @@ export class SignalProtocolStore extends EventsMixin {
window.storage.reset(); window.storage.reset();
await window.storage.fetch(); await window.storage.fetch();
} }
private _getAllSessions(): Array<SessionCacheEntry> {
const union = new Map<string, SessionCacheEntry>();
this.sessions?.forEach((value, key) => {
union.set(key, value);
});
this.pendingSessions.forEach((value, key) => {
union.set(key, value);
});
return Array.from(union.values());
}
private _checkNoPendingUnprocessed(): void {
assert(
!this.sessionLock || this.sessionLock === GLOBAL_LOCK,
"Can't use this function with a global lock"
);
assert(
this.pendingUnprocessed.size === 0,
'Missing support for pending unprocessed'
);
}
} }
window.SignalProtocolStore = SignalProtocolStore; window.SignalProtocolStore = SignalProtocolStore;

View file

@ -142,6 +142,7 @@ const dataInterface: ClientInterface = {
createOrUpdateSession, createOrUpdateSession,
createOrUpdateSessions, createOrUpdateSessions,
commitSessionsAndUnprocessed,
getSessionById, getSessionById,
getSessionsById, getSessionsById,
bulkAddSessions, bulkAddSessions,
@ -767,6 +768,12 @@ async function createOrUpdateSession(data: SessionType) {
async function createOrUpdateSessions(array: Array<SessionType>) { async function createOrUpdateSessions(array: Array<SessionType>) {
await channels.createOrUpdateSessions(array); await channels.createOrUpdateSessions(array);
} }
async function commitSessionsAndUnprocessed(options: {
sessions: Array<SessionType>;
unprocessed: Array<UnprocessedType>;
}) {
await channels.commitSessionsAndUnprocessed(options);
}
async function getSessionById(id: string) { async function getSessionById(id: string) {
const session = await channels.getSessionById(id); const session = await channels.getSessionById(id);
@ -1353,22 +1360,14 @@ async function getUnprocessedById(id: string) {
return channels.getUnprocessedById(id); return channels.getUnprocessedById(id);
} }
async function saveUnprocessed( async function saveUnprocessed(data: UnprocessedType) {
data: UnprocessedType, const id = await channels.saveUnprocessed(_cleanData(data));
{ forceSave }: { forceSave?: boolean } = {}
) {
const id = await channels.saveUnprocessed(_cleanData(data), { forceSave });
return id; return id;
} }
async function saveUnprocesseds( async function saveUnprocesseds(arrayOfUnprocessed: Array<UnprocessedType>) {
arrayOfUnprocessed: Array<UnprocessedType>, await channels.saveUnprocesseds(_cleanData(arrayOfUnprocessed));
{ forceSave }: { forceSave?: boolean } = {}
) {
await channels.saveUnprocesseds(_cleanData(arrayOfUnprocessed), {
forceSave,
});
} }
async function updateUnprocessedAttempts(id: string, attempts: number) { async function updateUnprocessedAttempts(id: string, attempts: number) {

View file

@ -131,11 +131,11 @@ export type UnprocessedType = {
timestamp: number; timestamp: number;
version: number; version: number;
attempts: number; attempts: number;
envelope: string; envelope?: string;
source?: string; source?: string;
sourceUuid?: string; sourceUuid?: string;
sourceDevice?: string; sourceDevice?: number;
serverTimestamp?: number; serverTimestamp?: number;
decrypted?: string; decrypted?: string;
}; };
@ -188,6 +188,10 @@ export type DataInterface = {
createOrUpdateSession: (data: SessionType) => Promise<void>; createOrUpdateSession: (data: SessionType) => Promise<void>;
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>; createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
commitSessionsAndUnprocessed(options: {
sessions: Array<SessionType>;
unprocessed: Array<UnprocessedType>;
}): Promise<void>;
getSessionById: (id: string) => Promise<SessionType | undefined>; getSessionById: (id: string) => Promise<SessionType | undefined>;
getSessionsById: (conversationId: string) => Promise<Array<SessionType>>; getSessionsById: (conversationId: string) => Promise<Array<SessionType>>;
bulkAddSessions: (array: Array<SessionType>) => Promise<void>; bulkAddSessions: (array: Array<SessionType>) => Promise<void>;

View file

@ -133,6 +133,7 @@ const dataInterface: ServerInterface = {
createOrUpdateSession, createOrUpdateSession,
createOrUpdateSessions, createOrUpdateSessions,
commitSessionsAndUnprocessed,
getSessionById, getSessionById,
getSessionsById, getSessionsById,
bulkAddSessions, bulkAddSessions,
@ -2217,6 +2218,26 @@ async function createOrUpdateSessions(
})(); })();
} }
async function commitSessionsAndUnprocessed({
sessions,
unprocessed,
}: {
sessions: Array<SessionType>;
unprocessed: Array<UnprocessedType>;
}): Promise<void> {
const db = getInstance();
db.transaction(() => {
for (const item of sessions) {
createOrUpdateSession(item);
}
for (const item of unprocessed) {
saveUnprocessedSync(item);
}
})();
}
async function getSessionById(id: string): Promise<SessionType | undefined> { async function getSessionById(id: string): Promise<SessionType | undefined> {
return getById(SESSIONS_TABLE, id); return getById(SESSIONS_TABLE, id);
} }
@ -3948,82 +3969,79 @@ async function getTapToViewMessagesNeedingErase(): Promise<Array<MessageType>> {
return rows.map(row => jsonToObject(row.json)); return rows.map(row => jsonToObject(row.json));
} }
function saveUnprocessedSync( function saveUnprocessedSync(data: UnprocessedType): string {
data: UnprocessedType,
{ forceSave }: { forceSave?: boolean } = {}
): string {
const db = getInstance(); const db = getInstance();
const { id, timestamp, version, attempts, envelope } = data; const {
id,
timestamp,
version,
attempts,
envelope,
source,
sourceUuid,
sourceDevice,
serverTimestamp,
decrypted,
} = data;
if (!id) { if (!id) {
throw new Error('saveUnprocessed: id was falsey'); throw new Error('saveUnprocessed: id was falsey');
} }
if (forceSave) {
prepare(
db,
`
INSERT INTO unprocessed (
id,
timestamp,
version,
attempts,
envelope
) values (
$id,
$timestamp,
$version,
$attempts,
$envelope
);
`
).run({
id,
timestamp,
version,
attempts,
envelope,
});
return id;
}
prepare( prepare(
db, db,
` `
UPDATE unprocessed SET INSERT OR REPLACE INTO unprocessed (
timestamp = $timestamp, id,
version = $version, timestamp,
attempts = $attempts, version,
envelope = $envelope attempts,
WHERE id = $id; envelope,
source,
sourceUuid,
sourceDevice,
serverTimestamp,
decrypted
) values (
$id,
$timestamp,
$version,
$attempts,
$envelope,
$source,
$sourceUuid,
$sourceDevice,
$serverTimestamp,
$decrypted
);
` `
).run({ ).run({
id, id,
timestamp, timestamp,
version, version,
attempts, attempts,
envelope, envelope: envelope || null,
source: source || null,
sourceUuid: sourceUuid || null,
sourceDevice: sourceDevice || null,
serverTimestamp: serverTimestamp || null,
decrypted: decrypted || null,
}); });
return id; return id;
} }
async function saveUnprocessed( async function saveUnprocessed(data: UnprocessedType): Promise<string> {
data: UnprocessedType, return saveUnprocessedSync(data);
options: { forceSave?: boolean } = {}
): Promise<string> {
return saveUnprocessedSync(data, options);
} }
async function saveUnprocesseds( async function saveUnprocesseds(
arrayOfUnprocessed: Array<UnprocessedType>, arrayOfUnprocessed: Array<UnprocessedType>
{ forceSave }: { forceSave?: boolean } = {}
): Promise<void> { ): Promise<void> {
const db = getInstance(); const db = getInstance();
db.transaction(() => { db.transaction(() => {
for (const unprocessed of arrayOfUnprocessed) { for (const unprocessed of arrayOfUnprocessed) {
assertSync(saveUnprocessedSync(unprocessed, { forceSave })); assertSync(saveUnprocessedSync(unprocessed));
} }
})(); })();
} }

View file

@ -3,7 +3,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { assert } from 'chai'; import chai, { assert } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { import {
Direction, Direction,
SenderKeyRecord, SenderKeyRecord,
@ -12,12 +13,15 @@ import {
import { signal } from '../protobuf/compiled'; import { signal } from '../protobuf/compiled';
import { sessionStructureToArrayBuffer } from '../util/sessionTranslation'; import { sessionStructureToArrayBuffer } from '../util/sessionTranslation';
import { Lock } from '../util/Lock';
import { getRandomBytes, constantTimeEqual } from '../Crypto'; import { getRandomBytes, constantTimeEqual } from '../Crypto';
import { clampPrivateKey, setPublicKeyTypeByte } from '../Curve'; import { clampPrivateKey, setPublicKeyTypeByte } from '../Curve';
import { SignalProtocolStore } from '../SignalProtocolStore'; import { SignalProtocolStore } from '../SignalProtocolStore';
import { IdentityKeyType, KeyPairType } from '../textsecure/Types.d'; import { IdentityKeyType, KeyPairType } from '../textsecure/Types.d';
chai.use(chaiAsPromised);
const { const {
RecordStructure, RecordStructure,
SessionStructure, SessionStructure,
@ -1233,7 +1237,7 @@ describe('SignalProtocolStore', () => {
await store.removeAllSessions(number); await store.removeAllSessions(number);
const records = await Promise.all( const records = await Promise.all(
devices.map(store.loadSession.bind(store)) devices.map(device => store.loadSession(device))
); );
for (let i = 0, max = records.length; i < max; i += 1) { for (let i = 0, max = records.length; i < max; i += 1) {
@ -1276,6 +1280,96 @@ describe('SignalProtocolStore', () => {
}); });
}); });
describe('sessionTransaction', () => {
beforeEach(async () => {
await store.removeAllUnprocessed();
await store.removeAllSessions(number);
});
it('commits session stores and unprocessed on success', async () => {
const id = `${number}.1`;
const testRecord = getSessionRecord();
await store.sessionTransaction('test', async () => {
await store.storeSession(id, testRecord);
await store.addUnprocessed({
id: '2-two',
envelope: 'second',
timestamp: 2,
version: 2,
attempts: 0,
});
assert.equal(await store.loadSession(id), testRecord);
});
assert.equal(await store.loadSession(id), testRecord);
const allUnprocessed = await store.getAllUnprocessed();
assert.deepEqual(
allUnprocessed.map(({ envelope }) => envelope),
['second']
);
});
it('reverts session stores and unprocessed on error', async () => {
const id = `${number}.1`;
const testRecord = getSessionRecord();
const failedRecord = getSessionRecord();
await store.storeSession(id, testRecord);
assert.equal(await store.loadSession(id), testRecord);
await assert.isRejected(
store.sessionTransaction('test', async () => {
await store.storeSession(id, failedRecord);
assert.equal(await store.loadSession(id), failedRecord);
await store.addUnprocessed({
id: '2-two',
envelope: 'second',
timestamp: 2,
version: 2,
attempts: 0,
});
throw new Error('Failure');
}),
'Failure'
);
assert.equal(await store.loadSession(id), testRecord);
assert.deepEqual(await store.getAllUnprocessed(), []);
});
it('can be re-entered', async () => {
const id = `${number}.1`;
const testRecord = getSessionRecord();
const lock = new Lock();
await store.sessionTransaction(
'test',
async () => {
await store.sessionTransaction(
'nested',
async () => {
await store.storeSession(id, testRecord, { lock });
assert.equal(await store.loadSession(id, { lock }), testRecord);
},
lock
);
assert.equal(await store.loadSession(id, { lock }), testRecord);
},
lock
);
assert.equal(await store.loadSession(id), testRecord);
});
});
describe('Not yet processed messages', () => { describe('Not yet processed messages', () => {
beforeEach(async () => { beforeEach(async () => {
await store.removeAllUnprocessed(); await store.removeAllUnprocessed();

4
ts/textsecure.d.ts vendored
View file

@ -61,15 +61,11 @@ export type TextSecureType = {
setUuidAndDeviceId: (uuid: string, deviceId: number) => Promise<void>; setUuidAndDeviceId: (uuid: string, deviceId: number) => Promise<void>;
}; };
unprocessed: { unprocessed: {
batchAdd: (dataArray: Array<UnprocessedType>) => Promise<void>;
remove: (id: string | Array<string>) => Promise<void>; remove: (id: string | Array<string>) => Promise<void>;
getCount: () => Promise<number>; getCount: () => Promise<number>;
removeAll: () => Promise<void>; removeAll: () => Promise<void>;
getAll: () => Promise<Array<UnprocessedType>>; getAll: () => Promise<Array<UnprocessedType>>;
updateAttempts: (id: string, attempts: number) => Promise<void>; updateAttempts: (id: string, attempts: number) => Promise<void>;
addDecryptedDataToList: (
array: Array<Partial<UnprocessedType>>
) => Promise<void>;
}; };
get: (key: string, defaultValue?: any) => any; get: (key: string, defaultValue?: any) => any;
put: (key: string, value: any) => Promise<void>; put: (key: string, value: any) => Promise<void>;

View file

@ -8,6 +8,7 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
/* eslint-disable no-restricted-syntax */
import { isNumber, map, omit, noop } from 'lodash'; import { isNumber, map, omit, noop } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
@ -121,9 +122,10 @@ type CacheAddItemType = {
request: IncomingWebSocketRequest; request: IncomingWebSocketRequest;
}; };
type CacheUpdateItemType = { type DecryptedEnvelope = {
id: string; readonly plaintext: ArrayBuffer;
data: Partial<UnprocessedType>; readonly data: UnprocessedType;
readonly envelope: EnvelopeClass;
}; };
class MessageReceiverInner extends EventTarget { class MessageReceiverInner extends EventTarget {
@ -135,8 +137,6 @@ class MessageReceiverInner extends EventTarget {
cacheRemoveBatcher: BatcherType<string>; cacheRemoveBatcher: BatcherType<string>;
cacheUpdateBatcher: BatcherType<CacheUpdateItemType>;
calledClose?: boolean; calledClose?: boolean;
count: number; count: number;
@ -233,12 +233,6 @@ class MessageReceiverInner extends EventTarget {
this.cacheAndQueueBatch(items); this.cacheAndQueueBatch(items);
}, },
}); });
this.cacheUpdateBatcher = createBatcher<CacheUpdateItemType>({
name: 'MessageReceiver.cacheUpdateBatcher',
wait: 75,
maxSize: 30,
processBatch: this.cacheUpdateBatch.bind(this),
});
this.cacheRemoveBatcher = createBatcher<string>({ this.cacheRemoveBatcher = createBatcher<string>({
name: 'MessageReceiver.cacheRemoveBatcher', name: 'MessageReceiver.cacheRemoveBatcher',
wait: 75, wait: 75,
@ -314,7 +308,6 @@ class MessageReceiverInner extends EventTarget {
unregisterBatchers() { unregisterBatchers() {
window.log.info('MessageReceiver: unregister batchers'); window.log.info('MessageReceiver: unregister batchers');
this.cacheAddBatcher.unregister(); this.cacheAddBatcher.unregister();
this.cacheUpdateBatcher.unregister();
this.cacheRemoveBatcher.unregister(); this.cacheRemoveBatcher.unregister();
} }
@ -514,7 +507,7 @@ class MessageReceiverInner extends EventTarget {
return messageAgeSec; return messageAgeSec;
} }
async addToQueue(task: () => Promise<void>) { async addToQueue<T>(task: () => Promise<T>): Promise<T> {
this.count += 1; this.count += 1;
const promise = this.pendingQueue.add(task); const promise = this.pendingQueue.add(task);
@ -538,7 +531,6 @@ class MessageReceiverInner extends EventTarget {
const emitEmpty = async () => { const emitEmpty = async () => {
await Promise.all([ await Promise.all([
this.cacheAddBatcher.flushAndWait(), this.cacheAddBatcher.flushAndWait(),
this.cacheUpdateBatcher.flushAndWait(),
this.cacheRemoveBatcher.flushAndWait(), this.cacheRemoveBatcher.flushAndWait(),
]); ]);
@ -655,7 +647,7 @@ class MessageReceiverInner extends EventTarget {
} }
this.queueDecryptedEnvelope(envelope, payloadPlaintext); this.queueDecryptedEnvelope(envelope, payloadPlaintext);
} else { } else {
this.queueEnvelope(envelope); this.queueEnvelope(new Sessions(), envelope);
} }
} catch (error) { } catch (error) {
window.log.error( window.log.error(
@ -755,21 +747,88 @@ class MessageReceiverInner extends EventTarget {
async cacheAndQueueBatch(items: Array<CacheAddItemType>) { async cacheAndQueueBatch(items: Array<CacheAddItemType>) {
window.log.info('MessageReceiver.cacheAndQueueBatch', items.length); window.log.info('MessageReceiver.cacheAndQueueBatch', items.length);
const dataArray = items.map(item => item.data);
const decrypted: Array<DecryptedEnvelope> = [];
try { try {
await window.textsecure.storage.unprocessed.batchAdd(dataArray); const sessionStore = new Sessions({
items.forEach(item => { transactionOnly: true,
});
const failed: Array<UnprocessedType> = [];
// Below we:
//
// 1. Enter session transaction
// 2. Decrypt all batched envelopes
// 3. Persist both decrypted envelopes and envelopes that we failed to
// decrypt (for future retries, see `attempts` field)
// 4. Leave session transaction and commit all pending session updates
// 5. Acknowledge envelopes (can't fail)
// 6. Finally process decrypted envelopes
await sessionStore.transaction(async () => {
await Promise.all<void>(
items.map(async ({ data, envelope }) => {
try {
const plaintext = await this.queueEnvelope(
sessionStore,
envelope
);
if (plaintext) {
decrypted.push({ plaintext, data, envelope });
}
} catch (error) {
failed.push(data);
window.log.error(
'cacheAndQueue error when processing the envelope',
error && error.stack ? error.stack : error
);
}
})
);
window.log.info(
'MessageReceiver.cacheAndQueueBatch storing ' +
`${decrypted.length} decrypted envelopes`
);
// Store both decrypted and failed unprocessed envelopes
const unprocesseds: Array<UnprocessedType> = decrypted.map(
({ envelope, data, plaintext }) => {
return {
...data,
// We have sucessfully decrypted the message so don't bother with
// storing the envelope.
envelope: '',
source: envelope.source,
sourceUuid: envelope.sourceUuid,
sourceDevice: envelope.sourceDevice,
serverTimestamp: envelope.serverTimestamp,
decrypted: MessageReceiverInner.arrayBufferToStringBase64(
plaintext
),
};
}
);
await sessionStore.addUnprocessed(unprocesseds.concat(failed));
});
window.log.info(
'MessageReceiver.cacheAndQueueBatch acknowledging receipt'
);
// Acknowledge all envelopes
for (const { request } of items) {
try { try {
item.request.respond(200, 'OK'); request.respond(200, 'OK');
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'cacheAndQueueBatch: Failed to send 200 to server; still queuing envelope' 'cacheAndQueueBatch: Failed to send 200 to server; still queuing envelope'
); );
} }
this.queueEnvelope(item.envelope); }
});
this.maybeScheduleRetryTimeout();
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'cacheAndQueue error trying to add messages to cache:', 'cacheAndQueue error trying to add messages to cache:',
@ -779,7 +838,25 @@ class MessageReceiverInner extends EventTarget {
items.forEach(item => { items.forEach(item => {
item.request.respond(500, 'Failed to cache message'); item.request.respond(500, 'Failed to cache message');
}); });
return;
} }
await Promise.all(
decrypted.map(async ({ envelope, plaintext }) => {
try {
await this.queueDecryptedEnvelope(envelope, plaintext);
} catch (error) {
window.log.error(
'cacheAndQueue error when processing decrypted envelope',
error && error.stack ? error.stack : error
);
}
})
);
window.log.info('MessageReceiver.cacheAndQueueBatch fully processed');
this.maybeScheduleRetryTimeout();
} }
cacheAndQueue( cacheAndQueue(
@ -802,23 +879,6 @@ class MessageReceiverInner extends EventTarget {
}); });
} }
async cacheUpdateBatch(items: Array<Partial<UnprocessedType>>) {
window.log.info('MessageReceiver.cacheUpdateBatch', items.length);
await window.textsecure.storage.unprocessed.addDecryptedDataToList(items);
}
updateCache(envelope: EnvelopeClass, plaintext: ArrayBuffer) {
const { id } = envelope;
const data = {
source: envelope.source,
sourceUuid: envelope.sourceUuid,
sourceDevice: envelope.sourceDevice,
serverTimestamp: envelope.serverTimestamp,
decrypted: MessageReceiverInner.arrayBufferToStringBase64(plaintext),
};
this.cacheUpdateBatcher.add({ id, data });
}
async cacheRemoveBatch(items: Array<string>) { async cacheRemoveBatch(items: Array<string>) {
await window.textsecure.storage.unprocessed.remove(items); await window.textsecure.storage.unprocessed.remove(items);
} }
@ -851,18 +911,22 @@ class MessageReceiverInner extends EventTarget {
}); });
} }
async queueEnvelope(envelope: EnvelopeClass) { async queueEnvelope(
sessionStore: Sessions,
envelope: EnvelopeClass
): Promise<ArrayBuffer | undefined> {
const id = this.getEnvelopeId(envelope); const id = this.getEnvelopeId(envelope);
window.log.info('queueing envelope', id); window.log.info('queueing envelope', id);
const task = this.handleEnvelope.bind(this, envelope); const task = this.decryptEnvelope.bind(this, sessionStore, envelope);
const taskWithTimeout = window.textsecure.createTaskWithTimeout( const taskWithTimeout = window.textsecure.createTaskWithTimeout(
task, task,
`queueEnvelope ${id}` `queueEnvelope ${id}`
); );
const promise = this.addToQueue(taskWithTimeout);
return promise.catch(error => { try {
return await this.addToQueue(taskWithTimeout);
} catch (error) {
const args = [ const args = [
'queueEnvelope error handling envelope', 'queueEnvelope error handling envelope',
this.getEnvelopeId(envelope), this.getEnvelopeId(envelope),
@ -875,12 +939,11 @@ class MessageReceiverInner extends EventTarget {
} else { } else {
window.log.error(...args); window.log.error(...args);
} }
}); return undefined;
}
} }
// Same as handleEnvelope, just without the decryption step. Necessary for handling // Called after `decryptEnvelope` decrypted the message.
// messages which were successfully decrypted, but application logic didn't finish
// processing.
async handleDecryptedEnvelope( async handleDecryptedEnvelope(
envelope: EnvelopeClass, envelope: EnvelopeClass,
plaintext: ArrayBuffer plaintext: ArrayBuffer
@ -906,21 +969,26 @@ class MessageReceiverInner extends EventTarget {
throw new Error('Received message with no content and no legacyMessage'); throw new Error('Received message with no content and no legacyMessage');
} }
async handleEnvelope(envelope: EnvelopeClass) { async decryptEnvelope(
sessionStore: Sessions,
envelope: EnvelopeClass
): Promise<ArrayBuffer | undefined> {
if (this.stoppingProcessing) { if (this.stoppingProcessing) {
return Promise.resolve(); return undefined;
} }
if (envelope.type === window.textsecure.protobuf.Envelope.Type.RECEIPT) { if (envelope.type === window.textsecure.protobuf.Envelope.Type.RECEIPT) {
return this.onDeliveryReceipt(envelope); await this.onDeliveryReceipt(envelope);
return undefined;
} }
if (envelope.content) { if (envelope.content) {
return this.handleContentMessage(envelope); return this.decryptContentMessage(sessionStore, envelope);
} }
if (envelope.legacyMessage) { if (envelope.legacyMessage) {
return this.handleLegacyMessage(envelope); return this.decryptLegacyMessage(sessionStore, envelope);
} }
this.removeFromCache(envelope); this.removeFromCache(envelope);
throw new Error('Received message with no content and no legacyMessage'); throw new Error('Received message with no content and no legacyMessage');
} }
@ -935,7 +1003,7 @@ class MessageReceiverInner extends EventTarget {
return -1; return -1;
} }
async onDeliveryReceipt(envelope: EnvelopeClass) { async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ev = new Event('delivery'); const ev = new Event('delivery');
ev.confirm = this.removeFromCache.bind(this, envelope); ev.confirm = this.removeFromCache.bind(this, envelope);
@ -968,6 +1036,7 @@ class MessageReceiverInner extends EventTarget {
} }
async decrypt( async decrypt(
sessionStore: Sessions,
envelope: EnvelopeClass, envelope: EnvelopeClass,
ciphertext: ByteBufferClass ciphertext: ByteBufferClass
): Promise<ArrayBuffer | null> { ): Promise<ArrayBuffer | null> {
@ -990,7 +1059,6 @@ class MessageReceiverInner extends EventTarget {
throw new Error('MessageReceiver.decrypt: Failed to fetch local UUID'); throw new Error('MessageReceiver.decrypt: Failed to fetch local UUID');
} }
const sessionStore = new Sessions();
const identityKeyStore = new IdentityKeys(); const identityKeyStore = new IdentityKeys();
const preKeyStore = new PreKeys(); const preKeyStore = new PreKeys();
const signedPreKeyStore = new SignedPreKeys(); const signedPreKeyStore = new SignedPreKeys();
@ -1216,15 +1284,6 @@ class MessageReceiverInner extends EventTarget {
return null; return null;
} }
// Note: this is an out of band update; there are cases where the item in the
// cache has already been deleted by the time this runs. That's okay.
try {
this.updateCache(envelope, plaintext);
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(`decrypt: updateCache failed: ${errorString}`);
}
return plaintext; return plaintext;
} }
) )
@ -1540,18 +1599,25 @@ class MessageReceiverInner extends EventTarget {
); );
} }
async handleLegacyMessage(envelope: EnvelopeClass) { async decryptLegacyMessage(
sessionStore: Sessions,
envelope: EnvelopeClass
): Promise<ArrayBuffer | undefined> {
window.log.info( window.log.info(
'MessageReceiver.handleLegacyMessage', 'MessageReceiver.decryptLegacyMessage',
this.getEnvelopeId(envelope) this.getEnvelopeId(envelope)
); );
return this.decrypt(envelope, envelope.legacyMessage).then(plaintext => { const plaintext = await this.decrypt(
if (!plaintext) { sessionStore,
window.log.warn('handleLegacyMessage: plaintext was falsey'); envelope,
return null; envelope.legacyMessage
} );
return this.innerHandleLegacyMessage(envelope, plaintext); if (!plaintext) {
}); window.log.warn('decryptLegacyMessage: plaintext was falsey');
return undefined;
}
return plaintext;
} }
async innerHandleLegacyMessage( async innerHandleLegacyMessage(
@ -1562,18 +1628,25 @@ class MessageReceiverInner extends EventTarget {
return this.handleDataMessage(envelope, message); return this.handleDataMessage(envelope, message);
} }
async handleContentMessage(envelope: EnvelopeClass) { async decryptContentMessage(
sessionStore: Sessions,
envelope: EnvelopeClass
): Promise<ArrayBuffer | undefined> {
window.log.info( window.log.info(
'MessageReceiver.handleContentMessage', 'MessageReceiver.decryptContentMessage',
this.getEnvelopeId(envelope) this.getEnvelopeId(envelope)
); );
return this.decrypt(envelope, envelope.content).then(plaintext => { const plaintext = await this.decrypt(
if (!plaintext) { sessionStore,
window.log.warn('handleContentMessage: plaintext was falsey'); envelope,
return null; envelope.content
} );
return this.innerHandleContentMessage(envelope, plaintext); if (!plaintext) {
}); window.log.warn('decryptContentMessage: plaintext was falsey');
return undefined;
}
return plaintext;
} }
async innerHandleContentMessage( async innerHandleContentMessage(

4
ts/util/Lock.ts Normal file
View file

@ -0,0 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export class Lock {}

View file

@ -2200,10 +2200,17 @@
"@types/connect" "*" "@types/connect" "*"
"@types/node" "*" "@types/node" "*"
"@types/chai@4.1.2": "@types/chai-as-promised@7.1.4":
version "4.1.2" version "7.1.4"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21" resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.4.tgz#caf64e76fb056b8c8ced4b761ed499272b737601"
integrity sha512-D8uQwKYUw2KESkorZ27ykzXgvkDJYXVEihGklgfp5I4HUP8D6IxtcdLTMB1emjQiWzV7WZ5ihm1cxIzVwjoleQ== integrity sha512-1y3L1cHePcIm5vXkh1DSGf/zQq5n5xDKG1fpCvf18+uOkpce0Z1ozNFPkyWsVswK7ntN1sZBw3oU6gmN+pDUcA==
dependencies:
"@types/chai" "*"
"@types/chai@*", "@types/chai@4.2.18":
version "4.2.18"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.18.tgz#0c8e298dbff8205e2266606c1ea5fbdba29b46e4"
integrity sha512-rS27+EkB/RE1Iz3u0XtVL5q36MGDWbgYe7zWiodyKNUnthxY0rukK5V36eiUCtCisB7NN8zKYH6DO2M37qxFEQ==
"@types/classnames@2.2.3": "@types/classnames@2.2.3":
version "2.2.3" version "2.2.3"
@ -3869,9 +3876,10 @@ assert@^1.1.1:
dependencies: dependencies:
util "0.10.3" util "0.10.3"
assertion-error@^1.0.1: assertion-error@^1.1.0:
version "1.0.2" version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
assign-symbols@^1.0.0: assign-symbols@^1.0.0:
version "1.0.0" version "1.0.0"
@ -5087,17 +5095,24 @@ catharsis@^0.8.10:
dependencies: dependencies:
lodash "^4.17.14" lodash "^4.17.14"
chai@4.1.2: chai-as-promised@7.1.1:
version "4.1.2" version "7.1.1"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c" resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0"
integrity sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw= integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==
dependencies: dependencies:
assertion-error "^1.0.1" check-error "^1.0.2"
check-error "^1.0.1"
deep-eql "^3.0.0" chai@4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==
dependencies:
assertion-error "^1.1.0"
check-error "^1.0.2"
deep-eql "^3.0.1"
get-func-name "^2.0.0" get-func-name "^2.0.0"
pathval "^1.0.0" pathval "^1.1.1"
type-detect "^4.0.0" type-detect "^4.0.5"
chainsaw@~0.1.0: chainsaw@~0.1.0:
version "0.1.0" version "0.1.0"
@ -5168,9 +5183,10 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
check-error@^1.0.1: check-error@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
chokidar@^2.0.2: chokidar@^2.0.2:
version "2.0.4" version "2.0.4"
@ -6342,9 +6358,10 @@ deep-diff@^0.3.5:
resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84"
integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ= integrity sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ=
deep-eql@^3.0.0: deep-eql@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
dependencies: dependencies:
type-detect "^4.0.0" type-detect "^4.0.0"
@ -13498,9 +13515,10 @@ path@^0.12.7:
process "^0.11.1" process "^0.11.1"
util "^0.10.3" util "^0.10.3"
pathval@^1.0.0: pathval@^1.1.1:
version "1.1.0" version "1.1.1"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
pbkdf2@^3.0.3: pbkdf2@^3.0.3:
version "3.0.14" version "3.0.14"
@ -17608,7 +17626,7 @@ type-check@~0.3.2:
dependencies: dependencies:
prelude-ls "~1.1.2" prelude-ls "~1.1.2"
type-detect@4.0.8, type-detect@^4.0.8: type-detect@4.0.8, type-detect@^4.0.5, type-detect@^4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==