diff --git a/background.html b/background.html index 3ced72b69..8e602dee0 100644 --- a/background.html +++ b/background.html @@ -329,7 +329,6 @@ - diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js deleted file mode 100644 index 75bbc41ae..000000000 --- a/js/signal_protocol_store.js +++ /dev/null @@ -1,1050 +0,0 @@ -// Copyright 2016-2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* global - dcodeIO, Backbone, _, libsignal, textsecure, ConversationController, stringObject */ - -/* eslint-disable no-proto */ - -// eslint-disable-next-line func-names -(function () { - const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds - const Direction = { - SENDING: 1, - RECEIVING: 2, - }; - - const VerifiedStatus = { - DEFAULT: 0, - VERIFIED: 1, - UNVERIFIED: 2, - }; - - function validateVerifiedStatus(status) { - if ( - status === VerifiedStatus.DEFAULT || - status === VerifiedStatus.VERIFIED || - status === VerifiedStatus.UNVERIFIED - ) { - return true; - } - return false; - } - - const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__; - const StaticArrayBufferProto = new ArrayBuffer().__proto__; - const StaticUint8ArrayProto = new Uint8Array().__proto__; - - function isStringable(thing) { - return ( - thing === Object(thing) && - (thing.__proto__ === StaticArrayBufferProto || - thing.__proto__ === StaticUint8ArrayProto || - thing.__proto__ === StaticByteBufferProto) - ); - } - function convertToArrayBuffer(thing) { - if (thing === undefined) { - return undefined; - } - if (thing === Object(thing)) { - if (thing.__proto__ === StaticArrayBufferProto) { - return thing; - } - // TODO: Several more cases here... - } - - if (thing instanceof Array) { - // Assuming Uint16Array from curve25519 - const res = new ArrayBuffer(thing.length * 2); - const uint = new Uint16Array(res); - for (let i = 0; i < thing.length; i += 1) { - uint[i] = thing[i]; - } - return res; - } - - let str; - if (isStringable(thing)) { - str = stringObject(thing); - } else if (typeof thing === 'string') { - str = thing; - } else { - throw new Error( - `Tried to convert a non-stringable thing of type ${typeof thing} to an array buffer` - ); - } - const res = new ArrayBuffer(str.length); - const uint = new Uint8Array(res); - for (let i = 0; i < str.length; i += 1) { - uint[i] = str.charCodeAt(i); - } - return res; - } - - function equalArrayBuffers(ab1, ab2) { - if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) { - return false; - } - if (ab1.byteLength !== ab2.byteLength) { - return false; - } - let result = 0; - const ta1 = new Uint8Array(ab1); - const ta2 = new Uint8Array(ab2); - for (let i = 0; i < ab1.byteLength; i += 1) { - // eslint-disable-next-line no-bitwise - result |= ta1[i] ^ ta2[i]; - } - return result === 0; - } - - const IdentityRecord = Backbone.Model.extend({ - storeName: 'identityKeys', - validAttributes: [ - 'id', - 'publicKey', - 'firstUse', - 'timestamp', - 'verified', - 'nonblockingApproval', - ], - validate(attrs) { - const attributeNames = _.keys(attrs); - const { validAttributes } = this; - const allValid = _.all(attributeNames, attributeName => - _.contains(validAttributes, attributeName) - ); - if (!allValid) { - return new Error('Invalid identity key attribute names'); - } - const allPresent = _.all(validAttributes, attributeName => - _.contains(attributeNames, attributeName) - ); - if (!allPresent) { - return new Error('Missing identity key attributes'); - } - - if (typeof attrs.id !== 'string') { - return new Error('Invalid identity key id'); - } - if (!(attrs.publicKey instanceof ArrayBuffer)) { - return new Error('Invalid identity key publicKey'); - } - if (typeof attrs.firstUse !== 'boolean') { - return new Error('Invalid identity key firstUse'); - } - if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { - return new Error('Invalid identity key timestamp'); - } - if (!validateVerifiedStatus(attrs.verified)) { - return new Error('Invalid identity key verified'); - } - if (typeof attrs.nonblockingApproval !== 'boolean') { - return new Error('Invalid identity key nonblockingApproval'); - } - - return null; - }, - }); - - async function normalizeEncodedAddress(encodedAddress) { - const [identifier, deviceId] = textsecure.utils.unencodeNumber( - encodedAddress - ); - try { - const conv = ConversationController.getOrCreate(identifier, 'private'); - return `${conv.get('id')}.${deviceId}`; - } catch (e) { - window.log.error( - `could not get conversation for identifier ${identifier}` - ); - throw e; - } - } - - function SignalProtocolStore() {} - - async function _hydrateCache(object, field, itemsPromise, idField) { - const items = await itemsPromise; - - const cache = Object.create(null); - for (let i = 0, max = items.length; i < max; i += 1) { - const item = items[i]; - const id = item[idField]; - - cache[id] = item; - } - - window.log.info(`SignalProtocolStore: Finished caching ${field} data`); - // eslint-disable-next-line no-param-reassign - object[field] = cache; - } - - SignalProtocolStore.prototype = { - constructor: SignalProtocolStore, - async hydrateCaches() { - 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; - })(), - _hydrateCache( - this, - 'identityKeys', - window.Signal.Data.getAllIdentityKeys(), - 'id' - ), - _hydrateCache( - this, - 'sessions', - await window.Signal.Data.getAllSessions(), - 'id' - ), - _hydrateCache( - this, - 'preKeys', - window.Signal.Data.getAllPreKeys(), - 'id' - ), - _hydrateCache( - this, - 'signedPreKeys', - window.Signal.Data.getAllSignedPreKeys(), - 'id' - ), - ]); - }, - - async getIdentityKeyPair() { - return this.ourIdentityKey; - }, - async getLocalRegistrationId() { - return this.ourRegistrationId; - }, - - // PreKeys - - async loadPreKey(keyId) { - const key = this.preKeys[keyId]; - if (key) { - window.log.info('Successfully fetched prekey:', keyId); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - }; - } - - window.log.error('Failed to fetch prekey:', keyId); - return undefined; - }, - async storePreKey(keyId, keyPair) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - }; - - this.preKeys[keyId] = data; - await window.Signal.Data.createOrUpdatePreKey(data); - }, - async removePreKey(keyId) { - 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() { - this.preKeys = Object.create(null); - await window.Signal.Data.removeAllPreKeys(); - }, - - // Signed PreKeys - - async loadSignedPreKey(keyId) { - const key = this.signedPreKeys[keyId]; - if (key) { - window.log.info('Successfully fetched signed prekey:', key.id); - return { - pubKey: key.publicKey, - privKey: key.privateKey, - created_at: key.created_at, - keyId: key.id, - confirmed: key.confirmed, - }; - } - - window.log.error('Failed to fetch signed prekey:', keyId); - return undefined; - }, - async loadSignedPreKeys() { - if (arguments.length > 0) { - throw new Error('loadSignedPreKeys takes no arguments'); - } - - const keys = Object.values(this.signedPreKeys); - return keys.map(prekey => ({ - pubKey: prekey.publicKey, - privKey: prekey.privateKey, - created_at: prekey.created_at, - keyId: prekey.id, - confirmed: prekey.confirmed, - })); - }, - async storeSignedPreKey(keyId, keyPair, confirmed) { - const data = { - id: keyId, - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - created_at: Date.now(), - confirmed: Boolean(confirmed), - }; - - this.signedPreKeys[keyId] = data; - await window.Signal.Data.createOrUpdateSignedPreKey(data); - }, - async removeSignedPreKey(keyId) { - delete this.signedPreKeys[keyId]; - await window.Signal.Data.removeSignedPreKeyById(keyId); - }, - async clearSignedPreKeysStore() { - this.signedPreKeys = Object.create(null); - await window.Signal.Data.removeAllSignedPreKeys(); - }, - - // Sessions - - async loadSession(encodedAddress) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get session for undefined/null number'); - } - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const session = this.sessions[id]; - - if (session) { - return session.record; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not load session ${encodedAddress}: ${errorString}` - ); - } - - return undefined; - }, - async storeSession(encodedAddress, record) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put session for undefined/null number'); - } - const unencoded = textsecure.utils.unencodeNumber(encodedAddress); - const deviceId = parseInt(unencoded[1], 10); - - try { - const id = await normalizeEncodedAddress(encodedAddress); - const previousData = this.sessions[id]; - - const data = { - id, - conversationId: textsecure.utils.unencodeNumber(id)[0], - deviceId, - record, - }; - - // Optimistically update in-memory cache; will revert if save fails. - this.sessions[id] = data; - - try { - await window.Signal.Data.createOrUpdateSession(data); - } catch (e) { - if (previousData) { - this.sessions[id] = previousData; - } - throw e; - } - } catch (error) { - const errorString = error && error.stack ? error.stack : error; - window.log.error( - `could not store session for ${encodedAddress}: ${errorString}` - ); - } - }, - async getDeviceIds(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get device ids for undefined/null number'); - } - - try { - const id = ConversationController.getConversationId(identifier); - const allSessions = Object.values(this.sessions); - const sessions = allSessions.filter( - session => session.conversationId === id - ); - const openSessions = await Promise.all( - sessions.map(async session => { - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - session.id - ); - - const hasOpenSession = await sessionCipher.hasOpenSession(); - if (hasOpenSession) { - return session; - } - - return undefined; - }) - ); - - return openSessions.filter(Boolean).map(item => item.deviceId); - } catch (error) { - window.log.error( - `could not get device ids for identifier ${identifier}`, - error && error.stack ? error.stack : error - ); - } - - return []; - }, - async removeSession(encodedAddress) { - window.log.info('removeSession: deleting session for', encodedAddress); - try { - const id = await normalizeEncodedAddress(encodedAddress); - delete this.sessions[id]; - await window.Signal.Data.removeSessionById(id); - } catch (e) { - window.log.error(`could not delete session for ${encodedAddress}`); - } - }, - async removeAllSessions(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to remove sessions for undefined/null number'); - } - - window.log.info('removeAllSessions: deleting sessions for', identifier); - - const id = ConversationController.getConversationId(identifier); - - const allSessions = Object.values(this.sessions); - - for (let i = 0, max = allSessions.length; i < max; i += 1) { - const session = allSessions[i]; - if (session.conversationId === id) { - delete this.sessions[session.id]; - } - } - - await window.Signal.Data.removeSessionsByConversation(identifier); - }, - async archiveSiblingSessions(identifier) { - window.log.info( - 'archiveSiblingSessions: archiving sibling sessions for', - identifier - ); - - const address = libsignal.SignalProtocolAddress.fromString(identifier); - - const deviceIds = await this.getDeviceIds(address.getName()); - const siblings = _.without(deviceIds, address.getDeviceId()); - - await Promise.all( - siblings.map(async deviceId => { - const sibling = new libsignal.SignalProtocolAddress( - address.getName(), - deviceId - ); - window.log.info( - 'archiveSiblingSessions: closing session for', - sibling.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - sibling - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async archiveAllSessions(identifier) { - window.log.info( - 'archiveAllSessions: archiving all sessions for', - identifier - ); - - const deviceIds = await this.getDeviceIds(identifier); - - await Promise.all( - deviceIds.map(async deviceId => { - const address = new libsignal.SignalProtocolAddress( - identifier, - deviceId - ); - window.log.info( - 'archiveAllSessions: closing session for', - address.toString() - ); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address - ); - await sessionCipher.closeOpenSessionForDevice(); - }) - ); - }, - async clearSessionStore() { - this.sessions = Object.create(null); - window.Signal.Data.removeAllSessions(); - }, - - // Identity Keys - - getIdentityRecord(identifier) { - try { - const id = ConversationController.getConversationId(identifier); - const record = this.identityKeys[id]; - - if (record) { - return record; - } - } catch (e) { - window.log.error( - `could not get identity record for identifier ${identifier}` - ); - } - - return undefined; - }, - - async isTrustedIdentity(encodedAddress, publicKey, direction) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const isOurIdentifier = - (ourNumber && identifier === ourNumber) || - (ourUuid && identifier === ourUuid); - - const identityRecord = this.getIdentityRecord(identifier); - - if (isOurIdentifier) { - if (identityRecord && identityRecord.publicKey) { - return equalArrayBuffers(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(`Unknown direction: ${direction}`); - } - }, - isTrustedForSending(publicKey, identityRecord) { - 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 (!equalArrayBuffers(existing, publicKey)) { - window.log.info("isTrustedForSending: Identity keys don't match..."); - return false; - } - if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { - window.log.error('Needs unverified approval!'); - return false; - } - if (this.isNonBlockingApprovalRequired(identityRecord)) { - window.log.error('isTrustedForSending: Needs non-blocking approval!'); - return false; - } - - return true; - }, - async loadIdentityKey(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to get identity key for undefined/null key'); - } - const id = textsecure.utils.unencodeNumber(identifier)[0]; - const identityRecord = this.getIdentityRecord(id); - - if (identityRecord) { - return identityRecord.publicKey; - } - - return undefined; - }, - async _saveIdentityKey(data) { - const { id } = data; - - const previousData = this.identityKeys[id]; - - // Optimistically update in-memory cache; will revert if save fails. - this.identityKeys[id] = data; - - try { - await window.Signal.Data.createOrUpdateIdentityKey(data); - } catch (error) { - if (previousData) { - this.identityKeys[id] = previousData; - } - - throw error; - } - }, - async saveIdentity(encodedAddress, publicKey, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - if (!(publicKey instanceof ArrayBuffer)) { - // eslint-disable-next-line no-param-reassign - publicKey = convertToArrayBuffer(publicKey); - } - if (typeof nonblockingApproval !== 'boolean') { - // eslint-disable-next-line no-param-reassign - nonblockingApproval = false; - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const id = 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('Saving new identity...'); - await this._saveIdentityKey({ - id, - publicKey, - firstUse: true, - timestamp: Date.now(), - verified: VerifiedStatus.DEFAULT, - nonblockingApproval, - }); - - return false; - } - - const oldpublicKey = identityRecord.publicKey; - if (!equalArrayBuffers(oldpublicKey, publicKey)) { - window.log.info('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('Setting approval status...'); - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - - return false; - } - - return false; - }, - isNonBlockingApprovalRequired(identityRecord) { - return ( - !identityRecord.firstUse && - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval - ); - }, - async saveIdentityWithAttributes(encodedAddress, attributes) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to put identity key for undefined/null key'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - const conv = ConversationController.getOrCreate(identifier, 'private'); - const id = conv.get('id'); - - const updates = { - id, - ...identityRecord, - ...attributes, - }; - - const model = new IdentityRecord(updates); - if (model.isValid()) { - await this._saveIdentityKey(updates); - } else { - throw model.validationError; - } - }, - async setApproval(encodedAddress, nonblockingApproval) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set approval for undefined/null identifier'); - } - if (typeof nonblockingApproval !== 'boolean') { - throw new Error('Invalid approval status'); - } - - const identifier = textsecure.utils.unencodeNumber(encodedAddress)[0]; - const identityRecord = this.getIdentityRecord(identifier); - - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - identityRecord.nonblockingApproval = nonblockingApproval; - await this._saveIdentityKey(identityRecord); - }, - async setVerified(encodedAddress, verifiedStatus, publicKey) { - if (encodedAddress === null || encodedAddress === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(encodedAddress); - - if (!identityRecord) { - throw new Error(`No identity record for ${encodedAddress}`); - } - - if ( - !publicKey || - equalArrayBuffers(identityRecord.publicKey, publicKey) - ) { - identityRecord.verified = verifiedStatus; - - const model = new IdentityRecord(identityRecord); - if (model.isValid()) { - await this._saveIdentityKey(identityRecord); - } else { - throw identityRecord.validationError; - } - } else { - window.log.info('No identity record for specified publicKey'); - } - }, - async getVerified(identifier) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`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, verifiedStatus, publicKey) { - 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, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.UNVERIFIED - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if (!isPresent || !isEqual) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !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, verifiedStatus, publicKey) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - if (!validateVerifiedStatus(verifiedStatus)) { - throw new Error('Invalid verified status'); - } - if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { - throw new Error('Invalid public key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - - const isPresent = Boolean(identityRecord); - let isEqual = false; - - if (isPresent && publicKey) { - isEqual = equalArrayBuffers(publicKey, identityRecord.publicKey); - } - - if (!isPresent && verifiedStatus === VerifiedStatus.DEFAULT) { - window.log.info('No existing record for default status'); - return false; - } - - if ( - isPresent && - isEqual && - identityRecord.verified !== VerifiedStatus.DEFAULT && - verifiedStatus === VerifiedStatus.DEFAULT - ) { - await textsecure.storage.protocol.setVerified( - identifier, - verifiedStatus, - publicKey - ); - return false; - } - - if ( - verifiedStatus === VerifiedStatus.VERIFIED && - (!isPresent || - (isPresent && !isEqual) || - (isPresent && identityRecord.verified !== VerifiedStatus.VERIFIED)) - ) { - await textsecure.storage.protocol.saveIdentityWithAttributes( - identifier, - { - publicKey, - verified: verifiedStatus, - firstUse: false, - timestamp: Date.now(), - nonblockingApproval: true, - } - ); - - if (isPresent && !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) { - if (identifier === null || identifier === undefined) { - throw new Error('Tried to set verified for undefined/null key'); - } - - const identityRecord = this.getIdentityRecord(identifier); - if (!identityRecord) { - throw new Error(`No identity record for ${identifier}`); - } - - if ( - Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && - !identityRecord.nonblockingApproval && - !identityRecord.firstUse - ) { - return true; - } - - return false; - }, - async removeIdentityKey(identifier) { - const id = ConversationController.getConversationId(identifier); - if (id) { - delete this.identityKeys[id]; - await window.Signal.Data.removeIdentityKeyById(id); - await textsecure.storage.protocol.removeAllSessions(id); - } - }, - - // Not yet processed messages - for resiliency - getUnprocessedCount() { - return window.Signal.Data.getUnprocessedCount(); - }, - getAllUnprocessed() { - return window.Signal.Data.getAllUnprocessed(); - }, - getUnprocessedById(id) { - return window.Signal.Data.getUnprocessedById(id); - }, - addUnprocessed(data) { - // 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) { - // 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, attempts) { - return window.Signal.Data.updateUnprocessedAttempts(id, attempts); - }, - updateUnprocessedWithData(id, data) { - return window.Signal.Data.updateUnprocessedWithData(id, data); - }, - updateUnprocessedsWithData(items) { - return window.Signal.Data.updateUnprocessedsWithData(items); - }, - removeUnprocessed(idOrArray) { - return window.Signal.Data.removeUnprocessed(idOrArray); - }, - removeAllUnprocessed() { - return window.Signal.Data.removeAllUnprocessed(); - }, - async removeAllData() { - await window.Signal.Data.removeAll(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - - ConversationController.reset(); - await ConversationController.load(); - }, - async removeAllConfiguration() { - await window.Signal.Data.removeAllConfiguration(); - await this.hydrateCaches(); - - window.storage.reset(); - await window.storage.fetch(); - }, - }; - _.extend(SignalProtocolStore.prototype, Backbone.Events); - - window.SignalProtocolStore = SignalProtocolStore; - window.SignalProtocolStore.prototype.Direction = Direction; - window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus; -})(); diff --git a/preload.js b/preload.js index 386fcf9d4..66e8dc143 100644 --- a/preload.js +++ b/preload.js @@ -523,6 +523,7 @@ try { require('./ts/backbone/views/whisper_view'); require('./ts/backbone/views/toast_view'); require('./ts/views/conversation_view'); + require('./ts/LibSignalStore'); require('./ts/background'); function wrapWithPromise(fn) { diff --git a/test/index.html b/test/index.html index 540786f11..f79d1d116 100644 --- a/test/index.html +++ b/test/index.html @@ -339,7 +339,6 @@ - @@ -351,8 +350,6 @@ - - diff --git a/ts/LibSignalStore.ts b/ts/LibSignalStore.ts new file mode 100644 index 000000000..eb34e1be2 --- /dev/null +++ b/ts/LibSignalStore.ts @@ -0,0 +1,1233 @@ +// Copyright 2016-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable class-methods-use-this */ + +import { fromEncodedBinaryToArrayBuffer, constantTimeEqual } from './Crypto'; +import { isNotNil } from './util/isNotNil'; + +const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds +const Direction = { + SENDING: 1, + RECEIVING: 2, +}; + +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 IdentityRecord = window.Backbone.Model.extend({ + storeName: 'identityKeys', + validAttributes: [ + 'id', + 'publicKey', + 'firstUse', + 'timestamp', + 'verified', + 'nonblockingApproval', + ], + validate(attrs: IdentityKeyType) { + const attributeNames = window._.keys(attrs); + const { validAttributes } = this; + const allValid = window._.all(attributeNames, attributeName => + window._.contains(validAttributes, attributeName) + ); + if (!allValid) { + return new Error('Invalid identity key attribute names'); + } + const allPresent = window._.all(validAttributes, attributeName => + window._.contains(attributeNames, attributeName) + ); + if (!allPresent) { + return new Error('Missing identity key attributes'); + } + + if (typeof attrs.id !== 'string') { + return new Error('Invalid identity key id'); + } + if (!(attrs.publicKey instanceof ArrayBuffer)) { + return new Error('Invalid identity key publicKey'); + } + if (typeof attrs.firstUse !== 'boolean') { + return new Error('Invalid identity key firstUse'); + } + if (typeof attrs.timestamp !== 'number' || !(attrs.timestamp >= 0)) { + return new Error('Invalid identity key timestamp'); + } + if (!validateVerifiedStatus(attrs.verified)) { + return new Error('Invalid identity key verified'); + } + if (typeof attrs.nonblockingApproval !== 'boolean') { + return new Error('Invalid identity key nonblockingApproval'); + } + + return null; + }, +}); + +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; +}; + +async function _hydrateCache( + 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 item = items[i]; + const { id } = item; + + cache[id] = item; + } + + 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; +} + +type KeyPairType = { + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; + +type IdentityKeyType = { + firstUse: boolean; + id: string; + nonblockingApproval: boolean; + publicKey: ArrayBuffer; + timestamp: number; + verified: number; +}; + +type SessionType = { + conversationId: string; + deviceId: number; + id: string; + record: string; +}; + +type SignedPreKeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; +type OuterSignedPrekeyType = { + confirmed: boolean; + // eslint-disable-next-line camelcase + created_at: number; + keyId: number; + privKey: ArrayBuffer; + pubKey: ArrayBuffer; +}; +type PreKeyType = { + id: number; + privateKey: ArrayBuffer; + publicKey: ArrayBuffer; +}; + +type UnprocessedType = { + id: string; + timestamp: number; + version: number; + attempts: number; + envelope: string; + decrypted?: string; + source?: string; + sourceDevice: string; + serverTimestamp: number; +}; + +// 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 + + Direction = Direction; + + VerifiedStatus = VerifiedStatus; + + // Cached values + + ourIdentityKey?: KeyPairType; + + ourRegistrationId?: number; + + identityKeys?: Record; + + sessions?: Record; + + signedPreKeys?: Record; + + preKeys?: Record; + + 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; + })(), + _hydrateCache( + this, + 'identityKeys', + window.Signal.Data.getAllIdentityKeys() + ), + _hydrateCache( + this, + 'sessions', + window.Signal.Data.getAllSessions() + ), + _hydrateCache( + this, + 'preKeys', + window.Signal.Data.getAllPreKeys() + ), + _hydrateCache( + 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 key = this.preKeys[keyId]; + if (key) { + window.log.info('Successfully fetched prekey:', keyId); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + }; + } + + window.log.error('Failed to fetch prekey:', keyId); + return undefined; + } + + async storePreKey(keyId: number, keyPair: KeyPairType): Promise { + if (!this.preKeys) { + throw new Error('storePreKey: this.preKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + }; + + this.preKeys[keyId] = data; + await window.Signal.Data.createOrUpdatePreKey(data); + } + + 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 key = this.signedPreKeys[keyId]; + if (key) { + window.log.info('Successfully fetched signed prekey:', key.id); + return { + pubKey: key.publicKey, + privKey: key.privateKey, + created_at: key.created_at, + keyId: key.id, + confirmed: key.confirmed, + }; + } + + window.log.error('Failed to fetch signed prekey:', keyId); + return undefined; + } + + 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 keys = Object.values(this.signedPreKeys); + return keys.map(prekey => ({ + pubKey: prekey.publicKey, + privKey: prekey.privateKey, + created_at: prekey.created_at, + keyId: prekey.id, + confirmed: prekey.confirmed, + })); + } + + async storeSignedPreKey( + keyId: number, + keyPair: KeyPairType, + confirmed?: boolean + ): Promise { + if (!this.signedPreKeys) { + throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); + } + + const data = { + id: keyId, + publicKey: keyPair.pubKey, + privateKey: keyPair.privKey, + created_at: Date.now(), + confirmed: Boolean(confirmed), + }; + + this.signedPreKeys[keyId] = data; + await window.Signal.Data.createOrUpdateSignedPreKey(data); + } + + 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(); + } + + // 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('Tried to get session for undefined/null number'); + } + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const session = this.sessions[id]; + + if (session) { + return session.record; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not load session ${encodedAddress}: ${errorString}` + ); + } + + return undefined; + } + + async storeSession(encodedAddress: string, record: string): Promise { + if (!this.sessions) { + throw new Error('storeSession: this.sessions not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put session for undefined/null number'); + } + const unencoded = window.textsecure.utils.unencodeNumber(encodedAddress); + const deviceId = parseInt(unencoded[1], 10); + + try { + const id = await normalizeEncodedAddress(encodedAddress); + const previousData = this.sessions[id]; + + const data = { + id, + conversationId: window.textsecure.utils.unencodeNumber(id)[0], + deviceId, + record, + }; + + // Optimistically update in-memory cache; will revert if save fails. + this.sessions[id] = data; + + try { + await window.Signal.Data.createOrUpdateSession(data); + } catch (e) { + if (previousData) { + this.sessions[id] = previousData; + } + throw e; + } + } catch (error) { + const errorString = error && error.stack ? error.stack : error; + window.log.error( + `could not store session for ${encodedAddress}: ${errorString}` + ); + } + } + + 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('Tried to get device ids for undefined/null number'); + } + + try { + const id = window.ConversationController.getConversationId(identifier); + const allSessions = Object.values(this.sessions); + const sessions = allSessions.filter( + session => session.conversationId === id + ); + const openSessions = await Promise.all( + sessions.map(async session => { + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + session.id + ); + + const hasOpenSession = await sessionCipher.hasOpenSession(); + if (hasOpenSession) { + return session; + } + + return undefined; + }) + ); + + return openSessions.filter(isNotNil).map(item => item.deviceId); + } catch (error) { + window.log.error( + `could not 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); + delete this.sessions[id]; + await window.Signal.Data.removeSessionById(id); + } catch (e) { + window.log.error(`could not 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('Tried to remove sessions for undefined/null number'); + } + + window.log.info('removeAllSessions: deleting sessions for', identifier); + + const id = window.ConversationController.getConversationId(identifier); + + const allSessions = Object.values(this.sessions); + + for (let i = 0, max = allSessions.length; i < max; i += 1) { + const session = allSessions[i]; + if (session.conversationId === id) { + delete this.sessions[session.id]; + } + } + + await window.Signal.Data.removeSessionsByConversation(identifier); + } + + async archiveSiblingSessions(identifier: string): Promise { + if (!this.sessions) { + throw new Error('archiveSiblingSessions: this.sessions not yet cached!'); + } + + window.log.info( + 'archiveSiblingSessions: archiving sibling sessions for', + identifier + ); + + const address = window.libsignal.SignalProtocolAddress.fromString( + identifier + ); + + const deviceIds = await this.getDeviceIds(address.getName()); + const siblings = window._.without(deviceIds, address.getDeviceId()); + + await Promise.all( + siblings.map(async deviceId => { + const sibling = new window.libsignal.SignalProtocolAddress( + address.getName(), + deviceId + ); + window.log.info( + 'archiveSiblingSessions: closing session for', + sibling.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + sibling + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + 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 deviceIds = await this.getDeviceIds(identifier); + + await Promise.all( + deviceIds.map(async deviceId => { + const address = new window.libsignal.SignalProtocolAddress( + identifier, + deviceId + ); + window.log.info( + 'archiveAllSessions: closing session for', + address.toString() + ); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + await sessionCipher.closeOpenSessionForDevice(); + }) + ); + } + + 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 record = this.identityKeys[id]; + + if (record) { + return record; + } + } catch (e) { + window.log.error( + `could not 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('Tried to get identity key for undefined/null key'); + } + 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(`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('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('Tried to get identity key for undefined/null key'); + } + 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; + + const previousData = this.identityKeys[id]; + + // Optimistically update in-memory cache; will revert if save fails. + this.identityKeys[id] = data; + + try { + await window.Signal.Data.createOrUpdateIdentityKey(data); + } catch (error) { + if (previousData) { + this.identityKeys[id] = previousData; + } + + throw error; + } + } + + async saveIdentity( + encodedAddress: string, + publicKey: ArrayBuffer, + nonblockingApproval: boolean + ): Promise { + if (!this.identityKeys) { + throw new Error('saveIdentity: this.identityKeys not yet cached!'); + } + + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + 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('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('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('Setting approval status...'); + + identityRecord.nonblockingApproval = nonblockingApproval; + await this._saveIdentityKey(identityRecord); + + return false; + } + + return false; + } + + isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean { + return ( + !identityRecord.firstUse && + Date.now() - identityRecord.timestamp < TIMESTAMP_THRESHOLD && + !identityRecord.nonblockingApproval + ); + } + + async saveIdentityWithAttributes( + encodedAddress: string, + attributes: IdentityKeyType + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to put identity key for undefined/null key'); + } + + 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 = { + ...identityRecord, + ...attributes, + id, + }; + + const model = new IdentityRecord(updates); + if (model.isValid()) { + await this._saveIdentityKey(updates); + } else { + throw model.validationError; + } + } + + async setApproval( + encodedAddress: string, + nonblockingApproval: boolean + ): Promise { + if (encodedAddress === null || encodedAddress === undefined) { + throw new Error('Tried to set approval for undefined/null identifier'); + } + if (typeof nonblockingApproval !== 'boolean') { + throw new Error('Invalid approval status'); + } + + const identifier = window.textsecure.utils.unencodeNumber( + encodedAddress + )[0]; + const identityRecord = this.getIdentityRecord(identifier); + + if (!identityRecord) { + throw new Error(`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('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { + throw new Error('Invalid public key'); + } + + const identityRecord = this.getIdentityRecord(encodedAddress); + + if (!identityRecord) { + throw new Error(`No identity record for ${encodedAddress}`); + } + + if (!publicKey || constantTimeEqual(identityRecord.publicKey, publicKey)) { + identityRecord.verified = verifiedStatus; + + const model = new IdentityRecord(identityRecord); + if (model.isValid()) { + await this._saveIdentityKey(identityRecord); + } else if (model.validationError) { + throw model.validationError; + } else { + throw new Error('setVerified: identity record data was invalid'); + } + } else { + window.log.info('No identity record for specified publicKey'); + } + } + + async getVerified(identifier: string): Promise { + if (identifier === null || identifier === undefined) { + throw new Error('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`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('Tried to set verified for undefined/null key'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('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 window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if (publicKey && (!identityRecord || !isEqual)) { + await window.textsecure.storage.protocol.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 + // 4. no publicKey was passed into this function + 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('Tried to set verified for undefined/null key'); + } + if (!validateVerifiedStatus(verifiedStatus)) { + throw new Error('Invalid verified status'); + } + if (publicKey !== undefined && !(publicKey instanceof ArrayBuffer)) { + throw new Error('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('No existing record for default status'); + return false; + } + + if ( + identityRecord && + isEqual && + identityRecord.verified !== VerifiedStatus.DEFAULT && + verifiedStatus === VerifiedStatus.DEFAULT + ) { + await window.textsecure.storage.protocol.setVerified( + identifier, + verifiedStatus, + publicKey + ); + return false; + } + + if ( + verifiedStatus === VerifiedStatus.VERIFIED && + (!identityRecord || + (identityRecord && !isEqual) || + (identityRecord && identityRecord.verified !== VerifiedStatus.VERIFIED)) + ) { + await window.textsecure.storage.protocol.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('Tried to set verified for undefined/null key'); + } + + const identityRecord = this.getIdentityRecord(identifier); + if (!identityRecord) { + throw new Error(`No identity record for ${identifier}`); + } + + if ( + Date.now() - 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 window.textsecure.storage.protocol.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: UnprocessedType): Promise { + return window.Signal.Data.updateUnprocessedWithData(id, data); + } + + updateUnprocessedsWithData(items: Array): 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; diff --git a/ts/libsignal.d.ts b/ts/libsignal.d.ts index e348c8ecc..a9f581b99 100644 --- a/ts/libsignal.d.ts +++ b/ts/libsignal.d.ts @@ -201,7 +201,7 @@ declare class SessionBuilderClass { export declare class SessionCipherClass { constructor( storage: StorageType, - remoteAddress: SignalProtocolAddressClass, + remoteAddress: SignalProtocolAddressClass | string, options?: { messageKeysLimit?: number | boolean } ); closeOpenSessionForDevice: () => Promise; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 14cdc41b9..728dd33ad 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -1226,7 +1226,7 @@ async function updateUnprocessedsWithData(array: Array) { await channels.updateUnprocessedsWithData(array); } -async function removeUnprocessed(id: string) { +async function removeUnprocessed(id: string | Array) { await channels.removeUnprocessed(id); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 469325563..848f37ea3 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -129,7 +129,7 @@ export type DataInterface = { arrayOfUnprocessed: Array, options?: { forceSave?: boolean } ) => Promise; - removeUnprocessed: (id: string) => Promise; + removeUnprocessed: (id: string | Array) => Promise; removeAllUnprocessed: () => Promise; getNextAttachmentDownloadJobs: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index f84965902..97b8bacd6 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3410,7 +3410,7 @@ async function getAllUnprocessed() { return rows; } -async function removeUnprocessed(id: string) { +async function removeUnprocessed(id: string | Array) { const db = getInstance(); if (!Array.isArray(id)) { diff --git a/ts/test-both/util/isNotNil_test.ts b/ts/test-both/util/isNotNil_test.ts new file mode 100644 index 000000000..1cdcf8343 --- /dev/null +++ b/ts/test-both/util/isNotNil_test.ts @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isNotNil } from '../../util/isNotNil'; + +describe('isNotNil', () => { + it('returns false if provided null value', () => { + assert.isFalse(isNotNil(null)); + }); + + it('returns false is provided undefined value', () => { + assert.isFalse(isNotNil(undefined)); + }); + + it('returns false is provided any other value', () => { + assert.isTrue(isNotNil(0)); + assert.isTrue(isNotNil(4)); + assert.isTrue(isNotNil('')); + assert.isTrue(isNotNil('string value')); + assert.isTrue(isNotNil({})); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 4543432b5..5e94fc1fd 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -147,6 +147,7 @@ export type StorageProtocolType = StorageType & { publicKey?: ArrayBuffer ) => Promise; removeSignedPreKey: (keyId: number) => Promise; + removeAllSessions: (identifier: string) => Promise; removeAllData: () => Promise; on: (key: string, callback: () => void) => WhatIsThis; removeAllConfiguration: () => Promise; diff --git a/ts/util/isNotNil.ts b/ts/util/isNotNil.ts new file mode 100644 index 000000000..c60598407 --- /dev/null +++ b/ts/util/isNotNil.ts @@ -0,0 +1,9 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isNotNil(value: T | null | undefined): value is T { + if (value === null || value === undefined) { + return false; + } + return true; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1872069bd..fd5779a12 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -281,14 +281,6 @@ "updated": "2020-08-21T11:29:29.636Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, - { - "rule": "jQuery-load(", - "path": "js/signal_protocol_store.js", - "line": " await ConversationController.load();", - "lineNumber": 1035, - "reasonCategory": "falseMatch", - "updated": "2020-06-12T14:20:09.936Z" - }, { "rule": "DOM-innerHTML", "path": "js/views/app_view.js", @@ -14326,6 +14318,22 @@ "reasonCategory": "falseMatch", "updated": "2020-07-21T18:34:59.251Z" }, + { + "rule": "jQuery-load(", + "path": "ts/LibSignalStore.js", + "line": " await window.ConversationController.load();", + "lineNumber": 810, + "reasonCategory": "falseMatch", + "updated": "2021-02-27T00:48:49.313Z" + }, + { + "rule": "jQuery-load(", + "path": "ts/LibSignalStore.ts", + "line": " await window.ConversationController.load();", + "lineNumber": 1221, + "reasonCategory": "falseMatch", + "updated": "2021-02-27T00:48:49.313Z" + }, { "rule": "DOM-innerHTML", "path": "ts/backbone/views/Lightbox.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index 5879559d7..d2b953b39 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -93,6 +93,7 @@ import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { MIMEType } from './types/MIME'; import { ElectronLocaleType } from './util/mapToSupportLocale'; +import { SignalProtocolStore } from './LibSignalStore'; export { Long } from 'long'; @@ -238,6 +239,7 @@ declare global { removeBlockedGroup: (group: string) => void; removeBlockedNumber: (number: string) => void; removeBlockedUuid: (uuid: string) => void; + reset: () => void; }; systemTheme: WhatIsThis; textsecure: TextSecureType; @@ -512,6 +514,7 @@ declare global { ConversationController: ConversationController; Events: WhatIsThis; MessageController: MessageControllerType; + SignalProtocolStore: typeof SignalProtocolStore; WebAPI: WebAPIConnectType; Whisper: WhisperType;