/* global dcodeIO: false */ /* global Backbone: false */ /* global Whisper: false */ /* global _: false */ /* global libsignal: false */ /* global textsecure: false */ /* global ConversationController: false */ /* global wrapDeferred: false */ /* global stringObject: false */ /* eslint-disable more/no-then, no-proto */ // eslint-disable-next-line func-names (function() { 'use strict'; 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 Model = Backbone.Model.extend({ database: Whisper.Database }); const PreKey = Model.extend({ storeName: 'preKeys' }); const PreKeyCollection = Backbone.Collection.extend({ storeName: 'preKeys', database: Whisper.Database, model: PreKey, }); const SignedPreKey = Model.extend({ storeName: 'signedPreKeys' }); const SignedPreKeyCollection = Backbone.Collection.extend({ storeName: 'signedPreKeys', database: Whisper.Database, model: SignedPreKey, }); const Session = Model.extend({ storeName: 'sessions' }); const SessionCollection = Backbone.Collection.extend({ storeName: 'sessions', database: Whisper.Database, model: Session, fetchSessionsForNumber(number) { return this.fetch({ range: [`${number}.1`, `${number}.:`] }); }, }); const Unprocessed = Model.extend(); const IdentityRecord = 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; }, }); const Group = Model.extend({ storeName: 'groups' }); const Item = Model.extend({ storeName: 'items' }); function SignalProtocolStore() {} SignalProtocolStore.prototype = { constructor: SignalProtocolStore, getIdentityKeyPair() { const item = new Item({ id: 'identityKey' }); return new Promise((resolve, reject) => { item.fetch().then(() => { resolve(item.get('value')); }, reject); }); }, getLocalRegistrationId() { const item = new Item({ id: 'registrationId' }); return new Promise((resolve, reject) => { item.fetch().then(() => { resolve(item.get('value')); }, reject); }); }, /* Returns a prekeypair object or undefined */ loadPreKey(keyId) { const prekey = new PreKey({ id: keyId }); return new Promise(resolve => { prekey.fetch().then( () => { window.log.info('Successfully fetched prekey:', keyId); resolve({ pubKey: prekey.get('publicKey'), privKey: prekey.get('privateKey'), }); }, () => { window.log.error('Failed to fetch prekey:', keyId); resolve(); } ); }); }, storePreKey(keyId, keyPair) { const prekey = new PreKey({ id: keyId, publicKey: keyPair.pubKey, privateKey: keyPair.privKey, }); return new Promise(resolve => { prekey.save().always(() => { resolve(); }); }); }, removePreKey(keyId) { const prekey = new PreKey({ id: keyId }); this.trigger('removePreKey'); return new Promise(resolve => { const deferred = prekey.destroy(); if (!deferred) { return resolve(); } return deferred.then(resolve, error => { window.log.error( 'removePreKey error:', error && error.stack ? error.stack : error ); resolve(); }); }); }, clearPreKeyStore() { return new Promise(resolve => { const preKeys = new PreKeyCollection(); preKeys.sync('delete', preKeys, {}).always(resolve); }); }, /* Returns a signed keypair object or undefined */ loadSignedPreKey(keyId) { const prekey = new SignedPreKey({ id: keyId }); return new Promise(resolve => { prekey .fetch() .then(() => { window.log.info( 'Successfully fetched signed prekey:', prekey.get('id') ); resolve({ pubKey: prekey.get('publicKey'), privKey: prekey.get('privateKey'), created_at: prekey.get('created_at'), keyId: prekey.get('id'), confirmed: prekey.get('confirmed'), }); }) .fail(() => { window.log.error('Failed to fetch signed prekey:', keyId); resolve(); }); }); }, loadSignedPreKeys() { if (arguments.length > 0) { return Promise.reject( new Error('loadSignedPreKeys takes no arguments') ); } const signedPreKeys = new SignedPreKeyCollection(); return new Promise(resolve => { signedPreKeys.fetch().then(() => { resolve( signedPreKeys.map(prekey => ({ pubKey: prekey.get('publicKey'), privKey: prekey.get('privateKey'), created_at: prekey.get('created_at'), keyId: prekey.get('id'), confirmed: prekey.get('confirmed'), })) ); }); }); }, storeSignedPreKey(keyId, keyPair, confirmed) { const prekey = new SignedPreKey({ id: keyId, publicKey: keyPair.pubKey, privateKey: keyPair.privKey, created_at: Date.now(), confirmed: Boolean(confirmed), }); return new Promise(resolve => { prekey.save().always(() => { resolve(); }); }); }, removeSignedPreKey(keyId) { const prekey = new SignedPreKey({ id: keyId }); return new Promise((resolve, reject) => { const deferred = prekey.destroy(); if (!deferred) { return resolve(); } return deferred.then(resolve, reject); }); }, clearSignedPreKeysStore() { return new Promise(resolve => { const signedPreKeys = new SignedPreKeyCollection(); signedPreKeys.sync('delete', signedPreKeys, {}).always(resolve); }); }, loadSession(encodedNumber) { if (encodedNumber === null || encodedNumber === undefined) { throw new Error('Tried to get session for undefined/null number'); } return new Promise(resolve => { const session = new Session({ id: encodedNumber }); session.fetch().always(() => { resolve(session.get('record')); }); }); }, storeSession(encodedNumber, record) { if (encodedNumber === null || encodedNumber === undefined) { throw new Error('Tried to put session for undefined/null number'); } return new Promise(resolve => { const number = textsecure.utils.unencodeNumber(encodedNumber)[0]; const deviceId = parseInt( textsecure.utils.unencodeNumber(encodedNumber)[1], 10 ); const session = new Session({ id: encodedNumber }); session.fetch().always(() => { session .save({ record, deviceId, number, }) .fail(e => { window.log.error('Failed to save session', encodedNumber, e); }) .always(() => { resolve(); }); }); }); }, getDeviceIds(number) { if (number === null || number === undefined) { throw new Error('Tried to get device ids for undefined/null number'); } return new Promise(resolve => { const sessions = new SessionCollection(); sessions.fetchSessionsForNumber(number).always(() => { resolve(sessions.pluck('deviceId')); }); }); }, removeSession(encodedNumber) { window.log.info('deleting session for ', encodedNumber); return new Promise(resolve => { const session = new Session({ id: encodedNumber }); session .fetch() .then(() => { session.destroy().then(resolve); }) .fail(resolve); }); }, removeAllSessions(number) { if (number === null || number === undefined) { throw new Error('Tried to remove sessions for undefined/null number'); } return new Promise((resolve, reject) => { const sessions = new SessionCollection(); sessions.fetchSessionsForNumber(number).always(() => { const promises = []; while (sessions.length > 0) { promises.push( new Promise((res, rej) => { sessions .pop() .destroy() .then(res, rej); }) ); } Promise.all(promises).then(resolve, reject); }); }); }, archiveSiblingSessions(identifier) { const address = libsignal.SignalProtocolAddress.fromString(identifier); return this.getDeviceIds(address.getName()).then(deviceIds => { const siblings = _.without(deviceIds, address.getDeviceId()); return Promise.all( siblings.map(deviceId => { const sibling = new libsignal.SignalProtocolAddress( address.getName(), deviceId ); window.log.info('closing session for', sibling.toString()); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, sibling ); return sessionCipher.closeOpenSessionForDevice(); }) ); }); }, archiveAllSessions(number) { return this.getDeviceIds(number).then(deviceIds => Promise.all( deviceIds.map(deviceId => { const address = new libsignal.SignalProtocolAddress( number, deviceId ); window.log.info('closing session for', address.toString()); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, address ); return sessionCipher.closeOpenSessionForDevice(); }) ) ); }, clearSessionStore() { return new Promise(resolve => { const sessions = new SessionCollection(); sessions.sync('delete', sessions, {}).always(resolve); }); }, isTrustedIdentity(identifier, publicKey, direction) { if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); } const number = textsecure.utils.unencodeNumber(identifier)[0]; const isOurNumber = number === textsecure.storage.user.getNumber(); const identityRecord = new IdentityRecord({ id: number }); return new Promise(resolve => { identityRecord.fetch().always(resolve); }).then(() => { const existing = identityRecord.get('publicKey'); if (isOurNumber) { return equalArrayBuffers(existing, publicKey); } 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) { const existing = identityRecord.get('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.get('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; }, loadIdentityKey(identifier) { if (identifier === null || identifier === undefined) { throw new Error('Tried to get identity key for undefined/null key'); } const number = textsecure.utils.unencodeNumber(identifier)[0]; return new Promise(resolve => { const identityRecord = new IdentityRecord({ id: number }); identityRecord.fetch().always(() => { resolve(identityRecord.get('publicKey')); }); }); }, saveIdentity(identifier, publicKey, nonblockingApproval) { if (identifier === null || identifier === 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 number = textsecure.utils.unencodeNumber(identifier)[0]; return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: number }); identityRecord.fetch().always(() => { const oldpublicKey = identityRecord.get('publicKey'); if (!oldpublicKey) { // Lookup failed, or the current key was removed, so save this one. window.log.info('Saving new identity...'); identityRecord .save({ publicKey, firstUse: true, timestamp: Date.now(), verified: VerifiedStatus.DEFAULT, nonblockingApproval, }) .then(() => { resolve(false); }, reject); } else if (!equalArrayBuffers(oldpublicKey, publicKey)) { window.log.info('Replacing existing identity...'); const previousStatus = identityRecord.get('verified'); let verifiedStatus; if ( previousStatus === VerifiedStatus.VERIFIED || previousStatus === VerifiedStatus.UNVERIFIED ) { verifiedStatus = VerifiedStatus.UNVERIFIED; } else { verifiedStatus = VerifiedStatus.DEFAULT; } identityRecord .save({ publicKey, firstUse: false, timestamp: Date.now(), verified: verifiedStatus, nonblockingApproval, }) .then(() => { this.trigger('keychange', number); this.archiveSiblingSessions(identifier).then(() => { resolve(true); }, reject); }, reject); } else if (this.isNonBlockingApprovalRequired(identityRecord)) { window.log.info('Setting approval status...'); identityRecord .save({ nonblockingApproval, }) .then(() => { resolve(false); }, reject); } else { resolve(false); } }); }); }, isNonBlockingApprovalRequired(identityRecord) { return ( !identityRecord.get('firstUse') && Date.now() - identityRecord.get('timestamp') < TIMESTAMP_THRESHOLD && !identityRecord.get('nonblockingApproval') ); }, saveIdentityWithAttributes(identifier, attributes) { if (identifier === null || identifier === undefined) { throw new Error('Tried to put identity key for undefined/null key'); } const number = textsecure.utils.unencodeNumber(identifier)[0]; return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: number }); identityRecord.set(attributes); if (identityRecord.isValid()) { // false if invalid attributes identityRecord.save().then(resolve); } else { reject(identityRecord.validationError); } }); }, setApproval(identifier, nonblockingApproval) { if (identifier === null || identifier === undefined) { throw new Error('Tried to set approval for undefined/null identifier'); } if (typeof nonblockingApproval !== 'boolean') { throw new Error('Invalid approval status'); } const number = textsecure.utils.unencodeNumber(identifier)[0]; return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: number }); identityRecord.fetch().then(() => { identityRecord .save({ nonblockingApproval, }) .then( () => { resolve(); }, () => { // catch reject(new Error(`No identity record for ${number}`)); } ); }); }); }, setVerified(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 (arguments.length > 2 && !(publicKey instanceof ArrayBuffer)) { throw new Error('Invalid public key'); } return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: identifier }); identityRecord.fetch().then( () => { if ( !publicKey || equalArrayBuffers(identityRecord.get('publicKey'), publicKey) ) { identityRecord.set({ verified: verifiedStatus }); if (identityRecord.isValid()) { identityRecord.save({}).then(() => { resolve(); }, reject); } else { reject(identityRecord.validationError); } } else { window.log.info('No identity record for specified publicKey'); resolve(); } }, () => { // catch reject(new Error(`No identity record for ${identifier}`)); } ); }); }, getVerified(identifier) { if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: identifier }); identityRecord.fetch().then( () => { const verifiedStatus = identityRecord.get('verified'); if (validateVerifiedStatus(verifiedStatus)) { resolve(verifiedStatus); } else { resolve(VerifiedStatus.DEFAULT); } }, () => { // catch reject(new Error(`No identity record for ${identifier}`)); } ); }); }, // 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 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'); } return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: identifier }); let isPresent = false; let isEqual = false; identityRecord .fetch() .then(() => { isPresent = true; if (publicKey) { isEqual = equalArrayBuffers( publicKey, identityRecord.get('publicKey') ); } }) .always(() => { if ( isPresent && isEqual && identityRecord.get('verified') !== VerifiedStatus.UNVERIFIED ) { return textsecure.storage.protocol .setVerified(identifier, verifiedStatus, publicKey) .then(resolve, reject); } if (!isPresent || !isEqual) { return textsecure.storage.protocol .saveIdentityWithAttributes(identifier, { publicKey, verified: verifiedStatus, firstUse: false, timestamp: Date.now(), nonblockingApproval: true, }) .then(() => { if (isPresent && !isEqual) { this.trigger('keychange', identifier); return this.archiveAllSessions(identifier).then( () => // true signifies that we overwrote a previous key with a new one resolve(true), reject ); } return resolve(); }, reject); } // 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 resolve(); }); }); }, // This matches the Java method as of // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 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'); } return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: identifier }); let isPresent = false; let isEqual = false; identityRecord .fetch() .then(() => { isPresent = true; if (publicKey) { isEqual = equalArrayBuffers( publicKey, identityRecord.get('publicKey') ); } }) .always(() => { if (!isPresent && verifiedStatus === VerifiedStatus.DEFAULT) { window.log.info('No existing record for default status'); return resolve(); } if ( isPresent && isEqual && identityRecord.get('verified') !== VerifiedStatus.DEFAULT && verifiedStatus === VerifiedStatus.DEFAULT ) { return textsecure.storage.protocol .setVerified(identifier, verifiedStatus, publicKey) .then(resolve, reject); } if ( verifiedStatus === VerifiedStatus.VERIFIED && (!isPresent || (isPresent && !isEqual) || (isPresent && identityRecord.get('verified') !== VerifiedStatus.VERIFIED)) ) { return textsecure.storage.protocol .saveIdentityWithAttributes(identifier, { publicKey, verified: verifiedStatus, firstUse: false, timestamp: Date.now(), nonblockingApproval: true, }) .then(() => { if (isPresent && !isEqual) { this.trigger('keychange', identifier); return this.archiveAllSessions(identifier).then( () => // true signifies that we overwrote a previous key with a new one resolve(true), reject ); } return resolve(); }, reject); } // 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 resolve(); }); }); }, isUntrusted(identifier) { if (identifier === null || identifier === undefined) { throw new Error('Tried to set verified for undefined/null key'); } return new Promise((resolve, reject) => { const identityRecord = new IdentityRecord({ id: identifier }); identityRecord.fetch().then( () => { if ( Date.now() - identityRecord.get('timestamp') < TIMESTAMP_THRESHOLD && !identityRecord.get('nonblockingApproval') && !identityRecord.get('firstUse') ) { resolve(true); } else { resolve(false); } }, () => { // catch reject(new Error(`No identity record for ${identifier}`)); } ); }); }, async removeIdentityKey(number) { const identityRecord = new IdentityRecord({ id: number }); try { await wrapDeferred(identityRecord.fetch()); await wrapDeferred(identityRecord.destroy()); return textsecure.storage.protocol.removeAllSessions(number); } catch (error) { throw new Error('Tried to remove identity for unknown number'); } }, // Groups getGroup(groupId) { if (groupId === null || groupId === undefined) { throw new Error('Tried to get group for undefined/null id'); } return new Promise(resolve => { const group = new Group({ id: groupId }); group.fetch().always(() => { resolve(group.get('data')); }); }); }, putGroup(groupId, group) { if (groupId === null || groupId === undefined) { throw new Error('Tried to put group key for undefined/null id'); } if (group === null || group === undefined) { throw new Error('Tried to put undefined/null group object'); } const newGroup = new Group({ id: groupId, data: group }); return new Promise(resolve => { newGroup.save().always(resolve); }); }, removeGroup(groupId) { if (groupId === null || groupId === undefined) { throw new Error('Tried to remove group key for undefined/null id'); } return new Promise(resolve => { const group = new Group({ id: groupId }); group.destroy().always(resolve); }); }, // Not yet processed messages - for resiliency getAllUnprocessed() { return window.Signal.Data.getAllUnprocessed(); }, 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, Unprocessed, }); }, updateUnprocessed(id, updates) { return window.Signal.Data.updateUnprocessed(id, updates, { Unprocessed }); }, removeUnprocessed(id) { return window.Signal.Data.removeUnprocessed(id, { Unprocessed }); }, async removeAllData() { // First the in-memory caches: window.storage.reset(); // items store ConversationController.reset(); // conversations store await ConversationController.load(); // Then, the entire database: await Whisper.Database.clear(); await window.Signal.Data.removeAll(); }, async removeAllConfiguration() { // First the in-memory cache for the items store: window.storage.reset(); // Then anything in the database that isn't a message/conversation/group: await Whisper.Database.clearStores([ 'items', 'identityKeys', 'sessions', 'signedPreKeys', 'preKeys', 'unprocessed', ]); await window.Signal.Data.removeAllUnprocessed(); }, }; _.extend(SignalProtocolStore.prototype, Backbone.Events); window.SignalProtocolStore = SignalProtocolStore; window.SignalProtocolStore.prototype.Direction = Direction; window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus; })();