signal-desktop/ts/util/storageService.ts

478 lines
13 KiB
TypeScript

/* tslint:disable no-backbone-get-set-outside-model */
import _ from 'lodash';
import PQueue from 'p-queue';
import Crypto from '../textsecure/Crypto';
import {
arrayBufferToBase64,
base64ToArrayBuffer,
constantTimeEqual,
deriveStorageItemKey,
deriveStorageManifestKey,
} from '../Crypto';
import dataInterface from '../sql/Client';
const { eraseStorageIdFromConversations, updateConversation } = dataInterface;
import {
AccountRecordClass,
ContactRecordClass,
GroupV1RecordClass,
ManifestRecordClass,
StorageItemClass,
} from '../textsecure.d';
import { ConversationModelType } from '../model-types.d';
function fromRecordVerified(verified: number): number {
const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus;
const STATE_ENUM = window.textsecure.protobuf.ContactRecord.IdentityState;
switch (verified) {
case STATE_ENUM.VERIFIED:
return VERIFIED_ENUM.VERIFIED;
case STATE_ENUM.UNVERIFIED:
return VERIFIED_ENUM.UNVERIFIED;
default:
return VERIFIED_ENUM.DEFAULT;
}
}
async function fetchManifest(manifestVersion: string) {
window.log.info('storageService.fetchManifest');
if (!window.textsecure.messaging) {
throw new Error('fetchManifest: We are offline!');
}
try {
const credentials = await window.textsecure.messaging.getStorageCredentials();
window.storage.put('storageCredentials', credentials);
const manifestBinary = await window.textsecure.messaging.getStorageManifest(
{
credentials,
greaterThanVersion: manifestVersion,
}
);
const encryptedManifest = window.textsecure.protobuf.StorageManifest.decode(
manifestBinary
);
// If we don't get a value we're assuming we're receiving a 204
// it would be nice to get an actual e.code 204 and check against that.
if (!encryptedManifest.value || !encryptedManifest.version) {
window.log.info('storageService.fetchManifest: nothing changed');
return;
}
const storageKeyBase64 = window.storage.get('storageKey');
const storageKey = base64ToArrayBuffer(storageKeyBase64);
const storageManifestKey = await deriveStorageManifestKey(
storageKey,
encryptedManifest.version.toNumber()
);
const decryptedManifest = await Crypto.decryptProfile(
encryptedManifest.value.toArrayBuffer(),
storageManifestKey
);
return window.textsecure.protobuf.ManifestRecord.decode(decryptedManifest);
} catch (err) {
window.log.error(`storageService.fetchManifest: ${err}`);
if (err.code === 404) {
// No manifest exists, we create one
return { version: 0, keys: [] };
} else if (err.code === 204) {
// noNewerManifest we're ok
return;
}
throw err;
}
}
type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass;
function applyMessageRequestState(
record: MessageRequestCapableRecord,
conversation: ConversationModelType
): void {
if (record.blocked) {
conversation.applyMessageRequestResponse(
conversation.messageRequestEnum.BLOCK,
{ fromSync: true }
);
} else if (record.whitelisted) {
// unblocking is also handled by this function which is why the next
// condition is part of the else-if and not separate
conversation.applyMessageRequestResponse(
conversation.messageRequestEnum.ACCEPT,
{ fromSync: true }
);
} else if (!record.blocked) {
// if the condition above failed the state could still be blocked=false
// in which case we should unblock the conversation
conversation.unblock();
}
if (!record.whitelisted) {
conversation.disableProfileSharing();
}
}
async function mergeGroupV1Record(
storageID: string,
groupV1Record: GroupV1RecordClass
): Promise<void> {
window.log.info(`storageService.mergeGroupV1Record: merging ${storageID}`);
if (!groupV1Record.id) {
window.log.info(
`storageService.mergeGroupV1Record: no ID for ${storageID}`
);
return;
}
const groupId = groupV1Record.id.toBinary();
// We do a get here because we don't get enough information from just this source to
// be able to do the right thing with this group. So we'll update the local group
// record if we have one; otherwise we'll just drop this update.
const conversation = window.ConversationController.get(groupId);
if (!conversation) {
window.log.warn(
`storageService.mergeGroupV1Record: No conversation for group(${groupId})`
);
return;
}
conversation.set({
isArchived: Boolean(groupV1Record.archived),
storageID,
});
applyMessageRequestState(groupV1Record, conversation);
updateConversation(conversation.attributes);
window.log.info(`storageService.mergeGroupV1Record: merged ${storageID}`);
}
async function mergeContactRecord(
storageID: string,
contactRecord: ContactRecordClass
): Promise<void> {
window.log.info(`storageService.mergeContactRecord: merging ${storageID}`);
window.normalizeUuids(
contactRecord,
['serviceUuid'],
'storageService.mergeContactRecord'
);
const e164 = contactRecord.serviceE164 || undefined;
const uuid = contactRecord.serviceUuid || undefined;
const id = window.ConversationController.ensureContactIds({
e164,
uuid,
highTrust: true,
});
if (!id) {
window.log.info(
`storageService.mergeContactRecord: no ID for ${storageID}`
);
return;
}
const conversation = await window.ConversationController.getOrCreateAndWait(
id,
'private'
);
const verified = contactRecord.identityState
? fromRecordVerified(contactRecord.identityState)
: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT;
conversation.set({
isArchived: Boolean(contactRecord.archived),
storageID,
verified,
});
if (contactRecord.profileKey) {
await conversation.setProfileKey(
arrayBufferToBase64(contactRecord.profileKey.toArrayBuffer())
);
} else {
await conversation.dropProfileKey();
}
applyMessageRequestState(contactRecord, conversation);
const identityKey = await window.textsecure.storage.protocol.loadIdentityKey(
conversation.id
);
const identityKeyChanged =
identityKey && contactRecord.identityKey
? !constantTimeEqual(
identityKey,
contactRecord.identityKey.toArrayBuffer()
)
: false;
if (identityKeyChanged && contactRecord.identityKey) {
await window.textsecure.storage.protocol.processVerifiedMessage(
conversation.id,
verified,
contactRecord.identityKey.toArrayBuffer()
);
} else if (conversation.get('verified')) {
await window.textsecure.storage.protocol.setVerified(
conversation.id,
verified
);
}
updateConversation(conversation.attributes);
window.log.info(`storageService.mergeContactRecord: merged ${storageID}`);
}
async function mergeAccountRecord(
storageID: string,
accountRecord: AccountRecordClass
): Promise<void> {
window.log.info(`storageService.mergeAccountRecord: merging ${storageID}`);
const {
profileKey,
linkPreviews,
readReceipts,
sealedSenderIndicators,
typingIndicators,
} = accountRecord;
window.storage.put('read-receipt-setting', readReceipts);
if (typeof sealedSenderIndicators === 'boolean') {
window.storage.put('sealedSenderIndicators', sealedSenderIndicators);
}
if (typeof typingIndicators === 'boolean') {
window.storage.put('typingIndicators', typingIndicators);
}
if (typeof linkPreviews === 'boolean') {
window.storage.put('linkPreviews', linkPreviews);
}
if (profileKey) {
window.storage.put('profileKey', profileKey.toArrayBuffer());
}
window.log.info(
`storageService.mergeAccountRecord: merged settings ${storageID}`
);
const ourID = window.ConversationController.getOurConversationId();
if (!ourID) {
return;
}
const conversation = await window.ConversationController.getOrCreateAndWait(
ourID,
'private'
);
conversation.set({
storageID,
});
if (accountRecord.profileKey) {
await conversation.setProfileKey(
arrayBufferToBase64(accountRecord.profileKey.toArrayBuffer())
);
} else {
await conversation.dropProfileKey();
}
updateConversation(conversation.attributes);
window.log.info(
`storageService.mergeAccountRecord: merged profile ${storageID}`
);
}
// tslint:disable-next-line max-func-body-length
async function processManifest(
manifest: ManifestRecordClass
): Promise<boolean> {
const credentials = window.storage.get('storageCredentials');
const storageKeyBase64 = window.storage.get('storageKey');
const storageKey = base64ToArrayBuffer(storageKeyBase64);
if (!window.textsecure.messaging) {
throw new Error('processManifest: We are offline!');
}
const remoteKeysTypeMap = new Map();
manifest.keys.forEach(key => {
remoteKeysTypeMap.set(
arrayBufferToBase64(key.raw.toArrayBuffer()),
key.type
);
});
const localKeys = window
.getConversations()
.map((conversation: ConversationModelType) => conversation.get('storageID'))
.filter(Boolean);
window.log.info(
`storageService.processManifest localKeys.length ${localKeys.length}`
);
const remoteKeys = Array.from(remoteKeysTypeMap.keys());
const remoteOnly = remoteKeys.filter(
(key: string) => !localKeys.includes(key)
);
window.log.info(
`storageService.processManifest remoteOnly.length ${remoteOnly.length}`
);
const readOperation = new window.textsecure.protobuf.ReadOperation();
readOperation.readKey = remoteOnly.map(base64ToArrayBuffer);
const storageItemsBuffer = await window.textsecure.messaging.getStorageRecords(
readOperation.toArrayBuffer(),
{
credentials,
}
);
const storageItems = window.textsecure.protobuf.StorageItems.decode(
storageItemsBuffer
);
if (!storageItems.items) {
return false;
}
const queue = new PQueue({ concurrency: 4 });
const mergedItems = storageItems.items.map(
(storageRecordWrapper: StorageItemClass) => async () => {
const { key, value: storageItemCiphertext } = storageRecordWrapper;
if (!key || !storageItemCiphertext) {
return;
}
const base64ItemID = arrayBufferToBase64(key.toArrayBuffer());
const storageItemKey = await deriveStorageItemKey(
storageKey,
base64ItemID
);
const storageItemPlaintext = await Crypto.decryptProfile(
storageItemCiphertext.toArrayBuffer(),
storageItemKey
);
const storageRecord = window.textsecure.protobuf.StorageRecord.decode(
storageItemPlaintext
);
const itemType = remoteKeysTypeMap.get(base64ItemID);
const ITEM_TYPE =
window.textsecure.protobuf.ManifestRecord.Identifier.Type;
try {
if (itemType === ITEM_TYPE.UNKNOWN) {
window.log.info('storageService.processManifest: Unknown item type');
} else if (itemType === ITEM_TYPE.CONTACT && storageRecord.contact) {
await mergeContactRecord(base64ItemID, storageRecord.contact);
} else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) {
await mergeGroupV1Record(base64ItemID, storageRecord.groupV1);
} else if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2) {
window.log.info(
'storageService.processManifest: Skipping GroupV2 item'
);
} else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) {
await mergeAccountRecord(base64ItemID, storageRecord.account);
}
} catch (err) {
window.log.error(
`storageService.processManifest: merging record failed ${base64ItemID}`
);
}
}
);
try {
await queue.addAll(mergedItems);
await queue.onEmpty();
return true;
} catch (err) {
window.log.error('storageService.processManifest: merging failed');
return false;
}
}
export async function runStorageServiceSyncJob() {
if (!window.storage.get('storageKey')) {
throw new Error('runStorageServiceSyncJob: Cannot start; no storage key!');
}
window.log.info('runStorageServiceSyncJob: starting...');
const localManifestVersion = window.storage.get('manifestVersion') || 0;
let manifest;
try {
manifest = await fetchManifest(localManifestVersion);
// Guarding against no manifests being returned, everything should be ok
if (!manifest) {
window.log.info('runStorageServiceSyncJob: no manifest, returning early');
return;
}
} catch (err) {
// We are supposed to retry here if it's a retryable error
window.log.error(
`storageService.runStorageServiceSyncJob: failed! ${
err && err.stack ? err.stack : String(err)
}`
);
return;
}
const version = manifest.version.toNumber();
window.log.info(
`runStorageServiceSyncJob: manifest versions - previous: ${localManifestVersion}, current: ${version}`
);
const shouldUpdateVersion = await processManifest(manifest);
if (shouldUpdateVersion) {
window.storage.put('manifestVersion', version);
}
window.log.info('runStorageServiceSyncJob: complete');
}
// Note: this function is meant to be called before ConversationController is hydrated.
// It goes directly to the database, so in-memory conversations will be out of date.
export async function eraseAllStorageServiceState() {
window.log.info('eraseAllStorageServiceState: starting...');
await window.storage.remove('manifestVersion');
await eraseStorageIdFromConversations();
window.log.info('eraseAllStorageServiceState: complete');
}