Storage Service: Updates for standalone readiness

This commit is contained in:
Scott Nonnenberg
2026-10-01 02:01:01 +10:00
committed by GitHub
parent d63a0e4b78
commit 334941a246
34 changed files with 2403 additions and 601 deletions
+87 -114
View File
@@ -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);
}
}
+308 -53
View File
@@ -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
//
+4
View File
@@ -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
+121 -33
View File
@@ -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>;
+4 -4
View File
@@ -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');
},
});
+7
View File
@@ -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,
};
}
-3
View File
@@ -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
View File
@@ -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: ",
+48
View File
@@ -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>
</>
);
+23
View File
@@ -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;
+4 -2
View File
@@ -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
View File
@@ -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');
+96 -29
View File
@@ -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
+1 -1
View File
@@ -194,7 +194,7 @@ function startInstaller(): ThunkAction<
window.IPC.addSetupMenuItems();
dispatch(cancelRegistration());
enableStorageService();
enableStorageService('ducks/installer: startInstaller');
dispatch({
type: START_INSTALLER,
+32 -5
View File
@@ -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);
+18 -5
View File
@@ -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);
}
+6 -1
View File
@@ -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
+2 -2
View File
@@ -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}
+2 -2
View File
@@ -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
+120 -5
View File
@@ -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');
});
+52 -1
View File
@@ -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'
);
});
});
+365 -103
View File
@@ -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'
);
});
});
+4 -1
View File
@@ -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 };
}
+5 -5
View File
@@ -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',