// Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import PQueue from 'p-queue'; import { isNumber, omit } from 'lodash'; import { z } from 'zod'; import { EventEmitter } from 'events'; import { Direction, IdentityKeyPair, KyberPreKeyRecord, PreKeyRecord, PrivateKey, PublicKey, SenderKeyRecord, SessionRecord, SignedPreKeyRecord, } from '@signalapp/libsignal-client'; import * as Bytes from './Bytes'; import { constantTimeEqual, sha256 } from './Crypto'; import { assertDev, strictAssert } from './util/assert'; import { isNotNil } from './util/isNotNil'; import { Zone } from './util/Zone'; import { isMoreRecentThan } from './util/timestamp'; import { sessionRecordToProtobuf, sessionStructureToBytes, } from './util/sessionTranslation'; import type { DeviceType, IdentityKeyType, IdentityKeyIdType, KeyPairType, KyberPreKeyType, OuterSignedPrekeyType, PniKeyMaterialType, PniSignatureMessageType, PreKeyIdType, PreKeyType, SenderKeyIdType, SenderKeyType, SessionIdType, SessionResetsType, SessionType, SignedPreKeyIdType, SignedPreKeyType, UnprocessedType, UnprocessedUpdateType, CompatPreKeyType, } from './textsecure/Types.d'; import type { ServiceIdString, PniString, AciString } from './types/ServiceId'; import { isServiceIdString, ServiceIdKind } from './types/ServiceId'; import type { Address } from './types/Address'; import type { QualifiedAddressStringType } from './types/QualifiedAddress'; import { QualifiedAddress } from './types/QualifiedAddress'; import * as log from './logging/log'; import * as Errors from './types/errors'; import { MINUTE } from './util/durations'; import { conversationJobQueue } from './jobs/conversationJobQueue'; import { KYBER_KEY_ID_KEY, SIGNED_PRE_KEY_ID_KEY, } from './textsecure/AccountManager'; import { formatGroups, groupWhile } from './util/groupWhile'; const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds const LOW_KEYS_THRESHOLD = 25; const VerifiedStatus = { DEFAULT: 0, VERIFIED: 1, UNVERIFIED: 2, }; function validateVerifiedStatus(status: number): boolean { if ( status === VerifiedStatus.DEFAULT || status === VerifiedStatus.VERIFIED || status === VerifiedStatus.UNVERIFIED ) { return true; } return false; } const identityKeySchema = z.object({ id: z.string(), publicKey: z.instanceof(Uint8Array), firstUse: z.boolean(), timestamp: z.number().refine((value: number) => value % 1 === 0 && value > 0), verified: z.number().refine(validateVerifiedStatus), nonblockingApproval: z.boolean(), }); function validateIdentityKey(attrs: unknown): attrs is IdentityKeyType { // We'll throw if this doesn't match identityKeySchema.parse(attrs); return true; } /* * Potentially hundreds of items, so we'll group together sequences, * take the first 10 of the sequences, format them as ranges, * and log that once. * => '1-10, 12, 14-20' */ function formatKeys(keys: Array): string { return formatGroups( groupWhile(keys.sort(), (a, b) => a + 1 === b).slice(0, 10), '-', ', ', String ); } type HasIdType = { id: T; }; type CacheEntryType = | { hydrated: false; fromDB: DBType; } | { hydrated: true; fromDB: DBType; item: HydratedType }; type MapFields = | 'kyberPreKeys' | 'identityKeys' | 'preKeys' | 'senderKeys' | 'sessions' | 'signedPreKeys'; export type SessionTransactionOptions = Readonly<{ zone?: Zone; }>; export type SaveIdentityOptions = Readonly<{ zone?: Zone; noOverwrite?: boolean; }>; export type VerifyAlternateIdentityOptionsType = Readonly<{ aci: AciString; pni: PniString; signature: Uint8Array; }>; export type SetVerifiedExtra = Readonly<{ firstUse?: boolean; nonblockingApproval?: boolean; }>; export const GLOBAL_ZONE = new Zone('GLOBAL_ZONE'); async function _fillCaches, HydratedType>( object: SignalProtocolStore, field: MapFields, itemsPromise: Promise> ): Promise { const items = await itemsPromise; const cache = new Map>(); for (let i = 0, max = items.length; i < max; i += 1) { const fromDB = items[i]; const { id } = fromDB; cache.set(id, { fromDB, hydrated: false, }); } log.info(`SignalProtocolStore: Finished caching ${field} data`); // eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any object[field] = cache as any; } export function hydrateSession(session: SessionType): SessionRecord { return SessionRecord.deserialize(Buffer.from(session.record, 'base64')); } export function hydratePublicKey(identityKey: IdentityKeyType): PublicKey { return PublicKey.deserialize(Buffer.from(identityKey.publicKey)); } export function hydratePreKey(preKey: PreKeyType): PreKeyRecord { const publicKey = PublicKey.deserialize(Buffer.from(preKey.publicKey)); const privateKey = PrivateKey.deserialize(Buffer.from(preKey.privateKey)); return PreKeyRecord.new(preKey.keyId, publicKey, privateKey); } export function hydrateSignedPreKey( signedPreKey: SignedPreKeyType ): SignedPreKeyRecord { const createdAt = signedPreKey.created_at; const pubKey = PublicKey.deserialize(Buffer.from(signedPreKey.publicKey)); const privKey = PrivateKey.deserialize(Buffer.from(signedPreKey.privateKey)); const signature = Buffer.from([]); return SignedPreKeyRecord.new( signedPreKey.keyId, createdAt, pubKey, privKey, signature ); } export function freezeSession(session: SessionRecord): string { return session.serialize().toString('base64'); } export function freezePublicKey(publicKey: PublicKey): Uint8Array { return publicKey.serialize(); } export function freezePreKey(preKey: PreKeyRecord): KeyPairType { const keyPair = { pubKey: preKey.publicKey().serialize(), privKey: preKey.privateKey().serialize(), }; return keyPair; } export function freezeSignedPreKey( signedPreKey: SignedPreKeyRecord ): KeyPairType { const keyPair = { pubKey: signedPreKey.publicKey().serialize(), privKey: signedPreKey.privateKey().serialize(), }; return keyPair; } type SessionCacheEntry = CacheEntryType; type SenderKeyCacheEntry = CacheEntryType; type ZoneQueueEntryType = Readonly<{ zone: Zone; callback(): void; }>; export class SignalProtocolStore extends EventEmitter { // Enums used across the app VerifiedStatus = VerifiedStatus; // Cached values private ourIdentityKeys = new Map(); private ourRegistrationIds = new Map(); private cachedPniSignatureMessage: PniSignatureMessageType | undefined; identityKeys?: Map< IdentityKeyIdType, CacheEntryType >; kyberPreKeys?: Map< PreKeyIdType, CacheEntryType >; senderKeys?: Map; sessions?: Map; preKeys?: Map>; signedPreKeys?: Map< SignedPreKeyIdType, CacheEntryType >; senderKeyQueues = new Map(); sessionQueues = new Map(); sessionQueueJobCounter = 0; private readonly identityQueues = new Map(); private currentZone?: Zone; private currentZoneDepth = 0; private readonly zoneQueue: Array = []; private pendingSessions = new Map(); private pendingSenderKeys = new Map(); private pendingUnprocessed = new Map(); async hydrateCaches(): Promise { await Promise.all([ (async () => { this.ourIdentityKeys.clear(); const map = await window.Signal.Data.getItemById('identityKeyMap'); if (!map) { return; } for (const serviceId of Object.keys(map.value)) { strictAssert( isServiceIdString(serviceId), 'Invalid identity key serviceId' ); const { privKey, pubKey } = map.value[serviceId]; this.ourIdentityKeys.set(serviceId, { privKey, pubKey, }); } })(), (async () => { this.ourRegistrationIds.clear(); const map = await window.Signal.Data.getItemById('registrationIdMap'); if (!map) { return; } for (const serviceId of Object.keys(map.value)) { strictAssert( isServiceIdString(serviceId), 'Invalid registration id serviceId' ); this.ourRegistrationIds.set(serviceId, map.value[serviceId]); } })(), _fillCaches( this, 'identityKeys', window.Signal.Data.getAllIdentityKeys() ), _fillCaches( this, 'kyberPreKeys', window.Signal.Data.getAllKyberPreKeys() ), _fillCaches( this, 'sessions', window.Signal.Data.getAllSessions() ), _fillCaches( this, 'preKeys', window.Signal.Data.getAllPreKeys() ), _fillCaches( this, 'senderKeys', window.Signal.Data.getAllSenderKeys() ), _fillCaches( this, 'signedPreKeys', window.Signal.Data.getAllSignedPreKeys() ), ]); } getIdentityKeyPair(ourServiceId: ServiceIdString): KeyPairType | undefined { return this.ourIdentityKeys.get(ourServiceId); } async getLocalRegistrationId( ourServiceId: ServiceIdString ): Promise { return this.ourRegistrationIds.get(ourServiceId); } private _getKeyId( ourServiceId: ServiceIdString, keyId: number ): PreKeyIdType { return `${ourServiceId}:${keyId}`; } // KyberPreKeys private _getKyberPreKeyEntry( id: PreKeyIdType, logContext: string ): | { hydrated: true; fromDB: KyberPreKeyType; item: KyberPreKeyRecord } | undefined { if (!this.kyberPreKeys) { throw new Error(`${logContext}: this.kyberPreKeys not yet cached!`); } const entry = this.kyberPreKeys.get(id); if (!entry) { log.error(`${logContext}: Failed to fetch kyber prekey: ${id}`); return undefined; } if (entry.hydrated) { log.info( `${logContext}: Successfully fetched kyber prekey (cache hit): ${id}` ); return entry; } const item = KyberPreKeyRecord.deserialize(Buffer.from(entry.fromDB.data)); const newEntry = { hydrated: true as const, fromDB: entry.fromDB, item, }; this.kyberPreKeys.set(id, newEntry); log.info( `${logContext}: Successfully fetched kyberPreKey (cache miss): ${id}` ); return newEntry; } async loadKyberPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const entry = this._getKyberPreKeyEntry(id, 'loadKyberPreKey'); return entry?.item; } loadKyberPreKeys( ourServiceId: ServiceIdString, { isLastResort }: { isLastResort: boolean } ): Array { if (!this.kyberPreKeys) { throw new Error('loadKyberPreKeys: this.kyberPreKeys not yet cached!'); } if (arguments.length > 2) { throw new Error('loadKyberPreKeys takes two arguments'); } const entries = Array.from(this.kyberPreKeys.values()); return entries .map(item => item.fromDB) .filter( item => item.ourServiceId === ourServiceId && item.isLastResort === isLastResort ); } async confirmKyberPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { const kyberPreKeyCache = this.kyberPreKeys; if (!kyberPreKeyCache) { throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!'); } const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const item = kyberPreKeyCache.get(id); if (!item) { throw new Error(`confirmKyberPreKey: missing kyber prekey ${id}!`); } const confirmedItem = { ...item, fromDB: { ...item.fromDB, isConfirmed: true, }, }; await window.Signal.Data.createOrUpdateKyberPreKey(confirmedItem.fromDB); kyberPreKeyCache.set(id, confirmedItem); } async storeKyberPreKeys( ourServiceId: ServiceIdString, keys: Array> ): Promise { const kyberPreKeyCache = this.kyberPreKeys; if (!kyberPreKeyCache) { throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!'); } const toSave: Array = []; keys.forEach(key => { const id: PreKeyIdType = this._getKeyId(ourServiceId, key.keyId); if (kyberPreKeyCache.has(id)) { throw new Error(`storeKyberPreKey: kyber prekey ${id} already exists!`); } const kyberPreKey = { id, createdAt: key.createdAt, data: key.data, isConfirmed: key.isConfirmed, isLastResort: key.isLastResort, keyId: key.keyId, ourServiceId, }; toSave.push(kyberPreKey); }); await window.Signal.Data.bulkAddKyberPreKeys(toSave); toSave.forEach(kyberPreKey => { kyberPreKeyCache.set(kyberPreKey.id, { hydrated: false, fromDB: kyberPreKey, }); }); } async maybeRemoveKyberPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const entry = this._getKyberPreKeyEntry(id, 'maybeRemoveKyberPreKey'); if (!entry) { return; } if (entry.fromDB.isLastResort) { log.info( `maybeRemoveKyberPreKey: Not removing kyber prekey ${id}; it's a last resort key` ); return; } await this.removeKyberPreKeys(ourServiceId, [keyId]); } async removeKyberPreKeys( ourServiceId: ServiceIdString, keyIds: Array ): Promise { const kyberPreKeyCache = this.kyberPreKeys; if (!kyberPreKeyCache) { throw new Error('removeKyberPreKeys: this.kyberPreKeys not yet cached!'); } const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); log.info('removeKyberPreKeys: Removing kyber prekeys:', formatKeys(keyIds)); const changes = await window.Signal.Data.removeKyberPreKeyById(ids); log.info(`removeKyberPreKeys: Removed ${changes} kyber prekeys`); ids.forEach(id => { kyberPreKeyCache.delete(id); }); if (kyberPreKeyCache.size < LOW_KEYS_THRESHOLD) { this.emitLowKeys( ourServiceId, `removeKyberPreKeys@${kyberPreKeyCache.size}` ); } } async clearKyberPreKeyStore(): Promise { if (this.kyberPreKeys) { this.kyberPreKeys.clear(); } const changes = await window.Signal.Data.removeAllKyberPreKeys(); log.info(`clearKyberPreKeyStore: Removed ${changes} kyber prekeys`); } // PreKeys async loadPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { if (!this.preKeys) { throw new Error('loadPreKey: this.preKeys not yet cached!'); } const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const entry = this.preKeys.get(id); if (!entry) { log.error('Failed to fetch prekey:', id); return undefined; } if (entry.hydrated) { log.info('Successfully fetched prekey (cache hit):', id); return entry.item; } const item = hydratePreKey(entry.fromDB); this.preKeys.set(id, { hydrated: true, fromDB: entry.fromDB, item, }); log.info('Successfully fetched prekey (cache miss):', id); return item; } loadPreKeys(ourServiceId: ServiceIdString): Array { if (!this.preKeys) { throw new Error('loadPreKeys: this.preKeys not yet cached!'); } if (arguments.length > 1) { throw new Error('loadPreKeys takes one argument'); } const entries = Array.from(this.preKeys.values()); return entries .map(item => item.fromDB) .filter(item => item.ourServiceId === ourServiceId); } async storePreKeys( ourServiceId: ServiceIdString, keys: Array ): Promise { const preKeyCache = this.preKeys; if (!preKeyCache) { throw new Error('storePreKey: this.preKeys not yet cached!'); } const now = Date.now(); const toSave: Array = []; keys.forEach(key => { const id: PreKeyIdType = this._getKeyId(ourServiceId, key.keyId); if (preKeyCache.has(id)) { throw new Error(`storePreKeys: prekey ${id} already exists!`); } const preKey = { id, keyId: key.keyId, ourServiceId, publicKey: key.keyPair.pubKey, privateKey: key.keyPair.privKey, createdAt: now, }; toSave.push(preKey); }); log.info(`storePreKeys: Saving ${toSave.length} prekeys`); await window.Signal.Data.bulkAddPreKeys(toSave); toSave.forEach(preKey => { preKeyCache.set(preKey.id, { hydrated: false, fromDB: preKey, }); }); } async removePreKeys( ourServiceId: ServiceIdString, keyIds: Array ): Promise { const preKeyCache = this.preKeys; if (!preKeyCache) { throw new Error('removePreKeys: this.preKeys not yet cached!'); } const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); log.info('removePreKeys: Removing prekeys:', formatKeys(keyIds)); const changes = await window.Signal.Data.removePreKeyById(ids); log.info(`removePreKeys: Removed ${changes} prekeys`); ids.forEach(id => { preKeyCache.delete(id); }); if (preKeyCache.size < LOW_KEYS_THRESHOLD) { this.emitLowKeys(ourServiceId, `removePreKeys@${preKeyCache.size}`); } } async clearPreKeyStore(): Promise { if (this.preKeys) { this.preKeys.clear(); } const changes = await window.Signal.Data.removeAllPreKeys(); log.info(`clearPreKeyStore: Removed ${changes} prekeys`); } // Signed PreKeys async loadSignedPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { if (!this.signedPreKeys) { throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!'); } const id: SignedPreKeyIdType = `${ourServiceId}:${keyId}`; const entry = this.signedPreKeys.get(id); if (!entry) { log.error('Failed to fetch signed prekey:', id); return undefined; } if (entry.hydrated) { log.info('Successfully fetched signed prekey (cache hit):', id); return entry.item; } const item = hydrateSignedPreKey(entry.fromDB); this.signedPreKeys.set(id, { hydrated: true, item, fromDB: entry.fromDB, }); log.info('Successfully fetched signed prekey (cache miss):', id); return item; } loadSignedPreKeys( ourServiceId: ServiceIdString ): Array { if (!this.signedPreKeys) { throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!'); } if (arguments.length > 1) { throw new Error('loadSignedPreKeys takes one argument'); } const entries = Array.from(this.signedPreKeys.values()); return entries .filter(({ fromDB }) => fromDB.ourServiceId === ourServiceId) .map(entry => { const preKey = entry.fromDB; return { pubKey: preKey.publicKey, privKey: preKey.privateKey, created_at: preKey.created_at, keyId: preKey.keyId, confirmed: preKey.confirmed, }; }); } async confirmSignedPreKey( ourServiceId: ServiceIdString, keyId: number ): Promise { const signedPreKeyCache = this.signedPreKeys; if (!signedPreKeyCache) { throw new Error('storeKyberPreKey: this.signedPreKeys not yet cached!'); } const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const item = signedPreKeyCache.get(id); if (!item) { throw new Error(`confirmSignedPreKey: missing prekey ${id}!`); } const confirmedItem = { ...item, fromDB: { ...item.fromDB, confirmed: true, }, }; await window.Signal.Data.createOrUpdateSignedPreKey(confirmedItem.fromDB); signedPreKeyCache.set(id, confirmedItem); } async storeSignedPreKey( ourServiceId: ServiceIdString, keyId: number, keyPair: KeyPairType, confirmed?: boolean, createdAt = Date.now() ): Promise { if (!this.signedPreKeys) { throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); } const id: SignedPreKeyIdType = this._getKeyId(ourServiceId, keyId); const fromDB = { id, ourServiceId, keyId, publicKey: keyPair.pubKey, privateKey: keyPair.privKey, created_at: createdAt, confirmed: Boolean(confirmed), }; await window.Signal.Data.createOrUpdateSignedPreKey(fromDB); this.signedPreKeys.set(id, { hydrated: false, fromDB, }); } async removeSignedPreKeys( ourServiceId: ServiceIdString, keyIds: Array ): Promise { const signedPreKeyCache = this.signedPreKeys; if (!signedPreKeyCache) { throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); } const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); log.info( 'removeSignedPreKeys: Removing signed prekeys:', formatKeys(keyIds) ); await window.Signal.Data.removeSignedPreKeyById(ids); ids.forEach(id => { signedPreKeyCache.delete(id); }); } async clearSignedPreKeysStore(): Promise { if (this.signedPreKeys) { this.signedPreKeys.clear(); } const changes = await window.Signal.Data.removeAllSignedPreKeys(); log.info(`clearSignedPreKeysStore: Removed ${changes} signed prekeys`); } // Sender Key // Re-entrant sender key transaction routine. Only one sender key transaction could // be running at the same time. // // While in transaction: // // - `saveSenderKey()` adds the updated session to the `pendingSenderKeys` // - `getSenderKey()` looks up the session first in `pendingSenderKeys` and only // then in the main `senderKeys` store // // When transaction ends: // // - successfully: pending sender key stores are batched into the database // - with an error: pending sender key stores are reverted async enqueueSenderKeyJob( qualifiedAddress: QualifiedAddress, task: () => Promise, zone = GLOBAL_ZONE ): Promise { return this.withZone(zone, 'enqueueSenderKeyJob', async () => { const queue = this._getSenderKeyQueue(qualifiedAddress); return queue.add(task); }); } private _createSenderKeyQueue(): PQueue { return new PQueue({ concurrency: 1, timeout: MINUTE * 30, throwOnTimeout: true, }); } private _getSenderKeyQueue(senderId: QualifiedAddress): PQueue { const cachedQueue = this.senderKeyQueues.get(senderId.toString()); if (cachedQueue) { return cachedQueue; } const freshQueue = this._createSenderKeyQueue(); this.senderKeyQueues.set(senderId.toString(), freshQueue); return freshQueue; } private getSenderKeyId( senderKeyId: QualifiedAddress, distributionId: string ): SenderKeyIdType { return `${senderKeyId.toString()}--${distributionId}`; } async saveSenderKey( qualifiedAddress: QualifiedAddress, distributionId: string, record: SenderKeyRecord, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { await this.withZone(zone, 'saveSenderKey', async () => { if (!this.senderKeys) { throw new Error('saveSenderKey: this.senderKeys not yet cached!'); } const senderId = qualifiedAddress.toString(); try { const id = this.getSenderKeyId(qualifiedAddress, distributionId); const fromDB: SenderKeyType = { id, senderId, distributionId, data: record.serialize(), lastUpdatedDate: Date.now(), }; this.pendingSenderKeys.set(id, { hydrated: true, fromDB, item: record, }); // Current zone doesn't support pending sessions - commit immediately if (!zone.supportsPendingSenderKeys()) { await this.commitZoneChanges('saveSenderKey'); } } catch (error) { const errorString = Errors.toLogFormat(error); log.error( `saveSenderKey: failed to save senderKey ${senderId}/${distributionId}: ${errorString}` ); } }); } async getSenderKey( qualifiedAddress: QualifiedAddress, distributionId: string, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { return this.withZone(zone, 'getSenderKey', async () => { if (!this.senderKeys) { throw new Error('getSenderKey: this.senderKeys not yet cached!'); } const senderId = qualifiedAddress.toString(); try { const id = this.getSenderKeyId(qualifiedAddress, distributionId); const map = this.pendingSenderKeys.has(id) ? this.pendingSenderKeys : this.senderKeys; const entry = map.get(id); if (!entry) { log.error('Failed to fetch sender key:', id); return undefined; } if (entry.hydrated) { log.info('Successfully fetched sender key (cache hit):', id); return entry.item; } const item = SenderKeyRecord.deserialize( Buffer.from(entry.fromDB.data) ); this.senderKeys.set(id, { hydrated: true, item, fromDB: entry.fromDB, }); log.info('Successfully fetched sender key(cache miss):', id); return item; } catch (error) { const errorString = Errors.toLogFormat(error); log.error( `getSenderKey: failed to load sender key ${senderId}/${distributionId}: ${errorString}` ); return undefined; } }); } async removeSenderKey( qualifiedAddress: QualifiedAddress, distributionId: string ): Promise { if (!this.senderKeys) { throw new Error('getSenderKey: this.senderKeys not yet cached!'); } const senderId = qualifiedAddress.toString(); try { const id = this.getSenderKeyId(qualifiedAddress, distributionId); await window.Signal.Data.removeSenderKeyById(id); this.senderKeys.delete(id); } catch (error) { const errorString = Errors.toLogFormat(error); log.error( `removeSenderKey: failed to remove senderKey ${senderId}/${distributionId}: ${errorString}` ); } } async removeAllSenderKeys(): Promise { return this.withZone(GLOBAL_ZONE, 'removeAllSenderKeys', async () => { if (this.senderKeys) { this.senderKeys.clear(); } if (this.pendingSenderKeys) { this.pendingSenderKeys.clear(); } await window.Signal.Data.removeAllSenderKeys(); }); } // Session Queue async enqueueSessionJob( qualifiedAddress: QualifiedAddress, name: string, task: () => Promise, zone: Zone = GLOBAL_ZONE ): Promise { this.sessionQueueJobCounter += 1; const id = this.sessionQueueJobCounter; const waitStart = Date.now(); return this.withZone(zone, 'enqueueSessionJob', async () => { const queue = this._getSessionQueue(qualifiedAddress); const waitTime = Date.now() - waitStart; log.info( `enqueueSessionJob(${id}): queuing task ${name}, waited ${waitTime}ms` ); const queueStart = Date.now(); return queue.add(() => { const queueTime = Date.now() - queueStart; log.info( `enqueueSessionJob(${id}): running task ${name}, waited ${queueTime}ms` ); return task(); }); }); } private _createSessionQueue(): PQueue { return new PQueue({ concurrency: 1, timeout: MINUTE * 30, throwOnTimeout: true, }); } private _getSessionQueue(id: QualifiedAddress): PQueue { const cachedQueue = this.sessionQueues.get(id.toString()); if (cachedQueue) { return cachedQueue; } const freshQueue = this._createSessionQueue(); this.sessionQueues.set(id.toString(), freshQueue); return freshQueue; } // Identity Queue private _createIdentityQueue(): PQueue { return new PQueue({ concurrency: 1, timeout: MINUTE * 30, throwOnTimeout: true, }); } private _runOnIdentityQueue( serviceId: ServiceIdString, zone: Zone, name: string, body: () => Promise ): Promise { let queue: PQueue; const cachedQueue = this.identityQueues.get(serviceId); if (cachedQueue) { queue = cachedQueue; } else { queue = this._createIdentityQueue(); this.identityQueues.set(serviceId, queue); } // We run the identity queue task in zone because `saveIdentity` needs to // be able to archive sibling sessions on keychange. Not entering the zone // now would mean that we can take locks in different order here and in // MessageReceiver which will lead to a deadlock. return this.withZone(zone, name, () => queue.add(body)); } // Sessions // Re-entrant session transaction routine. Only one session transaction could // be running at the same time. // // While in transaction: // // - `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 public async withZone( zone: Zone, name: string, body: () => Promise ): Promise { const debugName = `withZone(${zone.name}:${name})`; // Allow re-entering from LibSignalStores if (this.currentZone && this.currentZone !== zone) { const start = Date.now(); log.info(`${debugName}: locked by ${this.currentZone.name}, waiting`); return new Promise((resolve, reject) => { const callback = async () => { const duration = Date.now() - start; log.info(`${debugName}: unlocked after ${duration}ms`); // Call `.withZone` synchronously from `this.zoneQueue` to avoid // extra in-between ticks while we are on microtasks queue. try { resolve(await this.withZone(zone, name, body)); } catch (error) { reject(error); } }; this.zoneQueue.push({ zone, callback }); }); } this.enterZone(zone, name); let result: T; try { result = await body(); } catch (error) { if (this.isInTopLevelZone()) { await this.revertZoneChanges(name, error); } this.leaveZone(zone); throw error; } if (this.isInTopLevelZone()) { await this.commitZoneChanges(name); } this.leaveZone(zone); return result; } private async commitZoneChanges(name: string): Promise { const { pendingSenderKeys, pendingSessions, pendingUnprocessed } = this; if ( pendingSenderKeys.size === 0 && pendingSessions.size === 0 && pendingUnprocessed.size === 0 ) { return; } log.info( `commitZoneChanges(${name}): ` + `pending sender keys ${pendingSenderKeys.size}, ` + `pending sessions ${pendingSessions.size}, ` + `pending unprocessed ${pendingUnprocessed.size}` ); this.pendingSenderKeys = new Map(); this.pendingSessions = new Map(); this.pendingUnprocessed = new Map(); // Commit both sender keys, sessions and unprocessed in the same database transaction // to unroll both on error. await window.Signal.Data.commitDecryptResult({ senderKeys: Array.from(pendingSenderKeys.values()).map( ({ fromDB }) => fromDB ), sessions: Array.from(pendingSessions.values()).map( ({ fromDB }) => fromDB ), unprocessed: Array.from(pendingUnprocessed.values()), }); // Apply changes to in-memory storage after successful DB write. const { sessions } = this; assertDev( sessions !== undefined, "Can't commit unhydrated session storage" ); pendingSessions.forEach((value, key) => { sessions.set(key, value); }); const { senderKeys } = this; assertDev( senderKeys !== undefined, "Can't commit unhydrated sender key storage" ); pendingSenderKeys.forEach((value, key) => { senderKeys.set(key, value); }); } private async revertZoneChanges(name: string, error: Error): Promise { log.info( `revertZoneChanges(${name}): ` + `pending sender keys size ${this.pendingSenderKeys.size}, ` + `pending sessions size ${this.pendingSessions.size}, ` + `pending unprocessed size ${this.pendingUnprocessed.size}`, Errors.toLogFormat(error) ); this.pendingSenderKeys.clear(); this.pendingSessions.clear(); this.pendingUnprocessed.clear(); } private isInTopLevelZone(): boolean { return this.currentZoneDepth === 1; } private enterZone(zone: Zone, name: string): void { this.currentZoneDepth += 1; if (this.currentZoneDepth === 1) { assertDev(this.currentZone === undefined, 'Should not be in the zone'); this.currentZone = zone; if (zone !== GLOBAL_ZONE) { log.info(`SignalProtocolStore.enterZone(${zone.name}:${name})`); } } } private leaveZone(zone: Zone): void { assertDev(this.currentZone === zone, 'Should be in the correct zone'); this.currentZoneDepth -= 1; assertDev( this.currentZoneDepth >= 0, 'Unmatched number of leaveZone calls' ); // Since we allow re-entering zones we might actually be in two overlapping // async calls. Leave the zone and yield to another one only if there are // no active zone users anymore. if (this.currentZoneDepth !== 0) { return; } if (zone !== GLOBAL_ZONE) { log.info(`SignalProtocolStore.leaveZone(${zone.name})`); } this.currentZone = undefined; const next = this.zoneQueue.shift(); if (!next) { return; } const toEnter = [next]; while (this.zoneQueue[0]?.zone === next.zone) { const elem = this.zoneQueue.shift(); assertDev(elem, 'Zone element should be present'); toEnter.push(elem); } log.info( `SignalProtocolStore: running blocked ${toEnter.length} jobs in ` + `zone ${next.zone.name}` ); for (const { callback } of toEnter) { callback(); } } async loadSession( qualifiedAddress: QualifiedAddress, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { return this.withZone(zone, 'loadSession', async () => { if (!this.sessions) { throw new Error('loadSession: this.sessions not yet cached!'); } if (qualifiedAddress == null) { throw new Error('loadSession: qualifiedAddress was undefined/null'); } const id = qualifiedAddress.toString(); try { 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; } // We'll either just hydrate the item or we'll fully migrate the session // and save it to the database. return await this._maybeMigrateSession(entry.fromDB, { zone }); } catch (error) { const errorString = Errors.toLogFormat(error); log.error(`loadSession: failed to load session ${id}: ${errorString}`); return undefined; } }); } async loadSessions( qualifiedAddresses: Array, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise> { return this.withZone(zone, 'loadSessions', async () => { const sessions = await Promise.all( qualifiedAddresses.map(async address => this.loadSession(address, { zone }) ) ); return sessions.filter(isNotNil); }); } private async _maybeMigrateSession( session: SessionType, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { if (!this.sessions) { throw new Error('_maybeMigrateSession: this.sessions not yet cached!'); } // Already migrated, hydrate and update cache if (session.version === 2) { const item = hydrateSession(session); const map = this.pendingSessions.has(session.id) ? this.pendingSessions : this.sessions; map.set(session.id, { hydrated: true, item, fromDB: session, }); return item; } // Not yet converted, need to translate to new format and save if (session.version !== undefined) { throw new Error('_maybeMigrateSession: Unknown session version type!'); } const { ourServiceId } = session; const keyPair = this.getIdentityKeyPair(ourServiceId); if (!keyPair) { throw new Error('_maybeMigrateSession: No identity key for ourself!'); } const localRegistrationId = await this.getLocalRegistrationId(ourServiceId); if (!isNumber(localRegistrationId)) { throw new Error('_maybeMigrateSession: No registration id for ourself!'); } const localUserData = { identityKeyPublic: keyPair.pubKey, registrationId: localRegistrationId, }; log.info(`_maybeMigrateSession: Migrating session with id ${session.id}`); const sessionProto = sessionRecordToProtobuf( JSON.parse(session.record), localUserData ); const record = SessionRecord.deserialize( Buffer.from(sessionStructureToBytes(sessionProto)) ); await this.storeSession(QualifiedAddress.parse(session.id), record, { zone, }); return record; } async storeSession( qualifiedAddress: QualifiedAddress, record: SessionRecord, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { await this.withZone(zone, 'storeSession', async () => { if (!this.sessions) { throw new Error('storeSession: this.sessions not yet cached!'); } if (qualifiedAddress == null) { throw new Error('storeSession: qualifiedAddress was undefined/null'); } const { serviceId, deviceId } = qualifiedAddress; const conversation = window.ConversationController.lookupOrCreate({ serviceId, reason: 'SignalProtocolStore.storeSession', }); strictAssert( conversation !== undefined, 'storeSession: Ensure contact ids failed' ); const id = qualifiedAddress.toString(); try { const fromDB = { id, version: 2, ourServiceId: qualifiedAddress.ourServiceId, conversationId: conversation.id, serviceId, deviceId, record: record.serialize().toString('base64'), }; const newSession = { hydrated: true, fromDB, item: record, }; assertDev(this.currentZone, 'Must run in the zone'); this.pendingSessions.set(id, newSession); // Current zone doesn't support pending sessions - commit immediately if (!zone.supportsPendingSessions()) { await this.commitZoneChanges('storeSession'); } } catch (error) { const errorString = Errors.toLogFormat(error); log.error(`storeSession: Save failed for ${id}: ${errorString}`); throw error; } }); } async hasSessionWith(serviceId: ServiceIdString): Promise { return this.withZone(GLOBAL_ZONE, 'hasSessionWith', async () => { if (!this.sessions) { throw new Error('getOpenDevices: this.sessions not yet cached!'); } return this._getAllSessions().some( ({ fromDB }) => fromDB.serviceId === serviceId ); }); } async getOpenDevices( ourServiceId: ServiceIdString, serviceIds: ReadonlyArray, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise<{ devices: Array; emptyServiceIds: Array; }> { return this.withZone(zone, 'getOpenDevices', async () => { if (!this.sessions) { throw new Error('getOpenDevices: this.sessions not yet cached!'); } if (serviceIds.length === 0) { return { devices: [], emptyServiceIds: [] }; } try { const serviceIdSet = new Set(serviceIds); const allSessions = this._getAllSessions(); const entries = allSessions.filter( ({ fromDB }) => fromDB.ourServiceId === ourServiceId && serviceIdSet.has(fromDB.serviceId) ); const openEntries: Array< | undefined | { entry: SessionCacheEntry; record: SessionRecord; } > = await Promise.all( entries.map(async entry => { if (entry.hydrated) { const record = entry.item; if (record.hasCurrentState()) { return { record, entry }; } return undefined; } const record = await this._maybeMigrateSession(entry.fromDB, { zone, }); if (record.hasCurrentState()) { return { record, entry }; } return undefined; }) ); const devices = openEntries .map(item => { if (!item) { return undefined; } const { entry, record } = item; const { serviceId } = entry.fromDB; serviceIdSet.delete(serviceId); const id = entry.fromDB.deviceId; const registrationId = record.remoteRegistrationId(); return { serviceId, id, registrationId, }; }) .filter(isNotNil); const emptyServiceIds = Array.from(serviceIdSet.values()); return { devices, emptyServiceIds, }; } catch (error) { log.error( 'getOpenDevices: Failed to get devices', Errors.toLogFormat(error) ); throw error; } }); } async getDeviceIds({ ourServiceId, serviceId, }: Readonly<{ ourServiceId: ServiceIdString; serviceId: ServiceIdString; }>): Promise> { const { devices } = await this.getOpenDevices(ourServiceId, [serviceId]); return devices.map((device: DeviceType) => device.id); } async removeSession(qualifiedAddress: QualifiedAddress): Promise { return this.withZone(GLOBAL_ZONE, 'removeSession', async () => { if (!this.sessions) { throw new Error('removeSession: this.sessions not yet cached!'); } const id = qualifiedAddress.toString(); log.info('removeSession: deleting session for', id); try { await window.Signal.Data.removeSessionById(id); this.sessions.delete(id); this.pendingSessions.delete(id); } catch (e) { log.error(`removeSession: Failed to delete session for ${id}`); } }); } async removeSessionsByConversation(identifier: string): Promise { return this.withZone( GLOBAL_ZONE, 'removeSessionsByConversation', async () => { if (!this.sessions) { throw new Error( 'removeSessionsByConversation: this.sessions not yet cached!' ); } if (identifier == null) { throw new Error( 'removeSessionsByConversation: identifier was undefined/null' ); } log.info( 'removeSessionsByConversation: deleting sessions for', identifier ); const id = window.ConversationController.getConversationId(identifier); strictAssert( id, `removeSessionsByConversation: Conversation not found: ${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(id); } ); } async removeSessionsByServiceId(serviceId: ServiceIdString): Promise { return this.withZone(GLOBAL_ZONE, 'removeSessionsByServiceId', async () => { if (!this.sessions) { throw new Error( 'removeSessionsByServiceId: this.sessions not yet cached!' ); } log.info('removeSessionsByServiceId: deleting sessions for', serviceId); 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.serviceId === serviceId) { this.sessions.delete(entry.fromDB.id); this.pendingSessions.delete(entry.fromDB.id); } } await window.Signal.Data.removeSessionsByServiceId(serviceId); }); } private async _archiveSession(entry?: SessionCacheEntry, zone?: Zone) { if (!entry) { return; } const addr = QualifiedAddress.parse(entry.fromDB.id); await this.enqueueSessionJob( addr, `_archiveSession(${addr.toString()})`, async () => { const item = entry.hydrated ? entry.item : await this._maybeMigrateSession(entry.fromDB, { zone }); if (!item.hasCurrentState()) { return; } item.archiveCurrentState(); await this.storeSession(addr, item, { zone }); }, zone ); } async archiveSession(qualifiedAddress: QualifiedAddress): Promise { return this.withZone(GLOBAL_ZONE, 'archiveSession', async () => { if (!this.sessions) { throw new Error('archiveSession: this.sessions not yet cached!'); } const id = qualifiedAddress.toString(); log.info(`archiveSession: session for ${id}`); const entry = this.pendingSessions.get(id) || this.sessions.get(id); await this._archiveSession(entry); }); } async archiveSiblingSessions( encodedAddress: Address, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { return this.withZone(zone, 'archiveSiblingSessions', async () => { if (!this.sessions) { throw new Error( 'archiveSiblingSessions: this.sessions not yet cached!' ); } log.info( 'archiveSiblingSessions: archiving sibling sessions for', encodedAddress.toString() ); const { serviceId, deviceId } = encodedAddress; const allEntries = this._getAllSessions(); const entries = allEntries.filter( entry => entry.fromDB.serviceId === serviceId && entry.fromDB.deviceId !== deviceId ); await Promise.all( entries.map(async entry => { await this._archiveSession(entry, zone); }) ); }); } async archiveAllSessions(serviceId: ServiceIdString): Promise { return this.withZone(GLOBAL_ZONE, 'archiveAllSessions', async () => { if (!this.sessions) { throw new Error('archiveAllSessions: this.sessions not yet cached!'); } log.info('archiveAllSessions: archiving all sessions for', serviceId); const allEntries = this._getAllSessions(); const entries = allEntries.filter( entry => entry.fromDB.serviceId === serviceId ); await Promise.all( entries.map(async entry => { await this._archiveSession(entry); }) ); }); } async clearSessionStore(): Promise { return this.withZone(GLOBAL_ZONE, 'clearSessionStore', async () => { if (this.sessions) { this.sessions.clear(); } this.pendingSessions.clear(); const changes = await window.Signal.Data.removeAllSessions(); log.info(`clearSessionStore: Removed ${changes} sessions`); }); } async lightSessionReset(qualifiedAddress: QualifiedAddress): Promise { const id = qualifiedAddress.toString(); const sessionResets = window.storage.get( 'sessionResets', {} as SessionResetsType ); const lastReset = sessionResets[id]; const ONE_HOUR = 60 * 60 * 1000; if (lastReset && isMoreRecentThan(lastReset, ONE_HOUR)) { log.warn( `lightSessionReset/${id}: Skipping session reset, last reset at ${lastReset}` ); return; } sessionResets[id] = Date.now(); await window.storage.put('sessionResets', sessionResets); try { const { serviceId } = qualifiedAddress; // First, fetch this conversation const conversation = window.ConversationController.lookupOrCreate({ serviceId, reason: 'SignalProtocolStore.lightSessionReset', }); assertDev(conversation, `lightSessionReset/${id}: missing conversation`); log.warn(`lightSessionReset/${id}: Resetting session`); // Archive open session with this device await this.archiveSession(qualifiedAddress); // Enqueue a null message with newly-created session await conversationJobQueue.add({ type: 'NullMessage', conversationId: conversation.id, idForTracking: id, }); } catch (error) { // If we failed to queue the session reset, then we'll allow another attempt sooner // than one hour from now. delete sessionResets[id]; await window.storage.put('sessionResets', sessionResets); log.error( `lightSessionReset/${id}: Encountered error`, Errors.toLogFormat(error) ); } } // Identity Keys getIdentityRecord(serviceId: ServiceIdString): IdentityKeyType | undefined { if (!this.identityKeys) { throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); } try { const entry = this.identityKeys.get(serviceId); if (!entry) { return undefined; } return entry.fromDB; } catch (e) { log.error( `getIdentityRecord: Failed to get identity record for serviceId ${serviceId}` ); return undefined; } } async getOrMigrateIdentityRecord( serviceId: ServiceIdString ): Promise { if (!this.identityKeys) { throw new Error( 'getOrMigrateIdentityRecord: this.identityKeys not yet cached!' ); } const result = this.getIdentityRecord(serviceId); if (result) { return result; } const newId = serviceId; const conversation = window.ConversationController.get(newId); if (!conversation) { return undefined; } const conversationId = conversation.id; const record = this.identityKeys.get(`conversation:${conversationId}`); if (!record) { return undefined; } const newRecord = { ...record.fromDB, id: newId, }; log.info( `SignalProtocolStore: migrating identity key from ${record.fromDB.id} ` + `to ${newRecord.id}` ); await this._saveIdentityKey(newRecord); this.identityKeys.delete(record.fromDB.id); const changes = await window.Signal.Data.removeIdentityKeyById( record.fromDB.id ); log.info( `getOrMigrateIdentityRecord: Removed ${changes} old identity keys for ${record.fromDB.id}` ); return newRecord; } // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L128 async isTrustedIdentity( encodedAddress: Address, publicKey: Uint8Array, direction: number ): Promise { if (!this.identityKeys) { throw new Error('isTrustedIdentity: this.identityKeys not yet cached!'); } if (encodedAddress == null) { throw new Error('isTrustedIdentity: encodedAddress was undefined/null'); } const isOurIdentifier = window.textsecure.storage.user.isOurServiceId( encodedAddress.serviceId ); const identityRecord = await this.getOrMigrateIdentityRecord( encodedAddress.serviceId ); if (isOurIdentifier) { if (identityRecord && identityRecord.publicKey) { return constantTimeEqual(identityRecord.publicKey, publicKey); } log.warn( 'isTrustedIdentity: No local record for our own identifier. Returning true.' ); return true; } switch (direction) { case Direction.Sending: return this.isTrustedForSending( encodedAddress.serviceId, publicKey, identityRecord ); case Direction.Receiving: return true; default: throw new Error(`isTrustedIdentity: Unknown direction: ${direction}`); } } // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L233 isTrustedForSending( serviceId: ServiceIdString, publicKey: Uint8Array, identityRecord?: IdentityKeyType ): boolean { if (!identityRecord) { // To track key changes across session switches, we save an old identity key on the // conversation. const conversation = window.ConversationController.get(serviceId); const previousIdentityKeyBase64 = conversation?.get( 'previousIdentityKey' ); if (conversation && previousIdentityKeyBase64) { const previousIdentityKey = Bytes.fromBase64(previousIdentityKeyBase64); if (!constantTimeEqual(previousIdentityKey, publicKey)) { log.info( 'isTrustedForSending: previousIdentityKey does not match, returning false' ); return false; } } log.info( 'isTrustedForSending: No previous record or previousIdentityKey, returning true' ); return true; } const existing = identityRecord.publicKey; if (!existing) { log.info('isTrustedForSending: Nothing here, returning true...'); return true; } if (!constantTimeEqual(existing, publicKey)) { log.info("isTrustedForSending: Identity keys don't match..."); return false; } if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { log.error('isTrustedForSending: Needs unverified approval!'); return false; } if (this.isNonBlockingApprovalRequired(identityRecord)) { log.error('isTrustedForSending: Needs non-blocking approval!'); return false; } return true; } async loadIdentityKey( serviceId: ServiceIdString ): Promise { if (serviceId == null) { throw new Error('loadIdentityKey: serviceId was undefined/null'); } const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); if (identityRecord) { return identityRecord.publicKey; } return undefined; } async getFingerprint( serviceId: ServiceIdString ): Promise { if (serviceId == null) { throw new Error('loadIdentityKey: serviceId was undefined/null'); } const pubKey = await this.loadIdentityKey(serviceId); if (!pubKey) { return; } const hash = sha256(pubKey); const fingerprint = hash.slice(0, 4); return Bytes.toBase64(fingerprint); } private async _saveIdentityKey(data: IdentityKeyType): Promise { if (!this.identityKeys) { throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); } const { id } = data; await window.Signal.Data.createOrUpdateIdentityKey(data); this.identityKeys.set(id, { hydrated: false, fromDB: data, }); } // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L69 async saveIdentity( encodedAddress: Address, publicKey: Uint8Array, nonblockingApproval = false, { zone = GLOBAL_ZONE, noOverwrite = false }: SaveIdentityOptions = {} ): Promise { if (!this.identityKeys) { throw new Error('saveIdentity: this.identityKeys not yet cached!'); } if (encodedAddress == null) { throw new Error('saveIdentity: encodedAddress was undefined/null'); } if (!(publicKey instanceof Uint8Array)) { // eslint-disable-next-line no-param-reassign publicKey = Bytes.fromBinary(publicKey); } if (typeof nonblockingApproval !== 'boolean') { // eslint-disable-next-line no-param-reassign nonblockingApproval = false; } return this._runOnIdentityQueue( encodedAddress.serviceId, zone, 'saveIdentity', async () => { const identityRecord = await this.getOrMigrateIdentityRecord( encodedAddress.serviceId ); const id = encodedAddress.serviceId; const logId = `saveIdentity(${id})`; if (!identityRecord || !identityRecord.publicKey) { // Lookup failed, or the current key was removed, so save this one. log.info(`${logId}: Saving new identity...`); await this._saveIdentityKey({ id, publicKey, firstUse: true, timestamp: Date.now(), verified: VerifiedStatus.DEFAULT, nonblockingApproval, }); this.checkPreviousKey( encodedAddress.serviceId, publicKey, 'saveIdentity' ); return false; } if (noOverwrite) { return false; } const identityKeyChanged = !constantTimeEqual( identityRecord.publicKey, publicKey ); if (identityKeyChanged) { const isOurIdentifier = window.textsecure.storage.user.isOurServiceId( encodedAddress.serviceId ); if (isOurIdentifier && identityKeyChanged) { log.warn(`${logId}: ignoring identity for ourselves`); return false; } log.info(`${logId}: Replacing existing identity...`); const previousStatus = identityRecord.verified; let verifiedStatus; if ( previousStatus === VerifiedStatus.VERIFIED || previousStatus === VerifiedStatus.UNVERIFIED ) { verifiedStatus = VerifiedStatus.UNVERIFIED; } else { verifiedStatus = VerifiedStatus.DEFAULT; } await this._saveIdentityKey({ id, publicKey, firstUse: false, timestamp: Date.now(), verified: verifiedStatus, nonblockingApproval, }); // See `addKeyChange` in `ts/models/conversations.ts` for sender key info // update caused by this. try { this.emit( 'keychange', encodedAddress.serviceId, 'saveIdentity - change' ); } catch (error) { log.error( `${logId}: error triggering keychange:`, Errors.toLogFormat(error) ); } // Pass the zone to facilitate transactional session use in // MessageReceiver.ts await this.archiveSiblingSessions(encodedAddress, { zone, }); return true; } if (this.isNonBlockingApprovalRequired(identityRecord)) { log.info(`${logId}: Setting approval status...`); identityRecord.nonblockingApproval = nonblockingApproval; await this._saveIdentityKey(identityRecord); return false; } return false; } ); } // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L257 private isNonBlockingApprovalRequired( identityRecord: IdentityKeyType ): boolean { return ( !identityRecord.firstUse && isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && !identityRecord.nonblockingApproval ); } async saveIdentityWithAttributes( serviceId: ServiceIdString, attributes: Partial ): Promise { return this._runOnIdentityQueue( serviceId, GLOBAL_ZONE, 'saveIdentityWithAttributes', async () => { return this.saveIdentityWithAttributesOnQueue(serviceId, attributes); } ); } private async saveIdentityWithAttributesOnQueue( serviceId: ServiceIdString, attributes: Partial ): Promise { if (serviceId == null) { throw new Error( 'saveIdentityWithAttributes: serviceId was undefined/null' ); } const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); const id = serviceId; // When saving a PNI identity - don't create a separate conversation const serviceIdKind = window.textsecure.storage.user.getOurServiceIdKind(serviceId); if (serviceIdKind !== ServiceIdKind.PNI) { window.ConversationController.getOrCreate(id, 'private'); } const updates: Partial = { ...identityRecord, ...attributes, id, }; if (validateIdentityKey(updates)) { await this._saveIdentityKey(updates); } } async setApproval( serviceId: ServiceIdString, nonblockingApproval: boolean ): Promise { if (serviceId == null) { throw new Error('setApproval: serviceId was undefined/null'); } if (typeof nonblockingApproval !== 'boolean') { throw new Error('setApproval: Invalid approval status'); } return this._runOnIdentityQueue( serviceId, GLOBAL_ZONE, 'setApproval', async () => { const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); if (!identityRecord) { throw new Error(`setApproval: No identity record for ${serviceId}`); } identityRecord.nonblockingApproval = nonblockingApproval; await this._saveIdentityKey(identityRecord); } ); } // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L215 // and https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyDisplayFragment.java#L544 async setVerified( serviceId: ServiceIdString, verifiedStatus: number, extra: SetVerifiedExtra = {} ): Promise { if (serviceId == null) { throw new Error('setVerified: serviceId was undefined/null'); } if (!validateVerifiedStatus(verifiedStatus)) { throw new Error('setVerified: Invalid verified status'); } return this._runOnIdentityQueue( serviceId, GLOBAL_ZONE, 'setVerified', async () => { const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); if (!identityRecord) { throw new Error(`setVerified: No identity record for ${serviceId}`); } if (validateIdentityKey(identityRecord)) { await this._saveIdentityKey({ ...identityRecord, ...extra, verified: verifiedStatus, }); } } ); } async getVerified(serviceId: ServiceIdString): Promise { if (serviceId == null) { throw new Error('getVerified: serviceId was undefined/null'); } const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); if (!identityRecord) { throw new Error(`getVerified: No identity record for ${serviceId}`); } const verifiedStatus = identityRecord.verified; if (validateVerifiedStatus(verifiedStatus)) { return verifiedStatus; } return VerifiedStatus.DEFAULT; } // To track key changes across session switches, we save an old identity key on the // conversation. Whenever we get a new identity key for that contact, we need to // check it against that saved key - no need to pop a key change warning if it is // the same! checkPreviousKey( serviceId: ServiceIdString, publicKey: Uint8Array, context: string ): void { const conversation = window.ConversationController.get(serviceId); const previousIdentityKeyBase64 = conversation?.get('previousIdentityKey'); if (conversation && previousIdentityKeyBase64) { const previousIdentityKey = Bytes.fromBase64(previousIdentityKeyBase64); try { if (!constantTimeEqual(previousIdentityKey, publicKey)) { this.emit( 'keychange', serviceId, `${context} - previousIdentityKey check` ); } // We only want to clear previousIdentityKey on a match, or on successfully emit. conversation.set({ previousIdentityKey: undefined }); window.Signal.Data.updateConversation(conversation.attributes); } catch (error) { log.error( 'saveIdentity: error triggering keychange:', error && error.stack ? error.stack : error ); } } } // See https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java#L184 async updateIdentityAfterSync( serviceId: ServiceIdString, verifiedStatus: number, publicKey: Uint8Array ): Promise { strictAssert( validateVerifiedStatus(verifiedStatus), `Invalid verified status: ${verifiedStatus}` ); return this._runOnIdentityQueue( serviceId, GLOBAL_ZONE, 'updateIdentityAfterSync', async () => { const identityRecord = await this.getOrMigrateIdentityRecord(serviceId); const hadEntry = identityRecord !== undefined; const keyMatches = Boolean( identityRecord?.publicKey && constantTimeEqual(publicKey, identityRecord.publicKey) ); const statusMatches = keyMatches && verifiedStatus === identityRecord?.verified; if (!keyMatches || !statusMatches) { await this.saveIdentityWithAttributesOnQueue(serviceId, { publicKey, verified: verifiedStatus, firstUse: !hadEntry, timestamp: Date.now(), nonblockingApproval: true, }); } if (!hadEntry) { this.checkPreviousKey( serviceId, publicKey, 'updateIdentityAfterSync' ); } else if (hadEntry && !keyMatches) { try { this.emit( 'keychange', serviceId, 'updateIdentityAfterSync - change' ); } catch (error) { log.error( 'updateIdentityAfterSync: error triggering keychange:', Errors.toLogFormat(error) ); } } // See: https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt#L921-L936 if ( verifiedStatus === VerifiedStatus.VERIFIED && (!hadEntry || identityRecord?.verified !== VerifiedStatus.VERIFIED) ) { // Needs a notification. return true; } if ( verifiedStatus !== VerifiedStatus.VERIFIED && hadEntry && identityRecord?.verified === VerifiedStatus.VERIFIED ) { // Needs a notification. return true; } return false; } ); } isUntrusted( serviceId: ServiceIdString, timestampThreshold = TIMESTAMP_THRESHOLD ): boolean { if (serviceId == null) { throw new Error('isUntrusted: serviceId was undefined/null'); } const identityRecord = this.getIdentityRecord(serviceId); if (!identityRecord) { throw new Error(`isUntrusted: No identity record for ${serviceId}`); } if ( isMoreRecentThan(identityRecord.timestamp, timestampThreshold) && !identityRecord.nonblockingApproval && !identityRecord.firstUse ) { return true; } return false; } async removeIdentityKey(serviceId: ServiceIdString): Promise { if (!this.identityKeys) { throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); } const id = serviceId; this.identityKeys.delete(id); await window.Signal.Data.removeIdentityKeyById(serviceId); await this.removeSessionsByServiceId(serviceId); } // Not yet processed messages - for resiliency getUnprocessedCount(): Promise { return this.withZone(GLOBAL_ZONE, 'getUnprocessedCount', async () => { return window.Signal.Data.getUnprocessedCount(); }); } getAllUnprocessedIds(): Promise> { return this.withZone(GLOBAL_ZONE, 'getAllUnprocessedIds', () => { return window.Signal.Data.getAllUnprocessedIds(); }); } getUnprocessedByIdsAndIncrementAttempts( ids: ReadonlyArray ): Promise> { return this.withZone( GLOBAL_ZONE, 'getAllUnprocessedByIdsAndIncrementAttempts', async () => { return window.Signal.Data.getUnprocessedByIdsAndIncrementAttempts(ids); } ); } getUnprocessedById(id: string): Promise { return this.withZone(GLOBAL_ZONE, 'getUnprocessedById', async () => { return window.Signal.Data.getUnprocessedById(id); }); } addUnprocessed( data: UnprocessedType, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { return this.withZone(zone, 'addUnprocessed', async () => { this.pendingUnprocessed.set(data.id, data); // Current zone doesn't support pending unprocessed - commit immediately if (!zone.supportsPendingUnprocessed()) { await this.commitZoneChanges('addUnprocessed'); } }); } addMultipleUnprocessed( array: Array, { zone = GLOBAL_ZONE }: SessionTransactionOptions = {} ): Promise { return this.withZone(zone, 'addMultipleUnprocessed', async () => { for (const elem of array) { this.pendingUnprocessed.set(elem.id, elem); } // Current zone doesn't support pending unprocessed - commit immediately if (!zone.supportsPendingUnprocessed()) { await this.commitZoneChanges('addMultipleUnprocessed'); } }); } updateUnprocessedWithData( id: string, data: UnprocessedUpdateType ): Promise { return this.withZone(GLOBAL_ZONE, 'updateUnprocessedWithData', async () => { await window.Signal.Data.updateUnprocessedWithData(id, data); }); } updateUnprocessedsWithData( items: Array<{ id: string; data: UnprocessedUpdateType }> ): Promise { return this.withZone( GLOBAL_ZONE, 'updateUnprocessedsWithData', async () => { await window.Signal.Data.updateUnprocessedsWithData(items); } ); } removeUnprocessed(idOrArray: string | Array): Promise { return this.withZone(GLOBAL_ZONE, 'removeUnprocessed', async () => { await window.Signal.Data.removeUnprocessed(idOrArray); }); } /** only for testing */ removeAllUnprocessed(): Promise { log.info('removeAllUnprocessed'); return this.withZone(GLOBAL_ZONE, 'removeAllUnprocessed', async () => { await window.Signal.Data.removeAllUnprocessed(); }); } async removeOurOldPni(oldPni: PniString): Promise { const { storage } = window; log.info(`SignalProtocolStore.removeOurOldPni(${oldPni})`); // Update caches this.ourIdentityKeys.delete(oldPni); this.ourRegistrationIds.delete(oldPni); const preKeyPrefix = `${oldPni}:`; if (this.preKeys) { for (const key of this.preKeys.keys()) { if (key.startsWith(preKeyPrefix)) { this.preKeys.delete(key); } } } if (this.signedPreKeys) { for (const key of this.signedPreKeys.keys()) { if (key.startsWith(preKeyPrefix)) { this.signedPreKeys.delete(key); } } } if (this.kyberPreKeys) { for (const key of this.kyberPreKeys.keys()) { if (key.startsWith(preKeyPrefix)) { this.kyberPreKeys.delete(key); } } } // Update database await Promise.all([ storage.put( 'identityKeyMap', omit(storage.get('identityKeyMap') || {}, oldPni) ), storage.put( 'registrationIdMap', omit(storage.get('registrationIdMap') || {}, oldPni) ), window.Signal.Data.removePreKeysByServiceId(oldPni), window.Signal.Data.removeSignedPreKeysByServiceId(oldPni), window.Signal.Data.removeKyberPreKeysByServiceId(oldPni), ]); } async updateOurPniKeyMaterial( pni: PniString, { identityKeyPair: identityBytes, lastResortKyberPreKey: lastResortKyberPreKeyBytes, signedPreKey: signedPreKeyBytes, registrationId, }: PniKeyMaterialType ): Promise { const logId = `SignalProtocolStore.updateOurPniKeyMaterial(${pni})`; log.info(`${logId}: starting...`); const identityKeyPair = IdentityKeyPair.deserialize( Buffer.from(identityBytes) ); const signedPreKey = SignedPreKeyRecord.deserialize( Buffer.from(signedPreKeyBytes) ); const lastResortKyberPreKey = lastResortKyberPreKeyBytes ? KyberPreKeyRecord.deserialize(Buffer.from(lastResortKyberPreKeyBytes)) : undefined; const { storage } = window; const pniPublicKey = identityKeyPair.publicKey.serialize(); const pniPrivateKey = identityKeyPair.privateKey.serialize(); // Update caches this.ourIdentityKeys.set(pni, { pubKey: pniPublicKey, privKey: pniPrivateKey, }); this.ourRegistrationIds.set(pni, registrationId); // Update database await Promise.all([ storage.put('identityKeyMap', { ...(storage.get('identityKeyMap') || {}), [pni]: { pubKey: pniPublicKey, privKey: pniPrivateKey, }, }), storage.put('registrationIdMap', { ...(storage.get('registrationIdMap') || {}), [pni]: registrationId, }), (async () => { const newId = signedPreKey.id() + 1; log.warn(`${logId}: Updating next signed pre key id to ${newId}`); await storage.put(SIGNED_PRE_KEY_ID_KEY[ServiceIdKind.PNI], newId); })(), this.storeSignedPreKey( pni, signedPreKey.id(), { privKey: signedPreKey.privateKey().serialize(), pubKey: signedPreKey.publicKey().serialize(), }, true, signedPreKey.timestamp() ), (async () => { if (!lastResortKyberPreKey) { return; } const newId = lastResortKyberPreKey.id() + 1; log.warn(`${logId}: Updating next kyber pre key id to ${newId}`); await storage.put(KYBER_KEY_ID_KEY[ServiceIdKind.PNI], newId); })(), lastResortKyberPreKeyBytes && lastResortKyberPreKey ? this.storeKyberPreKeys(pni, [ { createdAt: lastResortKyberPreKey.timestamp(), data: lastResortKyberPreKeyBytes, isConfirmed: true, isLastResort: true, keyId: lastResortKyberPreKey.id(), ourServiceId: pni, }, ]) : undefined, ]); } async removeAllData(): Promise { await window.Signal.Data.removeAll(); await this.hydrateCaches(); window.storage.reset(); await window.storage.fetch(); window.ConversationController.reset(); await window.ConversationController.load(); this.emit('removeAllData'); } async removeAllConfiguration(): Promise { // Conversations. These properties are not present in redux. window.getConversations().forEach(conversation => { conversation.unset('storageID'); conversation.unset('needsStorageServiceSync'); conversation.unset('storageUnknownFields'); conversation.unset('senderKeyInfo'); }); await window.Signal.Data.removeAllConfiguration(); await this.hydrateCaches(); window.storage.reset(); await window.storage.fetch(); } signAlternateIdentity(): PniSignatureMessageType | undefined { const ourAci = window.textsecure.storage.user.getCheckedAci(); const ourPni = window.textsecure.storage.user.getPni(); if (!ourPni) { log.error('signAlternateIdentity: No local pni'); return undefined; } if (this.cachedPniSignatureMessage?.pni === ourPni) { return this.cachedPniSignatureMessage; } const aciKeyPair = this.getIdentityKeyPair(ourAci); const pniKeyPair = this.getIdentityKeyPair(ourPni); if (!aciKeyPair) { log.error('signAlternateIdentity: No local ACI key pair'); return undefined; } if (!pniKeyPair) { log.error('signAlternateIdentity: No local PNI key pair'); return undefined; } const pniIdentity = new IdentityKeyPair( PublicKey.deserialize(Buffer.from(pniKeyPair.pubKey)), PrivateKey.deserialize(Buffer.from(pniKeyPair.privKey)) ); const aciPubKey = PublicKey.deserialize(Buffer.from(aciKeyPair.pubKey)); this.cachedPniSignatureMessage = { pni: ourPni, signature: pniIdentity.signAlternateIdentity(aciPubKey), }; return this.cachedPniSignatureMessage; } async verifyAlternateIdentity({ aci, pni, signature, }: VerifyAlternateIdentityOptionsType): Promise { const logId = `SignalProtocolStore.verifyAlternateIdentity(${aci}, ${pni})`; const aciPublicKeyBytes = await this.loadIdentityKey(aci); if (!aciPublicKeyBytes) { log.warn(`${logId}: no ACI public key`); return false; } const pniPublicKeyBytes = await this.loadIdentityKey(pni); if (!pniPublicKeyBytes) { log.warn(`${logId}: no PNI public key`); return false; } const aciPublicKey = PublicKey.deserialize(Buffer.from(aciPublicKeyBytes)); const pniPublicKey = PublicKey.deserialize(Buffer.from(pniPublicKeyBytes)); return pniPublicKey.verifyAlternateIdentity( aciPublicKey, Buffer.from(signature) ); } private _getAllSessions(): Array { const union = new Map(); this.sessions?.forEach((value, key) => { union.set(key, value); }); this.pendingSessions.forEach((value, key) => { union.set(key, value); }); return Array.from(union.values()); } private emitLowKeys(ourServiceId: ServiceIdString, source: string) { const logId = `SignalProtocolStore.emitLowKeys/${source}:`; try { log.info(`${logId}: Emitting event`); this.emit('lowKeys', ourServiceId); } catch (error) { log.error(`${logId}: Error thrown from emit`, Errors.toLogFormat(error)); } } // // EventEmitter types // public override on( name: 'lowKeys', handler: (ourServiceId: ServiceIdString) => unknown ): this; public override on( name: 'keychange', handler: (theirServiceId: ServiceIdString, reason: string) => unknown ): this; public override on(name: 'removeAllData', handler: () => unknown): this; public override on( eventName: string | symbol, // eslint-disable-next-line @typescript-eslint/no-explicit-any listener: (...args: Array) => void ): this { return super.on(eventName, listener); } public override emit(name: 'lowKeys', ourServiceid: ServiceIdString): boolean; public override emit( name: 'keychange', theirServiceId: ServiceIdString, reason: string ): boolean; public override emit(name: 'removeAllData'): boolean; public override emit( eventName: string | symbol, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: Array ): boolean { return super.emit(eventName, ...args); } } export function getSignalProtocolStore(): SignalProtocolStore { return new SignalProtocolStore(); } window.SignalProtocolStore = SignalProtocolStore;