Add cache support to Signal Protocol Store

This commit is contained in:
Scott Nonnenberg 2019-02-04 15:54:37 -08:00
parent 1d2c3ae23c
commit 9c540ab977
7 changed files with 171 additions and 52 deletions

View File

@ -29,12 +29,14 @@ module.exports = {
bulkAddIdentityKeys,
removeIdentityKeyById,
removeAllIdentityKeys,
getAllIdentityKeys,
createOrUpdatePreKey,
getPreKeyById,
bulkAddPreKeys,
removePreKeyById,
removeAllPreKeys,
getAllPreKeys,
createOrUpdateSignedPreKey,
getSignedPreKeyById,
@ -57,6 +59,7 @@ module.exports = {
removeSessionById,
removeSessionsByNumber,
removeAllSessions,
getAllSessions,
getConversationCount,
saveConversation,
@ -701,6 +704,9 @@ async function removeIdentityKeyById(id) {
async function removeAllIdentityKeys() {
return removeAllFromTable(IDENTITY_KEYS_TABLE);
}
async function getAllIdentityKeys() {
return getAllFromTable(IDENTITY_KEYS_TABLE);
}
const PRE_KEYS_TABLE = 'preKeys';
async function createOrUpdatePreKey(data) {
@ -718,6 +724,9 @@ async function removePreKeyById(id) {
async function removeAllPreKeys() {
return removeAllFromTable(PRE_KEYS_TABLE);
}
async function getAllPreKeys() {
return getAllFromTable(PRE_KEYS_TABLE);
}
const SIGNED_PRE_KEYS_TABLE = 'signedPreKeys';
async function createOrUpdateSignedPreKey(data) {
@ -815,6 +824,9 @@ async function removeSessionsByNumber(number) {
async function removeAllSessions() {
return removeAllFromTable(SESSIONS_TABLE);
}
async function getAllSessions() {
return getAllFromTable(SESSIONS_TABLE);
}
async function createOrUpdate(table, data) {
const { id } = data;
@ -884,6 +896,11 @@ async function removeAllFromTable(table) {
await db.run(`DELETE FROM ${table};`);
}
async function getAllFromTable(table) {
const rows = await db.all(`SELECT json FROM ${table};`);
return rows.map(row => jsonToObject(row.json));
}
// Conversations
async function getConversationCount() {

View File

@ -414,7 +414,10 @@
window.Events.setThemeSetting(newThemeSetting);
try {
await ConversationController.load();
await Promise.all([
ConversationController.load(),
textsecure.storage.protocol.hydrateCaches(),
]);
} catch (error) {
window.log.error(
'background.js: ConversationController failed to load:',

View File

@ -60,12 +60,14 @@ module.exports = {
bulkAddIdentityKeys,
removeIdentityKeyById,
removeAllIdentityKeys,
getAllIdentityKeys,
createOrUpdatePreKey,
getPreKeyById,
bulkAddPreKeys,
removePreKeyById,
removeAllPreKeys,
getAllPreKeys,
createOrUpdateSignedPreKey,
getSignedPreKeyById,
@ -88,6 +90,7 @@ module.exports = {
removeSessionById,
removeSessionsByNumber,
removeAllSessions,
getAllSessions,
getConversationCount,
saveConversation,
@ -440,6 +443,10 @@ async function removeIdentityKeyById(id) {
async function removeAllIdentityKeys() {
await channels.removeAllIdentityKeys();
}
async function getAllIdentityKeys() {
const keys = await channels.getAllIdentityKeys();
return keys.map(key => keysToArrayBuffer(IDENTITY_KEY_KEYS, key));
}
// Pre Keys
@ -461,6 +468,10 @@ async function removePreKeyById(id) {
async function removeAllPreKeys() {
await channels.removeAllPreKeys();
}
async function getAllPreKeys() {
const keys = await channels.getAllPreKeys();
return keys.map(key => keysToArrayBuffer(PRE_KEY_KEYS, key));
}
// Signed Pre Keys
@ -475,7 +486,7 @@ async function getSignedPreKeyById(id) {
}
async function getAllSignedPreKeys() {
const keys = await channels.getAllSignedPreKeys();
return keys;
return keys.map(key => keysToArrayBuffer(PRE_KEY_KEYS, key));
}
async function bulkAddSignedPreKeys(array) {
const updated = map(array, data => keysFromArrayBuffer(PRE_KEY_KEYS, data));
@ -567,6 +578,10 @@ async function removeSessionsByNumber(number) {
async function removeAllSessions(id) {
await channels.removeAllSessions(id);
}
async function getAllSessions(id) {
const sessions = await channels.getAllSessions(id);
return sessions;
}
// Conversation

View File

@ -150,8 +150,51 @@
function SignalProtocolStore() {}
async function _hydrateCache(object, field, items, idField) {
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([
_hydrateCache(
this,
'identityKeys',
await window.Signal.Data.getAllIdentityKeys(),
'id'
),
_hydrateCache(
this,
'sessions',
await window.Signal.Data.getAllSessions(),
'id'
),
_hydrateCache(
this,
'preKeys',
await window.Signal.Data.getAllPreKeys(),
'id'
),
_hydrateCache(
this,
'signedPreKeys',
await window.Signal.Data.getAllSignedPreKeys(),
'id'
),
]);
},
async getIdentityKeyPair() {
const item = await window.Signal.Data.getItemById('identityKey');
if (item) {
@ -169,9 +212,10 @@
return undefined;
},
/* Returns a prekeypair object or undefined */
// PreKeys
async loadPreKey(keyId) {
const key = await window.Signal.Data.getPreKeyById(keyId);
const key = this.preKeys[keyId];
if (key) {
window.log.info('Successfully fetched prekey:', keyId);
return {
@ -190,6 +234,7 @@
privateKey: keyPair.privKey,
};
this.preKeys[keyId] = data;
await window.Signal.Data.createOrUpdatePreKey(data);
},
async removePreKey(keyId) {
@ -202,15 +247,18 @@
);
}
delete this.preKeys[keyId];
await window.Signal.Data.removePreKeyById(keyId);
},
async clearPreKeyStore() {
this.preKeys = Object.create(null);
await window.Signal.Data.removeAllPreKeys();
},
/* Returns a signed keypair object or undefined */
// Signed PreKeys
async loadSignedPreKey(keyId) {
const key = await window.Signal.Data.getSignedPreKeyById(keyId);
const key = this.signedPreKeys[keyId];
if (key) {
window.log.info('Successfully fetched signed prekey:', key.id);
return {
@ -230,7 +278,7 @@
throw new Error('loadSignedPreKeys takes no arguments');
}
const keys = await window.Signal.Data.getAllSignedPreKeys();
const keys = Object.values(this.signedPreKeys);
return keys.map(prekey => ({
pubKey: prekey.publicKey,
privKey: prekey.privateKey,
@ -240,28 +288,34 @@
}));
},
async storeSignedPreKey(keyId, keyPair, confirmed) {
const key = {
const data = {
id: keyId,
publicKey: keyPair.pubKey,
privateKey: keyPair.privKey,
created_at: Date.now(),
confirmed: Boolean(confirmed),
};
await window.Signal.Data.createOrUpdateSignedPreKey(key);
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(encodedNumber) {
if (encodedNumber === null || encodedNumber === undefined) {
throw new Error('Tried to get session for undefined/null number');
}
const session = await window.Signal.Data.getSessionById(encodedNumber);
const session = this.sessions[encodedNumber];
if (session) {
return session.record;
}
@ -283,6 +337,7 @@
record,
};
this.sessions[encodedNumber] = data;
await window.Signal.Data.createOrUpdateSession(data);
},
async getDeviceIds(number) {
@ -290,11 +345,13 @@
throw new Error('Tried to get device ids for undefined/null number');
}
const sessions = await window.Signal.Data.getSessionsByNumber(number);
const allSessions = Object.values(this.sessions);
const sessions = allSessions.filter(session => session.number === number);
return _.pluck(sessions, 'deviceId');
},
async removeSession(encodedNumber) {
window.log.info('deleting session for ', encodedNumber);
delete this.sessions[encodedNumber];
await window.Signal.Data.removeSessionById(encodedNumber);
},
async removeAllSessions(number) {
@ -302,6 +359,13 @@
throw new Error('Tried to remove sessions for undefined/null number');
}
const allSessions = Object.values(this.sessions);
for (let i = 0, max = allSessions.length; i < max; i += 1) {
const session = allSessions[i];
if (session.number === number) {
delete this.sessions[session.id];
}
}
await window.Signal.Data.removeSessionsByNumber(number);
},
async archiveSiblingSessions(identifier) {
@ -341,8 +405,12 @@
);
},
async clearSessionStore() {
this.sessions = Object.create(null);
window.Signal.Data.removeAllSessions();
},
// Identity Keys
async isTrustedIdentity(identifier, publicKey, direction) {
if (identifier === null || identifier === undefined) {
throw new Error('Tried to get identity key for undefined/null key');
@ -350,9 +418,7 @@
const number = textsecure.utils.unencodeNumber(identifier)[0];
const isOurNumber = number === textsecure.storage.user.getNumber();
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (isOurNumber) {
const existing = identityRecord ? identityRecord.publicKey : null;
@ -402,9 +468,7 @@
throw new Error('Tried to get identity key for undefined/null key');
}
const number = textsecure.utils.unencodeNumber(identifier)[0];
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (identityRecord) {
return identityRecord.publicKey;
@ -412,6 +476,11 @@
return undefined;
},
async _saveIdentityKey(data) {
const { id } = data;
this.identityKeys[id] = data;
await window.Signal.Data.createOrUpdateIdentityKey(data);
},
async saveIdentity(identifier, publicKey, nonblockingApproval) {
if (identifier === null || identifier === undefined) {
throw new Error('Tried to put identity key for undefined/null key');
@ -426,14 +495,12 @@
}
const number = textsecure.utils.unencodeNumber(identifier)[0];
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (!identityRecord || !identityRecord.publicKey) {
// Lookup failed, or the current key was removed, so save this one.
window.log.info('Saving new identity...');
await window.Signal.Data.createOrUpdateIdentityKey({
await this._saveIdentityKey({
id: number,
publicKey,
firstUse: true,
@ -459,7 +526,7 @@
verifiedStatus = VerifiedStatus.DEFAULT;
}
await window.Signal.Data.createOrUpdateIdentityKey({
await this._saveIdentityKey({
id: number,
publicKey,
firstUse: false,
@ -483,7 +550,7 @@
window.log.info('Setting approval status...');
identityRecord.nonblockingApproval = nonblockingApproval;
await window.Signal.Data.createOrUpdateIdentityKey(identityRecord);
await this._saveIdentityKey(identityRecord);
return false;
}
@ -503,9 +570,7 @@
}
const number = textsecure.utils.unencodeNumber(identifier)[0];
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
const updates = {
id: number,
@ -515,7 +580,7 @@
const model = new IdentityRecord(updates);
if (model.isValid()) {
await window.Signal.Data.createOrUpdateIdentityKey(updates);
await this._saveIdentityKey(updates);
} else {
throw model.validationError;
}
@ -529,16 +594,14 @@
}
const number = textsecure.utils.unencodeNumber(identifier)[0];
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (!identityRecord) {
throw new Error(`No identity record for ${number}`);
}
identityRecord.nonblockingApproval = nonblockingApproval;
await window.Signal.Data.createOrUpdateIdentityKey(identityRecord);
await this._saveIdentityKey(identityRecord);
},
async setVerified(number, verifiedStatus, publicKey) {
if (number === null || number === undefined) {
@ -551,9 +614,7 @@
throw new Error('Invalid public key');
}
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (!identityRecord) {
throw new Error(`No identity record for ${number}`);
}
@ -566,7 +627,7 @@
const model = new IdentityRecord(identityRecord);
if (model.isValid()) {
await window.Signal.Data.createOrUpdateIdentityKey(identityRecord);
await this._saveIdentityKey(identityRecord);
} else {
throw identityRecord.validationError;
}
@ -579,10 +640,7 @@
throw new Error('Tried to set verified for undefined/null key');
}
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (!identityRecord) {
throw new Error(`No identity record for ${number}`);
}
@ -616,9 +674,7 @@
throw new Error('Invalid public key');
}
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
const isPresent = Boolean(identityRecord);
let isEqual = false;
@ -683,9 +739,7 @@
throw new Error('Invalid public key');
}
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
const isPresent = Boolean(identityRecord);
let isEqual = false;
@ -754,10 +808,7 @@
throw new Error('Tried to set verified for undefined/null key');
}
const identityRecord = await window.Signal.Data.getIdentityKeyById(
number
);
const identityRecord = this.identityKeys[number];
if (!identityRecord) {
throw new Error(`No identity record for ${number}`);
}
@ -773,11 +824,13 @@
return false;
},
async removeIdentityKey(number) {
delete this.identityKeys[number];
await window.Signal.Data.removeIdentityKeyById(number);
return textsecure.storage.protocol.removeAllSessions(number);
await textsecure.storage.protocol.removeAllSessions(number);
},
// Groups
async getGroup(groupId) {
if (groupId === null || groupId === undefined) {
throw new Error('Tried to get group for undefined/null id');
@ -840,6 +893,7 @@
},
async removeAllData() {
await window.Signal.Data.removeAll();
await this.hydrateCaches();
window.storage.reset();
await window.storage.fetch();
@ -849,6 +903,7 @@
},
async removeAllConfiguration() {
await window.Signal.Data.removeAllConfiguration();
await this.hydrateCaches();
window.storage.reset();
await window.storage.fetch();

View File

@ -10,8 +10,9 @@ describe('KeyChangeListener', () => {
const newKey = libsignal.crypto.getRandomBytes(33);
let store;
beforeEach(() => {
beforeEach(async () => {
store = new SignalProtocolStore();
await store.hydrateCaches();
Whisper.KeyChangeListener.init(store);
return store.saveIdentity(address.toString(), oldKey);
});

View File

@ -10,6 +10,7 @@ describe('SignalProtocolStore', () => {
before(done => {
store = textsecure.storage.protocol;
store.hydrateCaches();
identityKey = {
pubKey: libsignal.crypto.getRandomBytes(33),
privKey: libsignal.crypto.getRandomBytes(32),
@ -86,6 +87,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.DEFAULT,
});
await store.hydrateCaches();
await store.saveIdentity(identifier, newIdentity);
});
it('marks the key not firstUse', async () => {
@ -107,6 +109,7 @@ describe('SignalProtocolStore', () => {
nonblockingApproval: false,
verified: store.VerifiedStatus.DEFAULT,
});
await store.hydrateCaches();
await store.saveIdentity(identifier, newIdentity);
});
@ -125,6 +128,8 @@ describe('SignalProtocolStore', () => {
nonblockingApproval: false,
verified: store.VerifiedStatus.VERIFIED,
});
await store.hydrateCaches();
await store.saveIdentity(identifier, newIdentity);
});
it('sets the new key to unverified', async () => {
@ -147,6 +152,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.UNVERIFIED,
});
await store.hydrateCaches();
await store.saveIdentity(identifier, newIdentity);
});
it('sets the new key to unverified', async () => {
@ -168,12 +174,14 @@ describe('SignalProtocolStore', () => {
nonblockingApproval: false,
verified: store.VerifiedStatus.DEFAULT,
});
await store.hydrateCaches();
});
describe('If it is marked firstUse', () => {
before(async () => {
const identity = await window.Signal.Data.getIdentityKeyById(number);
identity.firstUse = true;
await window.Signal.Data.createOrUpdateIdentityKey(identity);
await store.hydrateCaches();
});
it('nothing changes', async () => {
await store.saveIdentity(identifier, testKey.pubKey, true);
@ -188,6 +196,7 @@ describe('SignalProtocolStore', () => {
const identity = await window.Signal.Data.getIdentityKeyById(number);
identity.firstUse = false;
await window.Signal.Data.createOrUpdateIdentityKey(identity);
await store.hydrateCaches();
});
describe('If nonblocking approval is required', () => {
let now;
@ -198,6 +207,7 @@ describe('SignalProtocolStore', () => {
);
identity.timestamp = now;
await window.Signal.Data.createOrUpdateIdentityKey(identity);
await store.hydrateCaches();
});
it('sets non-blocking approval', async () => {
await store.saveIdentity(identifier, testKey.pubKey, true);
@ -311,6 +321,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.DEFAULT,
nonblockingApproval: false,
});
await store.hydrateCaches();
}
describe('with no public key argument', () => {
before(saveRecordDefault);
@ -370,6 +381,7 @@ describe('SignalProtocolStore', () => {
describe('when there is no existing record', () => {
before(async () => {
await window.Signal.Data.removeIdentityKeyById(number);
await store.hydrateCaches();
});
it('does nothing', async () => {
@ -403,6 +415,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not save the new identity (because this is a less secure state)', async () => {
@ -434,6 +447,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('updates the verified status', async () => {
@ -462,6 +476,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.DEFAULT,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
@ -480,6 +495,7 @@ describe('SignalProtocolStore', () => {
describe('when there is no existing record', () => {
before(async () => {
await window.Signal.Data.removeIdentityKeyById(number);
await store.hydrateCaches();
});
it('saves the new identity and marks it verified', async () => {
@ -510,6 +526,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the new identity and marks it UNVERIFIED', async () => {
@ -541,6 +558,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.DEFAULT,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('updates the verified status', async () => {
@ -571,6 +589,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.UNVERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
@ -589,6 +608,7 @@ describe('SignalProtocolStore', () => {
describe('when there is no existing record', () => {
before(async () => {
await window.Signal.Data.removeIdentityKeyById(number);
await store.hydrateCaches();
});
it('saves the new identity and marks it verified', async () => {
@ -615,6 +635,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the new identity and marks it VERIFIED', async () => {
@ -646,6 +667,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.UNVERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('saves the identity and marks it verified', async () => {
@ -676,6 +698,7 @@ describe('SignalProtocolStore', () => {
verified: store.VerifiedStatus.VERIFIED,
nonblockingApproval: false,
});
await store.hydrateCaches();
});
it('does not hang', async () => {
@ -703,6 +726,7 @@ describe('SignalProtocolStore', () => {
nonblockingApproval: false,
});
await store.hydrateCaches();
const untrusted = await store.isUntrusted(number);
assert.strictEqual(untrusted, false);
});
@ -716,6 +740,7 @@ describe('SignalProtocolStore', () => {
firstUse: false,
nonblockingApproval: true,
});
await store.hydrateCaches();
const untrusted = await store.isUntrusted(number);
assert.strictEqual(untrusted, false);
@ -730,6 +755,7 @@ describe('SignalProtocolStore', () => {
firstUse: true,
nonblockingApproval: false,
});
await store.hydrateCaches();
const untrusted = await store.isUntrusted(number);
assert.strictEqual(untrusted, false);
@ -744,6 +770,8 @@ describe('SignalProtocolStore', () => {
firstUse: false,
nonblockingApproval: false,
});
await store.hydrateCaches();
const untrusted = await store.isUntrusted(number);
assert.strictEqual(untrusted, true);
});

View File

@ -67,7 +67,7 @@ export class Image extends React.Component<Props> {
<div
role={canClick ? 'button' : undefined}
onClick={() => {
if (canClick) {
if (canClick && onClick) {
onClick(attachment);
}
}}