signal-desktop/ts/services/storage.ts

1954 lines
57 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2022-04-13 00:50:17 +00:00
import { debounce, isNumber, chunk } from 'lodash';
2020-09-09 00:56:23 +00:00
import pMap from 'p-map';
2022-03-23 20:49:27 +00:00
import Long from 'long';
2020-09-09 00:56:23 +00:00
import dataInterface from '../sql/Client';
2021-07-13 18:54:53 +00:00
import * as Bytes from '../Bytes';
2020-09-09 00:56:23 +00:00
import {
2021-09-24 00:49:05 +00:00
getRandomBytes,
2020-09-09 00:56:23 +00:00
deriveStorageItemKey,
deriveStorageManifestKey,
2021-09-24 00:49:05 +00:00
encryptProfile,
decryptProfile,
deriveMasterKeyFromGroupV1,
2020-09-09 00:56:23 +00:00
} from '../Crypto';
import {
mergeAccountRecord,
mergeContactRecord,
mergeGroupV1Record,
2020-09-09 02:25:05 +00:00
mergeGroupV2Record,
2022-07-01 00:52:03 +00:00
mergeStoryDistributionListRecord,
2022-08-03 17:10:49 +00:00
mergeStickerPackRecord,
2020-09-09 00:56:23 +00:00
toAccountRecord,
toContactRecord,
toGroupV1Record,
2020-09-09 02:25:05 +00:00
toGroupV2Record,
2022-07-01 00:52:03 +00:00
toStoryDistributionListRecord,
2022-08-03 17:10:49 +00:00
toStickerPackRecord,
2020-09-09 00:56:23 +00:00
} from './storageRecordOps';
2022-02-08 18:00:18 +00:00
import type { MergeResultType } from './storageRecordOps';
2022-04-13 00:50:17 +00:00
import { MAX_READ_KEYS } from './storageConstants';
import type { ConversationModel } from '../models/conversations';
2021-07-13 18:54:53 +00:00
import { strictAssert } from '../util/assert';
2022-03-23 20:49:27 +00:00
import { dropNull } from '../util/dropNull';
import * as durations from '../util/durations';
2021-06-09 22:28:54 +00:00
import { BackOff } from '../util/BackOff';
import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep';
2021-04-09 20:12:05 +00:00
import { isMoreRecentThan } from '../util/timestamp';
import { map, filter } from '../util/iterables';
import { ourProfileKeyService } from './ourProfileKey';
import {
ConversationTypes,
typeofConversation,
} from '../util/whatTypeOfConversation';
2021-07-13 18:54:53 +00:00
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import * as Errors from '../types/errors';
import type {
ExtendedStorageID,
RemoteRecord,
UnknownRecord,
} from '../types/StorageService.d';
import MessageSender from '../textsecure/SendMessage';
2022-08-03 17:10:49 +00:00
import type {
StoryDistributionWithMembersType,
StorageServiceFieldsType,
StickerPackType,
UninstalledStickerPackType,
} from '../sql/Interface';
import { MY_STORY_ID } from '../types/Stories';
import { isNotNil } from '../util/isNotNil';
2022-11-09 02:38:19 +00:00
import { isSignalConversation } from '../util/isSignalConversation';
2021-07-13 18:54:53 +00:00
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
2022-03-09 18:22:34 +00:00
const {
eraseStorageServiceStateFromConversations,
updateConversation,
updateConversations,
} = dataInterface;
2020-09-09 00:56:23 +00:00
2021-04-09 20:12:05 +00:00
const uploadBucket: Array<number> = [];
const validRecordTypes = new Set([
0, // UNKNOWN
1, // CONTACT
2, // GROUPV1
3, // GROUPV2
4, // ACCOUNT
2022-07-01 00:52:03 +00:00
5, // STORY_DISTRIBUTION_LIST
6, // STICKER_PACK
]);
2020-09-09 00:56:23 +00:00
2021-06-09 22:28:54 +00:00
const backOff = new BackOff([
durations.SECOND,
5 * durations.SECOND,
30 * durations.SECOND,
2 * durations.MINUTE,
5 * durations.MINUTE,
2021-06-09 22:28:54 +00:00
]);
const conflictBackOff = new BackOff([
durations.SECOND,
5 * durations.SECOND,
30 * durations.SECOND,
]);
2022-02-08 18:00:18 +00:00
function redactStorageID(
storageID: string,
version?: number,
conversation?: ConversationModel
): string {
const convoId = conversation ? ` ${conversation?.idForLogging()}` : '';
return `${version ?? '?'}:${storageID.substring(0, 3)}${convoId}`;
2021-04-06 22:54:47 +00:00
}
function redactExtendedStorageID({
2022-02-08 18:00:18 +00:00
storageID,
storageVersion,
}: ExtendedStorageID): string {
2022-02-08 18:00:18 +00:00
return redactStorageID(storageID, storageVersion);
}
2022-07-01 00:52:03 +00:00
function encryptRecord(
2020-09-09 00:56:23 +00:00
storageID: string | undefined,
2021-07-13 18:54:53 +00:00
storageRecord: Proto.IStorageRecord
2022-07-01 00:52:03 +00:00
): Proto.StorageItem {
2021-07-13 18:54:53 +00:00
const storageItem = new Proto.StorageItem();
2020-09-09 00:56:23 +00:00
const storageKeyBuffer = storageID
? Bytes.fromBase64(storageID)
2020-09-09 00:56:23 +00:00
: generateStorageID();
const storageKeyBase64 = window.storage.get('storageKey');
if (!storageKeyBase64) {
throw new Error('No storage key');
}
2021-09-24 00:49:05 +00:00
const storageKey = Bytes.fromBase64(storageKeyBase64);
const storageItemKey = deriveStorageItemKey(
2020-09-09 00:56:23 +00:00
storageKey,
2021-09-24 00:49:05 +00:00
Bytes.toBase64(storageKeyBuffer)
2020-09-09 00:56:23 +00:00
);
2021-09-24 00:49:05 +00:00
const encryptedRecord = encryptProfile(
Proto.StorageRecord.encode(storageRecord).finish(),
2020-09-09 00:56:23 +00:00
storageItemKey
);
2021-09-24 00:49:05 +00:00
storageItem.key = storageKeyBuffer;
storageItem.value = encryptedRecord;
2020-09-09 00:56:23 +00:00
return storageItem;
}
2021-09-24 00:49:05 +00:00
function generateStorageID(): Uint8Array {
return getRandomBytes(16);
2020-09-09 00:56:23 +00:00
}
type GeneratedManifestType = {
2022-03-04 21:14:52 +00:00
postUploadUpdateFunctions: Array<() => unknown>;
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
insertKeys: Set<string>;
deleteKeys: Set<string>;
2020-09-09 00:56:23 +00:00
};
async function generateManifest(
version: number,
2021-07-13 18:54:53 +00:00
previousManifest?: Proto.IManifestRecord,
isNewManifest = false
2020-09-09 00:56:23 +00:00
): Promise<GeneratedManifestType> {
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): generating manifest ` +
`new=${isNewManifest}`
2020-09-09 00:56:23 +00:00
);
await window.ConversationController.checkForConflicts();
2021-07-13 18:54:53 +00:00
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
2020-09-09 00:56:23 +00:00
2022-03-04 21:14:52 +00:00
const postUploadUpdateFunctions: Array<() => unknown> = [];
const insertKeys = new Set<string>();
const deleteKeys = new Set<string>();
const recordsByID = new Map<string, MergeableItemType | RemoteRecord>();
2020-09-09 00:56:23 +00:00
2022-07-01 00:52:03 +00:00
function processStorageRecord({
conversation,
currentStorageID,
currentStorageVersion,
identifierType,
storageNeedsSync,
storageRecord,
}: {
conversation?: ConversationModel;
currentStorageID?: string;
currentStorageVersion?: number;
identifierType: Proto.ManifestRecord.Identifier.Type;
storageNeedsSync: boolean;
storageRecord: Proto.IStorageRecord;
}) {
const currentRedactedID = currentStorageID
? redactStorageID(currentStorageID, currentStorageVersion)
: undefined;
const isNewItem = isNewManifest || storageNeedsSync || !currentStorageID;
const storageID = isNewItem
? Bytes.toBase64(generateStorageID())
: currentStorageID;
recordsByID.set(storageID, {
itemType: identifierType,
storageID,
storageRecord,
});
2022-07-01 00:52:03 +00:00
// When a client needs to update a given record it should create it
// under a new key and delete the existing key.
if (isNewItem) {
insertKeys.add(storageID);
2022-07-01 00:52:03 +00:00
const newRedactedID = redactStorageID(storageID, version, conversation);
if (currentStorageID) {
log.info(
`storageService.upload(${version}): ` +
`updating from=${currentRedactedID} ` +
`to=${newRedactedID}`
);
deleteKeys.add(currentStorageID);
2022-07-01 00:52:03 +00:00
} else {
log.info(
`storageService.upload(${version}): adding key=${newRedactedID}`
);
}
}
return {
isNewItem,
storageID,
};
}
2020-09-09 00:56:23 +00:00
const conversations = window.getConversations();
for (let i = 0; i < conversations.length; i += 1) {
const conversation = conversations.models[i];
2022-07-01 00:52:03 +00:00
let identifierType;
2020-09-09 00:56:23 +00:00
let storageRecord;
2022-11-09 02:38:19 +00:00
if (isSignalConversation(conversation.attributes)) {
continue;
}
const conversationType = typeofConversation(conversation.attributes);
if (conversationType === ConversationTypes.Me) {
2021-07-13 18:54:53 +00:00
storageRecord = new Proto.StorageRecord();
2020-09-09 00:56:23 +00:00
// eslint-disable-next-line no-await-in-loop
storageRecord.account = await toAccountRecord(conversation);
2022-07-01 00:52:03 +00:00
identifierType = ITEM_TYPE.ACCOUNT;
} else if (conversationType === ConversationTypes.Direct) {
// Contacts must have UUID
if (!conversation.get('uuid')) {
continue;
}
let shouldDrop = false;
let dropReason: string | undefined;
const validationError = conversation.validate();
if (validationError) {
shouldDrop = true;
dropReason = `local validation error=${validationError}`;
} else if (conversation.isUnregisteredAndStale()) {
shouldDrop = true;
dropReason = 'unregistered and stale';
}
if (shouldDrop) {
2022-02-11 21:05:24 +00:00
const droppedID = conversation.get('storageID');
const droppedVersion = conversation.get('storageVersion');
if (!droppedID) {
continue;
}
2022-02-11 21:05:24 +00:00
const recordID = redactStorageID(
droppedID,
droppedVersion,
conversation
);
log.warn(
`storageService.generateManifest(${version}): ` +
`dropping contact=${recordID} ` +
`due to ${dropReason}`
2022-02-11 21:05:24 +00:00
);
conversation.unset('storageID');
deleteKeys.add(droppedID);
continue;
}
2021-07-13 18:54:53 +00:00
storageRecord = new Proto.StorageRecord();
2020-09-09 00:56:23 +00:00
// eslint-disable-next-line no-await-in-loop
storageRecord.contact = await toContactRecord(conversation);
2022-07-01 00:52:03 +00:00
identifierType = ITEM_TYPE.CONTACT;
} else if (conversationType === ConversationTypes.GroupV2) {
2021-07-13 18:54:53 +00:00
storageRecord = new Proto.StorageRecord();
2022-07-01 00:52:03 +00:00
storageRecord.groupV2 = toGroupV2Record(conversation);
identifierType = ITEM_TYPE.GROUPV2;
} else if (conversationType === ConversationTypes.GroupV1) {
2021-07-13 18:54:53 +00:00
storageRecord = new Proto.StorageRecord();
2022-07-01 00:52:03 +00:00
storageRecord.groupV1 = toGroupV1Record(conversation);
identifierType = ITEM_TYPE.GROUPV1;
} else {
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.upload(${version}): ` +
`unknown conversation=${conversation.idForLogging()}`
);
2020-09-09 00:56:23 +00:00
}
2022-07-01 00:52:03 +00:00
if (!storageRecord || !identifierType) {
2022-02-08 18:00:18 +00:00
continue;
}
2022-07-01 00:52:03 +00:00
const { isNewItem, storageID } = processStorageRecord({
conversation,
currentStorageID: conversation.get('storageID'),
currentStorageVersion: conversation.get('storageVersion'),
identifierType,
storageNeedsSync: Boolean(conversation.get('needsStorageServiceSync')),
storageRecord,
});
2022-02-08 18:00:18 +00:00
if (isNewItem) {
2022-03-04 21:14:52 +00:00
postUploadUpdateFunctions.push(() => {
conversation.set({
needsStorageServiceSync: false,
storageVersion: version,
storageID,
});
updateConversation(conversation.attributes);
2022-02-08 18:00:18 +00:00
});
2020-09-09 00:56:23 +00:00
}
}
2022-08-03 17:10:49 +00:00
const {
storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
} = await getNonConversationRecords();
2022-07-01 00:52:03 +00:00
log.info(
2022-08-03 17:10:49 +00:00
`storageService.upload(${version}): ` +
`adding storyDistributionLists=${storyDistributionLists.length}`
2022-07-01 00:52:03 +00:00
);
storyDistributionLists.forEach(storyDistributionList => {
const storageRecord = new Proto.StorageRecord();
storageRecord.storyDistributionList = toStoryDistributionListRecord(
storyDistributionList
);
2022-07-01 00:52:03 +00:00
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: storyDistributionList.storageID,
currentStorageVersion: storyDistributionList.storageVersion,
identifierType: ITEM_TYPE.STORY_DISTRIBUTION_LIST,
storageNeedsSync: storyDistributionList.storageNeedsSync,
storageRecord,
2022-07-01 00:52:03 +00:00
});
if (isNewItem) {
postUploadUpdateFunctions.push(() => {
void dataInterface.modifyStoryDistribution({
2022-07-01 00:52:03 +00:00
...storyDistributionList,
storageID,
storageVersion: version,
storageNeedsSync: false,
});
});
}
});
2022-08-03 17:10:49 +00:00
log.info(
`storageService.upload(${version}): ` +
`adding uninstalled stickerPacks=${uninstalledStickerPacks.length}`
);
const uninstalledStickerPackIds = new Set<string>();
uninstalledStickerPacks.forEach(stickerPack => {
const storageRecord = new Proto.StorageRecord();
storageRecord.stickerPack = toStickerPackRecord(stickerPack);
uninstalledStickerPackIds.add(stickerPack.id);
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: stickerPack.storageID,
currentStorageVersion: stickerPack.storageVersion,
identifierType: ITEM_TYPE.STICKER_PACK,
storageNeedsSync: stickerPack.storageNeedsSync,
storageRecord,
});
if (isNewItem) {
postUploadUpdateFunctions.push(() => {
void dataInterface.addUninstalledStickerPack({
2022-08-03 17:10:49 +00:00
...stickerPack,
storageID,
storageVersion: version,
storageNeedsSync: false,
});
});
}
});
log.info(
`storageService.upload(${version}): ` +
`adding installed stickerPacks=${installedStickerPacks.length}`
);
installedStickerPacks.forEach(stickerPack => {
if (uninstalledStickerPackIds.has(stickerPack.id)) {
log.error(
`storageService.upload(${version}): ` +
`sticker pack ${stickerPack.id} is both installed and uninstalled`
);
window.reduxActions.stickers.uninstallStickerPack(
stickerPack.id,
stickerPack.key,
{ fromSync: true }
);
return;
}
const storageRecord = new Proto.StorageRecord();
storageRecord.stickerPack = toStickerPackRecord(stickerPack);
const { isNewItem, storageID } = processStorageRecord({
currentStorageID: stickerPack.storageID,
currentStorageVersion: stickerPack.storageVersion,
identifierType: ITEM_TYPE.STICKER_PACK,
storageNeedsSync: stickerPack.storageNeedsSync,
storageRecord,
});
if (isNewItem) {
postUploadUpdateFunctions.push(() => {
void dataInterface.createOrUpdateStickerPack({
2022-08-03 17:10:49 +00:00
...stickerPack,
storageID,
storageVersion: version,
storageNeedsSync: false,
});
});
}
});
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
2020-10-06 22:25:00 +00:00
window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
2020-09-09 00:56:23 +00:00
const redactedUnknowns = unknownRecordsArray.map(redactExtendedStorageID);
2022-02-08 18:00:18 +00:00
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): adding unknown ` +
`records=${JSON.stringify(redactedUnknowns)} ` +
`count=${redactedUnknowns.length}`
2020-09-09 00:56:23 +00:00
);
// When updating the manifest, ensure all "unknown" keys are added to the
// new manifest, so we don't inadvertently delete something we don't understand
unknownRecordsArray.forEach((record: UnknownRecord) => {
recordsByID.set(record.storageID, record);
});
const recordsWithErrors: ReadonlyArray<UnknownRecord> = window.storage.get(
'storage-service-error-records',
new Array<UnknownRecord>()
);
const redactedErrors = recordsWithErrors.map(redactExtendedStorageID);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): adding error ` +
`records=${JSON.stringify(redactedErrors)} count=${redactedErrors.length}`
);
// These records failed to merge in the previous fetchManifest, but we still
// need to include them so that the manifest is complete
recordsWithErrors.forEach((record: UnknownRecord) => {
recordsByID.set(record.storageID, record);
2020-09-09 00:56:23 +00:00
});
// Delete keys that we wanted to drop during the processing of the manifest.
const storedPendingDeletes = window.storage.get(
'storage-service-pending-deletes',
[]
);
const redactedPendingDeletes = storedPendingDeletes.map(
redactExtendedStorageID
);
log.info(
`storageService.upload(${version}): ` +
`deleting extra keys=${JSON.stringify(redactedPendingDeletes)} ` +
`count=${redactedPendingDeletes.length}`
);
for (const { storageID } of storedPendingDeletes) {
deleteKeys.add(storageID);
}
// Validate before writing
const duplicates = new Set<string>();
const typeDuplicates = new Set();
let hasAccountType = false;
for (const [storageID, { itemType }] of recordsByID) {
// Ensure there are no duplicate StorageIdentifiers in your manifest
// This can be broken down into two parts:
// There are no duplicate type+raw pairs
// There are no duplicate raw bytes
const typeAndID = `${itemType}+${storageID}`;
if (duplicates.has(storageID) || typeDuplicates.has(typeAndID)) {
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.upload(${version}): removing from duplicate item ` +
'from the manifest',
redactStorageID(storageID),
itemType
);
recordsByID.delete(storageID);
}
duplicates.add(storageID);
typeDuplicates.add(typeAndID);
// Ensure all deletes are not present in the manifest
const hasDeleteKey = deleteKeys.has(storageID);
if (hasDeleteKey) {
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.upload(${version}): removing key which has been deleted`,
redactStorageID(storageID),
itemType
);
recordsByID.delete(storageID);
}
// Ensure that there is *exactly* one Account type in the manifest
if (itemType === ITEM_TYPE.ACCOUNT) {
if (hasAccountType) {
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.upload(${version}): removing duplicate account`,
redactStorageID(storageID)
);
recordsByID.delete(storageID);
}
hasAccountType = true;
}
}
duplicates.clear();
typeDuplicates.clear();
2021-07-13 18:54:53 +00:00
const storageKeyDuplicates = new Set<string>();
for (const storageID of insertKeys) {
// Ensure there are no duplicate StorageIdentifiers in your list of inserts
if (storageKeyDuplicates.has(storageID)) {
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.upload(${version}): ` +
'removing duplicate identifier from inserts',
2021-04-06 22:54:47 +00:00
redactStorageID(storageID)
);
insertKeys.delete(storageID);
}
storageKeyDuplicates.add(storageID);
}
storageKeyDuplicates.clear();
2021-04-09 20:12:05 +00:00
// If we have a copy of what the current remote manifest is then we run these
// additional validations comparing our pending manifest to the remote
// manifest:
if (previousManifest) {
const pendingInserts: Set<string> = new Set();
const pendingDeletes: Set<string> = new Set();
const remoteKeys: Set<string> = new Set();
2021-07-13 18:54:53 +00:00
(previousManifest.keys ?? []).forEach(
(identifier: IManifestRecordIdentifier) => {
strictAssert(identifier.raw, 'Identifier without raw field');
const storageID = Bytes.toBase64(identifier.raw);
2021-04-09 20:12:05 +00:00
remoteKeys.add(storageID);
}
);
const localKeys: Set<string> = new Set();
for (const storageID of recordsByID.keys()) {
2021-04-09 20:12:05 +00:00
localKeys.add(storageID);
if (!remoteKeys.has(storageID)) {
pendingInserts.add(storageID);
}
}
2021-04-09 20:12:05 +00:00
remoteKeys.forEach(storageID => {
if (!localKeys.has(storageID)) {
pendingDeletes.add(storageID);
}
});
if (deleteKeys.size !== pendingDeletes.size) {
const localDeletes = Array.from(deleteKeys).map(key =>
redactStorageID(key)
);
const remoteDeletes: Array<string> = [];
pendingDeletes.forEach(id => remoteDeletes.push(redactStorageID(id)));
log.error(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): delete key sizes do not match`,
'local',
localDeletes.join(','),
'remote',
remoteDeletes.join(',')
);
2021-04-09 20:12:05 +00:00
throw new Error('invalid write delete keys length do not match');
}
if (insertKeys.size !== pendingInserts.size) {
2021-04-09 20:12:05 +00:00
throw new Error('invalid write insert items length do not match');
}
for (const storageID of deleteKeys) {
2021-04-09 20:12:05 +00:00
if (!pendingDeletes.has(storageID)) {
throw new Error(
'invalid write delete key missing from pending deletes'
);
}
}
for (const storageID of insertKeys) {
2021-04-09 20:12:05 +00:00
if (!pendingInserts.has(storageID)) {
throw new Error(
'invalid write insert key missing from pending inserts'
);
}
}
}
return {
postUploadUpdateFunctions,
recordsByID,
insertKeys,
deleteKeys,
};
}
type EncryptManifestOptionsType = {
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
insertKeys: Set<string>;
};
type EncryptedManifestType = {
newItems: Set<Proto.IStorageItem>;
storageManifest: Proto.IStorageManifest;
};
async function encryptManifest(
version: number,
{ recordsByID, insertKeys }: EncryptManifestOptionsType
): Promise<EncryptedManifestType> {
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
const newItems: Set<Proto.IStorageItem> = new Set();
for (const [storageID, { itemType, storageRecord }] of recordsByID) {
const identifier = new Proto.ManifestRecord.Identifier({
type: itemType,
raw: Bytes.fromBase64(storageID),
2021-04-09 20:12:05 +00:00
});
manifestRecordKeys.add(identifier);
if (insertKeys.has(storageID)) {
strictAssert(
storageRecord !== undefined,
'Inserted items must have an associated record'
);
let storageItem;
try {
storageItem = encryptRecord(storageID, storageRecord);
} catch (err) {
log.error(
`storageService.upload(${version}): encrypt record failed:`,
Errors.toLogFormat(err)
);
throw err;
}
newItems.add(storageItem);
}
2021-04-09 20:12:05 +00:00
}
2021-07-13 18:54:53 +00:00
const manifestRecord = new Proto.ManifestRecord();
2022-03-23 20:49:27 +00:00
manifestRecord.version = Long.fromNumber(version);
manifestRecord.sourceDevice = window.storage.user.getDeviceId() ?? 0;
2020-09-09 00:56:23 +00:00
manifestRecord.keys = Array.from(manifestRecordKeys);
const storageKeyBase64 = window.storage.get('storageKey');
if (!storageKeyBase64) {
throw new Error('No storage key');
}
2021-09-24 00:49:05 +00:00
const storageKey = Bytes.fromBase64(storageKeyBase64);
2022-03-23 20:49:27 +00:00
const storageManifestKey = deriveStorageManifestKey(
storageKey,
Long.fromNumber(version)
);
2021-09-24 00:49:05 +00:00
const encryptedManifest = encryptProfile(
Proto.ManifestRecord.encode(manifestRecord).finish(),
2020-09-09 00:56:23 +00:00
storageManifestKey
);
2021-07-13 18:54:53 +00:00
const storageManifest = new Proto.StorageManifest();
2022-03-23 20:49:27 +00:00
storageManifest.version = manifestRecord.version;
2021-09-24 00:49:05 +00:00
storageManifest.value = encryptedManifest;
2020-09-09 00:56:23 +00:00
return {
newItems,
storageManifest,
};
}
async function uploadManifest(
version: number,
{ postUploadUpdateFunctions, deleteKeys }: GeneratedManifestType,
{ newItems, storageManifest }: EncryptedManifestType
2020-09-09 00:56:23 +00:00
): Promise<void> {
if (!window.textsecure.messaging) {
throw new Error('storageService.uploadManifest: We are offline!');
}
if (newItems.size === 0 && deleteKeys.size === 0) {
2022-02-08 18:00:18 +00:00
log.info(`storageService.upload(${version}): nothing to upload`);
return;
}
2020-09-09 00:56:23 +00:00
const credentials = window.storage.get('storageCredentials');
try {
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): inserting=${newItems.size} ` +
`deleting=${deleteKeys.size}`
2020-09-09 00:56:23 +00:00
);
2021-07-13 18:54:53 +00:00
const writeOperation = new Proto.WriteOperation();
writeOperation.manifest = storageManifest;
writeOperation.insertItem = Array.from(newItems);
writeOperation.deleteKey = Array.from(deleteKeys).map(storageID =>
Bytes.fromBase64(storageID)
);
2020-09-09 00:56:23 +00:00
await window.textsecure.messaging.modifyStorageRecords(
2021-09-24 00:49:05 +00:00
Proto.WriteOperation.encode(writeOperation).finish(),
2020-09-09 00:56:23 +00:00
{
credentials,
}
);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): upload complete, updating ` +
2022-03-04 21:14:52 +00:00
`items=${postUploadUpdateFunctions.length}`
2020-09-09 00:56:23 +00:00
);
// update conversations with the new storageID
2022-03-04 21:14:52 +00:00
postUploadUpdateFunctions.forEach(fn => fn());
2020-09-09 00:56:23 +00:00
} catch (err) {
log.error(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): failed!`,
Errors.toLogFormat(err)
2020-09-09 00:56:23 +00:00
);
if (err.code === 409) {
2021-06-09 22:28:54 +00:00
if (conflictBackOff.isFull()) {
log.error(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): exceeded maximum consecutive ` +
'conflicts'
2020-09-09 00:56:23 +00:00
);
return;
}
log.info(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): conflict found with ` +
`version=${version}, running sync job ` +
`times=${conflictBackOff.getIndex()}`
);
2020-09-09 00:56:23 +00:00
throw err;
}
throw err;
}
2022-02-08 18:00:18 +00:00
log.info(`storageService.upload(${version}): setting new manifestVersion`);
await window.storage.put('manifestVersion', version);
2021-06-09 22:28:54 +00:00
conflictBackOff.reset();
backOff.reset();
try {
await singleProtoJobQueue.add(MessageSender.getFetchManifestSyncMessage());
} catch (error) {
log.error(
2022-02-08 18:00:18 +00:00
`storageService.upload(${version}): Failed to queue sync message`,
Errors.toLogFormat(error)
);
}
2020-09-09 00:56:23 +00:00
}
2022-02-08 18:00:18 +00:00
async function stopStorageServiceSync(reason: Error) {
log.warn('storageService.stopStorageServiceSync', Errors.toLogFormat(reason));
2020-09-09 00:56:23 +00:00
await window.storage.remove('storageKey');
2021-06-09 22:28:54 +00:00
if (backOff.isFull()) {
2022-02-08 18:00:18 +00:00
log.warn(
2021-06-09 22:28:54 +00:00
'storageService.stopStorageServiceSync: too many consecutive stops'
);
2021-06-09 22:28:54 +00:00
return;
2020-09-09 00:56:23 +00:00
}
2021-06-09 22:28:54 +00:00
await sleep(backOff.getAndIncrement());
log.info('storageService.stopStorageServiceSync: requesting new keys');
setTimeout(async () => {
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
'stopStorageServiceSync: We are primary device; not sending key sync request'
);
return;
}
try {
await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage());
} catch (error) {
log.error(
'storageService.stopStorageServiceSync: Failed to queue sync message',
Errors.toLogFormat(error)
);
}
2021-06-09 22:28:54 +00:00
});
2020-09-09 00:56:23 +00:00
}
async function createNewManifest() {
log.info('storageService.createNewManifest: creating new manifest');
2020-09-09 00:56:23 +00:00
const version = window.storage.get('manifestVersion', 0);
2020-09-09 00:56:23 +00:00
const generatedManifest = await generateManifest(version, undefined, true);
2020-09-09 00:56:23 +00:00
const encryptedManifest = await encryptManifest(version, generatedManifest);
await uploadManifest(
version,
{
...generatedManifest,
// we have created a new manifest, there should be no keys to delete
deleteKeys: new Set(),
},
encryptedManifest
);
2020-09-09 00:56:23 +00:00
}
async function decryptManifest(
2021-07-13 18:54:53 +00:00
encryptedManifest: Proto.IStorageManifest
): Promise<Proto.ManifestRecord> {
2020-09-09 00:56:23 +00:00
const { version, value } = encryptedManifest;
const storageKeyBase64 = window.storage.get('storageKey');
if (!storageKeyBase64) {
throw new Error('No storage key');
}
2021-09-24 00:49:05 +00:00
const storageKey = Bytes.fromBase64(storageKeyBase64);
const storageManifestKey = deriveStorageManifestKey(
2020-09-09 00:56:23 +00:00
storageKey,
2022-03-23 20:49:27 +00:00
dropNull(version)
2020-09-09 00:56:23 +00:00
);
2021-07-13 18:54:53 +00:00
strictAssert(value, 'StorageManifest has no value field');
2021-09-24 00:49:05 +00:00
const decryptedManifest = decryptProfile(value, storageManifestKey);
2020-09-09 00:56:23 +00:00
2021-09-24 00:49:05 +00:00
return Proto.ManifestRecord.decode(decryptedManifest);
2020-09-09 00:56:23 +00:00
}
async function fetchManifest(
manifestVersion: number
2021-07-13 18:54:53 +00:00
): Promise<Proto.ManifestRecord | undefined> {
2022-02-08 18:00:18 +00:00
log.info('storageService.sync: fetch start');
2020-09-09 00:56:23 +00:00
if (!window.textsecure.messaging) {
2022-02-08 18:00:18 +00:00
throw new Error('storageService.sync: we are offline!');
2020-09-09 00:56:23 +00:00
}
try {
2021-11-11 22:43:05 +00:00
const credentials =
await window.textsecure.messaging.getStorageCredentials();
await window.storage.put('storageCredentials', credentials);
2020-09-09 00:56:23 +00:00
const manifestBinary = await window.textsecure.messaging.getStorageManifest(
{
credentials,
greaterThanVersion: manifestVersion,
}
);
2021-09-24 00:49:05 +00:00
const encryptedManifest = Proto.StorageManifest.decode(manifestBinary);
2020-09-09 00:56:23 +00:00
try {
return decryptManifest(encryptedManifest);
} catch (err) {
2022-02-08 18:00:18 +00:00
await stopStorageServiceSync(err);
2020-09-09 00:56:23 +00:00
return;
}
} catch (err) {
if (err.code === 204) {
2022-02-08 18:00:18 +00:00
log.info('storageService.sync: no newer manifest, ok');
return;
}
2022-02-08 18:00:18 +00:00
log.error('storageService.sync: failed!', Errors.toLogFormat(err));
2020-09-09 00:56:23 +00:00
if (err.code === 404) {
await createNewManifest();
return;
}
throw err;
}
}
type MergeableItemType = {
itemType: number;
storageID: string;
2021-07-13 18:54:53 +00:00
storageRecord: Proto.IStorageRecord;
2020-09-09 00:56:23 +00:00
};
type MergedRecordType = UnknownRecord & {
hasConflict: boolean;
2022-02-11 21:05:24 +00:00
shouldDrop: boolean;
hasError: boolean;
2020-09-09 00:56:23 +00:00
isUnsupported: boolean;
2022-03-09 18:22:34 +00:00
updatedConversations: ReadonlyArray<ConversationModel>;
needProfileFetch: ReadonlyArray<ConversationModel>;
2020-09-09 00:56:23 +00:00
};
async function mergeRecord(
2022-02-08 18:00:18 +00:00
storageVersion: number,
2020-09-09 00:56:23 +00:00
itemToMerge: MergeableItemType
): Promise<MergedRecordType> {
const { itemType, storageID, storageRecord } = itemToMerge;
2021-07-13 18:54:53 +00:00
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
2020-09-09 00:56:23 +00:00
2022-02-08 18:00:18 +00:00
let mergeResult: MergeResultType = { hasConflict: false, details: [] };
2020-09-09 00:56:23 +00:00
let isUnsupported = false;
let hasError = false;
2022-03-09 18:22:34 +00:00
let updatedConversations = new Array<ConversationModel>();
const needProfileFetch = new Array<ConversationModel>();
2020-09-09 00:56:23 +00:00
try {
if (itemType === ITEM_TYPE.UNKNOWN) {
2022-02-08 18:00:18 +00:00
log.warn('storageService.mergeRecord: Unknown item type', storageID);
2020-09-09 00:56:23 +00:00
} else if (itemType === ITEM_TYPE.CONTACT && storageRecord.contact) {
2022-02-08 18:00:18 +00:00
mergeResult = await mergeContactRecord(
storageID,
storageVersion,
storageRecord.contact
);
2020-09-09 00:56:23 +00:00
} else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) {
2022-02-08 18:00:18 +00:00
mergeResult = await mergeGroupV1Record(
storageID,
storageVersion,
storageRecord.groupV1
);
2020-10-07 18:27:08 +00:00
} else if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2) {
2022-02-08 18:00:18 +00:00
mergeResult = await mergeGroupV2Record(
storageID,
storageVersion,
storageRecord.groupV2
);
2020-09-09 00:56:23 +00:00
} else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) {
2022-02-08 18:00:18 +00:00
mergeResult = await mergeAccountRecord(
storageID,
storageVersion,
storageRecord.account
);
2022-07-01 00:52:03 +00:00
} else if (
itemType === ITEM_TYPE.STORY_DISTRIBUTION_LIST &&
storageRecord.storyDistributionList
) {
mergeResult = await mergeStoryDistributionListRecord(
storageID,
storageVersion,
storageRecord.storyDistributionList
);
2022-08-03 17:10:49 +00:00
} else if (
itemType === ITEM_TYPE.STICKER_PACK &&
storageRecord.stickerPack
) {
mergeResult = await mergeStickerPackRecord(
storageID,
storageVersion,
storageRecord.stickerPack
);
2020-09-09 00:56:23 +00:00
} else {
isUnsupported = true;
2022-02-08 18:00:18 +00:00
log.warn(
`storageService.merge(${redactStorageID(
storageID,
storageVersion
)}): unknown item type=${itemType}`
);
2020-09-09 00:56:23 +00:00
}
2022-02-08 18:00:18 +00:00
const redactedID = redactStorageID(
storageID,
storageVersion,
mergeResult.conversation
);
const oldID = mergeResult.oldStorageID
? redactStorageID(mergeResult.oldStorageID, mergeResult.oldStorageVersion)
: '?';
2022-03-09 18:22:34 +00:00
updatedConversations = [
...updatedConversations,
...(mergeResult.updatedConversations ?? []),
];
if (mergeResult.needsProfileFetch) {
strictAssert(mergeResult.conversation, 'needsProfileFetch, but no convo');
needProfileFetch.push(mergeResult.conversation);
}
log.info(
2022-02-08 18:00:18 +00:00
`storageService.merge(${redactedID}): merged item type=${itemType} ` +
`oldID=${oldID} ` +
`conflict=${mergeResult.hasConflict} ` +
2022-02-11 21:05:24 +00:00
`shouldDrop=${Boolean(mergeResult.shouldDrop)} ` +
2022-02-08 18:00:18 +00:00
`details=${JSON.stringify(mergeResult.details)}`
2021-04-06 22:54:47 +00:00
);
2020-09-09 00:56:23 +00:00
} catch (err) {
hasError = true;
2022-02-08 18:00:18 +00:00
const redactedID = redactStorageID(storageID, storageVersion);
log.error(
2022-02-08 18:00:18 +00:00
`storageService.merge(${redactedID}): error with ` +
`item type=${itemType} ` +
`details=${Errors.toLogFormat(err)}`
2020-09-09 00:56:23 +00:00
);
}
return {
2022-02-08 18:00:18 +00:00
hasConflict: mergeResult.hasConflict,
2022-02-11 21:05:24 +00:00
shouldDrop: Boolean(mergeResult.shouldDrop),
hasError,
2020-09-09 00:56:23 +00:00
isUnsupported,
itemType,
storageID,
2022-03-09 18:22:34 +00:00
updatedConversations,
needProfileFetch,
2020-09-09 00:56:23 +00:00
};
}
2022-08-03 17:10:49 +00:00
type NonConversationRecordsResultType = Readonly<{
installedStickerPacks: ReadonlyArray<StickerPackType>;
uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>;
storyDistributionLists: ReadonlyArray<StoryDistributionWithMembersType>;
}>;
// TODO: DESKTOP-3929
async function getNonConversationRecords(): Promise<NonConversationRecordsResultType> {
const [
storyDistributionLists,
uninstalledStickerPacks,
installedStickerPacks,
] = await Promise.all([
dataInterface.getAllStoryDistributionsWithMembers(),
dataInterface.getUninstalledStickerPacks(),
dataInterface.getInstalledStickerPacks(),
]);
return {
storyDistributionLists,
uninstalledStickerPacks,
installedStickerPacks,
};
}
2020-09-09 00:56:23 +00:00
async function processManifest(
2022-02-08 18:00:18 +00:00
manifest: Proto.IManifestRecord,
version: number
): Promise<number> {
2020-09-09 00:56:23 +00:00
if (!window.textsecure.messaging) {
throw new Error('storageService.processManifest: We are offline!');
}
const remoteKeysTypeMap = new Map();
2021-07-13 18:54:53 +00:00
(manifest.keys || []).forEach(({ raw, type }: IManifestRecordIdentifier) => {
strictAssert(raw, 'Identifier without raw field');
remoteKeysTypeMap.set(Bytes.toBase64(raw), type);
2020-09-09 00:56:23 +00:00
});
2021-04-08 19:27:20 +00:00
const remoteKeys = new Set(remoteKeysTypeMap.keys());
2022-02-08 18:00:18 +00:00
const localVersions = new Map<string, number | undefined>();
2022-08-03 17:10:49 +00:00
let localRecordCount = 0;
2021-04-08 19:27:20 +00:00
const conversations = window.getConversations();
conversations.forEach((conversation: ConversationModel) => {
const storageID = conversation.get('storageID');
if (storageID) {
2022-02-08 18:00:18 +00:00
localVersions.set(storageID, conversation.get('storageVersion'));
2021-04-08 19:27:20 +00:00
}
});
2022-08-03 17:10:49 +00:00
localRecordCount += conversations.length;
{
const {
storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
} = await getNonConversationRecords();
const collectLocalKeysFromFields = ({
storageID,
storageVersion,
}: StorageServiceFieldsType): void => {
if (storageID) {
localVersions.set(storageID, storageVersion);
}
};
storyDistributionLists.forEach(collectLocalKeysFromFields);
localRecordCount += storyDistributionLists.length;
uninstalledStickerPacks.forEach(collectLocalKeysFromFields);
localRecordCount += uninstalledStickerPacks.length;
installedStickerPacks.forEach(collectLocalKeysFromFields);
localRecordCount += installedStickerPacks.length;
}
2020-09-09 00:56:23 +00:00
const unknownRecordsArray: ReadonlyArray<UnknownRecord> =
2020-09-09 00:56:23 +00:00
window.storage.get('storage-service-unknown-records') || [];
2021-04-08 19:27:20 +00:00
const stillUnknown = unknownRecordsArray.filter((record: UnknownRecord) => {
// Do not include any unknown records that we already support
if (!validRecordTypes.has(record.itemType)) {
2022-02-08 18:00:18 +00:00
localVersions.set(record.storageID, record.storageVersion);
2021-04-08 19:27:20 +00:00
return false;
}
2021-04-08 19:27:20 +00:00
return true;
2020-09-09 00:56:23 +00:00
});
2022-02-08 18:00:18 +00:00
const remoteOnlySet = new Set<string>();
for (const key of remoteKeys) {
if (!localVersions.has(key)) {
remoteOnlySet.add(key);
}
}
const localOnlySet = new Set<string>();
for (const key of localVersions.keys()) {
if (!remoteKeys.has(key)) {
localOnlySet.add(key);
}
}
const redactedRemoteOnly = Array.from(remoteOnlySet).map(id =>
redactStorageID(id, version)
);
const redactedLocalOnly = Array.from(localOnlySet).map(id =>
redactStorageID(id, localVersions.get(id))
);
log.info(
2022-08-03 17:10:49 +00:00
`storageService.process(${version}): localRecords=${localRecordCount} ` +
2022-02-08 18:00:18 +00:00
`localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` +
`remoteKeys=${remoteKeys.size}`
2021-04-08 19:27:20 +00:00
);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${version}): ` +
`remoteOnlyCount=${remoteOnlySet.size} ` +
`remoteOnlyKeys=${JSON.stringify(redactedRemoteOnly)}`
2021-04-08 19:27:20 +00:00
);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${version}): ` +
`localOnlyCount=${localOnlySet.size} ` +
`localOnlyKeys=${JSON.stringify(redactedLocalOnly)}`
2021-04-08 19:27:20 +00:00
);
const remoteOnlyRecords = new Map<string, RemoteRecord>();
remoteOnlySet.forEach(storageID => {
remoteOnlyRecords.set(storageID, {
storageID,
itemType: remoteKeysTypeMap.get(storageID),
});
});
2022-02-08 18:00:18 +00:00
let conflictCount = 0;
if (remoteOnlyRecords.size) {
const fetchResult = await fetchRemoteRecords(version, remoteOnlyRecords);
conflictCount = await processRemoteRecords(version, fetchResult);
}
2021-04-08 19:27:20 +00:00
// Post-merge, if our local records contain any storage IDs that were not
// present in the remote manifest then we'll need to clear it, generate a
// new storageID for that record, and upload.
// This might happen if a device pushes a manifest which doesn't contain
// the keys that we have in our local database.
window.getConversations().forEach((conversation: ConversationModel) => {
const storageID = conversation.get('storageID');
if (storageID && !remoteKeys.has(storageID)) {
2022-02-08 18:00:18 +00:00
const storageVersion = conversation.get('storageVersion');
const missingKey = redactStorageID(
storageID,
storageVersion,
conversation
);
// Remote might have dropped this conversation already, but our value of
// `firstUnregisteredAt` is too high for us to drop it. Don't reupload it!
if (conversation.isUnregistered()) {
log.info(
`storageService.process(${version}): localKey=${missingKey} is ` +
'unregistered and not in remote manifest'
);
conversation.setUnregistered({
timestamp: Date.now() - durations.MONTH,
fromStorageService: true,
// Saving below
shouldSave: false,
});
} else {
log.info(
`storageService.process(${version}): localKey=${missingKey} ` +
'was not in remote manifest'
);
}
2021-04-09 20:12:05 +00:00
conversation.unset('storageID');
2022-02-08 18:00:18 +00:00
conversation.unset('storageVersion');
2021-04-08 19:27:20 +00:00
updateConversation(conversation.attributes);
}
});
2022-08-03 17:10:49 +00:00
// Refetch various records post-merge
{
const {
storyDistributionLists,
installedStickerPacks,
uninstalledStickerPacks,
} = await getNonConversationRecords();
uninstalledStickerPacks.forEach(stickerPack => {
const { storageID, storageVersion } = stickerPack;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
2022-08-03 17:10:49 +00:00
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`storageService.process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
);
void dataInterface.addUninstalledStickerPack({
2022-08-03 17:10:49 +00:00
...stickerPack,
storageID: undefined,
storageVersion: undefined,
});
});
installedStickerPacks.forEach(stickerPack => {
const { storageID, storageVersion } = stickerPack;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`storageService.process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
);
void dataInterface.createOrUpdateStickerPack({
2022-08-03 17:10:49 +00:00
...stickerPack,
storageID: undefined,
storageVersion: undefined,
});
});
2022-08-03 17:10:49 +00:00
storyDistributionLists.forEach(storyDistributionList => {
const { storageID, storageVersion } = storyDistributionList;
if (!storageID || remoteKeys.has(storageID)) {
return;
}
2022-08-03 17:10:49 +00:00
const missingKey = redactStorageID(storageID, storageVersion);
log.info(
`storageService.process(${version}): localKey=${missingKey} was not ` +
'in remote manifest'
);
void dataInterface.modifyStoryDistribution({
2022-08-03 17:10:49 +00:00
...storyDistributionList,
storageID: undefined,
storageVersion: undefined,
});
});
// Check to make sure we have a "My Stories" distribution list set up
const myStories = storyDistributionLists.find(
({ id }) => id === MY_STORY_ID
);
2022-08-03 17:10:49 +00:00
if (!myStories) {
log.info(`storageService.process(${version}): creating my stories`);
2022-08-03 17:10:49 +00:00
const storyDistribution: StoryDistributionWithMembersType = {
allowsReplies: true,
id: MY_STORY_ID,
2022-08-03 17:10:49 +00:00
isBlockList: true,
members: [],
name: MY_STORY_ID,
2022-08-03 17:10:49 +00:00
senderKeyInfo: undefined,
storageNeedsSync: true,
};
await dataInterface.createNewStoryDistribution(storyDistribution);
const shouldSave = false;
window.reduxActions.storyDistributionLists.createDistributionList(
storyDistribution.name,
storyDistribution.members,
storyDistribution,
shouldSave
);
conflictCount += 1;
}
}
2022-02-08 18:00:18 +00:00
log.info(
`storageService.process(${version}): conflictCount=${conflictCount}`
2022-02-08 18:00:18 +00:00
);
return conflictCount;
}
export type FetchRemoteRecordsResultType = Readonly<{
missingKeys: Set<string>;
decryptedItems: ReadonlyArray<MergeableItemType>;
}>;
async function fetchRemoteRecords(
2022-02-08 18:00:18 +00:00
storageVersion: number,
2021-04-09 20:12:05 +00:00
remoteOnlyRecords: Map<string, RemoteRecord>
): Promise<FetchRemoteRecordsResultType> {
const storageKeyBase64 = window.storage.get('storageKey');
if (!storageKeyBase64) {
throw new Error('No storage key');
}
const { messaging } = window.textsecure;
if (!messaging) {
throw new Error('messaging is not available');
}
2021-09-24 00:49:05 +00:00
const storageKey = Bytes.fromBase64(storageKeyBase64);
2020-09-09 00:56:23 +00:00
log.info(
`storageService.fetchRemoteRecords(${storageVersion}): ` +
`fetching remote keys count=${remoteOnlyRecords.size}`
2020-09-09 00:56:23 +00:00
);
const credentials = window.storage.get('storageCredentials');
2022-04-13 00:50:17 +00:00
const batches = chunk(Array.from(remoteOnlyRecords.keys()), MAX_READ_KEYS);
const storageItems = (
await pMap(
batches,
async (
batch: ReadonlyArray<string>
): Promise<Array<Proto.IStorageItem>> => {
const readOperation = new Proto.ReadOperation();
readOperation.readKey = batch.map(Bytes.fromBase64);
const storageItemsBuffer = await messaging.getStorageRecords(
Proto.ReadOperation.encode(readOperation).finish(),
{
credentials,
}
);
2022-04-13 00:50:17 +00:00
return Proto.StorageItems.decode(storageItemsBuffer).items ?? [];
},
{ concurrency: 5 }
)
).flat();
2020-09-09 00:56:23 +00:00
2022-02-11 21:05:24 +00:00
const missingKeys = new Set<string>(remoteOnlyRecords.keys());
const decryptedItems = await pMap(
2022-04-13 00:50:17 +00:00
storageItems,
async (
2021-07-13 18:54:53 +00:00
storageRecordWrapper: Proto.IStorageItem
): Promise<MergeableItemType> => {
2020-09-09 00:56:23 +00:00
const { key, value: storageItemCiphertext } = storageRecordWrapper;
if (!key || !storageItemCiphertext) {
2022-02-08 18:00:18 +00:00
const error = new Error(
`storageService.process(${storageVersion}): ` +
'missing key and/or Ciphertext'
2020-09-09 00:56:23 +00:00
);
2022-02-08 18:00:18 +00:00
await stopStorageServiceSync(error);
throw error;
2020-09-09 00:56:23 +00:00
}
2021-07-13 18:54:53 +00:00
const base64ItemID = Bytes.toBase64(key);
2022-02-11 21:05:24 +00:00
missingKeys.delete(base64ItemID);
2020-09-09 00:56:23 +00:00
2021-09-24 00:49:05 +00:00
const storageItemKey = deriveStorageItemKey(storageKey, base64ItemID);
2020-09-09 00:56:23 +00:00
let storageItemPlaintext;
try {
2021-09-24 00:49:05 +00:00
storageItemPlaintext = decryptProfile(
storageItemCiphertext,
2020-09-09 00:56:23 +00:00
storageItemKey
);
} catch (err) {
log.error(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
'Error decrypting storage item',
Errors.toLogFormat(err)
);
2022-02-08 18:00:18 +00:00
await stopStorageServiceSync(err);
2020-09-09 00:56:23 +00:00
throw err;
}
2021-09-24 00:49:05 +00:00
const storageRecord = Proto.StorageRecord.decode(storageItemPlaintext);
2020-09-09 00:56:23 +00:00
const remoteRecord = remoteOnlyRecords.get(base64ItemID);
if (!remoteRecord) {
throw new Error(
"Got a remote record that wasn't requested with " +
`storageID: ${base64ItemID}`
);
}
2020-09-09 00:56:23 +00:00
return {
itemType: remoteRecord.itemType,
2020-09-09 00:56:23 +00:00
storageID: base64ItemID,
storageRecord,
};
},
{ concurrency: 5 }
2020-09-09 00:56:23 +00:00
);
2022-02-11 21:05:24 +00:00
const redactedMissingKeys = Array.from(missingKeys).map(id =>
redactStorageID(id, storageVersion)
);
log.info(
`storageService.fetchRemoteRecords(${storageVersion}): missing remote ` +
2022-02-11 21:05:24 +00:00
`keys=${JSON.stringify(redactedMissingKeys)} ` +
`count=${missingKeys.size}`
);
return { decryptedItems, missingKeys };
}
async function processRemoteRecords(
storageVersion: number,
{ decryptedItems, missingKeys }: FetchRemoteRecordsResultType
): Promise<number> {
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
2022-02-11 21:05:24 +00:00
const droppedKeys = new Set<string>();
// Drop all GV1 records for which we have GV2 record in the same manifest
const masterKeys = new Map<string, string>();
for (const { itemType, storageID, storageRecord } of decryptedItems) {
if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2?.masterKey) {
masterKeys.set(
Bytes.toBase64(storageRecord.groupV2.masterKey),
storageID
);
}
}
let accountItem: MergeableItemType | undefined;
const prunedStorageItems = decryptedItems.filter(item => {
const { itemType, storageID, storageRecord } = item;
if (itemType === ITEM_TYPE.ACCOUNT) {
if (accountItem !== undefined) {
log.warn(
`storageService.process(${storageVersion}): duplicate account ` +
`record=${redactStorageID(storageID, storageVersion)} ` +
`previous=${redactStorageID(accountItem.storageID, storageVersion)}`
);
droppedKeys.add(accountItem.storageID);
}
accountItem = item;
return false;
}
if (itemType !== ITEM_TYPE.GROUPV1 || !storageRecord.groupV1?.id) {
return true;
}
const masterKey = deriveMasterKeyFromGroupV1(storageRecord.groupV1.id);
const gv2StorageID = masterKeys.get(Bytes.toBase64(masterKey));
if (!gv2StorageID) {
return true;
}
log.warn(
`storageService.process(${storageVersion}): dropping ` +
`GV1 record=${redactStorageID(storageID, storageVersion)} ` +
`GV2 record=${redactStorageID(gv2StorageID, storageVersion)} ` +
'is in the same manifest'
);
droppedKeys.add(storageID);
return false;
});
2020-09-09 00:56:23 +00:00
try {
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
`attempting to merge records=${prunedStorageItems.length}`
2020-09-16 18:04:28 +00:00
);
if (accountItem !== undefined) {
log.info(
`storageService.process(${storageVersion}): account ` +
`record=${redactStorageID(accountItem.storageID, storageVersion)}`
);
}
const mergedRecords = [
...(await pMap(
prunedStorageItems,
(item: MergeableItemType) => mergeRecord(storageVersion, item),
2022-03-09 18:22:34 +00:00
{ concurrency: 32 }
)),
// Merge Account records last since it contains the pinned conversations
// and we need all other records merged first before we can find the pinned
// records in our db
...(accountItem ? [await mergeRecord(storageVersion, accountItem)] : []),
];
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
`processed records=${mergedRecords.length}`
2020-09-16 18:04:28 +00:00
);
2020-09-09 00:56:23 +00:00
2022-03-09 18:22:34 +00:00
const updatedConversations = mergedRecords
.map(record => record.updatedConversations)
.flat()
.map(convo => convo.attributes);
await updateConversations(updatedConversations);
log.info(
`storageService.process(${storageVersion}): ` +
`updated conversations=${updatedConversations.length}`
);
const needProfileFetch = mergedRecords
.map(record => record.needProfileFetch)
.flat();
log.info(
`storageService.process(${storageVersion}): ` +
`kicking off profile fetches=${needProfileFetch.length}`
);
// Intentionally not awaiting
needProfileFetch.map(convo => convo.getProfiles());
2022-03-09 18:22:34 +00:00
// Collect full map of previously and currently unknown records
2020-09-09 00:56:23 +00:00
const unknownRecords: Map<string, UnknownRecord> = new Map();
2022-02-08 18:00:18 +00:00
const previousUnknownRecords: ReadonlyArray<UnknownRecord> =
2021-11-11 22:43:05 +00:00
window.storage.get(
'storage-service-unknown-records',
new Array<UnknownRecord>()
);
2022-02-08 18:00:18 +00:00
previousUnknownRecords.forEach((record: UnknownRecord) => {
2020-09-09 00:56:23 +00:00
unknownRecords.set(record.storageID, record);
});
2020-10-06 22:25:00 +00:00
const newRecordsWithErrors: Array<UnknownRecord> = [];
let conflictCount = 0;
mergedRecords.forEach((mergedRecord: MergedRecordType) => {
2020-09-09 00:56:23 +00:00
if (mergedRecord.isUnsupported) {
unknownRecords.set(mergedRecord.storageID, {
itemType: mergedRecord.itemType,
storageID: mergedRecord.storageID,
2022-02-08 18:00:18 +00:00
storageVersion,
2020-09-09 00:56:23 +00:00
});
} else if (mergedRecord.hasError) {
2020-10-06 22:25:00 +00:00
newRecordsWithErrors.push({
itemType: mergedRecord.itemType,
storageID: mergedRecord.storageID,
2022-02-08 18:00:18 +00:00
storageVersion,
});
2020-09-09 00:56:23 +00:00
}
if (mergedRecord.hasConflict) {
conflictCount += 1;
}
2022-02-11 21:05:24 +00:00
if (mergedRecord.shouldDrop) {
droppedKeys.add(mergedRecord.storageID);
}
2020-09-09 00:56:23 +00:00
});
2022-02-11 21:05:24 +00:00
const redactedDroppedKeys = Array.from(droppedKeys.values()).map(key =>
redactStorageID(key, storageVersion)
);
log.info(
`storageService.process(${storageVersion}): ` +
`dropped keys=${JSON.stringify(redactedDroppedKeys)} ` +
2022-02-11 21:05:24 +00:00
`count=${redactedDroppedKeys.length}`
);
// Filter out all the unknown records we're already supporting
const newUnknownRecords = Array.from(unknownRecords.values()).filter(
(record: UnknownRecord) => !validRecordTypes.has(record.itemType)
);
const redactedNewUnknowns = newUnknownRecords.map(redactExtendedStorageID);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
`unknown records=${JSON.stringify(redactedNewUnknowns)} ` +
`count=${redactedNewUnknowns.length}`
);
await window.storage.put(
'storage-service-unknown-records',
newUnknownRecords
2020-09-09 00:56:23 +00:00
);
2022-02-08 18:00:18 +00:00
const redactedErrorRecords = newRecordsWithErrors.map(
redactExtendedStorageID
2022-02-08 18:00:18 +00:00
);
log.info(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
`error records=${JSON.stringify(redactedErrorRecords)} ` +
`count=${redactedErrorRecords.length}`
);
2020-10-06 22:25:00 +00:00
// Refresh the list of records that had errors with every push, that way
// this list doesn't grow unbounded and we keep the list of storage keys
// fresh.
2022-02-08 18:00:18 +00:00
await window.storage.put(
'storage-service-error-records',
newRecordsWithErrors
);
2020-09-09 00:56:23 +00:00
// Store/overwrite keys pending deletion, but use them only when we have to
// upload a new manifest to avoid oscillation.
const pendingDeletes = [...missingKeys, ...droppedKeys].map(storageID => ({
storageID,
storageVersion,
}));
const redactedPendingDeletes = pendingDeletes.map(redactExtendedStorageID);
log.info(
`storageService.process(${storageVersion}): ` +
`pending deletes=${JSON.stringify(redactedPendingDeletes)} ` +
`count=${redactedPendingDeletes.length}`
);
await window.storage.put('storage-service-pending-deletes', pendingDeletes);
2022-02-08 18:00:18 +00:00
if (conflictCount === 0) {
conflictBackOff.reset();
2020-09-09 00:56:23 +00:00
}
2020-09-09 02:25:05 +00:00
return conflictCount;
2020-09-09 00:56:23 +00:00
} catch (err) {
log.error(
2022-02-08 18:00:18 +00:00
`storageService.process(${storageVersion}): ` +
'failed to process remote records',
Errors.toLogFormat(err)
2020-09-09 00:56:23 +00:00
);
}
// conflictCount
return 0;
2020-09-09 00:56:23 +00:00
}
async function sync(
ignoreConflicts = false
): Promise<Proto.ManifestRecord | undefined> {
2020-09-09 00:56:23 +00:00
if (!window.storage.get('storageKey')) {
throw new Error('storageService.sync: Cannot start; no storage key!');
2020-09-09 00:56:23 +00:00
}
log.info(
`storageService.sync: starting... ignoreConflicts=${ignoreConflicts}`
);
2020-09-09 00:56:23 +00:00
2021-07-13 18:54:53 +00:00
let manifest: Proto.ManifestRecord | undefined;
2020-09-09 00:56:23 +00:00
try {
// If we've previously interacted with strage service, update 'fetchComplete' record
const previousFetchComplete = window.storage.get('storageFetchComplete');
const manifestFromStorage = window.storage.get('manifestVersion');
if (!previousFetchComplete && isNumber(manifestFromStorage)) {
await window.storage.put('storageFetchComplete', true);
}
const localManifestVersion = manifestFromStorage || 0;
2022-02-08 18:00:18 +00:00
log.info(
'storageService.sync: fetching latest ' +
`after version=${localManifestVersion}`
);
2021-04-09 20:12:05 +00:00
manifest = await fetchManifest(localManifestVersion);
2020-09-09 00:56:23 +00:00
// Guarding against no manifests being returned, everything should be ok
if (!manifest) {
2022-02-08 18:00:18 +00:00
log.info(
`storageService.sync: no updates, version=${localManifestVersion}`
);
2021-04-09 20:12:05 +00:00
return undefined;
2020-09-09 00:56:23 +00:00
}
strictAssert(manifest.version != null, 'Manifest without version');
2022-03-23 20:49:27 +00:00
const version = manifest.version?.toNumber() ?? 0;
2020-09-09 00:56:23 +00:00
log.info(
`storageService.sync: updating to remoteVersion=${version} ` +
`sourceDevice=${manifest.sourceDevice ?? '?'} from ` +
2022-02-08 18:00:18 +00:00
`version=${localManifestVersion}`
2020-09-09 00:56:23 +00:00
);
const conflictCount = await processManifest(manifest, version);
2022-02-08 18:00:18 +00:00
log.info(
`storageService.sync: updated to version=${version} ` +
`conflicts=${conflictCount}`
2022-02-08 18:00:18 +00:00
);
2022-02-08 18:00:18 +00:00
await window.storage.put('manifestVersion', version);
const hasConflicts = conflictCount !== 0;
if (hasConflicts && !ignoreConflicts) {
await upload(true);
2020-09-09 00:56:23 +00:00
}
// We now know that we've successfully completed a storage service fetch
2022-02-08 18:00:18 +00:00
await window.storage.put('storageFetchComplete', true);
if (window.CI) {
window.CI.handleEvent('storageServiceComplete', {
manifestVersion: version,
});
}
2020-09-09 00:56:23 +00:00
} catch (err) {
log.error(
2020-10-07 23:44:55 +00:00
'storageService.sync: error processing manifest',
2022-02-08 18:00:18 +00:00
Errors.toLogFormat(err)
2020-09-09 00:56:23 +00:00
);
}
2020-09-09 00:56:23 +00:00
log.info('storageService.sync: complete');
2021-04-09 20:12:05 +00:00
return manifest;
2020-09-09 00:56:23 +00:00
}
async function upload(fromSync = false): Promise<void> {
if (!window.textsecure.messaging) {
throw new Error('storageService.upload: We are offline!');
}
2021-04-09 20:12:05 +00:00
// Rate limit uploads coming from syncing
if (fromSync) {
uploadBucket.push(Date.now());
if (uploadBucket.length >= 3) {
const [firstMostRecentWrite] = uploadBucket;
if (isMoreRecentThan(5 * durations.MINUTE, firstMostRecentWrite)) {
2021-04-09 20:12:05 +00:00
throw new Error(
'storageService.uploadManifest: too many writes too soon.'
);
}
uploadBucket.shift();
}
}
2020-09-09 00:56:23 +00:00
if (!window.storage.get('storageKey')) {
// requesting new keys runs the sync job which will detect the conflict
// and re-run the upload job once we're merged and up-to-date.
log.info('storageService.upload: no storageKey, requesting new keys');
2021-06-09 22:28:54 +00:00
backOff.reset();
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
'storageService.upload: We are primary device; not sending key sync request'
);
return;
}
try {
await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage());
} catch (error) {
log.error(
'storageService.upload: Failed to queue sync message',
Errors.toLogFormat(error)
);
}
return;
2020-09-09 00:56:23 +00:00
}
2021-07-13 18:54:53 +00:00
let previousManifest: Proto.ManifestRecord | undefined;
2021-03-12 17:38:43 +00:00
if (!fromSync) {
// Syncing before we upload so that we repair any unknown records and
// records with errors as well as ensure that we have the latest up to date
// manifest.
// We are going to upload after this sync so we can ignore any conflicts
// that arise during the sync.
const ignoreConflicts = true;
previousManifest = await sync(ignoreConflicts);
2021-03-12 17:38:43 +00:00
}
const localManifestVersion = window.storage.get('manifestVersion', 0);
2020-09-09 00:56:23 +00:00
const version = Number(localManifestVersion) + 1;
2022-02-11 21:05:24 +00:00
log.info(
`storageService.upload(${version}): will update to manifest version`
);
2020-09-09 00:56:23 +00:00
try {
2022-02-11 21:05:24 +00:00
const generatedManifest = await generateManifest(
version,
previousManifest,
false
2022-02-11 21:05:24 +00:00
);
const encryptedManifest = await encryptManifest(version, generatedManifest);
await uploadManifest(version, generatedManifest, encryptedManifest);
// Clear pending delete keys after successful upload
await window.storage.put('storage-service-pending-deletes', []);
2020-09-09 00:56:23 +00:00
} catch (err) {
if (err.code === 409) {
2021-06-09 22:28:54 +00:00
await sleep(conflictBackOff.getAndIncrement());
log.info('storageService.upload: pushing sync on the queue');
// The sync job will check for conflicts and as part of that conflict
// check if an item needs sync and doesn't match with the remote record
// it'll kick off another upload.
setTimeout(runStorageServiceSyncJob);
return;
2020-09-09 00:56:23 +00:00
}
2022-02-11 21:05:24 +00:00
log.error(
`storageService.upload(${version}): error`,
Errors.toLogFormat(err)
);
2020-09-09 00:56:23 +00:00
}
}
let storageServiceEnabled = false;
export function enableStorageService(): void {
storageServiceEnabled = true;
}
// 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({
keepUnknownFields = false,
}: { keepUnknownFields?: boolean } = {}): Promise<void> {
log.info('storageService.eraseAllStorageServiceState: starting...');
await Promise.all([
window.storage.remove('manifestVersion'),
keepUnknownFields
? Promise.resolve()
: window.storage.remove('storage-service-unknown-records'),
window.storage.remove('storageCredentials'),
]);
await eraseStorageServiceStateFromConversations();
log.info('storageService.eraseAllStorageServiceState: complete');
}
export async function reprocessUnknownFields(): Promise<void> {
ourProfileKeyService.blockGetWithPromise(
storageJobQueue(async () => {
const version = window.storage.get('manifestVersion') ?? 0;
log.info(`storageService.reprocessUnknownFields(${version}): starting`);
const { recordsByID, insertKeys } = await generateManifest(
version,
undefined,
true
);
const newRecords = Array.from(
filter(
map(recordsByID, ([key, item]): MergeableItemType | undefined => {
if (!insertKeys.has(key)) {
return undefined;
}
strictAssert(
item.storageRecord !== undefined,
'Inserted records must have storageRecord'
);
if (!item.storageRecord.__unknownFields?.length) {
return undefined;
}
return {
...item,
storageRecord: Proto.StorageRecord.decode(
Proto.StorageRecord.encode(item.storageRecord).finish()
),
};
}),
isNotNil
)
);
const conflictCount = await processRemoteRecords(version, {
decryptedItems: newRecords,
missingKeys: new Set(),
});
log.info(
`storageService.reprocessUnknownFields(${version}): done, ` +
`conflictCount=${conflictCount}`
);
const hasConflicts = conflictCount !== 0;
if (hasConflicts) {
log.info(
`storageService.reprocessUnknownFields(${version}): uploading`
);
await upload();
}
})
);
}
export const storageServiceUploadJob = debounce(() => {
if (!storageServiceEnabled) {
log.info('storageService.storageServiceUploadJob: called before enabled');
return;
}
void storageJobQueue(async () => {
2021-04-09 20:12:05 +00:00
await upload();
}, `upload v${window.storage.get('manifestVersion')}`);
}, 500);
export const runStorageServiceSyncJob = debounce(() => {
if (!storageServiceEnabled) {
log.info('storageService.runStorageServiceSyncJob: called before enabled');
return;
}
ourProfileKeyService.blockGetWithPromise(
storageJobQueue(async () => {
await sync();
2022-03-02 22:53:47 +00:00
// Notify listeners about sync completion
window.Whisper.events.trigger('storageService:syncComplete');
}, `sync v${window.storage.get('manifestVersion')}`)
);
}, 500);