mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-10-02 09:50:04 +01:00
Storage Service: Updates for standalone readiness
This commit is contained in:
@@ -60,8 +60,6 @@ import {
|
||||
import { Contact } from '../data/contacts';
|
||||
import { Group as GroupData } from '../data/group';
|
||||
import {
|
||||
decryptStorageItem,
|
||||
decryptStorageManifest,
|
||||
deriveAccessKey,
|
||||
deriveMasterKey,
|
||||
deriveStorageKey,
|
||||
@@ -71,7 +69,6 @@ import {
|
||||
EnvelopeType,
|
||||
ModifyGroupOptions,
|
||||
ModifyGroupResult,
|
||||
StorageWriteResult,
|
||||
} from '../server/base';
|
||||
import { ServerGroup } from '../server/group';
|
||||
import {
|
||||
@@ -117,14 +114,62 @@ export type Config = Readonly<{
|
||||
modifyGroup: (options: ModifyGroupOptions) => Promise<ModifyGroupResult>;
|
||||
waitForGroupUpdate: (group: GroupData) => Promise<void>;
|
||||
|
||||
getStorageManifest: () => Proto.StorageManifest.Params | undefined;
|
||||
getStorageItem: (key: Buffer<ArrayBuffer>) => Buffer<ArrayBuffer> | undefined;
|
||||
getAllStorageKeys: () => Array<Buffer<ArrayBuffer>>;
|
||||
waitForStorageManifest: (afterVersion?: bigint) => Promise<void>;
|
||||
applyStorageWrite: (
|
||||
operation: Proto.WriteOperation.Params,
|
||||
shouldNotify?: boolean,
|
||||
) => Promise<StorageWriteResult>;
|
||||
waitForStorageState: ({
|
||||
aci,
|
||||
after,
|
||||
predicate,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
after?: StorageState;
|
||||
// Note: predicate runs on the current state, not on previous intermediate states
|
||||
predicate?: (state: StorageState) => boolean;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}) => Promise<StorageState>;
|
||||
getStorageState: ({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}) => Promise<StorageState | undefined>;
|
||||
expectStorageState: ({
|
||||
aci,
|
||||
reason,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
reason: string;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}) => Promise<StorageState>;
|
||||
setStorageState: ({
|
||||
aci,
|
||||
state,
|
||||
previousState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
state: StorageState;
|
||||
previousState?: StorageState;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}) => Promise<StorageState>;
|
||||
getOrphanedStorageKeys: ({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}) => Array<Buffer<ArrayBuffer>>;
|
||||
}>;
|
||||
|
||||
export type EncryptOptions = Readonly<{
|
||||
@@ -684,6 +729,10 @@ export class PrimaryDevice {
|
||||
device.accessKey = this.device.accessKey;
|
||||
}
|
||||
|
||||
public getContactsBlob(): Proto.AttachmentPointer.Params {
|
||||
return this.contactsBlob;
|
||||
}
|
||||
|
||||
//
|
||||
// Keys
|
||||
//
|
||||
@@ -1149,92 +1198,51 @@ export class PrimaryDevice {
|
||||
// Note: predicate runs on the current state, not on previous intermediate states
|
||||
predicate?: (state: StorageState) => boolean;
|
||||
} = {}): Promise<StorageState> {
|
||||
let afterVersion = after?.version;
|
||||
|
||||
while (true) {
|
||||
debug(
|
||||
'waiting for storage manifest for device=%s after version=%d predicate=%s',
|
||||
this.device.debugId,
|
||||
afterVersion,
|
||||
predicate !== undefined,
|
||||
);
|
||||
|
||||
await this.config.waitForStorageManifest(afterVersion);
|
||||
|
||||
const state = await this.getStorageState();
|
||||
assert(state, 'Missing storage state');
|
||||
|
||||
if (predicate !== undefined && !predicate(state)) {
|
||||
debug(
|
||||
'storage manifest for device=%s version=%d did not match predicate',
|
||||
this.device.debugId,
|
||||
state.version,
|
||||
);
|
||||
afterVersion = state.version;
|
||||
continue;
|
||||
}
|
||||
|
||||
debug(
|
||||
'got storage manifest for device=%s version=%d',
|
||||
this.device.debugId,
|
||||
state.version,
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
return this.config.waitForStorageState({
|
||||
aci: this.device.aci,
|
||||
after,
|
||||
predicate,
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
public async getStorageState(): Promise<StorageState | undefined> {
|
||||
const manifest = this.config.getStorageManifest();
|
||||
if (!manifest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.convertManifestToStorageState(manifest);
|
||||
return this.config.getStorageState({
|
||||
aci: this.device.aci,
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
public async expectStorageState(reason: string): Promise<StorageState> {
|
||||
const state = await this.getStorageState();
|
||||
if (!state) {
|
||||
throw new Error(`expectStorageState: no storage state, ${reason}`);
|
||||
}
|
||||
|
||||
return state;
|
||||
return this.config.expectStorageState({
|
||||
aci: this.device.aci,
|
||||
reason,
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
public async setStorageState(
|
||||
state: StorageState,
|
||||
previousState?: StorageState,
|
||||
): Promise<StorageState> {
|
||||
const writeOperation = state.createWriteOperation({
|
||||
return this.config.setStorageState({
|
||||
aci: this.device.aci,
|
||||
state,
|
||||
previousState,
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
previous: previousState,
|
||||
});
|
||||
assert(writeOperation.manifest, 'write operation without manifest');
|
||||
|
||||
const { updated, error } = await this.config.applyStorageWrite(
|
||||
writeOperation,
|
||||
false,
|
||||
);
|
||||
if (!updated) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`setStorageState: failed to update, ${error}`);
|
||||
}
|
||||
|
||||
return this.convertManifestToStorageState(writeOperation.manifest);
|
||||
}
|
||||
|
||||
public getOrphanedStorageKeys(): Array<Buffer<ArrayBuffer>> {
|
||||
const manifest = this.config.getStorageManifest();
|
||||
if (!manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const state = this.convertManifestToStorageState(manifest);
|
||||
const keys = this.config.getAllStorageKeys();
|
||||
|
||||
return keys.filter((key) => !state.hasKey(key));
|
||||
return this.config.getOrphanedStorageKeys({
|
||||
aci: this.device.aci,
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
@@ -2450,39 +2458,4 @@ export class PrimaryDevice {
|
||||
senderKeys,
|
||||
);
|
||||
}
|
||||
|
||||
private convertManifestToStorageState(
|
||||
manifest: Proto.StorageManifest.Params,
|
||||
): StorageState {
|
||||
const decryptedManifest = decryptStorageManifest(this.storageKey, manifest);
|
||||
assert(decryptedManifest.version, 'Consistency check');
|
||||
|
||||
const version = decryptedManifest.version;
|
||||
const items = decryptedManifest.identifiers.map(({ type, raw: key }) => {
|
||||
const keyBuffer = Buffer.from(key);
|
||||
const item = this.config.getStorageItem(keyBuffer);
|
||||
if (!item) {
|
||||
throw new Error(`Missing item ${keyBuffer.toString('base64')}`);
|
||||
}
|
||||
|
||||
const decrypted = decryptStorageItem({
|
||||
storageKey: this.storageKey,
|
||||
recordIkm: this.storageRecordIkm,
|
||||
item: {
|
||||
key,
|
||||
value: item,
|
||||
},
|
||||
});
|
||||
if (!decrypted.record) {
|
||||
throw new Error(`Missing item record ${keyBuffer.toString('base64')}`);
|
||||
}
|
||||
return {
|
||||
type: type as Proto.ManifestRecord.Identifier.Type,
|
||||
key: keyBuffer,
|
||||
record: decrypted.record,
|
||||
};
|
||||
});
|
||||
|
||||
return new StorageState(version, items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
import { serializeContacts } from '../data/contacts';
|
||||
import { Group as GroupData } from '../data/group';
|
||||
import {
|
||||
decryptStorageItem,
|
||||
decryptStorageManifest,
|
||||
encryptAttachment,
|
||||
encryptProvisionMessage,
|
||||
generateServerCertificate,
|
||||
@@ -71,6 +73,8 @@ import { createHandler as createGRPCHandler } from '../server/grpc';
|
||||
import { Connection as WSConnection } from '../server/ws';
|
||||
|
||||
import { PrimaryDevice } from './primary-device';
|
||||
import type { Config as PrimaryDeviceConfig } from './primary-device';
|
||||
import { StorageState } from './storage-state';
|
||||
|
||||
type TrustRoot = Readonly<{
|
||||
privateKey: string;
|
||||
@@ -328,13 +332,13 @@ export class Server extends BaseServer {
|
||||
}
|
||||
|
||||
private async waitForStorageManifest(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
afterVersion?: bigint,
|
||||
): Promise<void> {
|
||||
let queue = this.manifestQueueByAci.get(device.aci);
|
||||
let queue = this.manifestQueueByAci.get(aci);
|
||||
if (!queue) {
|
||||
queue = this.createQueue('api/Server/waitForStorageManifest');
|
||||
this.manifestQueueByAci.set(device.aci, queue);
|
||||
this.manifestQueueByAci.set(aci, queue);
|
||||
}
|
||||
|
||||
let version: bigint;
|
||||
@@ -360,6 +364,52 @@ export class Server extends BaseServer {
|
||||
// Helper methods
|
||||
//
|
||||
|
||||
public async reregisterPrimaryDevice({
|
||||
aci,
|
||||
password,
|
||||
profileName,
|
||||
}: {
|
||||
aci: AciString;
|
||||
password?: string;
|
||||
profileName: string;
|
||||
}): Promise<PrimaryDevice> {
|
||||
const existingPrimary = this.primaryDevices.get(aci);
|
||||
|
||||
if (!existingPrimary) {
|
||||
throw new Error(`No existing primary device found for aci ${aci}`);
|
||||
}
|
||||
|
||||
const hasE164 = Boolean(existingPrimary.device.number);
|
||||
const registrationId = generateRegistrationId();
|
||||
const pniRegistrationId = hasE164 ? generateRegistrationId() : undefined;
|
||||
|
||||
this.deleteAccount(existingPrimary.device);
|
||||
|
||||
const device = await this.registerDevice({
|
||||
primary: existingPrimary.device,
|
||||
password: password ?? generateDevicePassword(),
|
||||
registrationId,
|
||||
pniRegistrationId,
|
||||
});
|
||||
|
||||
const primary = new PrimaryDevice(device, {
|
||||
profileName,
|
||||
contacts: existingPrimary.getContactsBlob(),
|
||||
...this.getDefaultPrimaryDeviceParams(device),
|
||||
});
|
||||
await primary.init();
|
||||
|
||||
this.primaryDevices.set(primary.device.aci, primary);
|
||||
|
||||
debug(
|
||||
'reregistered primary device number=%s aci=%s',
|
||||
primary.device.number,
|
||||
primary.device.aci,
|
||||
);
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
public async createPrimaryDevice({
|
||||
profileName,
|
||||
contacts = [],
|
||||
@@ -370,12 +420,12 @@ export class Server extends BaseServer {
|
||||
const number = hasE164 ? await this.generateNumber() : undefined;
|
||||
|
||||
const registrationId = generateRegistrationId();
|
||||
const pniRegistrationId = generateRegistrationId();
|
||||
const pniRegistrationId = hasE164 ? generateRegistrationId() : undefined;
|
||||
const devicePassword = password ?? generateDevicePassword();
|
||||
const device = await this.registerDevice({
|
||||
number,
|
||||
registrationId,
|
||||
pniRegistrationId: hasE164 ? pniRegistrationId : undefined,
|
||||
pniRegistrationId,
|
||||
password: devicePassword,
|
||||
authCredentialSalt: randomBytes(16),
|
||||
});
|
||||
@@ -407,6 +457,25 @@ export class Server extends BaseServer {
|
||||
const primary = new PrimaryDevice(device, {
|
||||
profileName: profileName,
|
||||
contacts: attachmentToPointer(contactsCDNKey, contactsAttachment),
|
||||
...this.getDefaultPrimaryDeviceParams(device),
|
||||
});
|
||||
await primary.init();
|
||||
|
||||
this.primaryDevices.set(primary.device.aci, primary);
|
||||
|
||||
debug(
|
||||
'created primary device number=%s aci=%s',
|
||||
primary.device.number,
|
||||
primary.device.aci,
|
||||
);
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
private getDefaultPrimaryDeviceParams(
|
||||
device: Device,
|
||||
): Omit<PrimaryDeviceConfig, 'profileName' | 'contacts'> {
|
||||
return {
|
||||
trustRoot: this.trustRoot.getPublicKey(),
|
||||
serverPublicParams: this.zkSecret.getPublicParams(),
|
||||
|
||||
@@ -422,23 +491,13 @@ export class Server extends BaseServer {
|
||||
createGroup: this.createGroup.bind(this),
|
||||
modifyGroup: this.modifyGroup.bind(this),
|
||||
waitForGroupUpdate: this.waitForGroupUpdate.bind(this),
|
||||
getStorageManifest: this.getStorageManifest.bind(this, device),
|
||||
getStorageItem: this.getStorageItem.bind(this, device),
|
||||
getAllStorageKeys: this.getAllStorageKeys.bind(this, device),
|
||||
waitForStorageManifest: this.waitForStorageManifest.bind(this, device),
|
||||
applyStorageWrite: this.applyStorageWrite.bind(this, device),
|
||||
});
|
||||
await primary.init();
|
||||
|
||||
this.primaryDevices.set(primary.device.aci, primary);
|
||||
|
||||
debug(
|
||||
'created primary device number=%s aci=%s',
|
||||
primary.device.number,
|
||||
primary.device.aci,
|
||||
);
|
||||
|
||||
return primary;
|
||||
waitForStorageState: this.waitForStorageState.bind(this),
|
||||
getStorageState: this.getStorageState.bind(this),
|
||||
expectStorageState: this.expectStorageState.bind(this),
|
||||
setStorageState: this.setStorageState.bind(this),
|
||||
getOrphanedStorageKeys: this.getOrphanedStorageKeys.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
public async createSecondaryDevice(primary: PrimaryDevice): Promise<Device> {
|
||||
@@ -854,23 +913,6 @@ export class Server extends BaseServer {
|
||||
return device;
|
||||
}
|
||||
|
||||
// Override `getStorageItems` to provide configurable limit for maximum
|
||||
// storage read keys.
|
||||
public override getStorageItems(
|
||||
device: Device,
|
||||
keys: ReadonlyArray<Buffer<ArrayBuffer>>,
|
||||
): Array<Proto.StorageItem.Params> | undefined {
|
||||
if (
|
||||
this.config.maxStorageReadKeys !== undefined &&
|
||||
keys.length > this.config.maxStorageReadKeys
|
||||
) {
|
||||
debug('getStorageItems: requested more than max keys', device.debugId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return super.getStorageItems(device, keys);
|
||||
}
|
||||
|
||||
// Override updateGroup to notify about group modifications
|
||||
public override async modifyGroup(
|
||||
options: ModifyGroupOptions,
|
||||
@@ -891,21 +933,6 @@ export class Server extends BaseServer {
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override async onStorageManifestUpdate(
|
||||
device: Device,
|
||||
version: bigint,
|
||||
): Promise<void> {
|
||||
debug('onStorageManifestUpdate', device.debugId);
|
||||
|
||||
let queue = this.manifestQueueByAci.get(device.aci);
|
||||
if (!queue) {
|
||||
queue = this.createQueue('api/Server/onStorageManifestUpdate');
|
||||
this.manifestQueueByAci.set(device.aci, queue);
|
||||
}
|
||||
|
||||
queue.push(version);
|
||||
}
|
||||
|
||||
protected override async backupTransitAttachments(
|
||||
backupId: string,
|
||||
batch: BackupMediaBatch,
|
||||
@@ -1002,6 +1029,234 @@ export class Server extends BaseServer {
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Storage Service
|
||||
//
|
||||
|
||||
public async waitForStorageState({
|
||||
aci,
|
||||
after,
|
||||
predicate,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
after?: StorageState;
|
||||
// Note: predicate runs on the current state, not on previous intermediate states
|
||||
predicate?: (state: StorageState) => boolean;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): Promise<StorageState> {
|
||||
let afterVersion = after?.version;
|
||||
|
||||
while (true) {
|
||||
debug(
|
||||
'waiting for storage manifest for aci=%s after version=%d predicate=%s',
|
||||
aci,
|
||||
afterVersion,
|
||||
predicate !== undefined,
|
||||
);
|
||||
|
||||
await this.waitForStorageManifest(aci, afterVersion);
|
||||
|
||||
const state = await this.getStorageState({ aci, storageKey, recordIkm });
|
||||
assert(state, 'Missing storage state');
|
||||
|
||||
if (predicate !== undefined && !predicate(state)) {
|
||||
debug(
|
||||
'storage manifest for aci=%s version=%d did not match predicate',
|
||||
aci,
|
||||
state.version,
|
||||
);
|
||||
afterVersion = state.version;
|
||||
continue;
|
||||
}
|
||||
|
||||
debug('got storage manifest for aci=%s version=%d', aci, state.version);
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
public async getStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): Promise<StorageState | undefined> {
|
||||
const manifest = this.getStorageManifest(aci);
|
||||
if (!manifest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.convertManifestToStorageState({
|
||||
aci,
|
||||
manifest,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
public async expectStorageState({
|
||||
aci,
|
||||
reason,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
reason: string;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): Promise<StorageState> {
|
||||
const state = await this.getStorageState({ aci, storageKey, recordIkm });
|
||||
if (!state) {
|
||||
throw new Error(`expectStorageState: no storage state, ${reason}`);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public async setStorageState({
|
||||
aci,
|
||||
state,
|
||||
previousState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
state: StorageState;
|
||||
previousState?: StorageState;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): Promise<StorageState> {
|
||||
const writeOperation = state.createWriteOperation({
|
||||
storageKey,
|
||||
recordIkm,
|
||||
previous: previousState,
|
||||
});
|
||||
assert(writeOperation.manifest, 'write operation without manifest');
|
||||
|
||||
const { updated, error } = await this.applyStorageWrite(
|
||||
aci,
|
||||
writeOperation,
|
||||
false,
|
||||
);
|
||||
if (!updated) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`setStorageState: failed to update, ${error}`);
|
||||
}
|
||||
|
||||
return this.convertManifestToStorageState({
|
||||
aci,
|
||||
manifest: writeOperation.manifest,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
}
|
||||
|
||||
public getOrphanedStorageKeys({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): Array<Buffer<ArrayBuffer>> {
|
||||
const manifest = this.getStorageManifest(aci);
|
||||
if (!manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const state = this.convertManifestToStorageState({
|
||||
aci,
|
||||
manifest,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
const keys = this.getAllStorageKeys(aci);
|
||||
|
||||
return keys.filter((key) => !state.hasKey(key));
|
||||
}
|
||||
|
||||
// Override `getStorageItems` to provide configurable limit for maximum
|
||||
// storage read keys.
|
||||
public override getStorageItems(
|
||||
aci: AciString,
|
||||
keys: ReadonlyArray<Buffer<ArrayBuffer>>,
|
||||
): Array<Proto.StorageItem.Params> | undefined {
|
||||
if (
|
||||
this.config.maxStorageReadKeys !== undefined &&
|
||||
keys.length > this.config.maxStorageReadKeys
|
||||
) {
|
||||
debug('getStorageItems: requested more than max keys', aci);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return super.getStorageItems(aci, keys);
|
||||
}
|
||||
|
||||
protected override async onStorageManifestUpdate(
|
||||
aci: AciString,
|
||||
version: bigint,
|
||||
): Promise<void> {
|
||||
debug('onStorageManifestUpdate', aci);
|
||||
|
||||
let queue = this.manifestQueueByAci.get(aci);
|
||||
if (!queue) {
|
||||
queue = this.createQueue('api/Server/onStorageManifestUpdate');
|
||||
this.manifestQueueByAci.set(aci, queue);
|
||||
}
|
||||
|
||||
queue.push(version);
|
||||
}
|
||||
|
||||
private convertManifestToStorageState({
|
||||
aci,
|
||||
manifest,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
}: {
|
||||
aci: AciString;
|
||||
manifest: Proto.StorageManifest.Params;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm?: Buffer<ArrayBuffer>;
|
||||
}): StorageState {
|
||||
const decryptedManifest = decryptStorageManifest(storageKey, manifest);
|
||||
assert(typeof decryptedManifest.version === 'bigint', 'Consistency check');
|
||||
|
||||
const version = decryptedManifest.version;
|
||||
const items = decryptedManifest.identifiers.map(({ type, raw: key }) => {
|
||||
const keyBuffer = Buffer.from(key);
|
||||
const item = this.getStorageItem(aci, keyBuffer);
|
||||
if (!item) {
|
||||
throw new Error(`Missing item ${keyBuffer.toString('base64')}`);
|
||||
}
|
||||
|
||||
const decrypted = decryptStorageItem({
|
||||
storageKey,
|
||||
recordIkm,
|
||||
item: {
|
||||
key,
|
||||
value: item,
|
||||
},
|
||||
});
|
||||
if (!decrypted.record) {
|
||||
throw new Error(`Missing item record ${keyBuffer.toString('base64')}`);
|
||||
}
|
||||
return {
|
||||
type: type as Proto.ManifestRecord.Identifier.Type,
|
||||
key: keyBuffer,
|
||||
record: decrypted.record,
|
||||
};
|
||||
});
|
||||
|
||||
return new StorageState(version, items);
|
||||
}
|
||||
|
||||
//
|
||||
// Private
|
||||
//
|
||||
|
||||
@@ -166,6 +166,10 @@ export type RegisterAccountResponse = {
|
||||
reregistration: boolean;
|
||||
};
|
||||
|
||||
export const PutRegistrationLockSchema = z.object({
|
||||
registrationLock: z.string(),
|
||||
});
|
||||
|
||||
export const TransportSchema = z.literal('sms').or(z.literal('voice'));
|
||||
export type Transport = z.infer<typeof TransportSchema>;
|
||||
export const ClientTypeSchema = z
|
||||
|
||||
@@ -164,6 +164,7 @@ export type RegisterDeviceOptions = Readonly<
|
||||
) & {
|
||||
registrationId: RegistrationId;
|
||||
pniRegistrationId: RegistrationId | undefined;
|
||||
registrationLockToken?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -367,6 +368,11 @@ export type HardcodedResponseError = {
|
||||
|
||||
const debug = createDebug('mock:server:base');
|
||||
|
||||
function getDeviceUsername(device: Device): string {
|
||||
// This is awkward, but WebSockets use it.
|
||||
return `${device.aci}.${device.deviceId}`;
|
||||
}
|
||||
|
||||
// NOTE: This class is currently extended only by src/api/server.ts
|
||||
export abstract class Server {
|
||||
private readonly devices = new Map<AciString, Array<Device>>();
|
||||
@@ -384,6 +390,7 @@ export abstract class Server {
|
||||
AciString,
|
||||
Map<string, Buffer<ArrayBuffer>>
|
||||
>();
|
||||
private readonly registrationLockTokensByACI = new Map<AciString, string>();
|
||||
private readonly provisioningCodes = new Map<
|
||||
string,
|
||||
Map<ProvisioningCode, ProvisionIdString>
|
||||
@@ -423,6 +430,7 @@ export abstract class Server {
|
||||
private readonly backupMediaCursorById = new Map<string, BackupMediaCursor>();
|
||||
private readonly remoteConfig = new Map<string, RemoteConfigValueType>();
|
||||
|
||||
protected nextAci: AciString | undefined;
|
||||
protected privCertificate: ServerCertificate | undefined;
|
||||
protected privZKSecret: ServerSecretParams | undefined;
|
||||
protected privGenericServerSecret: GenericServerSecretParams | undefined;
|
||||
@@ -453,6 +461,12 @@ export abstract class Server {
|
||||
//
|
||||
|
||||
public async generateAci(): Promise<AciString> {
|
||||
const nextAci = this.nextAci;
|
||||
if (nextAci) {
|
||||
this.nextAci = undefined;
|
||||
return nextAci;
|
||||
}
|
||||
|
||||
let result: AciString;
|
||||
do {
|
||||
result = uuidv4() as AciString;
|
||||
@@ -474,6 +488,10 @@ export abstract class Server {
|
||||
// Provisioning
|
||||
//
|
||||
|
||||
public setNextAci(aci: AciString | undefined): void {
|
||||
this.nextAci = aci;
|
||||
}
|
||||
|
||||
public async generateProvisionId(): Promise<ProvisionIdString> {
|
||||
let result: ProvisionIdString;
|
||||
do {
|
||||
@@ -492,6 +510,21 @@ export abstract class Server {
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ProvisioningResponse>;
|
||||
|
||||
public setRegistrationLockToken(
|
||||
aci: AciString,
|
||||
registrationLock: string | undefined,
|
||||
): void {
|
||||
if (!registrationLock) {
|
||||
this.registrationLockTokensByACI.delete(aci);
|
||||
return;
|
||||
}
|
||||
|
||||
this.registrationLockTokensByACI.set(aci, registrationLock);
|
||||
}
|
||||
public getRegistrationLockToken(aci: AciString): string | undefined {
|
||||
return this.registrationLockTokensByACI.get(aci);
|
||||
}
|
||||
|
||||
public setRegisterResponseData(data: Partial<RegisterAccountResponse>): void {
|
||||
this.registerResponseData = data;
|
||||
}
|
||||
@@ -518,6 +551,7 @@ export abstract class Server {
|
||||
pniRegistrationId,
|
||||
password,
|
||||
authCredentialSalt: maybeAuthCredentialSalt,
|
||||
registrationLockToken,
|
||||
}: RegisterDeviceOptions): Promise<Device> {
|
||||
if (provisionId && !this.usedProvisionIds.has(provisionId)) {
|
||||
throw new Error('Use generateProvisionId() to create new provision id');
|
||||
@@ -539,6 +573,16 @@ export abstract class Server {
|
||||
authCredentialSalt = maybeAuthCredentialSalt;
|
||||
}
|
||||
|
||||
const currentToken = this.getRegistrationLockToken(aci);
|
||||
if (currentToken && registrationLockToken !== currentToken) {
|
||||
const errorText =
|
||||
'registerDevice: Matching registrationLock was not provided!';
|
||||
debug(errorText);
|
||||
const error = new Error(errorText);
|
||||
error.name = 'RegistrationLock';
|
||||
throw error;
|
||||
}
|
||||
|
||||
let list = this.devices.get(aci);
|
||||
if (!list) {
|
||||
list = [];
|
||||
@@ -576,6 +620,55 @@ export abstract class Server {
|
||||
return device;
|
||||
}
|
||||
|
||||
public deleteAccount(primaryDevice: Device): void {
|
||||
const { aci, pni } = primaryDevice;
|
||||
|
||||
const devices = this.devices.get(aci);
|
||||
|
||||
this.devices.delete(aci);
|
||||
this.primaryByServiceId.delete(aci);
|
||||
if (pni) {
|
||||
this.primaryByServiceId.delete(pni);
|
||||
}
|
||||
|
||||
// Note: we purposefully want to exclude these to test account recovery scenarios
|
||||
// storageManifestByAci
|
||||
// storageItemsByAci
|
||||
|
||||
devices?.forEach((device) => {
|
||||
const username = getDeviceUsername(device);
|
||||
this.devicesByAuth.delete(username);
|
||||
this.messageQueue.delete(device);
|
||||
this.webSockets.delete(device);
|
||||
|
||||
const auth = this.storageAuthByDevice.get(device);
|
||||
if (auth) {
|
||||
this.storageAuthByDevice.delete(device);
|
||||
this.storageAuthByUsername.delete(auth.username);
|
||||
}
|
||||
});
|
||||
|
||||
const username = this.usernameByAci.get(aci);
|
||||
if (username) {
|
||||
this.usernameByAci.delete(aci);
|
||||
this.aciByUsername.delete(username);
|
||||
}
|
||||
|
||||
const reservedUsername = this.reservedUsernameByAci.get(aci);
|
||||
if (reservedUsername) {
|
||||
this.reservedUsernameByAci.delete(aci);
|
||||
this.aciByReservedUsername.delete(reservedUsername);
|
||||
}
|
||||
|
||||
const linkId = this.usernameLinkIdByServiceId.get(aci);
|
||||
if (linkId !== undefined) {
|
||||
this.usernameLinkIdByServiceId.delete(aci);
|
||||
this.usernameLinkById.delete(linkId);
|
||||
}
|
||||
|
||||
this.backupAuthReqByAci.delete(aci);
|
||||
}
|
||||
|
||||
// Called from primary device
|
||||
public async getProvisioningCode(
|
||||
id: ProvisionIdString,
|
||||
@@ -628,8 +721,7 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
private setDeviceAuthPassword(device: Device, password: string) {
|
||||
// This is awkward, but WebSockets use it.
|
||||
const username = `${device.aci}.${device.deviceId}`;
|
||||
const username = getDeviceUsername(device);
|
||||
|
||||
// Add auth only after successfully registering the device
|
||||
assert(
|
||||
@@ -1039,27 +1131,27 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
public getStorageManifest(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
): Proto.StorageManifest.Params | undefined {
|
||||
return this.storageManifestByAci.get(device.aci);
|
||||
return this.storageManifestByAci.get(aci);
|
||||
}
|
||||
|
||||
public async applyStorageWrite(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
{ manifest, clearAll, insertItem, deleteKey }: Proto.WriteOperation.Params,
|
||||
shouldNotify = true,
|
||||
): Promise<StorageWriteResult> {
|
||||
if (!manifest) {
|
||||
return { error: 'missing `writeOperation.manifest`' };
|
||||
}
|
||||
if (!manifest.version) {
|
||||
if (manifest.version === null) {
|
||||
return { error: 'missing `writeOperation.manifest.version`' };
|
||||
}
|
||||
|
||||
const existing = this.getStorageManifest(device);
|
||||
const existing = this.getStorageManifest(aci);
|
||||
if (existing) {
|
||||
// Atomicity
|
||||
assert(existing.version, 'consistency check');
|
||||
assert(typeof existing.version === 'bigint', 'consistency check');
|
||||
if (manifest.version !== existing.version + 1n) {
|
||||
debug(
|
||||
'not updating storage manifest, current version=%j new version=%j',
|
||||
@@ -1071,8 +1163,8 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
if (clearAll) {
|
||||
debug('clearing storage items for=%j', device.debugId);
|
||||
this.clearStorageItems(device);
|
||||
debug('clearing storage items for=%j', aci);
|
||||
this.clearStorageItems(aci);
|
||||
}
|
||||
|
||||
for (const item of insertItem ?? []) {
|
||||
@@ -1081,54 +1173,50 @@ export abstract class Server {
|
||||
item.value instanceof Uint8Array,
|
||||
'insertItem.value must be a Buffer',
|
||||
);
|
||||
this.setStorageItem(
|
||||
device,
|
||||
Buffer.from(item.key),
|
||||
Buffer.from(item.value),
|
||||
);
|
||||
this.setStorageItem(aci, Buffer.from(item.key), Buffer.from(item.value));
|
||||
}
|
||||
|
||||
for (const key of deleteKey ?? []) {
|
||||
this.deleteStorageItem(device, Buffer.from(key));
|
||||
this.deleteStorageItem(aci, Buffer.from(key));
|
||||
}
|
||||
|
||||
debug(
|
||||
'updating storage manifest to version=%d for=%j',
|
||||
manifest.version,
|
||||
device.debugId,
|
||||
aci,
|
||||
);
|
||||
this.storageManifestByAci.set(device.aci, manifest);
|
||||
this.storageManifestByAci.set(aci, manifest);
|
||||
|
||||
if (shouldNotify) {
|
||||
await this.onStorageManifestUpdate(device, manifest.version);
|
||||
await this.onStorageManifestUpdate(aci, manifest.version);
|
||||
}
|
||||
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
private clearStorageItems(device: Device): void {
|
||||
this.storageItemsByAci.get(device.aci)?.clear();
|
||||
private clearStorageItems(aci: AciString): void {
|
||||
this.storageItemsByAci.get(aci)?.clear();
|
||||
}
|
||||
|
||||
private setStorageItem(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
key: Buffer<ArrayBuffer>,
|
||||
value: Buffer<ArrayBuffer>,
|
||||
): void {
|
||||
let map = this.storageItemsByAci.get(device.aci);
|
||||
let map = this.storageItemsByAci.get(aci);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this.storageItemsByAci.set(device.aci, map);
|
||||
this.storageItemsByAci.set(aci, map);
|
||||
}
|
||||
|
||||
map.set(key.toString('hex'), value);
|
||||
}
|
||||
|
||||
public getStorageItem(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
key: Buffer<ArrayBuffer>,
|
||||
): Buffer<ArrayBuffer> | undefined {
|
||||
const map = this.storageItemsByAci.get(device.aci);
|
||||
const map = this.storageItemsByAci.get(aci);
|
||||
if (!map) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1136,8 +1224,8 @@ export abstract class Server {
|
||||
return map.get(key.toString('hex'));
|
||||
}
|
||||
|
||||
public getAllStorageKeys(device: Device): Array<Buffer<ArrayBuffer>> {
|
||||
const map = this.storageItemsByAci.get(device.aci);
|
||||
public getAllStorageKeys(aci: AciString): Array<Buffer<ArrayBuffer>> {
|
||||
const map = this.storageItemsByAci.get(aci);
|
||||
if (!map) {
|
||||
return [];
|
||||
}
|
||||
@@ -1146,13 +1234,13 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
public getStorageItems(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
keys: ReadonlyArray<Buffer<ArrayBuffer>>,
|
||||
): Array<Proto.StorageItem.Params> | undefined {
|
||||
const result = new Array<Proto.StorageItem.Params>();
|
||||
|
||||
for (const key of keys) {
|
||||
const value = this.getStorageItem(device, key);
|
||||
const value = this.getStorageItem(aci, key);
|
||||
if (value !== undefined) {
|
||||
result.push({ key, value });
|
||||
}
|
||||
@@ -1161,8 +1249,8 @@ export abstract class Server {
|
||||
return result;
|
||||
}
|
||||
|
||||
public deleteStorageItem(device: Device, key: Buffer<ArrayBuffer>): void {
|
||||
const map = this.storageItemsByAci.get(device.aci);
|
||||
public deleteStorageItem(aci: AciString, key: Buffer<ArrayBuffer>): void {
|
||||
const map = this.storageItemsByAci.get(aci);
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
@@ -1171,7 +1259,7 @@ export abstract class Server {
|
||||
}
|
||||
|
||||
protected abstract onStorageManifestUpdate(
|
||||
device: Device,
|
||||
aci: AciString,
|
||||
version: bigint,
|
||||
): Promise<void>;
|
||||
|
||||
|
||||
@@ -848,7 +848,7 @@ export const createHandler = (
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = server.getStorageManifest(device);
|
||||
const manifest = server.getStorageManifest(device.aci);
|
||||
if (!manifest) {
|
||||
return send(res, 404, { error: 'Manifest not found' });
|
||||
}
|
||||
@@ -866,7 +866,7 @@ export const createHandler = (
|
||||
|
||||
assert(req.params.after != null, 'Missing after param');
|
||||
const after = BigInt(req.params.after);
|
||||
const manifest = server.getStorageManifest(device);
|
||||
const manifest = server.getStorageManifest(device.aci);
|
||||
if (manifest === undefined) {
|
||||
return send(res, 404);
|
||||
}
|
||||
@@ -888,7 +888,7 @@ export const createHandler = (
|
||||
Buffer.from(await buffer(req)),
|
||||
);
|
||||
|
||||
const result = await server.applyStorageWrite(device, writeOperation);
|
||||
const result = await server.applyStorageWrite(device.aci, writeOperation);
|
||||
if ('error' in result) {
|
||||
return send(res, 400, { error: result.error });
|
||||
}
|
||||
@@ -912,7 +912,7 @@ export const createHandler = (
|
||||
|
||||
const keys = readOperation.readKey.map((key) => Buffer.from(key));
|
||||
|
||||
const items = server.getStorageItems(device, keys);
|
||||
const items = server.getStorageItems(device.aci, keys);
|
||||
if (!items) {
|
||||
return send(res, 413, { error: 'Requested too many items' });
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DeviceKeysSchema,
|
||||
MessageListSchema,
|
||||
ModifyVerificationSessionSchema,
|
||||
PutRegistrationLockSchema,
|
||||
RegisterAccountResponse,
|
||||
RegisterAccountSchema,
|
||||
RequestVerificationCodeSchema,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
|
||||
import { Service, WSRequest, WSResponse } from './service';
|
||||
import { Handler, Router } from './router';
|
||||
import { DAY_IN_SECONDS } from '../../constants';
|
||||
|
||||
const debug = createDebug('mock:ws:connection');
|
||||
|
||||
@@ -701,7 +703,11 @@ export class Connection extends Service {
|
||||
|
||||
const { data } = parsedResult;
|
||||
const { accountAttributes, sessionId } = data;
|
||||
const { pniRegistrationId, registrationId } = accountAttributes;
|
||||
const {
|
||||
pniRegistrationId,
|
||||
registrationId,
|
||||
registrationLock: registrationLockToken,
|
||||
} = accountAttributes;
|
||||
|
||||
const storage = this.server.getVerificationSession(sessionId);
|
||||
if (!storage) {
|
||||
@@ -721,15 +727,31 @@ export class Connection extends Service {
|
||||
const { number } = storage;
|
||||
|
||||
const provisionId = await server.generateProvisionId();
|
||||
const primaryDevice = await server.registerDevice({
|
||||
provisionId,
|
||||
number,
|
||||
password,
|
||||
pniRegistrationId,
|
||||
registrationId,
|
||||
// TODO(inutny): take as an input
|
||||
authCredentialSalt: randomBytes(16),
|
||||
});
|
||||
let primaryDevice: Device;
|
||||
try {
|
||||
primaryDevice = await server.registerDevice({
|
||||
provisionId,
|
||||
number,
|
||||
password,
|
||||
pniRegistrationId,
|
||||
registrationId,
|
||||
registrationLockToken,
|
||||
// TODO(inutny): take as an input
|
||||
authCredentialSalt: randomBytes(16),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'RegistrationLock') {
|
||||
return [
|
||||
423,
|
||||
{
|
||||
timeRemaining: 5 * DAY_IN_SECONDS * 1000,
|
||||
svr2Credentials: { username: 'fake423', password: 'fake423' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
aciSignedPreKey,
|
||||
@@ -787,12 +809,15 @@ export class Connection extends Service {
|
||||
}
|
||||
|
||||
const mixinData = this.server.getRegisterResponseData();
|
||||
const hasStorageData = Boolean(
|
||||
this.server.getStorageManifest(primaryDevice.aci),
|
||||
);
|
||||
|
||||
const result: RegisterAccountResponse = {
|
||||
uuid: primaryDevice.aci.toString(),
|
||||
number,
|
||||
pni: primaryDevice.pni?.toString().replace(/^PNI:/i, ''),
|
||||
storageCapable: false,
|
||||
storageCapable: hasStorageData,
|
||||
entitlements: {
|
||||
badges: [],
|
||||
},
|
||||
@@ -927,6 +952,37 @@ export class Connection extends Service {
|
||||
}),
|
||||
);
|
||||
|
||||
this.router.put(
|
||||
'/v1/accounts/registration_lock',
|
||||
requireAuth(async (_params, body) => {
|
||||
const device = this.getDevice();
|
||||
if (!body) {
|
||||
return [400, { error: 'Missing body' }];
|
||||
}
|
||||
|
||||
const { registrationLock } = PutRegistrationLockSchema.parse(
|
||||
JSON.parse(body.toString()),
|
||||
);
|
||||
if (registrationLock.length !== 64) {
|
||||
return [400, { error: 'registrationLock should be 64 characters' }];
|
||||
}
|
||||
|
||||
this.server.setRegistrationLockToken(device.aci, registrationLock);
|
||||
|
||||
return [200, {}];
|
||||
}),
|
||||
);
|
||||
|
||||
this.router.del(
|
||||
'/v1/accounts/registration_lock',
|
||||
requireAuth(async () => {
|
||||
const device = this.getDevice();
|
||||
this.server.setRegistrationLockToken(device.aci, undefined);
|
||||
|
||||
return [200, {}];
|
||||
}),
|
||||
);
|
||||
|
||||
//
|
||||
// Call links
|
||||
//
|
||||
|
||||
@@ -103,19 +103,19 @@ async function createPrimaryDevice(name: string): Promise<PrimaryDevice> {
|
||||
async waitForGroupUpdate() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
getStorageManifest() {
|
||||
async waitForStorageState() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
getStorageItem() {
|
||||
async getStorageState() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
getAllStorageKeys() {
|
||||
async expectStorageState() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
async waitForStorageManifest() {
|
||||
async setStorageState() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
async applyStorageWrite() {
|
||||
getOrphanedStorageKeys() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { IPCResponse as ChallengeResponseType } from './challenge.dom.ts';
|
||||
import type { MessageAttributesType } from './model-types.d.ts';
|
||||
import type { SocketStatuses } from './textsecure/SocketManager.preload.ts';
|
||||
import type { RestoreResponseType } from './textsecure/WebAPI.preload.ts';
|
||||
import { runStorageServiceSyncJob } from './services/storage.preload.ts';
|
||||
|
||||
const log = createLogger('CI');
|
||||
|
||||
@@ -64,6 +65,7 @@ export type CIType = {
|
||||
startStandaloneRegistration: () => void;
|
||||
saveSVR2RestoreResponse: (response: RestoreResponseType) => void;
|
||||
getSVR2RestoreResponse: () => RestoreResponseType | undefined;
|
||||
fetchManifestForPrimary: () => void;
|
||||
};
|
||||
|
||||
export type GetCIOptionsType = Readonly<{
|
||||
@@ -299,6 +301,10 @@ export function getCI({
|
||||
return svr2RestoreResponse;
|
||||
}
|
||||
|
||||
function fetchManifestForPrimary(): void {
|
||||
runStorageServiceSyncJob({ reason: 'fetchManifestForPrimary' });
|
||||
}
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
getConversationId,
|
||||
@@ -326,5 +332,6 @@ export function getCI({
|
||||
startStandaloneRegistration,
|
||||
saveSVR2RestoreResponse,
|
||||
getSVR2RestoreResponse,
|
||||
fetchManifestForPrimary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,9 +277,6 @@ export class ConversationController {
|
||||
|
||||
const existing = this.get(conversation.id);
|
||||
if (!existing) {
|
||||
log.warn(
|
||||
`conversationChanged: Rejecting change from ${conversation.idForLogging()}, not in lookups`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+19
-15
@@ -1419,7 +1419,7 @@ async function startApp(): Promise<void> {
|
||||
|
||||
function enableStorageService({ andSync }: { andSync?: string } = {}) {
|
||||
log.info('enableStorageService: enabling and running');
|
||||
StorageService.enableStorageService();
|
||||
StorageService.enableStorageService('background');
|
||||
|
||||
if (andSync != null) {
|
||||
StorageService.runStorageServiceSyncJob({
|
||||
@@ -1909,8 +1909,15 @@ async function startApp(): Promise<void> {
|
||||
messageReceiver.startProcessingQueue();
|
||||
registerRequestHandler(messageReceiver);
|
||||
|
||||
// 6. Kickoff storage service sync
|
||||
if (isFirstAuthSocketConnect || !postRegistrationSyncsComplete) {
|
||||
// 6. Kickoff storage service sync if we're not still installing standalone
|
||||
const step6State = window.reduxStore.getState();
|
||||
const standaloneInstallInProgress = Boolean(
|
||||
step6State.standaloneInstaller.workflow
|
||||
);
|
||||
if (
|
||||
!standaloneInstallInProgress &&
|
||||
(isFirstAuthSocketConnect || !postRegistrationSyncsComplete)
|
||||
) {
|
||||
log.info(`${logId}: triggering storage service sync`);
|
||||
|
||||
storageServiceSyncComplete = waitForEvent(
|
||||
@@ -1919,7 +1926,7 @@ async function startApp(): Promise<void> {
|
||||
enableStorageService({
|
||||
andSync: 'afterFirstAuthSocketConnect',
|
||||
});
|
||||
} else {
|
||||
} else if (!standaloneInstallInProgress) {
|
||||
enableStorageService();
|
||||
}
|
||||
|
||||
@@ -1947,9 +1954,9 @@ async function startApp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Show inbox
|
||||
const state = window.reduxStore.getState();
|
||||
if (state.app.appView === AppViewType.Installer) {
|
||||
// 8. Show inbox if we were linking (standalone install might stll be happening)
|
||||
const step8State = window.reduxStore.getState();
|
||||
if (step8State.app.appView === AppViewType.Installer) {
|
||||
log.info(`${logId}: switching from installer to inbox`);
|
||||
window.reduxActions.app.openInbox();
|
||||
}
|
||||
@@ -2037,10 +2044,6 @@ async function startApp(): Promise<void> {
|
||||
}) {
|
||||
log.info('afterAuthSocketConnect/afterEveryLinkedStartupOnNewVersion');
|
||||
|
||||
if (window.ConversationController.areWePrimaryDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
!skipSyncRequests &&
|
||||
@@ -2051,10 +2054,11 @@ async function startApp(): Promise<void> {
|
||||
|
||||
drop(StorageService.reprocessUnknownFields());
|
||||
|
||||
await Promise.all([
|
||||
accountManager.maybeUpdateDeviceName(),
|
||||
itemStorage.user.removeSignalingKey(),
|
||||
]);
|
||||
if (!window.ConversationController.areWePrimaryDevice()) {
|
||||
await accountManager.maybeUpdateDeviceName();
|
||||
}
|
||||
|
||||
await itemStorage.user.removeSignalingKey();
|
||||
} catch (e) {
|
||||
log.error(
|
||||
"Problem with 'afterLinkedStartupOnNewVersion' tasks: ",
|
||||
|
||||
@@ -395,6 +395,7 @@ function renderNotificationProfilesHome(
|
||||
contentsRef={props.contentsRef}
|
||||
conversations={conversations}
|
||||
conversationSelector={conversationSelector}
|
||||
doWeHaveOtherDevices
|
||||
hasOnboardingBeenSeen={false}
|
||||
i18n={i18n}
|
||||
isSyncEnabled
|
||||
@@ -939,6 +940,53 @@ NotificationsPageWithThreeProfiles.args = {
|
||||
contentsRef={props.contentsRef}
|
||||
conversations={conversations}
|
||||
conversationSelector={conversationSelector}
|
||||
doWeHaveOtherDevices
|
||||
hasOnboardingBeenSeen
|
||||
i18n={i18n}
|
||||
isSyncEnabled
|
||||
loading={false}
|
||||
markProfileDeleted={action('markProfileDeleted')}
|
||||
preferredBadgeSelector={() => undefined}
|
||||
setHasOnboardingBeenSeen={action('setHasOnboardingBeenSeen')}
|
||||
setIsSyncEnabled={action('setIsSyncEnabled')}
|
||||
setSettingsLocation={props.setSettingsLocation}
|
||||
setProfileOverride={action('setProfileOverride)')}
|
||||
theme={ThemeType.light}
|
||||
updateProfile={action('updateProfile')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NotificationsPageWithThreeProfilesAndNoOtherDevices =
|
||||
Template.bind({});
|
||||
NotificationsPageWithThreeProfilesAndNoOtherDevices.args = {
|
||||
settingsLocation: { page: SettingsPage.Notifications },
|
||||
renderNotificationProfilesCreateFlow: (
|
||||
props: SmartNotificationProfilesProps
|
||||
) => {
|
||||
return (
|
||||
<NotificationProfilesCreateFlow
|
||||
contentsRef={props.contentsRef}
|
||||
conversations={conversations}
|
||||
conversationSelector={conversationSelector}
|
||||
createProfile={action('createProfile')}
|
||||
i18n={i18n}
|
||||
setSettingsLocation={props.setSettingsLocation}
|
||||
preferredBadgeSelector={() => undefined}
|
||||
theme={ThemeType.light}
|
||||
/>
|
||||
);
|
||||
},
|
||||
renderNotificationProfilesHome: (props: SmartNotificationProfilesProps) => {
|
||||
return (
|
||||
<NotificationProfilesHome
|
||||
activeProfileId={threeProfiles[0].id}
|
||||
allProfiles={threeProfiles}
|
||||
contentsRef={props.contentsRef}
|
||||
conversations={conversations}
|
||||
conversationSelector={conversationSelector}
|
||||
doWeHaveOtherDevices={false}
|
||||
hasOnboardingBeenSeen
|
||||
i18n={i18n}
|
||||
isSyncEnabled
|
||||
|
||||
@@ -119,6 +119,7 @@ type HomeProps = {
|
||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||
conversations: ReadonlyArray<ConversationType>;
|
||||
conversationSelector: GetConversationByIdType;
|
||||
doWeHaveOtherDevices: boolean;
|
||||
hasOnboardingBeenSeen: boolean;
|
||||
i18n: LocalizerType;
|
||||
isSyncEnabled: boolean;
|
||||
@@ -336,6 +337,7 @@ export function NotificationProfilesHome({
|
||||
contentsRef,
|
||||
conversations,
|
||||
conversationSelector,
|
||||
doWeHaveOtherDevices,
|
||||
hasOnboardingBeenSeen,
|
||||
i18n,
|
||||
isSyncEnabled,
|
||||
@@ -401,6 +403,7 @@ export function NotificationProfilesHome({
|
||||
<NotificationProfilesListPage
|
||||
allProfiles={allProfiles}
|
||||
contentsRef={contentsRef}
|
||||
doWeHaveOtherDevices={doWeHaveOtherDevices}
|
||||
i18n={i18n}
|
||||
isSyncEnabled={isSyncEnabled}
|
||||
loading={loading}
|
||||
@@ -958,6 +961,7 @@ function NotificationProfilesDonePage({
|
||||
function NotificationProfilesListPage({
|
||||
allProfiles,
|
||||
contentsRef,
|
||||
doWeHaveOtherDevices,
|
||||
i18n,
|
||||
isSyncEnabled,
|
||||
loading,
|
||||
@@ -968,6 +972,7 @@ function NotificationProfilesListPage({
|
||||
}: {
|
||||
allProfiles: ReadonlyArray<NotificationProfileType>;
|
||||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||||
doWeHaveOtherDevices: boolean;
|
||||
i18n: LocalizerType;
|
||||
isSyncEnabled: boolean;
|
||||
loading: boolean;
|
||||
@@ -1021,25 +1026,29 @@ function NotificationProfilesListPage({
|
||||
</FullWidthButton>
|
||||
);
|
||||
})}
|
||||
<FullWidthDivider />
|
||||
<FullWidthRow className={tw('flex min-h-[40px] items-start pt-1')}>
|
||||
<div className={tw('grow')}>
|
||||
<div className={tw('type-body-large text-primary')}>
|
||||
{i18n('icu:NotificationProfiles--list--sync')}
|
||||
</div>
|
||||
<div className={tw('mt-1 type-body-small text-secondary')}>
|
||||
{i18n('icu:NotificationProfiles--list--sync--description')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={tw('ms-4')}>
|
||||
<AxoSwitch.Root
|
||||
checked={isSyncEnabled}
|
||||
onCheckedChange={value => {
|
||||
setIsSyncEnabled(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FullWidthRow>
|
||||
{doWeHaveOtherDevices && (
|
||||
<>
|
||||
<FullWidthDivider />
|
||||
<FullWidthRow className={tw('flex min-h-[40px] items-start pt-1')}>
|
||||
<div className={tw('grow')}>
|
||||
<div className={tw('type-body-large text-primary')}>
|
||||
{i18n('icu:NotificationProfiles--list--sync')}
|
||||
</div>
|
||||
<div className={tw('mt-1 type-body-small text-secondary')}>
|
||||
{i18n('icu:NotificationProfiles--list--sync--description')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={tw('ms-4')}>
|
||||
<AxoSwitch.Root
|
||||
checked={isSyncEnabled}
|
||||
onCheckedChange={value => {
|
||||
setIsSyncEnabled(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FullWidthRow>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
ServiceIdKind,
|
||||
normalizeServiceId,
|
||||
normalizePni,
|
||||
isPniString,
|
||||
} from '../types/ServiceId.std.ts';
|
||||
import { isAciString } from '../util/isAciString.std.ts';
|
||||
import {
|
||||
@@ -4008,6 +4009,28 @@ export class ConversationModel {
|
||||
return this.get('pni');
|
||||
}
|
||||
|
||||
getServiceIdAsPni(): PniString | undefined {
|
||||
// If we have an untagged valid guid in the serviceId field, we assume it's an ACI
|
||||
const aci = this.getAci();
|
||||
if (aci) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serviceId = this.getServiceId();
|
||||
if (!serviceId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isPniString(serviceId)) {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'getServiceIdAsPni: serviceId is not a valid guid, and not a tagged PNI string'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGroupLink(): string | undefined {
|
||||
if (!isGroupV2(this.attributes)) {
|
||||
return undefined;
|
||||
|
||||
@@ -722,6 +722,8 @@ export class BackupExportStream {
|
||||
false
|
||||
);
|
||||
|
||||
const areWePrimary = window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
for (const profile of allNotificationProfiles) {
|
||||
const {
|
||||
id,
|
||||
@@ -745,8 +747,8 @@ export class BackupExportStream {
|
||||
continue;
|
||||
}
|
||||
|
||||
// sync=OFF, and so only exporting profiles with storageID (from Primary)
|
||||
if (isNotificationProfileSyncDisabled && !storageID) {
|
||||
// If linked device and sync=OFF, only export profiles with storageID (from Primary)
|
||||
if (isNotificationProfileSyncDisabled && !areWePrimary && !storageID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+276
-137
@@ -113,6 +113,13 @@ type IManifestRecordIdentifier = Proto.ManifestRecord.Identifier.Params;
|
||||
|
||||
const { getItemById } = DataReader;
|
||||
|
||||
class CannotDecryptError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = 'CannotDecryptError';
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
eraseStorageServiceState,
|
||||
flushUpdateConversationBatcher,
|
||||
@@ -261,7 +268,7 @@ async function generateManifest(
|
||||
);
|
||||
deleteKeys.add(currentStorageID);
|
||||
} else {
|
||||
log.info(`upload(${version}): adding key=${newRedactedID}`);
|
||||
log.info(`${logId}: adding key=${newRedactedID}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,10 +506,12 @@ async function generateManifest(
|
||||
}
|
||||
}
|
||||
|
||||
const notificationProfilesToUpload = notificationProfileSyncDisabled
|
||||
const areWePrimaryDevice = window.ConversationController.areWePrimaryDevice();
|
||||
const shouldExclude = notificationProfileSyncDisabled && !areWePrimaryDevice;
|
||||
const notificationProfilesToUpload = shouldExclude
|
||||
? notificationProfiles.filter(item => item.storageID)
|
||||
: notificationProfiles;
|
||||
if (notificationProfileSyncDisabled) {
|
||||
if (shouldExclude) {
|
||||
const localOnlyCount =
|
||||
notificationProfiles.length - notificationProfilesToUpload.length;
|
||||
log.info(
|
||||
@@ -633,8 +642,7 @@ async function generateManifest(
|
||||
installedStickerPacks.forEach(stickerPack => {
|
||||
if (uninstalledStickerPackIds.has(stickerPack.id)) {
|
||||
log.error(
|
||||
`upload(${version}): ` +
|
||||
`sticker pack ${stickerPack.id} is both installed and uninstalled`
|
||||
`${logId}: sticker pack ${stickerPack.id} is both installed and uninstalled`
|
||||
);
|
||||
window.reduxActions.stickers.uninstallStickerPack(
|
||||
stickerPack.id,
|
||||
@@ -680,7 +688,7 @@ async function generateManifest(
|
||||
`uninstalled=${newlyUninstalledPacks}/${uninstalledStickerPacks.length}`
|
||||
);
|
||||
|
||||
log.info(`upload(${version}): adding callLinks=${callLinkDbRecords.length}`);
|
||||
log.info(`${logId}: adding callLinks=${callLinkDbRecords.length}`);
|
||||
|
||||
const callLinkRoomIds = new Set<string>();
|
||||
|
||||
@@ -1037,6 +1045,11 @@ async function generateManifest(
|
||||
recordIkm = itemStorage.get('manifestRecordIkm');
|
||||
}
|
||||
|
||||
if (!recordIkm && isNewManifest) {
|
||||
log.info(`${logId}: generating new recordIkm`);
|
||||
recordIkm = getRandomBytes(RECORD_IKM_LENGTH);
|
||||
}
|
||||
|
||||
return {
|
||||
postUploadUpdateFunctions,
|
||||
recordsByID,
|
||||
@@ -1083,7 +1096,7 @@ async function encryptManifest(
|
||||
storageItem = encryptRecord(storageID, recordIkm, storageRecord);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`upload(${version}): encrypt record failed:`,
|
||||
`encryptManifest(${version}): encrypt record failed:`,
|
||||
Errors.toLogFormat(err)
|
||||
);
|
||||
throw err;
|
||||
@@ -1133,15 +1146,22 @@ async function uploadManifest(
|
||||
}: GeneratedManifestType,
|
||||
{ newItems, storageManifest }: EncryptedManifestType
|
||||
): Promise<void> {
|
||||
const logId = `uploadManifest(${version})`;
|
||||
if (newItems.size === 0 && deleteKeys.size === 0) {
|
||||
log.warn(`upload(${version}): nothing to upload`);
|
||||
log.warn(`${logId}: nothing to upload`);
|
||||
}
|
||||
|
||||
const recordIkm = itemStorage.get('manifestRecordIkm');
|
||||
window.SignalCI?.handleEvent('uploadManifest', {
|
||||
version,
|
||||
storageKeyBase64: itemStorage.get('storageKey'),
|
||||
recordIkmBase64: recordIkm ? Bytes.toBase64(recordIkm) : undefined,
|
||||
});
|
||||
|
||||
const credentials = itemStorage.get('storageCredentials');
|
||||
try {
|
||||
log.info(
|
||||
`upload(${version}): inserting=${newItems.size} ` +
|
||||
`deleting=${deleteKeys.size}`
|
||||
`${logId}: inserting=${newItems.size} deleting=${deleteKeys.size}`
|
||||
);
|
||||
|
||||
const writeOperation = Proto.WriteOperation.encode({
|
||||
@@ -1158,7 +1178,7 @@ async function uploadManifest(
|
||||
});
|
||||
|
||||
log.info(
|
||||
`upload(${version}): upload complete, updating ` +
|
||||
`${logId}: upload complete, updating ` +
|
||||
`items=${postUploadUpdateFunctions.length}`
|
||||
);
|
||||
|
||||
@@ -1167,12 +1187,12 @@ async function uploadManifest(
|
||||
} catch (err) {
|
||||
if (err.code === 409) {
|
||||
if (conflictBackOff.isFull()) {
|
||||
log.error(`upload(${version}): exceeded maximum consecutive conflicts`);
|
||||
log.error(`${logId}: exceeded maximum consecutive conflicts`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`upload(${version}): conflict found with ` +
|
||||
`${logId}: conflict found with ` +
|
||||
`version=${version}, running sync job ` +
|
||||
`times=${conflictBackOff.getIndex()}`
|
||||
);
|
||||
@@ -1180,11 +1200,11 @@ async function uploadManifest(
|
||||
throw err;
|
||||
}
|
||||
|
||||
log.error(`upload(${version}): failed!`, Errors.toLogFormat(err));
|
||||
log.error(`${logId}: failed!`, Errors.toLogFormat(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
log.info(`upload(${version}): setting new manifestVersion`);
|
||||
log.info(`${logId}: setting new manifestVersion`);
|
||||
await itemStorage.put('manifestVersion', version);
|
||||
conflictBackOff.reset();
|
||||
backOff.reset();
|
||||
@@ -1195,51 +1215,51 @@ async function uploadManifest(
|
||||
}
|
||||
|
||||
async function stopStorageServiceSync(reason: Error) {
|
||||
log.warn('stopStorageServiceSync', Errors.toLogFormat(reason));
|
||||
const logId = 'stopStorageServiceSync';
|
||||
|
||||
if (!window.ConversationController.areWePrimaryDevice()) {
|
||||
log.warn('stopStorageServiceSync: removing storageKey');
|
||||
await itemStorage.remove('storageKey');
|
||||
}
|
||||
|
||||
if (backOff.isFull()) {
|
||||
log.warn('stopStorageServiceSync: too many consecutive stops');
|
||||
return;
|
||||
}
|
||||
log.warn(logId, Errors.toLogFormat(reason));
|
||||
|
||||
await sleep(backOff.getAndIncrement());
|
||||
|
||||
if (window.ConversationController.areWePrimaryDevice()) {
|
||||
log.info(
|
||||
'stopStorageServiceSync: We are primary device; not sending key sync request'
|
||||
);
|
||||
if (backOff.isFull()) {
|
||||
log.warn(`${logId}: too many consecutive stops`);
|
||||
return;
|
||||
}
|
||||
|
||||
await resetWithNewKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('stopStorageServiceSync: requesting new keys');
|
||||
log.warn(`${logId}: removing storageKey`);
|
||||
await itemStorage.remove('storageKey');
|
||||
|
||||
if (backOff.isFull()) {
|
||||
log.warn(`${logId}: too many consecutive stops`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${logId}: requesting new keys`);
|
||||
setTimeout(async () => {
|
||||
await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage());
|
||||
});
|
||||
}
|
||||
|
||||
async function createNewManifest() {
|
||||
log.info('createNewManifest: creating new manifest');
|
||||
|
||||
const existingRecordIkm = itemStorage.get('manifestRecordIkm');
|
||||
if (Bytes.isEmpty(existingRecordIkm)) {
|
||||
log.info('createNewManifest: generating new recordIkm');
|
||||
await itemStorage.put(
|
||||
'manifestRecordIkm',
|
||||
getRandomBytes(RECORD_IKM_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
const version = itemStorage.get('manifestVersion', 0);
|
||||
|
||||
log.info(`createNewManifest: creating new manifest with version ${version}`);
|
||||
|
||||
const generatedManifest = await generateManifest(version, undefined, true);
|
||||
|
||||
const encryptedManifest = await encryptManifest(version, generatedManifest);
|
||||
|
||||
if (Bytes.isNotEmpty(generatedManifest.recordIkm)) {
|
||||
await itemStorage.put('manifestRecordIkm', generatedManifest.recordIkm);
|
||||
} else {
|
||||
await itemStorage.remove('manifestRecordIkm');
|
||||
}
|
||||
await uploadManifest(
|
||||
version,
|
||||
{
|
||||
@@ -1262,32 +1282,69 @@ export async function resetWithNewKey(): Promise<void> {
|
||||
|
||||
const temporaryKey = itemStorage.get('temporaryRegistrationMasterKey');
|
||||
if (!temporaryKey) {
|
||||
throw new Error(`${logId}: No temporary key!`);
|
||||
log.info(`${logId}: Starting without temporary key`);
|
||||
}
|
||||
|
||||
const existingManifest = await sync({ reason: logId });
|
||||
if (!existingManifest) {
|
||||
log.warn('No existing data in storage service, returning');
|
||||
return;
|
||||
let existingManifest: Proto.ManifestRecord | undefined;
|
||||
let currentVersion = itemStorage.get('manifestVersion', 0);
|
||||
|
||||
const syncResult = await sync({ reason: logId });
|
||||
|
||||
if (!syncResult.success && syncResult.error === 'cannotDecrypt') {
|
||||
currentVersion = syncResult.version;
|
||||
} else if (!syncResult.success && syncResult.error === 'sameManifest') {
|
||||
const fetchResult = await fetchManifest(Math.max(0, currentVersion - 1));
|
||||
|
||||
if (fetchResult.type === 'newerManifest') {
|
||||
existingManifest = fetchResult.manifest;
|
||||
currentVersion = toNumber(existingManifest.version) ?? 0;
|
||||
} else {
|
||||
throw new Error(
|
||||
`${logId}: fetch after sameManifest failed with reason ${fetchResult.type}`
|
||||
);
|
||||
}
|
||||
} else if (!syncResult.success) {
|
||||
throw new Error(`${logId}: Sync failed with reason ${syncResult.error}`);
|
||||
} else {
|
||||
if (!syncResult.manifest) {
|
||||
if (temporaryKey) {
|
||||
log.warn(
|
||||
`${logId}: No existing data in storage service, clearing temporary master key and returning.`
|
||||
);
|
||||
await itemStorage.remove('temporaryRegistrationMasterKey');
|
||||
} else {
|
||||
log.warn(
|
||||
`${logId}: No existing data in storage service, no need to reset. Returning.`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
existingManifest = syncResult.manifest;
|
||||
currentVersion = toNumber(existingManifest.version) ?? 0;
|
||||
}
|
||||
|
||||
const currentVersion = toNumber(existingManifest.version) ?? 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
log.info(`${logId}: Fetched manifest with version ${currentVersion}`);
|
||||
log.info(
|
||||
`${logId}: Storage service is at version ${currentVersion}, got manifest: ${Boolean(existingManifest)}`
|
||||
);
|
||||
|
||||
// if we have a recordIkm, we can just re-upload the existing manifest
|
||||
if (Bytes.isNotEmpty(existingManifest.recordIkm)) {
|
||||
if (existingManifest && Bytes.isNotEmpty(existingManifest.recordIkm)) {
|
||||
log.info(
|
||||
`${logId}: We have recordIkm, we will re-upload existing manifest ${currentVersion}`
|
||||
);
|
||||
|
||||
try {
|
||||
log.info(`${logId}: Clearing temporary master key`);
|
||||
await itemStorage.put('temporaryRegistrationMasterKey', undefined);
|
||||
if (temporaryKey) {
|
||||
log.info(`${logId}: Clearing temporary master key`);
|
||||
await itemStorage.remove('temporaryRegistrationMasterKey');
|
||||
}
|
||||
|
||||
await maybeFixStorageKey(logId);
|
||||
|
||||
existingManifest.version = BigInt(newVersion);
|
||||
const storageManifestKey = getStorageManifestKey(newVersion);
|
||||
const encryptedManifest = encryptProfile(
|
||||
Proto.ManifestRecord.encode(existingManifest),
|
||||
@@ -1328,8 +1385,10 @@ export async function resetWithNewKey(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`${logId}: Clearing temporary master key`);
|
||||
await itemStorage.put('temporaryRegistrationMasterKey', undefined);
|
||||
if (temporaryKey) {
|
||||
log.info(`${logId}: Clearing temporary master key`);
|
||||
await itemStorage.remove('temporaryRegistrationMasterKey');
|
||||
}
|
||||
|
||||
await maybeFixStorageKey(logId);
|
||||
|
||||
@@ -1353,6 +1412,12 @@ export async function resetWithNewKey(): Promise<void> {
|
||||
log.info(
|
||||
`${logId}: Uploading manifest with version ${newVersion} and clearAll=true`
|
||||
);
|
||||
|
||||
if (Bytes.isNotEmpty(generatedManifest.recordIkm)) {
|
||||
await itemStorage.put('manifestRecordIkm', generatedManifest.recordIkm);
|
||||
} else {
|
||||
await itemStorage.remove('manifestRecordIkm');
|
||||
}
|
||||
await uploadManifest(
|
||||
newVersion,
|
||||
{
|
||||
@@ -1361,16 +1426,12 @@ export async function resetWithNewKey(): Promise<void> {
|
||||
},
|
||||
encryptedManifest
|
||||
);
|
||||
|
||||
await itemStorage.put('manifestVersion', newVersion);
|
||||
if (Bytes.isNotEmpty(generatedManifest.recordIkm)) {
|
||||
await itemStorage.put('manifestRecordIkm', generatedManifest.recordIkm);
|
||||
} else {
|
||||
await itemStorage.remove('manifestRecordIkm');
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn(`${logId}: Ran into error; restoring temporary master key`);
|
||||
await itemStorage.put('temporaryRegistrationMasterKey', temporaryKey);
|
||||
if (temporaryKey) {
|
||||
log.warn(`${logId}: Ran into error; restoring temporary master key`);
|
||||
await itemStorage.put('temporaryRegistrationMasterKey', temporaryKey);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -1398,10 +1459,24 @@ async function decryptManifest(
|
||||
return Proto.ManifestRecord.decode(decryptedManifest);
|
||||
}
|
||||
|
||||
async function fetchManifest(
|
||||
manifestVersion: number
|
||||
): Promise<Proto.ManifestRecord | undefined> {
|
||||
const logId = `sync(${manifestVersion})`;
|
||||
type FetchResult =
|
||||
| {
|
||||
type: 'newerManifest';
|
||||
manifest: Proto.ManifestRecord;
|
||||
}
|
||||
| {
|
||||
type: 'cannotDecrypt';
|
||||
version: number;
|
||||
}
|
||||
| {
|
||||
type: 'sameManifest';
|
||||
}
|
||||
| {
|
||||
type: 'noManifest';
|
||||
};
|
||||
|
||||
async function fetchManifest(manifestVersion: number): Promise<FetchResult> {
|
||||
const logId = `fetchManifest(${manifestVersion})`;
|
||||
log.info(`${logId}: fetch start`);
|
||||
|
||||
try {
|
||||
@@ -1415,27 +1490,36 @@ async function fetchManifest(
|
||||
const encryptedManifest = Proto.StorageManifest.decode(manifestBinary);
|
||||
|
||||
try {
|
||||
return await decryptManifest(encryptedManifest);
|
||||
const decrypted = await decryptManifest(encryptedManifest);
|
||||
return {
|
||||
type: 'newerManifest',
|
||||
manifest: decrypted,
|
||||
};
|
||||
} catch (err) {
|
||||
await stopStorageServiceSync(err);
|
||||
return {
|
||||
type: 'cannotDecrypt',
|
||||
version: toNumber(encryptedManifest.version),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 204) {
|
||||
log.info(`${logId}: no newer manifest, ok`);
|
||||
return undefined;
|
||||
return {
|
||||
type: 'sameManifest',
|
||||
};
|
||||
}
|
||||
|
||||
if (err.code === 404) {
|
||||
log.info(`${logId}: missing`);
|
||||
await createNewManifest();
|
||||
return undefined;
|
||||
return {
|
||||
type: 'noManifest',
|
||||
};
|
||||
}
|
||||
|
||||
log.error(`${logId}: failed!`, err.code);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type GeneratedItemType = {
|
||||
@@ -1561,7 +1645,9 @@ async function mergeRecord(
|
||||
);
|
||||
} else {
|
||||
isUnsupported = true;
|
||||
log.warn(`merge(${redactedStorageID}): unknown item type=${itemType}`);
|
||||
log.warn(
|
||||
`mergeRecord(${redactedStorageID}): unknown item type=${itemType}`
|
||||
);
|
||||
}
|
||||
// Note: when updating this switch, update the validRecordTypes set upfile
|
||||
|
||||
@@ -1583,7 +1669,7 @@ async function mergeRecord(
|
||||
}
|
||||
|
||||
log.info(
|
||||
`merge(${redactedID}): merged item type=${itemType} ` +
|
||||
`mergeRecord(${redactedID}): merged item type=${itemType} ` +
|
||||
`oldID=${oldID} ` +
|
||||
`shouldDrop=${Boolean(mergeResult.shouldDrop)} ` +
|
||||
`details=${JSON.stringify(mergeResult.details)}`
|
||||
@@ -1592,7 +1678,7 @@ async function mergeRecord(
|
||||
hasError = true;
|
||||
const redactedID = redactStorageID(storageID, storageVersion);
|
||||
log.error(
|
||||
`merge(${redactedID}): error with ` +
|
||||
`mergeRecord(${redactedID}): error with ` +
|
||||
`item type=${itemType} ` +
|
||||
`details=${Errors.toLogFormat(err)}`
|
||||
);
|
||||
@@ -2046,6 +2132,9 @@ async function processManifest(
|
||||
'notificationProfileSyncDisabled',
|
||||
false
|
||||
);
|
||||
const areWePrimaryDevice =
|
||||
window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
notificationProfiles.forEach(notificationProfile => {
|
||||
const { deletedAtTimestampMs, id, storageID, storageVersion } =
|
||||
notificationProfile;
|
||||
@@ -2063,7 +2152,7 @@ async function processManifest(
|
||||
window.reduxActions.notificationProfiles.profileWasRemoved(id);
|
||||
return;
|
||||
}
|
||||
if (isNotificationProfileSyncDisabled) {
|
||||
if (isNotificationProfileSyncDisabled && !areWePrimaryDevice) {
|
||||
log.info(
|
||||
`${logId}/notificationProfile: localKey=${missingKey} was not in remote manifest, but sync=OFF. Removing.`
|
||||
);
|
||||
@@ -2144,12 +2233,10 @@ async function fetchRemoteRecords(
|
||||
const { key, value: storageItemCiphertext } = storageRecordWrapper;
|
||||
|
||||
if (!key || !storageItemCiphertext) {
|
||||
const error = new Error(
|
||||
throw new CannotDecryptError(
|
||||
`storageService.process(${storageVersion}): ` +
|
||||
'missing key and/or Ciphertext'
|
||||
);
|
||||
await stopStorageServiceSync(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const base64ItemID = Bytes.toBase64(key);
|
||||
@@ -2172,8 +2259,9 @@ async function fetchRemoteRecords(
|
||||
`process(${storageVersion}): Error decrypting storage item ${redactStorageID(base64ItemID)}`,
|
||||
Errors.toLogFormat(err)
|
||||
);
|
||||
await stopStorageServiceSync(err);
|
||||
throw err;
|
||||
throw new CannotDecryptError(
|
||||
`Error decrypting storage item ${redactStorageID(base64ItemID)}`
|
||||
);
|
||||
}
|
||||
|
||||
const storageRecord = Proto.StorageRecord.decode(storageItemPlaintext);
|
||||
@@ -2216,6 +2304,7 @@ async function processRemoteRecords(
|
||||
storageVersion: number,
|
||||
{ decryptedItems, keysWithoutRecords }: FetchRemoteRecordsResultType
|
||||
): Promise<void> {
|
||||
const logId = `processRemoteRecords(${storageVersion})`;
|
||||
const keysToDrop = new Set<string>();
|
||||
|
||||
// Drop all GV1 records for which we have GV2 record in the same manifest
|
||||
@@ -2248,7 +2337,7 @@ async function processRemoteRecords(
|
||||
if (itemType === ITEM_TYPE.ACCOUNT) {
|
||||
if (accountItem !== undefined) {
|
||||
log.warn(
|
||||
`process(${storageVersion}): duplicate account ` +
|
||||
`${logId}: duplicate account ` +
|
||||
`record=${redactStorageID(storageID, storageVersion)} ` +
|
||||
`previous=${redactStorageID(accountItem.storageID, storageVersion)}`
|
||||
);
|
||||
@@ -2281,10 +2370,8 @@ async function processRemoteRecords(
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`process(${storageVersion}): dropping ` +
|
||||
`GV1 record=${redactStorageID(storageID, storageVersion)} ` +
|
||||
`GV2 record=${redactStorageID(gv2StorageID, storageVersion)} ` +
|
||||
'is in the same manifest'
|
||||
`${logId}: dropping GV1 record=${redactStorageID(storageID, storageVersion)}; ` +
|
||||
`GV2 record=${redactStorageID(gv2StorageID, storageVersion)} is in the same manifest`
|
||||
);
|
||||
keysToDrop.add(storageID);
|
||||
|
||||
@@ -2322,20 +2409,15 @@ async function processRemoteRecords(
|
||||
|
||||
try {
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`attempting to merge records=${prunedStorageItems.length}`
|
||||
`${logId}: attempting to merge records=${prunedStorageItems.length}`
|
||||
);
|
||||
if (accountItem !== undefined) {
|
||||
log.info(
|
||||
`process(${storageVersion}): account ` +
|
||||
`record=${redactStorageID(accountItem.storageID, storageVersion)}`
|
||||
`${logId}: account record=${redactStorageID(accountItem.storageID, storageVersion)}`
|
||||
);
|
||||
}
|
||||
if (splitPNIContacts.length !== 0) {
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`split pni contacts=${splitPNIContacts.length}`
|
||||
);
|
||||
log.info(`${logId}: split pni contacts=${splitPNIContacts.length}`);
|
||||
}
|
||||
|
||||
const mergeWithConcurrency = (
|
||||
@@ -2378,10 +2460,7 @@ async function processRemoteRecords(
|
||||
...(mergedAccountRecord ? [mergedAccountRecord] : []),
|
||||
];
|
||||
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`processed records=${mergedRecords.length}`
|
||||
);
|
||||
log.info(`${logId}: processed records=${mergedRecords.length}`);
|
||||
|
||||
const updatedConversations = mergedRecords
|
||||
.map(record => record.updatedConversations)
|
||||
@@ -2389,18 +2468,14 @@ async function processRemoteRecords(
|
||||
.map(convo => convo.attributes);
|
||||
await updateConversations(updatedConversations);
|
||||
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`updated conversations=${updatedConversations.length}`
|
||||
);
|
||||
log.info(`${logId}: updated conversations=${updatedConversations.length}`);
|
||||
|
||||
const needProfileFetch = mergedRecords
|
||||
.map(record => record.needProfileFetch)
|
||||
.flat();
|
||||
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`kicking off profile fetches=${needProfileFetch.length}`
|
||||
`${logId}: kicking off profile fetches=${needProfileFetch.length}`
|
||||
);
|
||||
|
||||
// Intentionally not awaiting
|
||||
@@ -2444,8 +2519,7 @@ async function processRemoteRecords(
|
||||
redactStorageID(key, storageVersion)
|
||||
);
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`will drop keys=${JSON.stringify(redactedKeysToDrop)} ` +
|
||||
`${logId} will drop keys=${JSON.stringify(redactedKeysToDrop)} ` +
|
||||
`count=${redactedKeysToDrop.length}`
|
||||
);
|
||||
|
||||
@@ -2456,8 +2530,7 @@ async function processRemoteRecords(
|
||||
const redactedNewUnknowns = newUnknownRecords.map(redactExtendedStorageID);
|
||||
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`unknown records=${JSON.stringify(redactedNewUnknowns)} ` +
|
||||
`${logId}: unknown records=${JSON.stringify(redactedNewUnknowns)} ` +
|
||||
`count=${redactedNewUnknowns.length}`
|
||||
);
|
||||
await itemStorage.put('storage-service-unknown-records', newUnknownRecords);
|
||||
@@ -2466,8 +2539,7 @@ async function processRemoteRecords(
|
||||
redactExtendedStorageID
|
||||
);
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`error records=${JSON.stringify(redactedErrorRecords)} ` +
|
||||
`${logId}: error records=${JSON.stringify(redactedErrorRecords)} ` +
|
||||
`count=${redactedErrorRecords.length}`
|
||||
);
|
||||
// Refresh the list of records that had errors with every push, that way
|
||||
@@ -2504,14 +2576,13 @@ async function processRemoteRecords(
|
||||
const pendingDeletes = Array.from(pendingDeletesByID.values());
|
||||
const redactedPendingDeletes = pendingDeletes.map(redactExtendedStorageID);
|
||||
log.info(
|
||||
`process(${storageVersion}): ` +
|
||||
`pending deletes=${JSON.stringify(redactedPendingDeletes)} ` +
|
||||
`${logId}: pending deletes=${JSON.stringify(redactedPendingDeletes)} ` +
|
||||
`count=${redactedPendingDeletes.length}`
|
||||
);
|
||||
await itemStorage.put('storage-service-pending-deletes', pendingDeletes);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`process(${storageVersion}): failed to process remote records`,
|
||||
`${logId}: failed to process remote records`,
|
||||
Errors.toLogFormat(err)
|
||||
);
|
||||
}
|
||||
@@ -2539,17 +2610,31 @@ async function maybeFixStorageKey(reason: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sync({
|
||||
reason,
|
||||
}: {
|
||||
reason: string;
|
||||
}): Promise<Proto.ManifestRecord | undefined> {
|
||||
type SyncResult =
|
||||
| {
|
||||
success: true;
|
||||
manifest: Proto.ManifestRecord | undefined;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: 'missingKey' | 'sameManifest' | 'unregistered' | 'versionMismatch';
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: 'cannotDecrypt';
|
||||
version: number;
|
||||
};
|
||||
|
||||
async function sync({ reason }: { reason: string }): Promise<SyncResult> {
|
||||
const temporaryKey = itemStorage.get('temporaryRegistrationMasterKey');
|
||||
const logId = `maybeFixStorageKey(${reason}, temporaryKey=${Boolean(temporaryKey)})`;
|
||||
const logId = `sync(${reason}, temporaryKey=${Boolean(temporaryKey)})`;
|
||||
|
||||
if (!isRegistrationDone()) {
|
||||
log.warn(`${logId}: unlinked; cancelling storage service sync`);
|
||||
return;
|
||||
return {
|
||||
success: false,
|
||||
error: 'unregistered',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -2562,13 +2647,13 @@ async function sync({
|
||||
log.info(`${logId}: no storageKey, requesting new keys`);
|
||||
await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage());
|
||||
|
||||
return;
|
||||
return { success: false, error: 'missingKey' };
|
||||
}
|
||||
|
||||
log.info(`${logId}: starting...`);
|
||||
await maybeFixStorageKey(`sync/${reason}`);
|
||||
|
||||
let manifest: Proto.ManifestRecord | undefined;
|
||||
let manifest: Proto.ManifestRecord;
|
||||
try {
|
||||
// If we've previously interacted with storage service, update 'fetchComplete' record
|
||||
const previousFetchComplete = itemStorage.get('storageFetchComplete');
|
||||
@@ -2580,13 +2665,29 @@ async function sync({
|
||||
const localManifestVersion = manifestFromStorage || 0;
|
||||
|
||||
log.info(`sync: fetching latest after version=${localManifestVersion}`);
|
||||
manifest = await fetchManifest(localManifestVersion);
|
||||
|
||||
// Guarding against no manifests being returned, everything should be ok
|
||||
if (!manifest) {
|
||||
log.info(`sync: no updates, version=${localManifestVersion}`);
|
||||
return undefined;
|
||||
const fetchResult = await fetchManifest(localManifestVersion);
|
||||
if (fetchResult.type === 'cannotDecrypt') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'cannotDecrypt',
|
||||
version: fetchResult.version,
|
||||
};
|
||||
}
|
||||
if (fetchResult.type === 'noManifest') {
|
||||
log.info(
|
||||
`sync: no updates, version=${localManifestVersion}, marking storageFetchComplete=true`
|
||||
);
|
||||
await itemStorage.put('storageFetchComplete', true);
|
||||
return {
|
||||
success: true,
|
||||
manifest: undefined,
|
||||
};
|
||||
}
|
||||
if (fetchResult.type === 'sameManifest') {
|
||||
return { success: false, error: 'sameManifest' };
|
||||
}
|
||||
|
||||
manifest = fetchResult.manifest;
|
||||
|
||||
strictAssert(manifest.version != null, 'Manifest without version');
|
||||
const version = toNumber(manifest.version) ?? 0;
|
||||
@@ -2598,7 +2699,10 @@ async function sync({
|
||||
'sync: remote manifest version mismatch ' +
|
||||
`${version} <= ${localManifestVersion}`
|
||||
);
|
||||
return undefined;
|
||||
return {
|
||||
success: false,
|
||||
error: 'versionMismatch',
|
||||
};
|
||||
}
|
||||
|
||||
await window.waitForEmptyEventQueue();
|
||||
@@ -2630,11 +2734,12 @@ async function sync({
|
||||
}
|
||||
|
||||
log.info('sync: complete');
|
||||
} catch (err) {
|
||||
log.error('sync: error processing manifest', Errors.toLogFormat(err));
|
||||
} catch (error) {
|
||||
log.error('sync: error processing manifest', Errors.toLogFormat(error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
return manifest;
|
||||
return { success: true, manifest };
|
||||
}
|
||||
|
||||
async function upload({
|
||||
@@ -2688,9 +2793,18 @@ async function upload({
|
||||
// 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.
|
||||
previousManifest = await sync({
|
||||
const result = await sync({
|
||||
reason: `upload/${reason}`,
|
||||
});
|
||||
if (result.success) {
|
||||
previousManifest = result.manifest;
|
||||
} else if (result.error === 'sameManifest') {
|
||||
// continue on with no previousManifest
|
||||
} else if (result.error === 'cannotDecrypt') {
|
||||
throw new CannotDecryptError(`sync, version ${result.version}`);
|
||||
} else {
|
||||
throw new Error(`${logId}: Sync failed with reason ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const localManifestVersion = itemStorage.get('manifestVersion', 0);
|
||||
@@ -2735,13 +2849,13 @@ async function upload({
|
||||
let storageServiceEnabled = false;
|
||||
let storageServiceNeedsUploadAfterEnabled = false;
|
||||
|
||||
export function enableStorageService(): void {
|
||||
export function enableStorageService(reason: string): void {
|
||||
if (storageServiceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
storageServiceEnabled = true;
|
||||
log.info('enableStorageService');
|
||||
log.info(`enableStorageService/${reason}`);
|
||||
|
||||
if (storageServiceNeedsUploadAfterEnabled) {
|
||||
runStorageServiceUploadJob({
|
||||
@@ -2909,9 +3023,21 @@ export const runStorageServiceUploadJob = debounce(
|
||||
|
||||
void storageJobQueue(
|
||||
async () => {
|
||||
await upload({
|
||||
reason: `runStorageServiceUploadJob/${reason}`,
|
||||
});
|
||||
try {
|
||||
await upload({
|
||||
reason: `runStorageServiceUploadJob/${reason}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CannotDecryptError) {
|
||||
drop(stopStorageServiceSync(error));
|
||||
return;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'runStorageServiceUploadJob: Upload failed',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
},
|
||||
`upload v${itemStorage.get('manifestVersion')}`
|
||||
);
|
||||
@@ -2929,7 +3055,20 @@ export const runStorageServiceSyncJob = debounce(
|
||||
ourProfileKeyService.blockGetWithPromise(
|
||||
storageJobQueue(
|
||||
async () => {
|
||||
await sync({ reason });
|
||||
try {
|
||||
await sync({ reason });
|
||||
} catch (error) {
|
||||
if (error instanceof CannotDecryptError) {
|
||||
drop(stopStorageServiceSync(error));
|
||||
return;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'runStorageServiceSyncJob: Sync failed',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify listeners about sync completion
|
||||
window.Whisper.events.emit('storageService:syncComplete');
|
||||
|
||||
@@ -7,7 +7,10 @@ import { ServiceId } from '@signalapp/libsignal-client';
|
||||
import { MuteExpiration } from '@signalapp/types';
|
||||
|
||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.std.ts';
|
||||
import { deriveMasterKeyFromGroupV1 } from '../Crypto.node.ts';
|
||||
import {
|
||||
constantTimeEqual,
|
||||
deriveMasterKeyFromGroupV1,
|
||||
} from '../Crypto.node.ts';
|
||||
import * as Bytes from '../Bytes.std.ts';
|
||||
import {
|
||||
deriveGroupFields,
|
||||
@@ -339,7 +342,8 @@ export async function toContactRecord(
|
||||
const aci = conversation.getAci();
|
||||
const username = conversation.get('username');
|
||||
const ourID = window.ConversationController.getOurConversationId();
|
||||
const pni = conversation.getPni();
|
||||
// If we don't have an ACI, then serviceId might be a PNI.
|
||||
const pni = conversation.getPni() ?? conversation.getServiceIdAsPni();
|
||||
const e164 = conversation.get('e164');
|
||||
|
||||
const profileKey = conversation.get('profileKey');
|
||||
@@ -560,9 +564,11 @@ export function toAccountRecord({
|
||||
}
|
||||
}
|
||||
|
||||
const override = notificationProfileSyncDisabled
|
||||
? itemStorage.get('notificationProfileOverrideFromPrimary')
|
||||
: itemStorage.get('notificationProfileOverride');
|
||||
const areWePrimaryDevice = window.ConversationController.areWePrimaryDevice();
|
||||
const override =
|
||||
notificationProfileSyncDisabled && !areWePrimaryDevice
|
||||
? itemStorage.get('notificationProfileOverrideFromPrimary')
|
||||
: itemStorage.get('notificationProfileOverride');
|
||||
|
||||
let notificationProfileManualOverride: Proto.AccountRecord.NotificationProfileManualOverride.Params | null =
|
||||
null;
|
||||
@@ -1504,6 +1510,7 @@ export async function mergeContactRecord(
|
||||
const pni = dropNull(contactRecord.pni);
|
||||
const pniSignatureVerified = contactRecord.pniSignatureVerified || false;
|
||||
const serviceId = aci || pni;
|
||||
const details: Array<string> = [];
|
||||
|
||||
// All contacts must have UUID
|
||||
if (!serviceId) {
|
||||
@@ -1529,11 +1536,6 @@ export async function mergeContactRecord(
|
||||
reason: 'mergeContactRecord',
|
||||
});
|
||||
|
||||
const details = logRecordChanges(
|
||||
await toContactRecord(conversation),
|
||||
originalContactRecord
|
||||
);
|
||||
|
||||
// We're going to ignore this; it's likely a PNI-only contact we've already merged
|
||||
if (conversation.getServiceId() !== serviceId) {
|
||||
const previousStorageID = conversation.get('storageID');
|
||||
@@ -1560,21 +1562,27 @@ export async function mergeContactRecord(
|
||||
});
|
||||
|
||||
let needsProfileFetch = false;
|
||||
let needsStorageServiceSync = false;
|
||||
const isFirstSync = !itemStorage.get('storageFetchComplete');
|
||||
|
||||
const localProfileKey = conversation.get('profileKey');
|
||||
if (
|
||||
(isFirstSync || !localProfileKey) &&
|
||||
contactRecord.profileKey &&
|
||||
contactRecord.profileKey.length > 0
|
||||
) {
|
||||
log.info(
|
||||
`mergeContactRecord: ${conversation.idForLogging()} had no profileKey; using remote`
|
||||
);
|
||||
const haveRemoteProfileKey = contactRecord.profileKey.length > 0;
|
||||
if ((isFirstSync || !localProfileKey) && haveRemoteProfileKey) {
|
||||
if (!localProfileKey) {
|
||||
details.push('updated profile key, had nothing local');
|
||||
} else {
|
||||
details.push(`updated profile key, isFirstSync=${isFirstSync}`);
|
||||
}
|
||||
needsProfileFetch = await conversation.setProfileKey(
|
||||
Bytes.toBase64(contactRecord.profileKey),
|
||||
{ viaStorageServiceSync: true, reason: 'mergeContactRecord' }
|
||||
);
|
||||
} else if (
|
||||
localProfileKey &&
|
||||
haveRemoteProfileKey &&
|
||||
localProfileKey !== Bytes.toBase64(contactRecord.profileKey)
|
||||
) {
|
||||
needsStorageServiceSync = true;
|
||||
}
|
||||
|
||||
const remoteName = normalizeProfileName(contactRecord.givenName);
|
||||
@@ -1583,28 +1591,79 @@ export async function mergeContactRecord(
|
||||
const localFamilyName = conversation.get('profileFamilyName');
|
||||
const noLocalProfileName = !localName && !localFamilyName;
|
||||
if (remoteName && (isFirstSync || noLocalProfileName)) {
|
||||
log.info(
|
||||
`mergeContactRecord: ${conversation.idForLogging()} had no profileName; using remote`
|
||||
);
|
||||
details.push('updated profile name');
|
||||
if (noLocalProfileName) {
|
||||
details.push('updated profile name, had nothing local');
|
||||
} else {
|
||||
details.push(`updated profile name, isFirstSync=${isFirstSync}`);
|
||||
}
|
||||
|
||||
conversation.set({
|
||||
profileName: remoteName,
|
||||
profileFamilyName: remoteFamilyName,
|
||||
});
|
||||
|
||||
needsProfileFetch = true;
|
||||
} else if (
|
||||
(remoteName && localName && remoteName !== localName) ||
|
||||
(remoteFamilyName &&
|
||||
localFamilyName &&
|
||||
remoteFamilyName !== localFamilyName)
|
||||
) {
|
||||
needsProfileFetch = true;
|
||||
needsStorageServiceSync = true;
|
||||
}
|
||||
|
||||
const weArePrimary = window.ConversationController.areWePrimaryDevice();
|
||||
const existingSystemGivenName = conversation.get('systemGivenName');
|
||||
const existingSystemFamilyName = conversation.get('systemFamilyName');
|
||||
const existingSystemNickname = conversation.get('systemNickname');
|
||||
const remoteSystemGivenName = contactRecord.systemGivenName || undefined;
|
||||
const remoteSystemFamilyName = contactRecord.systemFamilyName || undefined;
|
||||
const remoteSystemNickname = contactRecord.systemNickname || undefined;
|
||||
const haveAnySystemData = Boolean(
|
||||
existingSystemGivenName ||
|
||||
existingSystemFamilyName ||
|
||||
existingSystemNickname
|
||||
);
|
||||
const hasSystemNameChanged =
|
||||
existingSystemGivenName !== remoteSystemGivenName ||
|
||||
existingSystemFamilyName !== remoteSystemFamilyName ||
|
||||
existingSystemNickname !== remoteSystemNickname;
|
||||
if (
|
||||
(!weArePrimary || isFirstSync || !haveAnySystemData) &&
|
||||
hasSystemNameChanged
|
||||
) {
|
||||
details.push(`system name changed, isFirstSync=${isFirstSync}`);
|
||||
conversation.set({
|
||||
systemGivenName: remoteSystemGivenName,
|
||||
systemFamilyName: remoteSystemFamilyName,
|
||||
systemNickname: remoteSystemNickname,
|
||||
});
|
||||
} else if (weArePrimary && haveAnySystemData && hasSystemNameChanged) {
|
||||
needsStorageServiceSync = true;
|
||||
}
|
||||
conversation.set({
|
||||
systemGivenName: dropNull(contactRecord.systemGivenName || null),
|
||||
systemFamilyName: dropNull(contactRecord.systemFamilyName || null),
|
||||
systemNickname: dropNull(contactRecord.systemNickname || null),
|
||||
nicknameGivenName: dropNull(contactRecord.nickname?.given || null),
|
||||
nicknameFamilyName: dropNull(contactRecord.nickname?.family || null),
|
||||
note: dropNull(contactRecord.note),
|
||||
});
|
||||
|
||||
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt#L921-L936
|
||||
if (contactRecord.identityKey.length) {
|
||||
const haveRemoteKey = Boolean(contactRecord.identityKey.length);
|
||||
const identityRecord = signalProtocolStore.getIdentityRecord(serviceId);
|
||||
const haveLocalKey = Boolean(identityRecord);
|
||||
const keysMatch =
|
||||
haveRemoteKey &&
|
||||
identityRecord &&
|
||||
contactRecord.identityKey.length === identityRecord.publicKey.length &&
|
||||
constantTimeEqual(contactRecord.identityKey, identityRecord.publicKey);
|
||||
|
||||
if ((isFirstSync || !haveLocalKey || keysMatch) && haveRemoteKey) {
|
||||
log.info(
|
||||
`mergeContactRecord: Updating identity/verified for ${conversation.idForLogging()} ` +
|
||||
`(isFirstSync=${isFirstSync}, haveLocalKey=${haveLocalKey}, keysMatch=${keysMatch})`
|
||||
);
|
||||
|
||||
const verified = await conversation.safeGetVerified();
|
||||
let { identityState } = contactRecord;
|
||||
if (identityState == null) {
|
||||
@@ -1637,6 +1696,9 @@ export async function mergeContactRecord(
|
||||
{ local: false }
|
||||
);
|
||||
}
|
||||
} else if (haveRemoteKey && haveLocalKey && !keysMatch && aci) {
|
||||
needsProfileFetch = true;
|
||||
needsStorageServiceSync = true;
|
||||
}
|
||||
|
||||
await applyMessageRequestState(contactRecord, conversation);
|
||||
@@ -1656,7 +1718,7 @@ export async function mergeContactRecord(
|
||||
showUnreadReminders: fromOptionalBool(contactRecord.showUnreadReminders),
|
||||
storageID,
|
||||
storageVersion,
|
||||
needsStorageServiceSync: false,
|
||||
needsStorageServiceSync,
|
||||
});
|
||||
|
||||
if (contactRecord.hidden) {
|
||||
@@ -1693,13 +1755,17 @@ export async function mergeContactRecord(
|
||||
|
||||
applyAvatarColor(conversation, contactRecord.avatarColor);
|
||||
|
||||
const fullDetails = details.concat(
|
||||
logRecordChanges(await toContactRecord(conversation), originalContactRecord)
|
||||
);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
updatedConversations: [conversation],
|
||||
needsProfileFetch,
|
||||
oldStorageID,
|
||||
oldStorageVersion,
|
||||
details,
|
||||
details: fullDetails,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2201,7 +2267,8 @@ export async function mergeAccountRecord(
|
||||
overrideToSave = undefined;
|
||||
}
|
||||
|
||||
if (notificationProfileSyncDisabled) {
|
||||
const areWePrimaryDevice = window.ConversationController.areWePrimaryDevice();
|
||||
if (notificationProfileSyncDisabled && !areWePrimaryDevice) {
|
||||
await itemStorage.put(
|
||||
'notificationProfileOverrideFromPrimary',
|
||||
overrideToSave
|
||||
|
||||
@@ -194,7 +194,7 @@ function startInstaller(): ThunkAction<
|
||||
|
||||
window.IPC.addSetupMenuItems();
|
||||
dispatch(cancelRegistration());
|
||||
enableStorageService();
|
||||
enableStorageService('ducks/installer: startInstaller');
|
||||
|
||||
dispatch({
|
||||
type: START_INSTALLER,
|
||||
|
||||
@@ -152,7 +152,10 @@ export const useNotificationProfilesActions = (): BoundActionCreatorsMapObject<
|
||||
const updateStorageService = debounce(
|
||||
(reason: string, options: { force?: boolean } = {}) => {
|
||||
const disabled = itemStorage.get('notificationProfileSyncDisabled');
|
||||
if (disabled && !options.force) {
|
||||
const areWePrimaryDevice =
|
||||
window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
if (disabled && !areWePrimaryDevice && !options.force) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +228,22 @@ function setIsSyncEnabled(
|
||||
return;
|
||||
}
|
||||
|
||||
const areWePrimaryDevice =
|
||||
window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
// In the primary case, nothing much needs to change when this option changes
|
||||
if (areWePrimaryDevice) {
|
||||
await itemStorage.put('notificationProfileSyncDisabled', disabled);
|
||||
|
||||
if (!fromStorageService) {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
me.captureChange(logId);
|
||||
updateStorageService(logId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Because we can't update everything (itemStorage and our redux slice), there is
|
||||
// the risk of a flash of content on the list page when enabling/disabling sync. So
|
||||
// we set this loading flag and show something else until everything is ready.
|
||||
@@ -320,9 +339,11 @@ function setProfileOverride(
|
||||
'notificationProfileSyncDisabled',
|
||||
false
|
||||
);
|
||||
const areWePrimaryDevice =
|
||||
window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
if (isNotificationProfileSyncEnabled) {
|
||||
if (isNotificationProfileSyncEnabled || areWePrimaryDevice) {
|
||||
me.captureChange(logId);
|
||||
}
|
||||
|
||||
@@ -351,7 +372,7 @@ function setProfileOverride(
|
||||
payload: newOverride,
|
||||
});
|
||||
fastUpdateProfileService();
|
||||
if (isNotificationProfileSyncEnabled) {
|
||||
if (isNotificationProfileSyncEnabled || areWePrimaryDevice) {
|
||||
updateStorageService(logId);
|
||||
}
|
||||
|
||||
@@ -368,7 +389,8 @@ function setProfileOverride(
|
||||
payload: newOverride,
|
||||
});
|
||||
fastUpdateProfileService();
|
||||
if (isNotificationProfileSyncEnabled) {
|
||||
|
||||
if (isNotificationProfileSyncEnabled || areWePrimaryDevice) {
|
||||
updateStorageService(logId);
|
||||
}
|
||||
};
|
||||
@@ -415,8 +437,13 @@ function updateOverride(
|
||||
'notificationProfileSyncDisabled',
|
||||
false
|
||||
);
|
||||
const areWePrimaryDevice =
|
||||
window.ConversationController.areWePrimaryDevice();
|
||||
|
||||
if (!fromStorageService && isNotificationProfileSyncEnabled) {
|
||||
if (
|
||||
!fromStorageService &&
|
||||
(isNotificationProfileSyncEnabled || areWePrimaryDevice)
|
||||
) {
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
me.captureChange(logId);
|
||||
updateStorageService(logId);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { itemStorage } from '../../textsecure/Storage.preload.ts';
|
||||
import {
|
||||
disableStorageService,
|
||||
enableStorageService,
|
||||
runStorageServiceUploadJob,
|
||||
} from '../../services/storage.preload.ts';
|
||||
import { assertDev } from '../../util/assert.std.ts';
|
||||
import { FatalErrorType } from '../../types/StandaloneRegistration.std.ts';
|
||||
@@ -676,7 +677,13 @@ export function submitVerificationCode({
|
||||
PartialRegistrationType.EXISTING__PROFILE
|
||||
);
|
||||
} else {
|
||||
enableStorageService(); // submitVerificationCode: no prior data there, just turn it on
|
||||
enableStorageService(
|
||||
'submitVerificationCode: just created account, no prior data in storage service'
|
||||
);
|
||||
runStorageServiceUploadJob({
|
||||
reason:
|
||||
'submitVerificationCode: just created account, no prior data in storage service',
|
||||
});
|
||||
await itemStorage.put(
|
||||
'standaloneRegistrationPartialState',
|
||||
PartialRegistrationType.NEW_ACCOUNT__PROFILE
|
||||
@@ -711,7 +718,9 @@ export function submitVerificationCode({
|
||||
avatars: undefined,
|
||||
};
|
||||
} else {
|
||||
enableStorageService(); // submitVerificationCode: Got a random error back from account creation
|
||||
enableStorageService(
|
||||
'submitVerificationCode: Got a random error back from account creation'
|
||||
);
|
||||
workflow = {
|
||||
...workflow,
|
||||
status: {
|
||||
@@ -924,7 +933,9 @@ export function verifyPIN({
|
||||
// Something has really gone wrong - we're in reglock, but SVR has nothing for us
|
||||
dispatch(updateWorkflow(workflow, FatalErrorType.UNEXPECTED));
|
||||
} else {
|
||||
enableStorageService(); // verifyPIN: nothing in SVR; will start afresh with new key
|
||||
enableStorageService(
|
||||
'verifyPIN: nothing in SVR; will start afresh with new key'
|
||||
);
|
||||
await itemStorage.put(
|
||||
'standaloneRegistrationPartialState',
|
||||
PartialRegistrationType.NEW_ACCOUNT__PIN
|
||||
@@ -968,7 +979,7 @@ export function verifyPIN({
|
||||
);
|
||||
await itemStorage.put('standaloneRegistrationPartialState', undefined);
|
||||
await itemStorage.put('registrationLock', false);
|
||||
enableStorageService(); // verifyPIN: No reglock, got temporary master key
|
||||
enableStorageService('verifyPIN: No reglock, got temporary master key');
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`${logId}: error saving data after creating account`,
|
||||
@@ -1083,7 +1094,9 @@ export function verifyPIN({
|
||||
toLogFormat(error)
|
||||
);
|
||||
} finally {
|
||||
enableStorageService(); // verifyPIN: Created account with reglock, tried to set things up
|
||||
enableStorageService(
|
||||
'verifyPIN: Created account with reglock, tried to set things up'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
NotificationProfileOverride,
|
||||
NotificationProfileType,
|
||||
} from '../../types/NotificationProfile.std.ts';
|
||||
import { getAreWePrimaryDevice } from './user.std.ts';
|
||||
|
||||
const getNotificationProfileData = (
|
||||
state: StateType
|
||||
@@ -24,9 +25,11 @@ const getNotificationProfileData = (
|
||||
export const getProfiles = createSelector(
|
||||
getNotificationProfileSyncDisabled,
|
||||
getNotificationProfileData,
|
||||
getAreWePrimaryDevice,
|
||||
(
|
||||
syncDisabled: boolean,
|
||||
state: NotificationProfilesStateType
|
||||
state: NotificationProfilesStateType,
|
||||
areWePrimaryDevice: boolean
|
||||
): ReadonlyArray<NotificationProfileType> => {
|
||||
const notDeleted = state.profiles.filter(
|
||||
profile =>
|
||||
@@ -34,7 +37,7 @@ export const getProfiles = createSelector(
|
||||
profile.deletedAtTimestampMs === 0
|
||||
);
|
||||
|
||||
if (syncDisabled) {
|
||||
if (syncDisabled && !areWePrimaryDevice) {
|
||||
return notDeleted.filter(profile => !profile.storageID);
|
||||
}
|
||||
|
||||
@@ -45,9 +48,11 @@ export const getProfiles = createSelector(
|
||||
export const getDeletedProfiles = createSelector(
|
||||
getNotificationProfileSyncDisabled,
|
||||
getNotificationProfileData,
|
||||
getAreWePrimaryDevice,
|
||||
(
|
||||
syncDisabled: boolean,
|
||||
state: NotificationProfilesStateType
|
||||
state: NotificationProfilesStateType,
|
||||
areWePrimaryDevice: boolean
|
||||
): ReadonlyArray<NotificationProfileType> => {
|
||||
const deleted = state.profiles.filter(
|
||||
profile =>
|
||||
@@ -55,7 +60,7 @@ export const getDeletedProfiles = createSelector(
|
||||
profile.deletedAtTimestampMs !== 0
|
||||
);
|
||||
|
||||
if (syncDisabled) {
|
||||
if (syncDisabled && !areWePrimaryDevice) {
|
||||
return deleted.filter(profile => !profile.storageID);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,11 +44,16 @@ export const getUserPNI = createSelector(
|
||||
(state: UserStateType): PniString | undefined => state.ourPni
|
||||
);
|
||||
|
||||
export const areWePrimaryDevice = createSelector(
|
||||
export const getAreWePrimaryDevice = createSelector(
|
||||
getUserDeviceId,
|
||||
(deviceId: number | undefined): boolean => deviceId === 1
|
||||
);
|
||||
|
||||
export const getDoWeHaveOtherDevices = createSelector(
|
||||
getAreWePrimaryDevice,
|
||||
(areWePrimaryDevice: boolean): boolean => !areWePrimaryDevice
|
||||
);
|
||||
|
||||
export const getIntl = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): LocalizerType => state.i18n
|
||||
|
||||
@@ -39,7 +39,7 @@ import { SIGNAL_ACI } from '../../types/SignalConversation.std.ts';
|
||||
import { saveAttachmentToDisk } from '../../util/migrations.preload.ts';
|
||||
import { format } from '../../types/PhoneNumber.std.ts';
|
||||
import {
|
||||
areWePrimaryDevice,
|
||||
getAreWePrimaryDevice,
|
||||
getIntl,
|
||||
getTheme,
|
||||
getUser,
|
||||
@@ -270,7 +270,7 @@ export function SmartPreferences(): JSX.Element | null {
|
||||
const donationReceipts = useSelector(
|
||||
(state: StateType) => state.donations.receipts
|
||||
);
|
||||
const weArePrimaryDevice = useSelector(areWePrimaryDevice);
|
||||
const weArePrimaryDevice = useSelector(getAreWePrimaryDevice);
|
||||
const conversationSelector = useSelector(getConversationSelector);
|
||||
|
||||
const shouldShowUpdateDialog = dialogType !== DialogType.None;
|
||||
|
||||
@@ -5,7 +5,11 @@ import { memo, useCallback } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getIntl, getTheme } from '../selectors/user.std.ts';
|
||||
import {
|
||||
getDoWeHaveOtherDevices,
|
||||
getIntl,
|
||||
getTheme,
|
||||
} from '../selectors/user.std.ts';
|
||||
import {
|
||||
NotificationProfilesCreateFlow,
|
||||
NotificationProfilesHome,
|
||||
@@ -42,6 +46,7 @@ export const SmartNotificationProfilesHome = memo(
|
||||
const allProfiles = useSelector(getProfiles);
|
||||
const activeProfile = useSelector(getActiveProfile);
|
||||
const loading = useSelector(getLoading);
|
||||
const doWeHaveOtherDevices = useSelector(getDoWeHaveOtherDevices);
|
||||
|
||||
const conversations = useSelector(
|
||||
getAllConversationsForNotificationProfiles
|
||||
@@ -82,6 +87,7 @@ export const SmartNotificationProfilesHome = memo(
|
||||
contentsRef={contentsRef}
|
||||
conversations={conversations}
|
||||
conversationSelector={conversationSelector}
|
||||
doWeHaveOtherDevices={doWeHaveOtherDevices}
|
||||
i18n={i18n}
|
||||
isSyncEnabled={isSyncEnabled}
|
||||
hasOnboardingBeenSeen={hasOnboardingBeenSeen}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { DialogRelink } from '../../components/DialogRelink.dom.tsx';
|
||||
import { areWePrimaryDevice, getIntl } from '../selectors/user.std.ts';
|
||||
import { getAreWePrimaryDevice, getIntl } from '../selectors/user.std.ts';
|
||||
import { useNetworkActions } from '../ducks/network.dom.ts';
|
||||
|
||||
import type { WidthBreakpoint } from '../../components/_util.std.ts';
|
||||
@@ -19,7 +19,7 @@ export const SmartRelinkDialog = memo(function SmartRelinkDialog({
|
||||
}: SmartRelinkDialogProps) {
|
||||
const i18n = useSelector(getIntl);
|
||||
const { relinkDevice, reregister } = useNetworkActions();
|
||||
const weArePrimaryDevice = useSelector(areWePrimaryDevice);
|
||||
const weArePrimaryDevice = useSelector(getAreWePrimaryDevice);
|
||||
|
||||
return (
|
||||
<DialogRelink
|
||||
|
||||
@@ -13,21 +13,21 @@ import pTimeout from 'p-timeout';
|
||||
import normalizePath from 'normalize-path';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { PNG } from 'pngjs';
|
||||
import type { Page } from 'playwright';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { Device, PrimaryDevice, Proto } from '@signalapp/mock-server';
|
||||
import { expect } from 'playwright/test';
|
||||
import {
|
||||
Server,
|
||||
ServiceIdKind,
|
||||
loadCertificates,
|
||||
} from '@signalapp/mock-server';
|
||||
|
||||
import type { Page } from 'playwright';
|
||||
import type { Device, PrimaryDevice, Proto } from '@signalapp/mock-server';
|
||||
|
||||
import { MAX_READ_KEYS as MAX_STORAGE_READ_KEYS } from '../services/storageConstants.std.ts';
|
||||
import { SECOND, MINUTE, WEEK, MONTH } from '../util/durations/index.std.ts';
|
||||
import { drop } from '../util/drop.std.ts';
|
||||
import { regress } from '../test-helpers/benchmarkStats.std.ts';
|
||||
import type { RendererConfigType } from '../types/RendererConfig.std.ts';
|
||||
import type { MIMEType } from '../types/MIME.std.ts';
|
||||
import { App } from './playwright.node.ts';
|
||||
import { CONTACT_COUNT } from './benchmarks/fixtures.node.ts';
|
||||
import { strictAssert } from '../util/assert.std.ts';
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
generateAttachmentKeys,
|
||||
} from '../AttachmentCrypto.node.ts';
|
||||
import { isVideoTypeSupported } from '../util/GoogleChrome.std.ts';
|
||||
import { typeIntoInput, typeVerificationCode } from './helpers.node.ts';
|
||||
|
||||
import type { RendererConfigType } from '../types/RendererConfig.std.ts';
|
||||
import type { MIMEType } from '../types/MIME.std.ts';
|
||||
import type { AciString } from '../types/ServiceId.std.ts';
|
||||
|
||||
export { App };
|
||||
|
||||
@@ -167,6 +172,13 @@ export type RegressionSample = Readonly<{
|
||||
metrics?: Record<string, number>;
|
||||
}>;
|
||||
|
||||
export type StandaloneLinkData = Readonly<{
|
||||
aci: AciString;
|
||||
startingStorageServiceVersion: bigint;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm: Buffer<ArrayBuffer> | undefined;
|
||||
}>;
|
||||
|
||||
function sanitizePathComponent(component: string): string {
|
||||
return normalizePath(component.replace(/[^a-z]+/gi, '-'));
|
||||
}
|
||||
@@ -460,6 +472,109 @@ export class Bootstrap {
|
||||
return app;
|
||||
}
|
||||
|
||||
public async doStandaloneRegistration({
|
||||
aci: providedAci,
|
||||
app,
|
||||
e164,
|
||||
pin,
|
||||
verificationCode,
|
||||
}: {
|
||||
aci?: AciString;
|
||||
app: App;
|
||||
e164: string;
|
||||
pin: string;
|
||||
verificationCode: string;
|
||||
}): Promise<StandaloneLinkData> {
|
||||
const window = await app.getWindow();
|
||||
|
||||
let aci = providedAci;
|
||||
|
||||
if (aci) {
|
||||
debug('doStandaloneRegistration: aci was provided');
|
||||
this.server.setNextAci(aci);
|
||||
} else {
|
||||
debug('doStandaloneRegistration: aci was provided');
|
||||
this.server.setNextAci(undefined);
|
||||
aci = await this.server.generateAci();
|
||||
this.server.setNextAci(aci);
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: PHONE_NUMBER');
|
||||
const phoneInput = window.getByPlaceholder('Phone number');
|
||||
await typeIntoInput(phoneInput, e164, '');
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
const dialogText = window.getByText(
|
||||
'Is your phone number above correct?'
|
||||
);
|
||||
await expect(dialogText).toBeVisible();
|
||||
|
||||
await window.getByRole('button', { name: 'Yes' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: CAPTCHA');
|
||||
|
||||
await window.getByRole('button', { name: 'Verify in Browser' }).click();
|
||||
|
||||
const { seq, reason } = await app.waitForChallenge();
|
||||
assert.strictEqual(reason, 'standalone registration');
|
||||
|
||||
await app.solveChallenge({ seq, data: { captcha: 'unused' } });
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: VERIFICATION_CODE');
|
||||
await typeVerificationCode(window, verificationCode);
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: PROFILE_ENTRY');
|
||||
|
||||
const firstNameInput = window.getByPlaceholder('First name (required)');
|
||||
await typeIntoInput(firstNameInput, 'John', '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: CREATE_PIN');
|
||||
|
||||
const phoneInput = window.getByPlaceholder('Create your PIN');
|
||||
await typeIntoInput(phoneInput, pin, '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: CREATE_PIN_CONFIRM');
|
||||
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
await typeIntoInput(phoneInput, pin, '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('doStandaloneRegistration: COMPLETE');
|
||||
|
||||
await expect(window.getByText('Welcome to Signal')).toBeVisible();
|
||||
}
|
||||
|
||||
const { version, storageKey, recordIkm } =
|
||||
await app.waitForUploadManifest();
|
||||
|
||||
return {
|
||||
aci,
|
||||
startingStorageServiceVersion: BigInt(version),
|
||||
storageKey: Buffer.from(storageKey),
|
||||
recordIkm: recordIkm ? Buffer.from(recordIkm) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public async link({
|
||||
extraConfig,
|
||||
ephemeralBackup,
|
||||
|
||||
@@ -77,18 +77,24 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
async function changeIdentityKey(): Promise<void> {
|
||||
const { phone, contacts } = bootstrap;
|
||||
const [alice, bob] = contacts as [PrimaryDevice, PrimaryDevice];
|
||||
async function changeIdentityKey(): Promise<PrimaryDevice> {
|
||||
const { contacts, phone, server } = bootstrap;
|
||||
const [alice] = contacts as [PrimaryDevice];
|
||||
|
||||
await app.waitForStorageService();
|
||||
|
||||
debug('change public key in storage service');
|
||||
debug('reregistering contact');
|
||||
const newAlicePrimary = await server.reregisterPrimaryDevice({
|
||||
aci: alice.device.aci,
|
||||
profileName: 'Updated Profile Name',
|
||||
});
|
||||
|
||||
debug('updating public key in storage service');
|
||||
let state = await phone.expectStorageState('after link');
|
||||
|
||||
// Break identity key
|
||||
// Update identity key
|
||||
state = state.updateContact(alice, {
|
||||
identityKey: bob.publicKey.serialize(),
|
||||
identityKey: newAlicePrimary.publicKey.serialize(),
|
||||
});
|
||||
|
||||
await phone.setStorageState(state);
|
||||
@@ -97,6 +103,8 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
});
|
||||
|
||||
await app.waitForStorageService();
|
||||
|
||||
return newAlicePrimary;
|
||||
}
|
||||
|
||||
it('show safety number change UI on regular send', async () => {
|
||||
@@ -110,10 +118,11 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
const input = await waitForEnabledComposer(window);
|
||||
await typeIntoInput(input, 'Hello Alice!', '');
|
||||
|
||||
await changeIdentityKey();
|
||||
const newAlicePrimary = await changeIdentityKey();
|
||||
|
||||
await expectSystemMessages(window, [
|
||||
/Safety Number with Alice/, // Alice's key from storage service
|
||||
// Alice's updated key via storage service, leading to profile fetch
|
||||
/Safety Number with Alice/,
|
||||
]);
|
||||
|
||||
debug('Sending message');
|
||||
@@ -123,18 +132,15 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
const dialog = window.getByRole('alertdialog', {
|
||||
name: 'Safety Number Changes',
|
||||
});
|
||||
await dialog.locator(`"${alice.profileName}"`).waitFor();
|
||||
|
||||
await expectSystemMessages(window, [
|
||||
/Safety Number with/, // One is a fixed Alice's key from backend
|
||||
/Safety Number with/, // Other is Bob's key from storage service
|
||||
]);
|
||||
debug(`Checking for alice in dialog: ${alice.profileName}`);
|
||||
await dialog.locator(`"${alice.profileName}"`).waitFor();
|
||||
|
||||
debug('Confirming send');
|
||||
await dialog.getByRole('button', { name: 'Send anyway' }).click();
|
||||
|
||||
debug('Getting a message');
|
||||
const { body } = await alice.waitForMessage();
|
||||
const { body } = await newAlicePrimary.waitForMessage();
|
||||
assert.strictEqual(body, 'Hello Alice!');
|
||||
});
|
||||
|
||||
@@ -175,7 +181,7 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
.locator('.SendStoryModal__distribution-list__name >> "My Story"')
|
||||
.click();
|
||||
|
||||
await changeIdentityKey();
|
||||
const newAlicePrimary = await changeIdentityKey();
|
||||
|
||||
debug('Hitting Send');
|
||||
await window.locator('button.SendStoryModal__send').click();
|
||||
@@ -190,7 +196,7 @@ describe('safety number', function (this: Mocha.Suite) {
|
||||
await dialog.getByRole('button', { name: 'Send anyway' }).click();
|
||||
|
||||
debug('Getting a story');
|
||||
const { storyMessage } = await alice.waitForStory();
|
||||
const { storyMessage } = await newAlicePrimary.waitForStory();
|
||||
assert.ok(storyMessage.attachment?.textAttachment != null);
|
||||
assert.strictEqual(storyMessage.attachment.textAttachment.text, '123');
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
StoreParameters,
|
||||
} from '../textsecure/WebAPI.preload.ts';
|
||||
import { toLogFormat } from '../types/errors.std.ts';
|
||||
import { fromBase64 } from '../Bytes.std.ts';
|
||||
|
||||
export type AppLoadedInfoType = Readonly<{
|
||||
loadTime: number;
|
||||
@@ -197,12 +198,57 @@ export class App extends EventEmitter {
|
||||
return this.#waitForEvent('storageServiceComplete');
|
||||
}
|
||||
|
||||
public async waitForUploadManifest(desiredVersion?: bigint): Promise<{
|
||||
version: number;
|
||||
storageKey: Buffer<ArrayBuffer>;
|
||||
recordIkm: Buffer<ArrayBuffer> | undefined;
|
||||
}> {
|
||||
// oxlint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// oxlint-disable-next-line no-await-in-loop
|
||||
const result = (await this.#waitForEvent('uploadManifest')) as
|
||||
| {
|
||||
version: number;
|
||||
storageKeyBase64: string;
|
||||
recordIkmBase64: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
if (!result) {
|
||||
throw new Error('waitForUploadManifest: Found no data!');
|
||||
}
|
||||
|
||||
const { version, storageKeyBase64, recordIkmBase64 } = result;
|
||||
if (desiredVersion !== undefined && BigInt(version) < desiredVersion) {
|
||||
debug(
|
||||
`waitForUploadManifest: version ${version} is below desired version ${desiredVersion}, trying again...`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
debug(`waitForUploadManifest: Returning with version ${version}`);
|
||||
|
||||
const storageKey = Buffer.from(fromBase64(storageKeyBase64));
|
||||
const recordIkm = recordIkmBase64
|
||||
? Buffer.from(fromBase64(recordIkmBase64))
|
||||
: undefined;
|
||||
|
||||
return { version, storageKey, recordIkm };
|
||||
}
|
||||
}
|
||||
|
||||
public async waitForQueuedStickerPacks(): Promise<void> {
|
||||
return this.#waitForEvent('queuedStickerPacksDownloaded');
|
||||
}
|
||||
|
||||
public async waitForSVRStore(): Promise<StoreParameters> {
|
||||
return this.#waitForEvent('svrStore');
|
||||
const result = (await this.#waitForEvent('svrStore')) as {
|
||||
pin: string;
|
||||
dataBase64: string;
|
||||
};
|
||||
return {
|
||||
pin: result.pin,
|
||||
data: fromBase64(result.dataBase64),
|
||||
};
|
||||
}
|
||||
|
||||
public async waitForManifestVersion(version: bigint): Promise<void> {
|
||||
@@ -313,6 +359,11 @@ export class App extends EventEmitter {
|
||||
return this.#waitForEvent('conversationOpenComplete');
|
||||
}
|
||||
|
||||
public async fetchManifestForPrimary(): Promise<void> {
|
||||
const window = await this.getWindow();
|
||||
return window.evaluate('window.SignalCI.fetchManifestForPrimary()');
|
||||
}
|
||||
|
||||
// EventEmitter types
|
||||
|
||||
public override on(type: 'close', callback: () => void): this;
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import createDebug from 'debug';
|
||||
import assert from 'node:assert';
|
||||
import { expect } from 'playwright/test';
|
||||
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
|
||||
import * as durations from '../../util/durations/index.std.ts';
|
||||
import { dropNull } from '../../util/dropNull.std.ts';
|
||||
import { constantTimeEqual } from '../../Crypto.node.ts';
|
||||
import { Bootstrap } from '../bootstrap.node.ts';
|
||||
import { typeIntoInput } from '../helpers.node.ts';
|
||||
import { SignalService as Proto } from '../../protobuf/index.std.ts';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { fromHex } from '../../Bytes.std.ts';
|
||||
import { generateNotificationProfileId } from '../../types/NotificationProfile-node.node.ts';
|
||||
import { DEFAULT_PROFILE } from '../storage/notification_profiles_test.node.ts';
|
||||
|
||||
import type { App, StandaloneLinkData } from '../bootstrap.node.ts';
|
||||
|
||||
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
||||
|
||||
export const debug = createDebug('mock:test:standalone:notification-profiles');
|
||||
|
||||
describe('standalone/notification profiles', function (this: Mocha.Suite) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let standaloneLinkData: StandaloneLinkData | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap();
|
||||
await bootstrap.init({ isStandalone: true });
|
||||
|
||||
app = await bootstrap.prepareForStandaloneRegistration();
|
||||
standaloneLinkData = await bootstrap.doStandaloneRegistration({
|
||||
app,
|
||||
e164: '+14155551111',
|
||||
verificationCode: '111111',
|
||||
pin: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('updates storage service even if sync=OFF when primary', async () => {
|
||||
const { server, contacts } = bootstrap;
|
||||
const window = await app.getWindow();
|
||||
|
||||
strictAssert(standaloneLinkData, 'Need basics in place!');
|
||||
const { aci, storageKey, recordIkm } = standaloneLinkData;
|
||||
|
||||
const now = Date.now();
|
||||
const notificationProfileName = 'One';
|
||||
const notificationProfileId = fromHex(generateNotificationProfileId());
|
||||
|
||||
debug('add initial data to storage service');
|
||||
|
||||
const firstState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
const [firstContact, secondContact] = contacts as [
|
||||
PrimaryDevice,
|
||||
PrimaryDevice,
|
||||
];
|
||||
|
||||
let secondState = firstState.addContact(firstContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: firstContact.publicKey.serialize(),
|
||||
profileKey: firstContact.profileKey.serialize(),
|
||||
givenName: firstContact.profileName,
|
||||
});
|
||||
secondState = secondState.addContact(secondContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: secondContact.publicKey.serialize(),
|
||||
profileKey: secondContact.profileKey.serialize(),
|
||||
givenName: secondContact.profileName,
|
||||
});
|
||||
|
||||
secondState = secondState.pin(firstContact);
|
||||
|
||||
secondState = secondState.addRecord({
|
||||
type: IdentifierType.NOTIFICATION_PROFILE,
|
||||
record: {
|
||||
notificationProfile: {
|
||||
id: notificationProfileId,
|
||||
name: notificationProfileName,
|
||||
color: 0xffff0000,
|
||||
createdAtMs: BigInt(now + 1),
|
||||
...DEFAULT_PROFILE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: secondState,
|
||||
previousState: firstState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
await app.fetchManifestForPrimary();
|
||||
|
||||
{
|
||||
debug('verify firstContact is now pinned');
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
await leftPane
|
||||
.locator(`[data-testid="${firstContact.device.aci}"]`)
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
debug('Opening settings tab');
|
||||
await window.locator('[data-testid="NavTabsItem--Settings"]').click();
|
||||
|
||||
debug('Opening Notifications page');
|
||||
await window.getByRole('button', { name: 'Notifications' }).click();
|
||||
|
||||
debug('Opening Notification Profiles list page');
|
||||
await window.getByRole('button', { name: 'Notification profiles' }).click();
|
||||
|
||||
let thirdState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
thirdState = thirdState.updateAccount({
|
||||
notificationProfileSyncDisabled: true,
|
||||
});
|
||||
|
||||
thirdState = thirdState.pin(secondContact);
|
||||
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: thirdState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
await app.fetchManifestForPrimary();
|
||||
|
||||
debug('Opening inbox tab');
|
||||
await window.locator('[data-testid="NavTabsItem--Chats"]').click();
|
||||
|
||||
{
|
||||
debug('verify secondContact is now pinned');
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
await leftPane
|
||||
.locator(`[data-testid="${secondContact.device.aci}"]`)
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
debug('Opening settings tab');
|
||||
await window.locator('[data-testid="NavTabsItem--Settings"]').click();
|
||||
|
||||
debug('Opening Notifications page');
|
||||
await window.getByRole('button', { name: 'Notifications' }).click();
|
||||
|
||||
const profileName = 'NewProfile';
|
||||
debug('Opening Notification Profiles list page');
|
||||
await window.getByRole('button', { name: 'Notification profiles' }).click();
|
||||
|
||||
debug('Start the create flow');
|
||||
await window.getByRole('button', { name: 'Create profile' }).click();
|
||||
|
||||
debug('Name page');
|
||||
const nameInput = window.locator('.Input__input');
|
||||
await typeIntoInput(nameInput, profileName, '');
|
||||
await window.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
debug('Allowed page');
|
||||
await window.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
debug('Schedule page');
|
||||
await window.locator('button[role="switch"]').click();
|
||||
await window.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
await expect(
|
||||
window.getByTestId(`EditProfile--${profileName}`)
|
||||
).toBeVisible();
|
||||
|
||||
// finally, this storage service update should include the new notification profile
|
||||
const fourthState = await server.waitForStorageState({
|
||||
aci,
|
||||
after: thirdState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
let profileId: Uint8Array<ArrayBuffer> | undefined;
|
||||
const profilewasAdded = fourthState.hasRecord(record => {
|
||||
if (record.record.notificationProfile == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
assert.ok(record.type === IdentifierType.NOTIFICATION_PROFILE);
|
||||
|
||||
const isMatch =
|
||||
record.record.notificationProfile.name === profileName &&
|
||||
record.record.notificationProfile.scheduleEnabled === true;
|
||||
if (isMatch) {
|
||||
profileId = dropNull(record.record.notificationProfile.id);
|
||||
}
|
||||
|
||||
return isMatch;
|
||||
});
|
||||
if (!profilewasAdded) {
|
||||
throw new Error('Did not find new profile in storage service');
|
||||
}
|
||||
if (!profileId || !profileId.length) {
|
||||
throw new Error('No profileId found on new notification record');
|
||||
}
|
||||
|
||||
debug('Open edit page for profile');
|
||||
await window.getByTestId(`EditProfile--${profileName}`).click();
|
||||
|
||||
debug('Open edit schedule page');
|
||||
await window.getByTestId('EditSchedule').click();
|
||||
await window.locator('button[role="switch"]').click();
|
||||
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('Done with edit page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
await expect(
|
||||
window.getByTestId(`EditProfile--${profileName}`)
|
||||
).toBeVisible();
|
||||
|
||||
// finally, this storage service update should include the new notification profile
|
||||
const fifthState = await server.waitForStorageState({
|
||||
aci,
|
||||
after: fourthState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
const profileScheduleIsOff = fifthState.hasRecord(record => {
|
||||
if (record.record.notificationProfile == null) {
|
||||
return false;
|
||||
}
|
||||
assert.ok(record.type === IdentifierType.NOTIFICATION_PROFILE);
|
||||
|
||||
return (
|
||||
record.record.notificationProfile.name === profileName &&
|
||||
record.record.notificationProfile.scheduleEnabled === false
|
||||
);
|
||||
});
|
||||
if (!profileScheduleIsOff) {
|
||||
throw new Error('Profile schedule was not disabled in storage service');
|
||||
}
|
||||
|
||||
debug('Opening chats tab');
|
||||
await window.locator('[data-testid="NavTabsItem--Chats"]').click();
|
||||
|
||||
debug('Click triple-dot button');
|
||||
await window.getByRole('button', { name: 'More Actions' }).click();
|
||||
await window
|
||||
.getByRole('menuitem', { name: 'Notification profile', exact: true })
|
||||
.click();
|
||||
|
||||
debug('Click to add enabled=true override');
|
||||
await window.getByRole('menuitem', { name: profileName }).click();
|
||||
|
||||
// finally, this storage service update should have the new override
|
||||
const sixthState = await server.waitForStorageState({
|
||||
aci,
|
||||
after: fifthState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
const acountRecordHasOverride = sixthState.hasRecord(record => {
|
||||
if (record.record.account == null) {
|
||||
return false;
|
||||
}
|
||||
const { notificationProfileManualOverride } = record.record.account;
|
||||
if (notificationProfileManualOverride?.override?.enabled == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id } = notificationProfileManualOverride.override.enabled;
|
||||
|
||||
return Boolean(
|
||||
record.type === IdentifierType.ACCOUNT &&
|
||||
id &&
|
||||
id.length &&
|
||||
profileId &&
|
||||
constantTimeEqual(id, profileId)
|
||||
);
|
||||
});
|
||||
if (!acountRecordHasOverride) {
|
||||
throw new Error('Did not find matching override in storage service');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import createDebug from 'debug';
|
||||
import { assert } from 'chai';
|
||||
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
|
||||
import * as durations from '../../util/durations/index.std.ts';
|
||||
import { getRandomBytes } from '../../Crypto.node.ts';
|
||||
import { Bootstrap } from '../bootstrap.node.ts';
|
||||
import { SignalService as Proto } from '../../protobuf/index.std.ts';
|
||||
import { strictAssert } from '../../util/assert.std.ts';
|
||||
import { isNotEmpty } from '../../Bytes.std.ts';
|
||||
|
||||
import type { App, StandaloneLinkData } from '../bootstrap.node.ts';
|
||||
|
||||
export const debug = createDebug('mock:test:standalone:reencrypt');
|
||||
|
||||
describe('standalone/reencrypt', function (this: Mocha.Suite) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let standaloneLinkData: StandaloneLinkData | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap();
|
||||
await bootstrap.init({ isStandalone: true });
|
||||
|
||||
app = await bootstrap.prepareForStandaloneRegistration();
|
||||
standaloneLinkData = await bootstrap.doStandaloneRegistration({
|
||||
app,
|
||||
e164: '+14155551111',
|
||||
verificationCode: '111111',
|
||||
pin: '123456',
|
||||
});
|
||||
|
||||
const { contacts, server } = bootstrap;
|
||||
const { aci, storageKey, recordIkm } = standaloneLinkData;
|
||||
|
||||
const firstState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
const [firstContact, secondContact] = contacts as [
|
||||
PrimaryDevice,
|
||||
PrimaryDevice,
|
||||
];
|
||||
let secondState = firstState.addContact(firstContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: firstContact.publicKey.serialize(),
|
||||
profileKey: firstContact.profileKey.serialize(),
|
||||
givenName: firstContact.profileName,
|
||||
});
|
||||
secondState = secondState.addContact(secondContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: secondContact.publicKey.serialize(),
|
||||
profileKey: secondContact.profileKey.serialize(),
|
||||
givenName: secondContact.profileName,
|
||||
});
|
||||
|
||||
secondState = secondState.pin(firstContact);
|
||||
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: secondState,
|
||||
previousState: firstState,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
await app.fetchManifestForPrimary();
|
||||
|
||||
const window = await app.getWindow();
|
||||
{
|
||||
debug('verify firstContact is now pinned');
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
await leftPane
|
||||
.locator(`[data-testid="${firstContact.device.aci}"]`)
|
||||
.waitFor();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it(`reencrypts on failed decrypt of storage service when primary`, async () => {
|
||||
const { server, contacts } = bootstrap;
|
||||
|
||||
strictAssert(standaloneLinkData, 'Need basics in place!');
|
||||
const { aci, storageKey, recordIkm } = standaloneLinkData;
|
||||
|
||||
debug('First, update storage service on server with bad key');
|
||||
|
||||
const existingState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
const stateForEncryptionUpdate = existingState.updateAccount({
|
||||
givenName: 'Cannot decypt!',
|
||||
});
|
||||
|
||||
const badStorageKey = Buffer.from(getRandomBytes(32));
|
||||
const badRecordIkm = Buffer.from(getRandomBytes(32));
|
||||
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: stateForEncryptionUpdate,
|
||||
storageKey: badStorageKey,
|
||||
recordIkm: badRecordIkm,
|
||||
});
|
||||
|
||||
debug('Tell app to fetch the latest manifest');
|
||||
|
||||
await app.fetchManifestForPrimary();
|
||||
|
||||
debug('Wait for desktop to upload with a fixed key');
|
||||
|
||||
const expectedVersion = stateForEncryptionUpdate.version + 2n;
|
||||
const {
|
||||
version: newVersion,
|
||||
storageKey: newStorageKey,
|
||||
recordIkm: newRecordIkm,
|
||||
} = await app.waitForUploadManifest(expectedVersion);
|
||||
|
||||
debug('Check details of the upload');
|
||||
|
||||
assert.strictEqual(BigInt(newVersion), expectedVersion);
|
||||
assert.deepEqual(newStorageKey, storageKey, 'Storage key should match!');
|
||||
assert.isTrue(isNotEmpty(newRecordIkm), 'newRecordIkm should be present');
|
||||
assert.notDeepEqual(
|
||||
newRecordIkm,
|
||||
badRecordIkm,
|
||||
'newRecordIkm should not be the badRecordIkm'
|
||||
);
|
||||
|
||||
const finalState = await server.waitForStorageState({
|
||||
aci,
|
||||
after: stateForEncryptionUpdate,
|
||||
storageKey,
|
||||
recordIkm: newRecordIkm,
|
||||
});
|
||||
|
||||
debug('Ensure firstContact is in storage service');
|
||||
|
||||
const [firstContact] = contacts as [PrimaryDevice];
|
||||
|
||||
assert.isTrue(
|
||||
finalState.hasRecord(
|
||||
item => item.record.contact?.givenName === firstContact.profileName
|
||||
),
|
||||
'looking for contact record that matches givenName'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,14 @@
|
||||
|
||||
import createDebug from 'debug';
|
||||
import { expect } from 'playwright/test';
|
||||
import { StorageState } from '@signalapp/mock-server';
|
||||
import {
|
||||
AccountEntropyPool,
|
||||
SvrKey,
|
||||
} from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
|
||||
import type { App } from '../playwright.node.ts';
|
||||
import { Bootstrap } from '../bootstrap.node.ts';
|
||||
import { DAY, MINUTE } from '../../util/durations/index.std.ts';
|
||||
import {
|
||||
@@ -13,12 +19,15 @@ import {
|
||||
typeVerificationCode,
|
||||
} from '../helpers.node.ts';
|
||||
import { assert } from 'chai';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { toHex } from '../../Bytes.std.ts';
|
||||
import { isNotEmpty, toHex } from '../../Bytes.std.ts';
|
||||
import { deriveStorageServiceKey, getRandomBytes } from '../../Crypto.node.ts';
|
||||
import { SignalService as Proto } from '../../protobuf/index.std.ts';
|
||||
|
||||
export const debug = createDebug('mock:test:registration');
|
||||
import type { App } from '../playwright.node.ts';
|
||||
|
||||
describe('registration', function (this: Mocha.Suite) {
|
||||
export const debug = createDebug('mock:test:standalone:registration');
|
||||
|
||||
describe('standalone/registration', function (this: Mocha.Suite) {
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
|
||||
@@ -41,8 +50,19 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
});
|
||||
|
||||
it('should create new account, creating a new PIN with SVR2', async () => {
|
||||
const { server } = bootstrap;
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
debug('Set next ACI, verify nothing in in storage service');
|
||||
|
||||
server.setNextAci(undefined);
|
||||
const aci = await server.generateAci();
|
||||
server.setNextAci(aci);
|
||||
|
||||
const beforeManifest = server.getStorageManifest(aci);
|
||||
assert.isUndefined(beforeManifest, 'beforeManifest');
|
||||
|
||||
{
|
||||
debug('PHONE_NUMBER: Enter phone number');
|
||||
const phoneInput = window.getByPlaceholder('Phone number');
|
||||
@@ -91,6 +111,23 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
debug(
|
||||
'VERIFICATION_CODE: verify that first storage service manifest is uploaded'
|
||||
);
|
||||
|
||||
const { version, storageKey, recordIkm } =
|
||||
await app.waitForUploadManifest();
|
||||
|
||||
assert.strictEqual(version, 0, 'uploadManifest: first');
|
||||
|
||||
const firstState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
});
|
||||
|
||||
assert.strictEqual(firstState.version, 0n, 'first state from server');
|
||||
|
||||
{
|
||||
debug('PROFILE_ENTRY: enter first name');
|
||||
const firstNameInput = window.getByPlaceholder('First name (required)');
|
||||
@@ -131,115 +168,284 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
debug('COMPLETE: verify welcome screen');
|
||||
await expect(window.getByText('Welcome to Signal')).toBeVisible();
|
||||
}
|
||||
|
||||
debug('COMPLETE: verify that storage service is updated with profile');
|
||||
|
||||
const lastState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
after: firstState,
|
||||
});
|
||||
|
||||
assert.strictEqual(lastState.version, 1n, 'last state from server');
|
||||
});
|
||||
|
||||
it('should reregister account, verifying PIN with SVR2', async () => {
|
||||
const window = await app.getWindow();
|
||||
const { server } = bootstrap;
|
||||
[true, false].forEach((withRecordIkm: boolean) => {
|
||||
const text = withRecordIkm ? 'with recordIkm' : 'WITHOUT recordIkm';
|
||||
|
||||
{
|
||||
debug('PHONE_NUMBER: Enter phone number');
|
||||
const phoneInput = window.getByPlaceholder('Phone number');
|
||||
await typeIntoInput(phoneInput, '+14155551111', '');
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
it(`should reregister account, verifying PIN with SVR2, ${text}`, async () => {
|
||||
const window = await app.getWindow();
|
||||
const { server, contacts } = bootstrap;
|
||||
|
||||
{
|
||||
debug('PHONE_NUMBER: Confirm phone number');
|
||||
const dialogText = window.getByText(
|
||||
'Is your phone number above correct?'
|
||||
);
|
||||
await expect(dialogText).toBeVisible();
|
||||
debug('Put data into storage service before registration');
|
||||
|
||||
await window.getByRole('button', { name: 'Yes' }).click();
|
||||
}
|
||||
server.setNextAci(undefined);
|
||||
const aci = await server.generateAci();
|
||||
server.setNextAci(aci);
|
||||
|
||||
{
|
||||
debug('CAPTCHA: kick off validation');
|
||||
await window.getByRole('button', { name: 'Verify in Browser' }).click();
|
||||
}
|
||||
const originalAep = AccountEntropyPool.generate();
|
||||
const originalMasterKey = AccountEntropyPool.deriveSvrKey(originalAep);
|
||||
const originalStorageKey = deriveStorageServiceKey(originalMasterKey);
|
||||
const originalRecordIkm = getRandomBytes(32);
|
||||
|
||||
{
|
||||
debug('CAPTCHA: complete validation');
|
||||
const { seq, reason } = await app.waitForChallenge();
|
||||
assert.strictEqual(reason, 'standalone registration');
|
||||
const beforeManifest = server.getStorageManifest(aci);
|
||||
assert.isUndefined(beforeManifest, 'beforeManifest');
|
||||
|
||||
await app.solveChallenge({ seq, data: { captcha: 'unused' } });
|
||||
}
|
||||
let originalState = StorageState.getEmpty();
|
||||
|
||||
{
|
||||
debug('VERIFICATION_CODE: enter code');
|
||||
await typeVerificationCode(window, '111111');
|
||||
for (const contact of contacts) {
|
||||
originalState = originalState.addContact(contact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
// Force server to return storageCapable: true
|
||||
server.setRegisterResponseData({ storageCapable: true });
|
||||
identityKey: contact.publicKey.serialize(),
|
||||
profileKey: contact.profileKey.serialize(),
|
||||
givenName: contact.profileName,
|
||||
});
|
||||
}
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
const [firstContact] = contacts as [PrimaryDevice];
|
||||
originalState = originalState.pin(firstContact);
|
||||
|
||||
{
|
||||
debug('PROFILE_ENTRY: enter first name');
|
||||
const firstNameInput = window.getByPlaceholder('First name (required)');
|
||||
await typeIntoInput(firstNameInput, 'John', '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: enter incorrect PIN');
|
||||
|
||||
const INCORRECT_PIN = '123456';
|
||||
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
await typeIntoInput(phoneInput, INCORRECT_PIN, '');
|
||||
|
||||
await app.saveSVR2RestoreResponse({
|
||||
success: false,
|
||||
error: 'pin-incorrect',
|
||||
triesRemaining: 3,
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: originalState,
|
||||
storageKey: Buffer.from(originalStorageKey),
|
||||
recordIkm: withRecordIkm ? Buffer.from(originalRecordIkm) : undefined,
|
||||
});
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
{
|
||||
debug('PHONE_NUMBER: Enter phone number');
|
||||
const phoneInput = window.getByPlaceholder('Phone number');
|
||||
await typeIntoInput(phoneInput, '+14155551111', '');
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: dismiss dialog');
|
||||
await expect(
|
||||
window.getByText('You have 3 attempts remaining')
|
||||
).toBeVisible();
|
||||
{
|
||||
debug('PHONE_NUMBER: Confirm phone number');
|
||||
const dialogText = window.getByText(
|
||||
'Is your phone number above correct?'
|
||||
);
|
||||
await expect(dialogText).toBeVisible();
|
||||
|
||||
await window.getByRole('button', { name: 'OK' }).click();
|
||||
}
|
||||
await window.getByRole('button', { name: 'Yes' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: enter correct PIN');
|
||||
{
|
||||
debug('CAPTCHA: kick off validation');
|
||||
await window.getByRole('button', { name: 'Verify in Browser' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('CAPTCHA: complete validation');
|
||||
const { seq, reason } = await app.waitForChallenge();
|
||||
assert.strictEqual(reason, 'standalone registration');
|
||||
|
||||
await app.solveChallenge({ seq, data: { captcha: 'unused' } });
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFICATION_CODE: enter code');
|
||||
await typeVerificationCode(window, '111111');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('PROFILE_ENTRY: enter first name');
|
||||
const firstNameInput = window.getByPlaceholder('First name (required)');
|
||||
await typeIntoInput(firstNameInput, 'John', '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: enter incorrect PIN');
|
||||
|
||||
const INCORRECT_PIN = '123456';
|
||||
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
await typeIntoInput(phoneInput, INCORRECT_PIN, '');
|
||||
|
||||
await app.saveSVR2RestoreResponse({
|
||||
success: false,
|
||||
error: 'pin-incorrect',
|
||||
triesRemaining: 3,
|
||||
});
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: dismiss dialog');
|
||||
await expect(
|
||||
window.getByText('You have 3 attempts remaining')
|
||||
).toBeVisible();
|
||||
|
||||
await window.getByRole('button', { name: 'OK' }).click();
|
||||
}
|
||||
|
||||
const CORRECT_PIN = '876543';
|
||||
const DATA = randomBytes(32);
|
||||
{
|
||||
debug('VERIFY_PIN: enter correct PIN');
|
||||
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
const DATA = originalMasterKey;
|
||||
|
||||
await phoneInput.clear();
|
||||
await typeIntoInput(phoneInput, CORRECT_PIN, '');
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
|
||||
await app.saveSVR2RestoreResponse({
|
||||
success: true,
|
||||
data: DATA,
|
||||
triesRemaining: 3,
|
||||
});
|
||||
await phoneInput.clear();
|
||||
await typeIntoInput(phoneInput, CORRECT_PIN, '');
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
await app.saveSVR2RestoreResponse({
|
||||
success: true,
|
||||
// @ts-expect-error We need to get this data through JSON
|
||||
data: toHex(DATA),
|
||||
triesRemaining: 3,
|
||||
});
|
||||
|
||||
{
|
||||
debug('COMPLETE: verify welcome screen');
|
||||
await expect(window.getByText('Welcome to Signal')).toBeVisible();
|
||||
}
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
{
|
||||
debug('COMPLETE: verify welcome screen');
|
||||
await expect(window.getByText('Welcome to Signal')).toBeVisible();
|
||||
}
|
||||
|
||||
{
|
||||
debug(
|
||||
'COMPLETE: verify that we pull pinned contact down from storage service'
|
||||
);
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
await leftPane
|
||||
.locator(`[data-testid="${firstContact.device.aci}"]`)
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
let newStorageKey: Buffer<ArrayBuffer> | undefined;
|
||||
{
|
||||
debug(
|
||||
'COMPLETE: verify that we re-uploaded manifest encrypted with new storageKey'
|
||||
);
|
||||
|
||||
const expectedVersion = 2;
|
||||
const { version, storageKey, recordIkm } =
|
||||
await app.waitForUploadManifest(BigInt(expectedVersion));
|
||||
|
||||
assert.notDeepEqual(
|
||||
storageKey,
|
||||
Buffer.from(originalStorageKey),
|
||||
'storageKey should not match'
|
||||
);
|
||||
if (withRecordIkm) {
|
||||
assert.deepEqual(
|
||||
recordIkm,
|
||||
Buffer.from(originalRecordIkm),
|
||||
'recordIkm should match'
|
||||
);
|
||||
} else {
|
||||
assert.isTrue(isNotEmpty(recordIkm), 'recordIkm should exist');
|
||||
}
|
||||
assert.strictEqual(
|
||||
version,
|
||||
expectedVersion,
|
||||
'first manifest was v1, now we are at v2'
|
||||
);
|
||||
|
||||
newStorageKey = storageKey;
|
||||
|
||||
const secondState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
after: originalState,
|
||||
});
|
||||
assert.strictEqual(secondState.version, 2n, 'second state from server');
|
||||
|
||||
assert.isEmpty(
|
||||
server.getOrphanedStorageKeys({ aci, storageKey, recordIkm })
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
debug('COMPLETE: Verify new data was stored back to SVR');
|
||||
|
||||
const storedData = await app.waitForSVRStore();
|
||||
assert.strictEqual(
|
||||
storedData?.pin,
|
||||
CORRECT_PIN,
|
||||
'checking what was stored in SVR'
|
||||
);
|
||||
|
||||
const newMasterKey = storedData.data;
|
||||
const derivedStorageKey = deriveStorageServiceKey(newMasterKey);
|
||||
assert.deepEqual(derivedStorageKey, newStorageKey);
|
||||
assert.notDeepEqual(newMasterKey, originalMasterKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should reregister account with reglock enabled, verifying PIN with SVR2', async () => {
|
||||
const window = await app.getWindow();
|
||||
const { server } = bootstrap;
|
||||
|
||||
debug('Put data into storage service before registration');
|
||||
|
||||
const { server, contacts } = bootstrap;
|
||||
|
||||
server.setNextAci(undefined);
|
||||
const aci = await server.generateAci();
|
||||
server.setNextAci(aci);
|
||||
|
||||
debug(`aci is: ${aci}`);
|
||||
|
||||
const originalAep = AccountEntropyPool.generate();
|
||||
const originalMasterKey = AccountEntropyPool.deriveSvrKey(originalAep);
|
||||
const originalStorageKey = deriveStorageServiceKey(originalMasterKey);
|
||||
const originalRecordIkm = getRandomBytes(32);
|
||||
|
||||
const originalSvrKey = new SvrKey(originalMasterKey);
|
||||
const originalRegistrationLockData =
|
||||
originalSvrKey.deriveRegistrationLock();
|
||||
const originalRegistrationLockToken = toHex(originalRegistrationLockData);
|
||||
|
||||
server.setRegistrationLockToken(aci, originalRegistrationLockToken);
|
||||
|
||||
const beforeManifest = server.getStorageManifest(aci);
|
||||
assert.isUndefined(beforeManifest, 'beforeManifest');
|
||||
|
||||
let originalState = StorageState.getEmpty();
|
||||
|
||||
for (const contact of contacts) {
|
||||
originalState = originalState.addContact(contact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: contact.publicKey.serialize(),
|
||||
profileKey: contact.profileKey.serialize(),
|
||||
givenName: contact.profileName,
|
||||
});
|
||||
}
|
||||
|
||||
const [firstContact] = contacts as [PrimaryDevice];
|
||||
originalState = originalState.pin(firstContact);
|
||||
|
||||
await server.setStorageState({
|
||||
aci,
|
||||
state: originalState,
|
||||
storageKey: Buffer.from(originalStorageKey),
|
||||
recordIkm: Buffer.from(originalRecordIkm),
|
||||
});
|
||||
|
||||
{
|
||||
debug('PHONE_NUMBER: Enter phone number');
|
||||
@@ -275,14 +481,7 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
debug('VERIFICATION_CODE: enter code');
|
||||
await typeVerificationCode(window, '111111');
|
||||
|
||||
// Force server to return error telling us that reglock is active
|
||||
server.setRegisterResponseError({
|
||||
code: 423,
|
||||
data: {
|
||||
timeRemaining: 5 * DAY,
|
||||
svr2Credentials: { username: 'fake423', password: 'fake423' },
|
||||
},
|
||||
});
|
||||
// Server will automatically return 423; we don't provide registrationLock token
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
@@ -321,8 +520,9 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
await window.getByRole('button', { name: 'OK' }).click();
|
||||
}
|
||||
|
||||
const PIN = '876543';
|
||||
const DATA = randomBytes(32);
|
||||
const CORRECT_PIN = '876543';
|
||||
|
||||
server.setNextAci(aci);
|
||||
|
||||
{
|
||||
debug('VERIFY_PIN: enter correct PIN');
|
||||
@@ -330,21 +530,16 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
const phoneInput = window.getByPlaceholder('Enter your PIN');
|
||||
|
||||
await phoneInput.clear();
|
||||
await typeIntoInput(phoneInput, PIN, '');
|
||||
await typeIntoInput(phoneInput, CORRECT_PIN, '');
|
||||
|
||||
server.setRegisterResponseError(undefined);
|
||||
await app.saveSVR2RestoreResponse({
|
||||
success: true,
|
||||
// @ts-expect-error We need to get this data through JSON
|
||||
data: toHex(DATA),
|
||||
data: toHex(originalMasterKey),
|
||||
triesRemaining: 5,
|
||||
});
|
||||
|
||||
// Force server to return storageCapable: true
|
||||
server.setRegisterResponseData({ storageCapable: true });
|
||||
|
||||
server.setRegisterResponseError(undefined);
|
||||
|
||||
await window.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
@@ -352,6 +547,73 @@ describe('registration', function (this: Mocha.Suite) {
|
||||
debug('COMPLETE: verify welcome screen');
|
||||
await expect(window.getByText('Welcome to Signal')).toBeVisible();
|
||||
}
|
||||
|
||||
{
|
||||
debug(
|
||||
'COMPLETE: verify that we pull pinned contact down from storage service'
|
||||
);
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
await leftPane
|
||||
.locator(`[data-testid="${firstContact.device.aci}"]`)
|
||||
.waitFor();
|
||||
}
|
||||
|
||||
let newStorageKey: Buffer<ArrayBuffer> | undefined;
|
||||
{
|
||||
debug(
|
||||
'COMPLETE: verify that we re-uploaded manifest encrypted with new storageKey'
|
||||
);
|
||||
|
||||
const expectedVersion = 3;
|
||||
const { version, storageKey, recordIkm } =
|
||||
await app.waitForUploadManifest(BigInt(expectedVersion));
|
||||
|
||||
assert.notDeepEqual(
|
||||
storageKey,
|
||||
Buffer.from(originalStorageKey),
|
||||
'storageKey should not match old storage key'
|
||||
);
|
||||
assert.deepEqual(recordIkm, Buffer.from(originalRecordIkm));
|
||||
assert.strictEqual(version, expectedVersion, 'we expect to be at v3!');
|
||||
|
||||
newStorageKey = storageKey;
|
||||
|
||||
const secondState = await server.waitForStorageState({
|
||||
aci,
|
||||
storageKey,
|
||||
recordIkm,
|
||||
after: originalState,
|
||||
});
|
||||
assert.strictEqual(
|
||||
secondState.version,
|
||||
BigInt(expectedVersion),
|
||||
'second state from server'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
debug('COMPLETE: Verify new data was stored back to SVR');
|
||||
|
||||
const storedData = await app.waitForSVRStore();
|
||||
assert.strictEqual(
|
||||
storedData?.pin,
|
||||
CORRECT_PIN,
|
||||
'checking what was stored in SVR'
|
||||
);
|
||||
|
||||
const newMasterKey = storedData.data;
|
||||
const derivedStorageKey = deriveStorageServiceKey(newMasterKey);
|
||||
assert.deepEqual(derivedStorageKey, newStorageKey);
|
||||
assert.notDeepEqual(newMasterKey, originalMasterKey);
|
||||
|
||||
const newSvrKey = new SvrKey(newMasterKey);
|
||||
const newRegistrationLockData = newSvrKey.deriveRegistrationLock();
|
||||
const newRegistrationLockToken = toHex(newRegistrationLockData);
|
||||
|
||||
const actualRegistrationLockToken = server.getRegistrationLockToken(aci);
|
||||
assert.strictEqual(newRegistrationLockToken, actualRegistrationLockToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('should show account locked screen on failed PIN with reglock', async () => {
|
||||
|
||||
@@ -18,6 +18,24 @@ import { DayOfWeek } from '../../types/NotificationProfile.std.ts';
|
||||
|
||||
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
|
||||
|
||||
export const DEFAULT_PROFILE = {
|
||||
allowAllCalls: true,
|
||||
allowAllMentions: false,
|
||||
scheduleStartTime: 900,
|
||||
scheduleEndTime: 1700,
|
||||
scheduleEnabled: false,
|
||||
scheduleDaysEnabled: [
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY,
|
||||
],
|
||||
emoji: null,
|
||||
allowedMembers: null,
|
||||
deletedAtTimestampMs: null,
|
||||
};
|
||||
|
||||
describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
@@ -91,7 +109,7 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
await window.locator('button[role="switch"]').click();
|
||||
await window.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
@@ -135,10 +153,10 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
await window.getByTestId('EditSchedule').click();
|
||||
await window.locator('button[role="switch"]').click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with edit page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
@@ -246,24 +264,6 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
generateNotificationProfileId()
|
||||
);
|
||||
|
||||
const DEFAULT_PROFILE = {
|
||||
allowAllCalls: true,
|
||||
allowAllMentions: false,
|
||||
scheduleStartTime: 900,
|
||||
scheduleEndTime: 1700,
|
||||
scheduleEnabled: false,
|
||||
scheduleDaysEnabled: [
|
||||
DayOfWeek.MONDAY,
|
||||
DayOfWeek.TUESDAY,
|
||||
DayOfWeek.WEDNESDAY,
|
||||
DayOfWeek.THURSDAY,
|
||||
DayOfWeek.FRIDAY,
|
||||
],
|
||||
emoji: null,
|
||||
allowedMembers: null,
|
||||
deletedAtTimestampMs: null,
|
||||
};
|
||||
|
||||
let uploadedState: StorageState;
|
||||
{
|
||||
let newState = firstState.addRecord({
|
||||
@@ -381,10 +381,10 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
await window.getByTestId('EditSchedule').click();
|
||||
await window.locator('button[role="switch"]').click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with edit page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
@@ -406,7 +406,7 @@ describe('storage service/notification profiles', function (this: Mocha.Suite) {
|
||||
debug('Schedule page');
|
||||
await window.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
debug('Done page');
|
||||
debug('Done with schedule page');
|
||||
await window.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
debug('List page');
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { PrivateKey } from '@signalapp/libsignal-client';
|
||||
|
||||
import * as durations from '../../util/durations/index.std.ts';
|
||||
import { initStorage } from './fixtures.node.ts';
|
||||
import { debug } from './fixtures.node.ts';
|
||||
import { getRandomBytes } from '../../Crypto.node.ts';
|
||||
import { isNotEmpty, toBase64 } from '../../Bytes.std.ts';
|
||||
|
||||
import type { Bootstrap } from './fixtures.node.ts';
|
||||
import type { App } from './fixtures.node.ts';
|
||||
import { fromAciUuidBytes } from '../../util/ServiceId.node.ts';
|
||||
|
||||
describe('storage service/protection', function (this: Mocha.Suite) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ bootstrap, app } = await initStorage());
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('should not take contact fields from storage service', async () => {
|
||||
const { phone, contacts } = bootstrap;
|
||||
|
||||
const alice = contacts[0];
|
||||
assert.exists(alice, 'first contact');
|
||||
|
||||
let state = await phone.expectStorageState('initial state');
|
||||
|
||||
debug(`waitng for desktop to pick up manifest version ${state.version}`);
|
||||
await app.waitForManifestVersion(state.version);
|
||||
|
||||
debug('updating contact record in storage service via phone');
|
||||
const newIdentityKey = PrivateKey.generate().getPublicKey().serialize();
|
||||
const newProfileKey = getRandomBytes(32);
|
||||
const newGivenName = 'Alice, updated';
|
||||
|
||||
state = state.updateContact(alice, {
|
||||
identityKey: newIdentityKey,
|
||||
profileKey: newProfileKey,
|
||||
givenName: newGivenName,
|
||||
});
|
||||
state = state.pin(alice);
|
||||
|
||||
const updatedState = await phone.setStorageState(state);
|
||||
await phone.sendFetchStorage({
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
});
|
||||
|
||||
debug(
|
||||
`waiting for Desktop to pick up manifest version ${updatedState.version}`
|
||||
);
|
||||
await app.waitForManifestVersion(updatedState.version);
|
||||
|
||||
const window = await app.getWindow();
|
||||
const conversationStack = window.locator('.Inbox__conversation-stack');
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
|
||||
debug('verifying that contact is pinned');
|
||||
await leftPane.locator(`[data-testid="${alice.device.aci}"]`).waitFor();
|
||||
|
||||
debug('unpinning via desktop');
|
||||
{
|
||||
const convo = leftPane.getByTestId(alice.device.aci);
|
||||
await convo.click();
|
||||
|
||||
const moreButton = conversationStack.getByRole('button', {
|
||||
name: 'More Info',
|
||||
});
|
||||
await moreButton.click();
|
||||
|
||||
const pinButton = window.getByRole('menuitem', {
|
||||
name: 'Unpin chat',
|
||||
exact: true,
|
||||
});
|
||||
await pinButton.click();
|
||||
}
|
||||
|
||||
debug("waiting for desktop's storage service update to get back to phone");
|
||||
|
||||
const newState = await phone.waitForStorageState({
|
||||
after: updatedState,
|
||||
predicate: maybeState => !maybeState.isPinned(alice),
|
||||
});
|
||||
|
||||
debug(
|
||||
"validating what's in storage service - alice should have original data"
|
||||
);
|
||||
|
||||
const aliceIdentityKey = (await alice.device.getIdentityKey()).serialize();
|
||||
|
||||
assert.isTrue(
|
||||
newState.hasRecord(item => {
|
||||
const contactRecord = item.record.contact;
|
||||
if (!isNotEmpty(contactRecord?.aciBinary)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aci = fromAciUuidBytes(contactRecord.aciBinary);
|
||||
if (aci !== alice.device.aci) {
|
||||
return false;
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
contactRecord.identityKey ? toBase64(contactRecord.identityKey) : '',
|
||||
toBase64(aliceIdentityKey),
|
||||
'identityKey'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
contactRecord.profileKey ? toBase64(contactRecord.profileKey) : '',
|
||||
toBase64(alice.profileKey.serialize()),
|
||||
'profileKey'
|
||||
);
|
||||
assert.strictEqual(
|
||||
contactRecord.givenName,
|
||||
alice.profileName,
|
||||
'profileName'
|
||||
);
|
||||
|
||||
return true;
|
||||
}),
|
||||
'verifying data on alice record'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5219,7 +5219,10 @@ export async function storeWithSVR2(
|
||||
|
||||
if (window.SignalCI) {
|
||||
log.info(`${logId}: Running under CI; saving data`);
|
||||
window.SignalCI.handleEvent('svrStore', options);
|
||||
window.SignalCI.handleEvent('svrStore', {
|
||||
pin: options.pin,
|
||||
dataBase64: Bytes.toBase64(options.data),
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -489,6 +489,11 @@ export const STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY = [
|
||||
'sendEditWarningShown',
|
||||
'formattingWarningShown',
|
||||
'localDeleteWarningShown',
|
||||
'masterKey',
|
||||
'linkPreviews',
|
||||
'typingIndicators',
|
||||
'manifestRecordIkm',
|
||||
'unidentifiedDeliveryIndicators',
|
||||
] as const satisfies ReadonlyArray<keyof StorageAccessType>;
|
||||
|
||||
const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [
|
||||
@@ -503,7 +508,6 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [
|
||||
'lastAttemptedToRefreshProfilesAt',
|
||||
'lastResortKeyUpdateTime',
|
||||
'lastResortKeyUpdateTimePNI',
|
||||
'masterKey',
|
||||
'accountEntropyPoolLastRequestTime',
|
||||
'maxPreKeyId',
|
||||
'maxPreKeyIdPNI',
|
||||
@@ -523,15 +527,12 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [
|
||||
'synced_at',
|
||||
'userAgent',
|
||||
'useRingrtcAdm',
|
||||
'linkPreviews',
|
||||
'retryPlaceholders',
|
||||
'donationPermits',
|
||||
'donationWorkflow',
|
||||
'chromiumRegistrationDone',
|
||||
'typingIndicators',
|
||||
'storageFetchComplete',
|
||||
'manifestVersion',
|
||||
'manifestRecordIkm',
|
||||
'storageCredentials',
|
||||
'storage-service-error-records',
|
||||
'storage-service-unknown-records',
|
||||
@@ -539,7 +540,6 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [
|
||||
'remoteConfig',
|
||||
'remoteConfigHash',
|
||||
'serverTimeSkew',
|
||||
'unidentifiedDeliveryIndicators',
|
||||
'groupCredentials',
|
||||
'callLinkAuthCredentials',
|
||||
'backupCombinedCredentials',
|
||||
|
||||
Reference in New Issue
Block a user