Link-and-sync
This commit is contained in:
parent
455ff88918
commit
6565daa5c8
25 changed files with 388 additions and 59 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
@ -126,7 +126,7 @@
|
|||
"@indutny/parallel-prettier": "3.0.0",
|
||||
"@indutny/rezip-electron": "1.3.2",
|
||||
"@indutny/symbolicate-mac": "2.3.0",
|
||||
"@signalapp/mock-server": "8.1.1",
|
||||
"@signalapp/mock-server": "8.2.0",
|
||||
"@storybook/addon-a11y": "8.1.11",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
|
@ -7306,9 +7306,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@signalapp/mock-server": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.1.1.tgz",
|
||||
"integrity": "sha512-TlQpOyUYnDBV7boxyaLDaeGTN5WIn4trbF+9rq4+6rXfpzIBnf2A4Y1fzFRVL9F9/F4ZEPtrv+V3oplNrfoZ9w==",
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.2.0.tgz",
|
||||
"integrity": "sha512-gHg6sWMxh+VJ6KW5qGPcI+ITwkO45wieT148iTDKaWVchWo7vQh4yEW4B+OLJY29NXlfjf0TZb6ZLoFfnmEUSA==",
|
||||
"dev": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
|
|
@ -210,7 +210,7 @@
|
|||
"@indutny/parallel-prettier": "3.0.0",
|
||||
"@indutny/rezip-electron": "1.3.2",
|
||||
"@indutny/symbolicate-mac": "2.3.0",
|
||||
"@signalapp/mock-server": "8.1.1",
|
||||
"@signalapp/mock-server": "8.2.0",
|
||||
"@storybook/addon-a11y": "8.1.11",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
|
|
|
@ -27,6 +27,7 @@ message ProvisionMessage {
|
|||
optional bool readReceipts = 7;
|
||||
optional uint32 ProvisioningVersion = 9;
|
||||
optional bytes masterKey = 13;
|
||||
optional bytes ephemeralBackupKey = 14; // 32 bytes
|
||||
}
|
||||
|
||||
enum ProvisioningVersion {
|
||||
|
|
3
ts/CI.ts
3
ts/CI.ts
|
@ -170,6 +170,9 @@ export function getCI({ deviceName }: GetCIOptionsType): CIType {
|
|||
async function uploadBackup() {
|
||||
await backupsService.upload();
|
||||
await AttachmentBackupManager.waitForIdle();
|
||||
|
||||
// Remove the disclaimer from conversation hero for screenshot backup test
|
||||
await window.storage.put('isRestoredFromBackup', true);
|
||||
}
|
||||
|
||||
function unlink() {
|
||||
|
|
|
@ -27,6 +27,7 @@ export default {
|
|||
updateSharedGroups: action('updateSharedGroups'),
|
||||
viewUserStories: action('viewUserStories'),
|
||||
toggleAboutContactModal: action('toggleAboutContactModal'),
|
||||
isRestoredFromBackup: false,
|
||||
},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
|
@ -153,6 +154,11 @@ NoteToSelf.args = {
|
|||
isMe: true,
|
||||
};
|
||||
|
||||
export const ImportedFromBackup = Template.bind({});
|
||||
ImportedFromBackup.args = {
|
||||
isRestoredFromBackup: true,
|
||||
};
|
||||
|
||||
export const UnreadStories = Template.bind({});
|
||||
UnreadStories.args = {
|
||||
hasStories: HasStories.Unread,
|
||||
|
|
|
@ -27,6 +27,7 @@ export type Props = {
|
|||
i18n: LocalizerType;
|
||||
isMe: boolean;
|
||||
isSignalConversation?: boolean;
|
||||
isRestoredFromBackup: boolean;
|
||||
membersCount?: number;
|
||||
phoneNumber?: string;
|
||||
sharedGroupNames?: ReadonlyArray<string>;
|
||||
|
@ -144,6 +145,7 @@ export function ConversationHero({
|
|||
hasStories,
|
||||
id,
|
||||
isMe,
|
||||
isRestoredFromBackup,
|
||||
isSignalConversation,
|
||||
membersCount,
|
||||
sharedGroupNames = [],
|
||||
|
@ -276,7 +278,7 @@ export function ConversationHero({
|
|||
phoneNumber,
|
||||
sharedGroupNames,
|
||||
})}
|
||||
{!isSignalConversation && (
|
||||
{!isSignalConversation && !isRestoredFromBackup && (
|
||||
<div className="module-conversation-hero__linkNotification">
|
||||
{i18n('icu:messageHistoryUnsynced')}
|
||||
</div>
|
||||
|
|
|
@ -405,6 +405,7 @@ const renderHeroRow = () => {
|
|||
id={getDefaultConversation().id}
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
isRestoredFromBackup={false}
|
||||
phoneNumber={getPhoneNumber()}
|
||||
profileName={getProfileName()}
|
||||
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
|
||||
|
|
|
@ -91,6 +91,24 @@ export class BackupAPI {
|
|||
});
|
||||
}
|
||||
|
||||
public async downloadEphemeral({
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
}: DownloadOptionsType): Promise<Readable> {
|
||||
const { cdn, key } = await this.server.getTransferArchive({
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
return this.server.getEphemeralBackupStream({
|
||||
cdn,
|
||||
key,
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaUploadForm(): Promise<AttachmentUploadFormResponseType> {
|
||||
return this.server.getBackupMediaUploadForm(
|
||||
await this.credentials.getHeadersForToday()
|
||||
|
|
|
@ -46,8 +46,9 @@ const getMemoizedKeyMaterial = memoizee(
|
|||
}
|
||||
);
|
||||
|
||||
export function getKeyMaterial(): BackupKeyMaterialType {
|
||||
const backupKey = getBackupKey();
|
||||
export function getKeyMaterial(
|
||||
backupKey = getBackupKey()
|
||||
): BackupKeyMaterialType {
|
||||
const aci = window.storage.user.getCheckedAci();
|
||||
return getMemoizedKeyMaterial(backupKey, aci);
|
||||
}
|
||||
|
|
|
@ -59,8 +59,19 @@ export type DownloadOptionsType = Readonly<{
|
|||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
type DoDownloadOptionsType = Readonly<{
|
||||
downloadPath: string;
|
||||
ephemeralKey?: Uint8Array;
|
||||
onProgress?: (
|
||||
backupStep: InstallScreenBackupStep,
|
||||
currentBytes: number,
|
||||
totalBytes: number
|
||||
) => void;
|
||||
}>;
|
||||
|
||||
export type ImportOptionsType = Readonly<{
|
||||
backupType?: BackupType;
|
||||
ephemeralKey?: Uint8Array;
|
||||
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
||||
}>;
|
||||
|
||||
|
@ -104,16 +115,23 @@ export class BackupsService {
|
|||
return;
|
||||
}
|
||||
|
||||
log.info('backups.download: downloading...');
|
||||
|
||||
const ephemeralKey = window.storage.get('backupEphemeralKey');
|
||||
|
||||
const absoluteDownloadPath =
|
||||
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
|
||||
let hasBackup = false;
|
||||
log.info('backups.download: downloading...');
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
hasBackup = await this.doDownload(absoluteDownloadPath, options);
|
||||
hasBackup = await this.doDownload({
|
||||
downloadPath: absoluteDownloadPath,
|
||||
onProgress: options.onProgress,
|
||||
ephemeralKey,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'backups.download: error, prompting user to retry',
|
||||
|
@ -141,6 +159,8 @@ export class BackupsService {
|
|||
}
|
||||
|
||||
await window.storage.remove('backupDownloadPath');
|
||||
await window.storage.remove('backupEphemeralKey');
|
||||
await window.storage.put('isRestoredFromBackup', hasBackup);
|
||||
|
||||
log.info(`backups.download: done, had backup=${hasBackup}`);
|
||||
}
|
||||
|
@ -240,7 +260,11 @@ export class BackupsService {
|
|||
|
||||
public async importBackup(
|
||||
createBackupStream: () => Readable,
|
||||
{ backupType = BackupType.Ciphertext, onProgress }: ImportOptionsType = {}
|
||||
{
|
||||
backupType = BackupType.Ciphertext,
|
||||
ephemeralKey,
|
||||
onProgress,
|
||||
}: ImportOptionsType = {}
|
||||
): Promise<void> {
|
||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||
|
||||
|
@ -250,7 +274,7 @@ export class BackupsService {
|
|||
try {
|
||||
const importStream = await BackupImportStream.create(backupType);
|
||||
if (backupType === BackupType.Ciphertext) {
|
||||
const { aesKey, macKey } = getKeyMaterial();
|
||||
const { aesKey, macKey } = getKeyMaterial(ephemeralKey);
|
||||
|
||||
// First pass - don't decrypt, only verify mac
|
||||
let hmac = createHmac(HashType.size256, macKey);
|
||||
|
@ -311,6 +335,10 @@ export class BackupsService {
|
|||
isTestOrMockEnvironment(),
|
||||
'Plaintext backups can be imported only in test harness'
|
||||
);
|
||||
strictAssert(
|
||||
ephemeralKey == null,
|
||||
'Plaintext backups cannot have ephemeral key'
|
||||
);
|
||||
await pipeline(
|
||||
createBackupStream(),
|
||||
new DelimitedStream(),
|
||||
|
@ -376,10 +404,11 @@ export class BackupsService {
|
|||
return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber };
|
||||
}
|
||||
|
||||
private async doDownload(
|
||||
downloadPath: string,
|
||||
{ onProgress }: Pick<DownloadOptionsType, 'onProgress'>
|
||||
): Promise<boolean> {
|
||||
private async doDownload({
|
||||
downloadPath,
|
||||
ephemeralKey,
|
||||
onProgress,
|
||||
}: DoDownloadOptionsType): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort previous download
|
||||
|
@ -397,6 +426,13 @@ export class BackupsService {
|
|||
// File is missing - start from the beginning
|
||||
}
|
||||
|
||||
const onDownloadProgress = (
|
||||
currentBytes: number,
|
||||
totalBytes: number
|
||||
): void => {
|
||||
onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes);
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureFile(downloadPath);
|
||||
|
||||
|
@ -404,17 +440,20 @@ export class BackupsService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const stream = await this.api.download({
|
||||
downloadOffset,
|
||||
onProgress: (currentBytes, totalBytes) => {
|
||||
onProgress?.(
|
||||
InstallScreenBackupStep.Download,
|
||||
currentBytes,
|
||||
totalBytes
|
||||
);
|
||||
},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
let stream: Readable;
|
||||
if (ephemeralKey == null) {
|
||||
stream = await this.api.download({
|
||||
downloadOffset,
|
||||
onProgress: onDownloadProgress,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
} else {
|
||||
stream = await this.api.downloadEphemeral({
|
||||
downloadOffset,
|
||||
onProgress: onDownloadProgress,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
|
@ -437,6 +476,7 @@ export class BackupsService {
|
|||
// Too late to cancel now
|
||||
try {
|
||||
await this.importFromDisk(downloadPath, {
|
||||
ephemeralKey,
|
||||
onProgress: (currentBytes, totalBytes) => {
|
||||
onProgress?.(
|
||||
InstallScreenBackupStep.Process,
|
||||
|
|
|
@ -431,6 +431,7 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
|||
senderCertificateNoE164: ['value.serialized'],
|
||||
subscriberId: ['value'],
|
||||
backupsSubscriberId: ['value'],
|
||||
backupEphemeralKey: ['value'],
|
||||
usernameLink: ['value.entropy', 'value.serverId'],
|
||||
};
|
||||
async function createOrUpdateItem<K extends ItemKeyType>(
|
||||
|
|
|
@ -196,7 +196,7 @@ function startInstaller(): ThunkAction<
|
|||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'Expected a server');
|
||||
|
||||
const provisioner = new Provisioner(server);
|
||||
const provisioner = new Provisioner(server, window.getVersion());
|
||||
|
||||
const abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
|
|
|
@ -265,3 +265,8 @@ export const getBackupMediaDownloadProgress = createSelector(
|
|||
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
|
||||
})
|
||||
);
|
||||
|
||||
export const getIsRestoredFromBackup = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean => state.isRestoredFromBackup === true
|
||||
);
|
||||
|
|
|
@ -8,6 +8,7 @@ import { getIntl, getTheme } from '../selectors/user';
|
|||
import { getHasStoriesSelector } from '../selectors/stories2';
|
||||
import { isSignalConversation } from '../../util/isSignalConversation';
|
||||
import { getConversationSelector } from '../selectors/conversations';
|
||||
import { getIsRestoredFromBackup } from '../selectors/items';
|
||||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||
import { useStoriesActions } from '../ducks/stories';
|
||||
|
@ -24,6 +25,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
|
|||
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||
const hasStoriesSelector = useSelector(getHasStoriesSelector);
|
||||
const conversationSelector = useSelector(getConversationSelector);
|
||||
const isRestoredFromBackup = useSelector(getIsRestoredFromBackup);
|
||||
const conversation = conversationSelector(id);
|
||||
if (conversation == null) {
|
||||
throw new Error(`Did not find conversation ${id} in state!`);
|
||||
|
@ -60,6 +62,7 @@ export const SmartHeroRow = memo(function SmartHeroRow({
|
|||
i18n={i18n}
|
||||
id={id}
|
||||
isMe={isMe}
|
||||
isRestoredFromBackup={isRestoredFromBackup}
|
||||
isSignalConversation={isSignalConversationValue}
|
||||
membersCount={membersCount}
|
||||
phoneNumber={phoneNumber}
|
||||
|
|
|
@ -22,13 +22,22 @@ import {
|
|||
import { BACKUP_VERSION } from '../../services/backups/constants';
|
||||
import { Backups } from '../../protobuf';
|
||||
|
||||
export type BackupGeneratorConfigType = Readonly<{
|
||||
aci: AciString;
|
||||
profileKey: Buffer;
|
||||
masterKey: Buffer;
|
||||
conversations: number;
|
||||
messages: number;
|
||||
}>;
|
||||
export type BackupGeneratorConfigType = Readonly<
|
||||
{
|
||||
aci: AciString;
|
||||
profileKey: Buffer;
|
||||
conversations: number;
|
||||
conversationAcis?: ReadonlyArray<AciString>;
|
||||
messages: number;
|
||||
} & (
|
||||
| {
|
||||
masterKey: Buffer;
|
||||
}
|
||||
| {
|
||||
backupKey: Buffer;
|
||||
}
|
||||
)
|
||||
>;
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
|
@ -40,8 +49,13 @@ export type GenerateBackupResultType = Readonly<{
|
|||
export function generateBackup(
|
||||
options: BackupGeneratorConfigType
|
||||
): GenerateBackupResultType {
|
||||
const { aci, masterKey } = options;
|
||||
const backupKey = deriveBackupKey(masterKey);
|
||||
const { aci } = options;
|
||||
let backupKey: Uint8Array;
|
||||
if ('masterKey' in options) {
|
||||
backupKey = deriveBackupKey(options.masterKey);
|
||||
} else {
|
||||
({ backupKey } = options);
|
||||
}
|
||||
const aciBytes = toAciObject(aci).getServiceIdBinary();
|
||||
const backupId = Buffer.from(deriveBackupId(backupKey, aciBytes));
|
||||
const { aesKey, macKey } = deriveBackupKeyMaterial(backupKey, backupId);
|
||||
|
@ -71,6 +85,7 @@ function getTimestamp(): Long {
|
|||
function* createRecords({
|
||||
profileKey,
|
||||
conversations,
|
||||
conversationAcis = [],
|
||||
messages,
|
||||
}: BackupGeneratorConfigType): Iterable<Buffer> {
|
||||
yield Buffer.from(
|
||||
|
@ -129,7 +144,9 @@ function* createRecords({
|
|||
|
||||
for (let i = 1; i <= conversations; i += 1) {
|
||||
const id = Long.fromNumber(i);
|
||||
const chatAci = toAciObject(generateAci()).getRawUuidBytes();
|
||||
const chatAci = toAciObject(
|
||||
conversationAcis.at(i - 1) ?? generateAci()
|
||||
).getRawUuidBytes();
|
||||
|
||||
chats.push({
|
||||
id,
|
||||
|
@ -202,7 +219,7 @@ function* createRecords({
|
|||
{
|
||||
recipientId: chat.id,
|
||||
timestamp: dateSent,
|
||||
sent: { sealedSender: true },
|
||||
delivered: { sealedSender: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import createDebug from 'debug';
|
||||
|
@ -10,6 +11,8 @@ import { expect } from 'playwright/test';
|
|||
|
||||
import { generateStoryDistributionId } from '../../types/StoryDistributionId';
|
||||
import { MY_STORY_ID } from '../../types/Stories';
|
||||
import { generateAci } from '../../types/ServiceId';
|
||||
import { generateBackup } from '../../test-both/helpers/generateBackup';
|
||||
import { IMAGE_JPEG } from '../../types/MIME';
|
||||
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||
import * as durations from '../../util/durations';
|
||||
|
@ -45,7 +48,19 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap();
|
||||
await bootstrap.init();
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('exports and imports regular backup', async function () {
|
||||
let state = StorageState.getEmpty();
|
||||
|
||||
const { phone, contacts } = bootstrap;
|
||||
|
@ -102,21 +117,8 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
await phone.setStorageState(state);
|
||||
|
||||
app = await bootstrap.link();
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('exports and imports backup', async function () {
|
||||
const { contacts, phone, desktop, server } = bootstrap;
|
||||
const [friend, pinned] = contacts;
|
||||
const { desktop, server } = bootstrap;
|
||||
|
||||
{
|
||||
const window = await app.getWindow();
|
||||
|
@ -312,4 +314,52 @@ describe('backups', function (this: Mocha.Suite) {
|
|||
|
||||
await comparator(app);
|
||||
});
|
||||
|
||||
it('imports ephemeral backup', async function () {
|
||||
const ephemeralBackupKey = randomBytes(32);
|
||||
const cdnKey = randomBytes(16).toString('hex');
|
||||
|
||||
const { phone, server } = bootstrap;
|
||||
|
||||
const contact1 = generateAci();
|
||||
const contact2 = generateAci();
|
||||
|
||||
phone.ephemeralBackupKey = ephemeralBackupKey;
|
||||
|
||||
// Store backup attachment in transit tier
|
||||
const { stream: backupStream } = generateBackup({
|
||||
aci: phone.device.aci,
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
backupKey: ephemeralBackupKey,
|
||||
conversations: 2,
|
||||
conversationAcis: [contact1, contact2],
|
||||
messages: 50,
|
||||
});
|
||||
|
||||
await server.storeAttachmentOnCdn(3, cdnKey, backupStream);
|
||||
|
||||
app = await bootstrap.link({
|
||||
ephemeralBackup: {
|
||||
cdn: 3,
|
||||
key: cdnKey,
|
||||
},
|
||||
});
|
||||
|
||||
await app.waitForBackupImportComplete();
|
||||
|
||||
const window = await app.getWindow();
|
||||
|
||||
const leftPane = window.locator('#LeftPane');
|
||||
|
||||
const contact1Elem = leftPane.locator(
|
||||
`[data-testid="${contact1}"] >> "Message 48"`
|
||||
);
|
||||
const contact2Elem = leftPane.locator(
|
||||
`[data-testid="${contact2}"] >> "Message 49"`
|
||||
);
|
||||
await contact1Elem.waitFor();
|
||||
|
||||
await contact2Elem.click();
|
||||
await window.locator('.module-message >> "Message 33"').waitFor();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -116,6 +116,16 @@ export type BootstrapOptions = Readonly<{
|
|||
contactPreKeyCount?: number;
|
||||
}>;
|
||||
|
||||
export type EphemeralBackupType = Readonly<{
|
||||
cdn: 3;
|
||||
key: string;
|
||||
}>;
|
||||
|
||||
export type LinkOptionsType = Readonly<{
|
||||
extraConfig?: Partial<RendererConfigType>;
|
||||
ephemeralBackup?: EphemeralBackupType;
|
||||
}>;
|
||||
|
||||
type BootstrapInternalOptions = BootstrapOptions &
|
||||
Readonly<{
|
||||
benchmark: boolean;
|
||||
|
@ -302,7 +312,10 @@ export class Bootstrap {
|
|||
]);
|
||||
}
|
||||
|
||||
public async link(extraConfig?: Partial<RendererConfigType>): Promise<App> {
|
||||
public async link({
|
||||
extraConfig,
|
||||
ephemeralBackup,
|
||||
}: LinkOptionsType = {}): Promise<App> {
|
||||
debug('linking');
|
||||
|
||||
const app = await this.startApp(extraConfig);
|
||||
|
@ -333,6 +346,10 @@ export class Bootstrap {
|
|||
primaryDevice: this.phone,
|
||||
});
|
||||
|
||||
if (ephemeralBackup != null) {
|
||||
await this.server.provideTransferArchive(this.desktop, ephemeralBackup);
|
||||
}
|
||||
|
||||
debug('new desktop device %j', this.desktop.debugId);
|
||||
|
||||
const desktopKey = await this.desktop.popSingleUseKey();
|
||||
|
|
|
@ -98,16 +98,40 @@ describe('signalRoutes', () => {
|
|||
check(`sgnl://joingroup#${fooNoSlash}`, result);
|
||||
});
|
||||
|
||||
it('linkDevice', () => {
|
||||
it('linkDevice without capabilities', () => {
|
||||
const result: ParsedSignalRoute = {
|
||||
key: 'linkDevice',
|
||||
args: { uuid: foo, pubKey: foo },
|
||||
args: { uuid: foo, pubKey: foo, capabilities: [] },
|
||||
};
|
||||
const check = createCheck({ hasWebUrl: false });
|
||||
check(`sgnl://linkdevice/?uuid=${foo}&pub_key=${foo}`, result);
|
||||
check(`sgnl://linkdevice?uuid=${foo}&pub_key=${foo}`, result);
|
||||
});
|
||||
|
||||
it('linkDevice with one capability', () => {
|
||||
const result: ParsedSignalRoute = {
|
||||
key: 'linkDevice',
|
||||
args: { uuid: foo, pubKey: foo, capabilities: ['backup'] },
|
||||
};
|
||||
const check = createCheck({ hasWebUrl: false });
|
||||
check(
|
||||
`sgnl://linkdevice/?uuid=${foo}&pub_key=${foo}&capabilities=backup`,
|
||||
result
|
||||
);
|
||||
});
|
||||
|
||||
it('linkDevice with multiple capabilities', () => {
|
||||
const result: ParsedSignalRoute = {
|
||||
key: 'linkDevice',
|
||||
args: { uuid: foo, pubKey: foo, capabilities: ['a', 'b'] },
|
||||
};
|
||||
const check = createCheck({ hasWebUrl: false });
|
||||
check(
|
||||
`sgnl://linkdevice/?uuid=${foo}&pub_key=${foo}&capabilities=a%2Cb`,
|
||||
result
|
||||
);
|
||||
});
|
||||
|
||||
it('captcha', () => {
|
||||
const captchaId =
|
||||
'signal-hcaptcha.Foo-bAr_baz.challenge.fOo-bAR_baZ.fOO-BaR_baz';
|
||||
|
|
|
@ -134,6 +134,7 @@ type CreatePrimaryDeviceOptionsType = Readonly<{
|
|||
ourAci?: undefined;
|
||||
ourPni?: undefined;
|
||||
userAgent?: undefined;
|
||||
ephemeralBackupKey?: undefined;
|
||||
|
||||
readReceipts: true;
|
||||
|
||||
|
@ -149,6 +150,7 @@ export type CreateLinkedDeviceOptionsType = Readonly<{
|
|||
ourAci: AciString;
|
||||
ourPni: PniString;
|
||||
userAgent?: string;
|
||||
ephemeralBackupKey: Uint8Array | undefined;
|
||||
|
||||
readReceipts: boolean;
|
||||
|
||||
|
@ -333,6 +335,7 @@ export default class AccountManager extends EventTarget {
|
|||
profileKey,
|
||||
accessKey,
|
||||
masterKey,
|
||||
ephemeralBackupKey: undefined,
|
||||
readReceipts: true,
|
||||
});
|
||||
});
|
||||
|
@ -1098,6 +1101,9 @@ export default class AccountManager extends EventTarget {
|
|||
// storage service and message receiver are not operating
|
||||
// until the backup is downloaded and imported.
|
||||
if (isBackupEnabled() && cleanStart) {
|
||||
if (options.type === AccountType.Linked && options.ephemeralBackupKey) {
|
||||
await storage.put('backupEphemeralKey', options.ephemeralBackupKey);
|
||||
}
|
||||
await storage.put('backupDownloadPath', getRelativePath(createName()));
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { linkDeviceRoute } from '../util/signalRoutes';
|
|||
import { strictAssert } from '../util/assert';
|
||||
import { normalizeAci } from '../util/normalizeAci';
|
||||
import { normalizeDeviceName } from '../util/normalizeDeviceName';
|
||||
import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled';
|
||||
import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen';
|
||||
import * as Errors from '../types/errors';
|
||||
import {
|
||||
|
@ -77,7 +78,10 @@ export class Provisioner {
|
|||
private state: StateType = { step: Step.Idle };
|
||||
private wsr: IWebSocketResource | undefined;
|
||||
|
||||
constructor(private readonly server: WebAPIType) {}
|
||||
constructor(
|
||||
private readonly server: WebAPIType,
|
||||
private readonly appVersion: string
|
||||
) {}
|
||||
|
||||
public close(error = new Error('Provisioner closed')): void {
|
||||
try {
|
||||
|
@ -171,6 +175,7 @@ export class Provisioner {
|
|||
untaggedPni,
|
||||
userAgent,
|
||||
readReceipts,
|
||||
ephemeralBackupKey,
|
||||
} = envelope;
|
||||
|
||||
strictAssert(number, 'prepareLinkData: missing number');
|
||||
|
@ -214,6 +219,7 @@ export class Provisioner {
|
|||
ourPni,
|
||||
readReceipts: Boolean(readReceipts),
|
||||
masterKey,
|
||||
ephemeralBackupKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -239,6 +245,7 @@ export class Provisioner {
|
|||
.toAppUrl({
|
||||
uuid,
|
||||
pubKey: Bytes.toBase64(pubKey),
|
||||
capabilities: isLinkAndSyncEnabled(this.appVersion) ? ['backup'] : [],
|
||||
})
|
||||
.toString();
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ export type ProvisionDecryptResult = Readonly<{
|
|||
readReceipts?: boolean;
|
||||
profileKey?: Uint8Array;
|
||||
masterKey?: Uint8Array;
|
||||
ephemeralBackupKey: Uint8Array | undefined;
|
||||
}>;
|
||||
|
||||
class ProvisioningCipherInner {
|
||||
|
@ -90,6 +91,9 @@ class ProvisioningCipherInner {
|
|||
masterKey: Bytes.isNotEmpty(provisionMessage.masterKey)
|
||||
? provisionMessage.masterKey
|
||||
: undefined,
|
||||
ephemeralBackupKey: Bytes.isNotEmpty(provisionMessage.ephemeralBackupKey)
|
||||
? provisionMessage.ephemeralBackupKey
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -628,6 +628,7 @@ const URL_CALLS = {
|
|||
storageToken: 'v1/storage/auth',
|
||||
subscriptions: 'v1/subscription',
|
||||
subscriptionConfiguration: 'v1/subscription/configuration',
|
||||
transferArchive: 'v1/devices/transfer_archive',
|
||||
updateDeviceName: 'v1/accounts/name',
|
||||
username: 'v1/accounts/username_hash',
|
||||
reserveUsername: 'v1/accounts/username_hash/reserve',
|
||||
|
@ -660,6 +661,7 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
|||
'devices',
|
||||
'linkDevice',
|
||||
'registerCapabilities',
|
||||
'transferArchive',
|
||||
|
||||
// Directory
|
||||
'directoryAuthV2',
|
||||
|
@ -719,7 +721,12 @@ type AjaxOptionsType = {
|
|||
jsonData?: unknown;
|
||||
password?: string;
|
||||
redactUrl?: RedactUrl;
|
||||
responseType?: 'json' | 'bytes' | 'byteswithdetails' | 'stream';
|
||||
responseType?:
|
||||
| 'json'
|
||||
| 'jsonwithdetails'
|
||||
| 'bytes'
|
||||
| 'byteswithdetails'
|
||||
| 'stream';
|
||||
schema?: unknown;
|
||||
timeout?: number;
|
||||
urlParameters?: string;
|
||||
|
@ -1188,6 +1195,14 @@ export type GetBackupStreamOptionsType = Readonly<{
|
|||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type GetEphemeralBackupStreamOptionsType = Readonly<{
|
||||
cdn: number;
|
||||
key: string;
|
||||
downloadOffset: number;
|
||||
onProgress: (currentBytes: number, totalBytes: number) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export const getBackupInfoResponseSchema = z.object({
|
||||
cdn: z.literal(3),
|
||||
backupDir: z.string(),
|
||||
|
@ -1225,6 +1240,18 @@ const StickerPackUploadFormSchema = z.object({
|
|||
stickers: z.array(StickerPackUploadAttributesSchema),
|
||||
});
|
||||
|
||||
const TransferArchiveSchema = z.object({
|
||||
cdn: z.literal(3),
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export type TransferArchiveType = z.infer<typeof TransferArchiveSchema>;
|
||||
|
||||
export type GetTransferArchiveOptionsType = Readonly<{
|
||||
timeout?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export type WebAPIType = {
|
||||
startRegistration(): unknown;
|
||||
finishRegistration(baton: unknown): void;
|
||||
|
@ -1426,6 +1453,9 @@ export type WebAPIType = {
|
|||
headers: BackupPresentationHeadersType
|
||||
) => Promise<GetBackupInfoResponseType>;
|
||||
getBackupStream: (options: GetBackupStreamOptionsType) => Promise<Readable>;
|
||||
getEphemeralBackupStream: (
|
||||
options: GetEphemeralBackupStreamOptionsType
|
||||
) => Promise<Readable>;
|
||||
getBackupUploadForm: (
|
||||
headers: BackupPresentationHeadersType
|
||||
) => Promise<AttachmentUploadFormResponseType>;
|
||||
|
@ -1439,6 +1469,9 @@ export type WebAPIType = {
|
|||
getBackupCDNCredentials: (
|
||||
options: GetBackupCDNCredentialsOptionsType
|
||||
) => Promise<GetBackupCDNCredentialsResponseType>;
|
||||
getTransferArchive: (
|
||||
options: GetTransferArchiveOptionsType
|
||||
) => Promise<TransferArchiveType>;
|
||||
setBackupId: (options: SetBackupIdOptionsType) => Promise<void>;
|
||||
setBackupSignatureKey: (
|
||||
options: SetBackupSignatureKeyOptionsType
|
||||
|
@ -1765,6 +1798,7 @@ export function initialize({
|
|||
getGroup,
|
||||
getGroupAvatar,
|
||||
getGroupCredentials,
|
||||
getEphemeralBackupStream,
|
||||
getExternalGroupCredential,
|
||||
getGroupFromLink,
|
||||
getGroupLog,
|
||||
|
@ -1777,6 +1811,7 @@ export function initialize({
|
|||
getProfile,
|
||||
getProfileUnauth,
|
||||
getProvisioningResource,
|
||||
getTransferArchive,
|
||||
getSenderCertificate,
|
||||
getSocketStatus,
|
||||
getSticker,
|
||||
|
@ -1841,6 +1876,9 @@ export function initialize({
|
|||
function _ajax(
|
||||
param: AjaxOptionsType & { responseType: 'json' }
|
||||
): Promise<unknown>;
|
||||
function _ajax(
|
||||
param: AjaxOptionsType & { responseType: 'jsonwithdetails' }
|
||||
): Promise<JSONWithDetailsType>;
|
||||
|
||||
async function _ajax(param: AjaxOptionsType): Promise<unknown> {
|
||||
if (
|
||||
|
@ -2209,6 +2247,45 @@ export function initialize({
|
|||
})) as ProfileType;
|
||||
}
|
||||
|
||||
async function getTransferArchive({
|
||||
timeout = durations.HOUR,
|
||||
abortSignal,
|
||||
}: GetTransferArchiveOptionsType): Promise<TransferArchiveType> {
|
||||
const timeoutTime = Date.now() + timeout;
|
||||
|
||||
const urlParameters = timeout
|
||||
? `?timeout=${encodeURIComponent(Math.round(timeout / SECOND))}`
|
||||
: undefined;
|
||||
|
||||
let remainingTime: number;
|
||||
do {
|
||||
remainingTime = Math.max(timeoutTime - Date.now(), 0);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { data, response }: JSONWithDetailsType = await _ajax({
|
||||
call: 'transferArchive',
|
||||
httpType: 'GET',
|
||||
responseType: 'jsonwithdetails',
|
||||
urlParameters,
|
||||
timeout: remainingTime,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
return TransferArchiveSchema.parse(data);
|
||||
}
|
||||
|
||||
strictAssert(
|
||||
response.status === 204,
|
||||
'Invalid transfer archive status code'
|
||||
);
|
||||
|
||||
// Timed out, see if we can retry
|
||||
} while (!timeout || remainingTime != null);
|
||||
|
||||
throw new Error('Timed out');
|
||||
}
|
||||
|
||||
async function getAccountForUsername({
|
||||
hash,
|
||||
}: GetAccountForUsernameOptionsType) {
|
||||
|
@ -2874,6 +2951,25 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function getEphemeralBackupStream({
|
||||
cdn,
|
||||
key,
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
}: GetEphemeralBackupStreamOptionsType): Promise<Readable> {
|
||||
return _getAttachment({
|
||||
cdnNumber: cdn,
|
||||
cdnPath: `/attachments/${encodeURIComponent(key)}`,
|
||||
redactor: _createRedactor(key),
|
||||
options: {
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getBackupMediaUploadForm(
|
||||
headers: BackupPresentationHeadersType
|
||||
) {
|
||||
|
|
7
ts/types/Storage.d.ts
vendored
7
ts/types/Storage.d.ts
vendored
|
@ -192,6 +192,13 @@ export type StorageAccessType = {
|
|||
// If present - we are downloading backup
|
||||
backupDownloadPath: string;
|
||||
|
||||
// If present together with backupDownloadPath - we are downloading
|
||||
// link-and-sync backup
|
||||
backupEphemeralKey: Uint8Array;
|
||||
|
||||
// If true Desktop message history was restored from backup
|
||||
isRestoredFromBackup: boolean;
|
||||
|
||||
// Deprecated
|
||||
'challenge:retry-message-ids': never;
|
||||
nextSignedKeyRotationTime: number;
|
||||
|
|
16
ts/util/isLinkAndSyncEnabled.ts
Normal file
16
ts/util/isLinkAndSyncEnabled.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isTestOrMockEnvironment } from '../environment';
|
||||
import { isStagingServer } from './isStagingServer';
|
||||
import { isAlpha } from './version';
|
||||
import { everDone as wasRegistrationEverDone } from './registration';
|
||||
|
||||
export function isLinkAndSyncEnabled(version: string): boolean {
|
||||
// Cannot overwrite existing message history
|
||||
if (wasRegistrationEverDone()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isStagingServer() || isTestOrMockEnvironment() || isAlpha(version);
|
||||
}
|
|
@ -313,8 +313,9 @@ export const groupInvitesRoute = _route('groupInvites', {
|
|||
* linkDeviceRoute.toAppUrl({
|
||||
* uuid: "123",
|
||||
* pubKey: "abc",
|
||||
* capabilities: "backuo"
|
||||
* })
|
||||
* // URL { "sgnl://linkdevice?uuid=123&pub_key=abc" }
|
||||
* // URL { "sgnl://linkdevice?uuid=123&pub_key=abc&capabilities=backup" }
|
||||
* ```
|
||||
*/
|
||||
export const linkDeviceRoute = _route('linkDevice', {
|
||||
|
@ -322,18 +323,21 @@ export const linkDeviceRoute = _route('linkDevice', {
|
|||
schema: z.object({
|
||||
uuid: paramSchema, // base64url?
|
||||
pubKey: paramSchema, // percent-encoded base64 (with padding) of PublicKey with type byte included
|
||||
capabilities: paramSchema.array(), // comma-separated list of capabilities
|
||||
}),
|
||||
parse(result) {
|
||||
const params = new URLSearchParams(result.search.groups.params);
|
||||
return {
|
||||
uuid: params.get('uuid'),
|
||||
pubKey: params.get('pub_key'),
|
||||
capabilities: params.get('capabilities')?.split(',') ?? [],
|
||||
};
|
||||
},
|
||||
toAppUrl(args) {
|
||||
const params = new URLSearchParams({
|
||||
uuid: args.uuid,
|
||||
pub_key: args.pubKey,
|
||||
capabilities: args.capabilities.join(','),
|
||||
});
|
||||
return new URL(`sgnl://linkdevice?${params.toString()}`);
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue