// Copyright 2016-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable class-methods-use-this */ import PQueue from 'p-queue'; import { isNumber } from 'lodash'; import * as z from 'zod'; import { SessionRecord, PreKeyRecord, PrivateKey, PublicKey, SignedPreKeyRecord, Direction, } from 'libsignal-client'; import { constantTimeEqual, fromEncodedBinaryToArrayBuffer, typedArrayToArrayBuffer, } from './Crypto'; import { isNotNil } from './util/isNotNil'; import { isMoreRecentThan } from './util/timestamp'; import { sessionRecordToProtobuf, sessionStructureToArrayBuffer, } from './util/sessionTranslation'; import { KeyPairType, IdentityKeyType, SessionType, SignedPreKeyType, OuterSignedPrekeyType, PreKeyType, UnprocessedType, UnprocessedUpdateType, } from './textsecure/Types.d'; const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds 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(ArrayBuffer), 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; } async function normalizeEncodedAddress( encodedAddress: string ): Promise { const [identifier, deviceId] = window.textsecure.utils.unencodeNumber( encodedAddress ); try { const conv = window.ConversationController.getOrCreate( identifier, 'private' ); return `${conv.get('id')}.${deviceId}`; } catch (e) { window.log.error(`could not get conversation for identifier ${identifier}`); throw e; } } type HasIdType = { id: string | number; }; type CacheEntryType = | { hydrated: false; fromDB: DBType; } | { hydrated: true; fromDB: DBType; item: HydratedType }; async function _fillCaches( object: SignalProtocolStore, field: keyof SignalProtocolStore, itemsPromise: Promise> ): Promise { const items = await itemsPromise; const cache: Record> = Object.create( null ); for (let i = 0, max = items.length; i < max; i += 1) { const fromDB = items[i]; const { id } = fromDB; cache[id] = { fromDB, hydrated: false, }; } window.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.id, 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.id, createdAt, pubKey, privKey, signature ); } export function freezeSession(session: SessionRecord): string { return session.serialize().toString('base64'); } export function freezePublicKey(publicKey: PublicKey): ArrayBuffer { return typedArrayToArrayBuffer(publicKey.serialize()); } export function freezePreKey(preKey: PreKeyRecord): KeyPairType { const keyPair = { pubKey: typedArrayToArrayBuffer(preKey.publicKey().serialize()), privKey: typedArrayToArrayBuffer(preKey.privateKey().serialize()), }; return keyPair; } export function freezeSignedPreKey( signedPreKey: SignedPreKeyRecord ): KeyPairType { const keyPair = { pubKey: typedArrayToArrayBuffer(signedPreKey.publicKey().serialize()), privKey: typedArrayToArrayBuffer(signedPreKey.privateKey().serialize()), }; return keyPair; } // We add a this parameter to avoid an 'implicit any' error on the next line const EventsMixin = (function EventsMixin(this: unknown) { window._.assign(this, window.Backbone.Events); // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) as typeof window.Backbone.EventsMixin; export class SignalProtocolStore extends EventsMixin { // Enums used across the app VerifiedStatus = VerifiedStatus; // Cached values ourIdentityKey?: KeyPairType; ourRegistrationId?: number; identityKeys?: Record>; sessions?: Record>; preKeys?: Record>; signedPreKeys?: Record< string, CacheEntryType >; sessionQueues: Map = new Map(); async hydrateCaches(): Promise { await Promise.all([ (async () => { const item = await window.Signal.Data.getItemById('identityKey'); this.ourIdentityKey = item ? item.value : undefined; })(), (async () => { const item = await window.Signal.Data.getItemById('registrationId'); this.ourRegistrationId = item ? item.value : undefined; })(), _fillCaches( this, 'identityKeys', window.Signal.Data.getAllIdentityKeys() ), _fillCaches( this, 'sessions', window.Signal.Data.getAllSessions() ), _fillCaches( this, 'preKeys', window.Signal.Data.getAllPreKeys() ), _fillCaches( this, 'signedPreKeys', window.Signal.Data.getAllSignedPreKeys() ), ]); } async getIdentityKeyPair(): Promise { return this.ourIdentityKey; } async getLocalRegistrationId(): Promise { return this.ourRegistrationId; } // PreKeys async loadPreKey(keyId: string | number): Promise { if (!this.preKeys) { throw new Error('loadPreKey: this.preKeys not yet cached!'); } const entry = this.preKeys[keyId]; if (!entry) { window.log.error('Failed to fetch prekey:', keyId); return undefined; } if (entry.hydrated) { window.log.info('Successfully fetched prekey (cache hit):', keyId); return entry.item; } const item = hydratePreKey(entry.fromDB); this.preKeys[keyId] = { hydrated: true, fromDB: entry.fromDB, item, }; window.log.info('Successfully fetched prekey (cache miss):', keyId); return item; } async storePreKey(keyId: number, keyPair: KeyPairType): Promise { if (!this.preKeys) { throw new Error('storePreKey: this.preKeys not yet cached!'); } if (this.preKeys[keyId]) { throw new Error(`storePreKey: prekey ${keyId} already exists!`); } const fromDB = { id: keyId, publicKey: keyPair.pubKey, privateKey: keyPair.privKey, }; await window.Signal.Data.createOrUpdatePreKey(fromDB); this.preKeys[keyId] = { hydrated: false, fromDB, }; } async removePreKey(keyId: number): Promise { if (!this.preKeys) { throw new Error('removePreKey: this.preKeys not yet cached!'); } try { this.trigger('removePreKey'); } catch (error) { window.log.error( 'removePreKey error triggering removePreKey:', error && error.stack ? error.stack : error ); } delete this.preKeys[keyId]; await window.Signal.Data.removePreKeyById(keyId); } async clearPreKeyStore(): Promise { this.preKeys = Object.create(null); await window.Signal.Data.removeAllPreKeys(); } // Signed PreKeys async loadSignedPreKey( keyId: number ): Promise { if (!this.signedPreKeys) { throw new Error('loadSignedPreKey: this.signedPreKeys not yet cached!'); } const entry = this.signedPreKeys[keyId]; if (!entry) { window.log.error('Failed to fetch signed prekey:', keyId); return undefined; } if (entry.hydrated) { window.log.info('Successfully fetched signed prekey (cache hit):', keyId); return entry.item; } const item = hydrateSignedPreKey(entry.fromDB); this.signedPreKeys[keyId] = { hydrated: true, item, fromDB: entry.fromDB, }; window.log.info('Successfully fetched signed prekey (cache miss):', keyId); return item; } async loadSignedPreKeys(): Promise> { if (!this.signedPreKeys) { throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!'); } if (arguments.length > 0) { throw new Error('loadSignedPreKeys takes no arguments'); } const entries = Object.values(this.signedPreKeys); return entries.map(entry => { const preKey = entry.fromDB; return { pubKey: preKey.publicKey, privKey: preKey.privateKey, created_at: preKey.created_at, keyId: preKey.id, confirmed: preKey.confirmed, }; }); } // Note that this is also called in update scenarios, for confirming that signed prekeys // have indeed been accepted by the server. async storeSignedPreKey( keyId: number, keyPair: KeyPairType, confirmed?: boolean ): Promise { if (!this.signedPreKeys) { throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); } const fromDB = { id: keyId, publicKey: keyPair.pubKey, privateKey: keyPair.privKey, created_at: Date.now(), confirmed: Boolean(confirmed), }; await window.Signal.Data.createOrUpdateSignedPreKey(fromDB); this.signedPreKeys[keyId] = { hydrated: false, fromDB, }; } async removeSignedPreKey(keyId: number): Promise { if (!this.signedPreKeys) { throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); } delete this.signedPreKeys[keyId]; await window.Signal.Data.removeSignedPreKeyById(keyId); } async clearSignedPreKeysStore(): Promise { this.signedPreKeys = Object.create(null); await window.Signal.Data.removeAllSignedPreKeys(); } // Session Queue async enqueueSessionJob( encodedAddress: string, task: () => Promise ): Promise { const id = await normalizeEncodedAddress(encodedAddress); const queue = this._getSessionQueue(id); return queue.add(task); } private _createSessionQueue(): PQueue { return new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 }); } private _getSessionQueue(id: string): PQueue { const cachedQueue = this.sessionQueues.get(id); if (cachedQueue) { return cachedQueue; } const freshQueue = this._createSessionQueue(); this.sessionQueues.set(id, freshQueue); return freshQueue; } // Sessions async loadSession( encodedAddress: string ): Promise { 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 entry = this.sessions[id]; if (!entry) { return undefined; } if (entry.hydrated) { return entry.item; } const item = await this._maybeMigrateSession(entry.fromDB); this.sessions[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; } } private async _maybeMigrateSession( session: SessionType ): Promise { // Already migrated, return record directly if (session.version === 2) { return hydrateSession(session); } // Not yet converted, need to translate to new format if (session.version !== undefined) { throw new Error('_maybeMigrateSession: Unknown session version type!'); } const keyPair = await this.getIdentityKeyPair(); if (!keyPair) { throw new Error('_maybeMigrateSession: No identity key for ourself!'); } const localRegistrationId = await this.getLocalRegistrationId(); if (!isNumber(localRegistrationId)) { throw new Error('_maybeMigrateSession: No registration id for ourself!'); } const localUserData = { identityKeyPublic: keyPair.pubKey, registrationId: localRegistrationId, }; window.log.info( `_maybeMigrateSession: Migrating session with id ${session.id}` ); const sessionProto = sessionRecordToProtobuf( JSON.parse(session.record), localUserData ); return SessionRecord.deserialize( Buffer.from(sessionStructureToArrayBuffer(sessionProto)) ); } async storeSession( encodedAddress: string, record: SessionRecord ): Promise { if (!this.sessions) { throw new Error('storeSession: this.sessions not yet cached!'); } if (encodedAddress === null || encodedAddress === undefined) { throw new Error('storeSession: encodedAddress was undefined/null'); } const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress); const deviceId = parseInt(unencoded[1], 10); try { const id = await normalizeEncodedAddress(encodedAddress); const fromDB = { id, version: 2, conversationId: window.textsecure.utils.unencodeNumber(id)[0], deviceId, record: record.serialize().toString('base64'), }; await window.Signal.Data.createOrUpdateSession(fromDB); this.sessions[id] = { hydrated: true, fromDB, item: record, }; } catch (error) { const errorString = error && error.stack ? error.stack : error; window.log.error( `storeSession: Save failed fo ${encodedAddress}: ${errorString}` ); throw error; } } async getDeviceIds(identifier: string): Promise> { 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'); } try { const id = window.ConversationController.getConversationId(identifier); if (!id) { throw new Error( `getDeviceIds: No conversationId found for identifier ${identifier}` ); } const allSessions = Object.values(this.sessions); 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()) { return entry.fromDB.deviceId; } return undefined; }) ); 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 { if (!this.sessions) { throw new Error('removeSession: this.sessions not yet cached!'); } window.log.info('removeSession: deleting session for', encodedAddress); try { const id = await normalizeEncodedAddress(encodedAddress); await window.Signal.Data.removeSessionById(id); delete this.sessions[id]; } catch (e) { window.log.error( `removeSession: Failed to delete session for ${encodedAddress}` ); } } async removeAllSessions(identifier: string): Promise { 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 = Object.values(this.sessions); for (let i = 0, max = entries.length; i < max; i += 1) { const entry = entries[i]; if (entry.fromDB.conversationId === id) { delete this.sessions[entry.fromDB.id]; } } await window.Signal.Data.removeSessionsByConversation(identifier); } private async _archiveSession( entry?: CacheEntryType ) { if (!entry) { return; } await this.enqueueSessionJob(entry.fromDB.id, async () => { const item = entry.hydrated ? entry.item : await this._maybeMigrateSession(entry.fromDB); if (!item.hasCurrentState()) { return; } item.archiveCurrentState(); await this.storeSession(entry.fromDB.id, item); }); } async archiveSession(encodedAddress: string): Promise { if (!this.sessions) { throw new Error('archiveSession: this.sessions not yet cached!'); } window.log.info(`archiveSession: session for ${encodedAddress}`); const id = await normalizeEncodedAddress(encodedAddress); const entry = this.sessions[id]; await this._archiveSession(entry); } async archiveSiblingSessions(encodedAddress: string): Promise { if (!this.sessions) { throw new Error('archiveSiblingSessions: this.sessions not yet cached!'); } window.log.info( 'archiveSiblingSessions: archiving sibling sessions for', encodedAddress ); const id = await normalizeEncodedAddress(encodedAddress); const [identifier, deviceId] = window.textsecure.utils.unencodeNumber(id); const deviceIdNumber = parseInt(deviceId, 10); const allEntries = Object.values(this.sessions); const entries = allEntries.filter( entry => entry.fromDB.conversationId === identifier && entry.fromDB.deviceId !== deviceIdNumber ); await Promise.all( entries.map(async entry => { await this._archiveSession(entry); }) ); } async archiveAllSessions(identifier: string): Promise { if (!this.sessions) { throw new Error('archiveAllSessions: this.sessions not yet cached!'); } window.log.info( 'archiveAllSessions: archiving all sessions for', identifier ); const id = window.ConversationController.getConversationId(identifier); const allEntries = Object.values(this.sessions); const entries = allEntries.filter( entry => entry.fromDB.conversationId === id ); await Promise.all( entries.map(async entry => { await this._archiveSession(entry); }) ); } async clearSessionStore(): Promise { this.sessions = Object.create(null); window.Signal.Data.removeAllSessions(); } // Identity Keys getIdentityRecord(identifier: string): IdentityKeyType | undefined { if (!this.identityKeys) { throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); } try { const id = window.ConversationController.getConversationId(identifier); if (!id) { throw new Error( `getIdentityRecord: No conversation id for identifier ${identifier}` ); } const entry = this.identityKeys[id]; if (!entry) { return undefined; } return entry.fromDB; } catch (e) { window.log.error( `getIdentityRecord: Failed to get identity record for identifier ${identifier}` ); return undefined; } } async isTrustedIdentity( encodedAddress: string, publicKey: ArrayBuffer, direction: number ): Promise { if (!this.identityKeys) { throw new Error('getIdentityRecord: this.identityKeys not yet cached!'); } if (encodedAddress === null || encodedAddress === undefined) { throw new Error('isTrustedIdentity: encodedAddress was undefined/null'); } const identifier = window.textsecure.utils.unencodeNumber( encodedAddress )[0]; const ourNumber = window.textsecure.storage.user.getNumber(); const ourUuid = window.textsecure.storage.user.getUuid(); const isOurIdentifier = (ourNumber && identifier === ourNumber) || (ourUuid && identifier === ourUuid); const identityRecord = this.getIdentityRecord(identifier); if (isOurIdentifier) { if (identityRecord && identityRecord.publicKey) { return constantTimeEqual(identityRecord.publicKey, publicKey); } window.log.warn( 'isTrustedIdentity: No local record for our own identifier. Returning true.' ); return true; } switch (direction) { case Direction.Sending: return this.isTrustedForSending(publicKey, identityRecord); case Direction.Receiving: return true; default: throw new Error(`isTrustedIdentity: Unknown direction: ${direction}`); } } isTrustedForSending( publicKey: ArrayBuffer, identityRecord?: IdentityKeyType ): boolean { if (!identityRecord) { window.log.info( 'isTrustedForSending: No previous record, returning true...' ); return true; } const existing = identityRecord.publicKey; if (!existing) { window.log.info('isTrustedForSending: Nothing here, returning true...'); return true; } if (!constantTimeEqual(existing, publicKey)) { window.log.info("isTrustedForSending: Identity keys don't match..."); return false; } if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { window.log.error('isTrustedIdentity: Needs unverified approval!'); return false; } if (this.isNonBlockingApprovalRequired(identityRecord)) { window.log.error('isTrustedForSending: Needs non-blocking approval!'); return false; } return true; } async loadIdentityKey(identifier: string): Promise { if (identifier === null || identifier === undefined) { throw new Error('loadIdentityKey: identifier was undefined/null'); } const id = window.textsecure.utils.unencodeNumber(identifier)[0]; const identityRecord = this.getIdentityRecord(id); if (identityRecord) { return identityRecord.publicKey; } return undefined; } 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[id] = { hydrated: false, fromDB: data, }; } async saveIdentity( encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval = false ): Promise { if (!this.identityKeys) { throw new Error('saveIdentity: this.identityKeys not yet cached!'); } if (encodedAddress === null || encodedAddress === undefined) { throw new Error('saveIdentity: encodedAddress was undefined/null'); } if (!(publicKey instanceof ArrayBuffer)) { // eslint-disable-next-line no-param-reassign publicKey = fromEncodedBinaryToArrayBuffer(publicKey); } if (typeof nonblockingApproval !== 'boolean') { // eslint-disable-next-line no-param-reassign nonblockingApproval = false; } const identifier = window.textsecure.utils.unencodeNumber( encodedAddress )[0]; const identityRecord = this.getIdentityRecord(identifier); const id = window.ConversationController.getOrCreate( identifier, 'private' ).get('id'); if (!identityRecord || !identityRecord.publicKey) { // Lookup failed, or the current key was removed, so save this one. window.log.info('saveIdentity: Saving new identity...'); await this._saveIdentityKey({ id, publicKey, firstUse: true, timestamp: Date.now(), verified: VerifiedStatus.DEFAULT, nonblockingApproval, }); return false; } const oldpublicKey = identityRecord.publicKey; if (!constantTimeEqual(oldpublicKey, publicKey)) { window.log.info('saveIdentity: 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, }); try { this.trigger('keychange', identifier); } catch (error) { window.log.error( 'saveIdentity: error triggering keychange:', error && error.stack ? error.stack : error ); } await this.archiveSiblingSessions(encodedAddress); return true; } if (this.isNonBlockingApprovalRequired(identityRecord)) { window.log.info('saveIdentity: Setting approval status...'); identityRecord.nonblockingApproval = nonblockingApproval; await this._saveIdentityKey(identityRecord); return false; } return false; } isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean { return ( !identityRecord.firstUse && isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && !identityRecord.nonblockingApproval ); } async saveIdentityWithAttributes( encodedAddress: string, attributes: Partial ): Promise { if (encodedAddress === null || encodedAddress === undefined) { throw new Error( 'saveIdentityWithAttributes: encodedAddress was undefined/null' ); } const identifier = window.textsecure.utils.unencodeNumber( encodedAddress )[0]; const identityRecord = this.getIdentityRecord(identifier); const conv = window.ConversationController.getOrCreate( identifier, 'private' ); const id = conv.get('id'); const updates: Partial = { ...identityRecord, ...attributes, id, }; if (validateIdentityKey(updates)) { await this._saveIdentityKey(updates); } } async setApproval( encodedAddress: string, nonblockingApproval: boolean ): Promise { if (encodedAddress === null || encodedAddress === undefined) { throw new Error('setApproval: encodedAddress was undefined/null'); } if (typeof nonblockingApproval !== 'boolean') { throw new Error('setApproval: Invalid approval status'); } const identifier = window.textsecure.utils.unencodeNumber( encodedAddress )[0]; const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { throw new Error(`setApproval: No identity record for ${identifier}`); } identityRecord.nonblockingApproval = nonblockingApproval; await this._saveIdentityKey(identityRecord); } async setVerified( encodedAddress: string, verifiedStatus: number, publicKey?: ArrayBuffer ): Promise { if (encodedAddress === null || encodedAddress === undefined) { throw new Error('setVerified: encodedAddress was undefined/null'); } if (!validateVerifiedStatus(verifiedStatus)) { throw new Error('setVerified: Invalid verified status'); } if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { throw new Error('setVerified: Invalid public key'); } const identityRecord = this.getIdentityRecord(encodedAddress); if (!identityRecord) { throw new Error(`setVerified: No identity record for ${encodedAddress}`); } if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) { identityRecord.verified = verifiedStatus; if (validateIdentityKey(identityRecord)) { await this._saveIdentityKey(identityRecord); } } else { window.log.info( 'setVerified: No identity record for specified publicKey' ); } } async getVerified(identifier: string): Promise { if (identifier === null || identifier === undefined) { throw new Error('getVerified: identifier was undefined/null'); } const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { throw new Error(`getVerified: No identity record for ${identifier}`); } const verifiedStatus = identityRecord.verified; if (validateVerifiedStatus(verifiedStatus)) { return verifiedStatus; } return VerifiedStatus.DEFAULT; } // Resolves to true if a new identity key was saved processContactSyncVerificationState( identifier: string, verifiedStatus: number, publicKey: ArrayBuffer ): Promise { if (verifiedStatus === VerifiedStatus.UNVERIFIED) { return this.processUnverifiedMessage( identifier, verifiedStatus, publicKey ); } return this.processVerifiedMessage(identifier, verifiedStatus, publicKey); } // This function encapsulates the non-Java behavior, since the mobile apps don't // currently receive contact syncs and therefore will see a verify sync with // UNVERIFIED status async processUnverifiedMessage( identifier: string, verifiedStatus: number, publicKey?: ArrayBuffer ): Promise { if (identifier === null || identifier === undefined) { throw new Error( 'processUnverifiedMessage: identifier was undefined/null' ); } if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { throw new Error('processUnverifiedMessage: Invalid public key'); } const identityRecord = this.getIdentityRecord(identifier); let isEqual = false; if (identityRecord && publicKey) { isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); } if ( identityRecord && isEqual && identityRecord.verified !== VerifiedStatus.UNVERIFIED ) { await this.setVerified(identifier, verifiedStatus, publicKey); return false; } if (!identityRecord || !isEqual) { await this.saveIdentityWithAttributes(identifier, { publicKey, verified: verifiedStatus, firstUse: false, timestamp: Date.now(), nonblockingApproval: true, }); if (identityRecord && !isEqual) { try { this.trigger('keychange', identifier); } catch (error) { window.log.error( 'processUnverifiedMessage: error triggering keychange:', error && error.stack ? error.stack : error ); } await this.archiveAllSessions(identifier); return true; } } // The situation which could get us here is: // 1. had a previous key // 2. new key is the same // 3. desired new status is same as what we had before return false; } // This matches the Java method as of // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 async processVerifiedMessage( identifier: string, verifiedStatus: number, publicKey?: ArrayBuffer ): Promise { if (identifier === null || identifier === undefined) { throw new Error('processVerifiedMessage: identifier was undefined/null'); } if (!validateVerifiedStatus(verifiedStatus)) { throw new Error('processVerifiedMessage: Invalid verified status'); } if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { throw new Error('processVerifiedMessage: Invalid public key'); } const identityRecord = this.getIdentityRecord(identifier); let isEqual = false; if (identityRecord && publicKey) { isEqual = constantTimeEqual(publicKey, identityRecord.publicKey); } if (!identityRecord && verifiedStatus === VerifiedStatus.DEFAULT) { window.log.info( 'processVerifiedMessage: No existing record for default status' ); return false; } if ( identityRecord && isEqual && identityRecord.verified !== VerifiedStatus.DEFAULT && verifiedStatus === VerifiedStatus.DEFAULT ) { await this.setVerified(identifier, verifiedStatus, publicKey); return false; } if ( verifiedStatus === VerifiedStatus.VERIFIED && (!identityRecord || (identityRecord && !isEqual) || (identityRecord && identityRecord.verified !== VerifiedStatus.VERIFIED)) ) { await this.saveIdentityWithAttributes(identifier, { publicKey, verified: verifiedStatus, firstUse: false, timestamp: Date.now(), nonblockingApproval: true, }); if (identityRecord && !isEqual) { try { this.trigger('keychange', identifier); } catch (error) { window.log.error( 'processVerifiedMessage error triggering keychange:', error && error.stack ? error.stack : error ); } await this.archiveAllSessions(identifier); // true signifies that we overwrote a previous key with a new one return true; } } // We get here if we got a new key and the status is DEFAULT. If the // message is out of date, we don't want to lose whatever more-secure // state we had before. return false; } isUntrusted(identifier: string): boolean { if (identifier === null || identifier === undefined) { throw new Error('isUntrusted: identifier was undefined/null'); } const identityRecord = this.getIdentityRecord(identifier); if (!identityRecord) { throw new Error(`isUntrusted: No identity record for ${identifier}`); } if ( isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && !identityRecord.nonblockingApproval && !identityRecord.firstUse ) { return true; } return false; } async removeIdentityKey(identifier: string): Promise { if (!this.identityKeys) { throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); } const id = window.ConversationController.getConversationId(identifier); if (id) { delete this.identityKeys[id]; await window.Signal.Data.removeIdentityKeyById(id); await this.removeAllSessions(id); } } // Not yet processed messages - for resiliency getUnprocessedCount(): Promise { return window.Signal.Data.getUnprocessedCount(); } getAllUnprocessed(): Promise> { return window.Signal.Data.getAllUnprocessed(); } getUnprocessedById(id: string): Promise { return window.Signal.Data.getUnprocessedById(id); } addUnprocessed(data: UnprocessedType): Promise { // 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): Promise { // 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.saveUnprocesseds(array, { forceSave: true, }); } updateUnprocessedAttempts(id: string, attempts: number): Promise { return window.Signal.Data.updateUnprocessedAttempts(id, attempts); } updateUnprocessedWithData( id: string, data: UnprocessedUpdateType ): Promise { return window.Signal.Data.updateUnprocessedWithData(id, data); } updateUnprocessedsWithData( items: Array<{ id: string; data: UnprocessedUpdateType }> ): Promise { return window.Signal.Data.updateUnprocessedsWithData(items); } removeUnprocessed(idOrArray: string | Array): Promise { return window.Signal.Data.removeUnprocessed(idOrArray); } removeAllUnprocessed(): Promise { return window.Signal.Data.removeAllUnprocessed(); } 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(); } async removeAllConfiguration(): Promise { await window.Signal.Data.removeAllConfiguration(); await this.hydrateCaches(); window.storage.reset(); await window.storage.fetch(); } } window.SignalProtocolStore = SignalProtocolStore;