diff --git a/package-lock.json b/package-lock.json index 069162411687..006f7c33c33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 6e60e2fd2243..fc6754d434a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto index 3809a47a26c1..fad683bbfbcd 100644 --- a/protos/DeviceMessages.proto +++ b/protos/DeviceMessages.proto @@ -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 { diff --git a/ts/CI.ts b/ts/CI.ts index 34ba2cf4a4e9..54ee015393ed 100644 --- a/ts/CI.ts +++ b/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() { diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index 7dde0641375b..34f1a75a0b49 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -27,6 +27,7 @@ export default { updateSharedGroups: action('updateSharedGroups'), viewUserStories: action('viewUserStories'), toggleAboutContactModal: action('toggleAboutContactModal'), + isRestoredFromBackup: false, }, } satisfies Meta; @@ -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, diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index 70b9f0617ff5..dd32112c29dd 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -27,6 +27,7 @@ export type Props = { i18n: LocalizerType; isMe: boolean; isSignalConversation?: boolean; + isRestoredFromBackup: boolean; membersCount?: number; phoneNumber?: string; sharedGroupNames?: ReadonlyArray; @@ -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 && (
{i18n('icu:messageHistoryUnsynced')}
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 400617f19c20..6a23057bdb5b 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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']} diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index ee21cf882a40..4b6a41ed8069 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -91,6 +91,24 @@ export class BackupAPI { }); } + public async downloadEphemeral({ + downloadOffset, + onProgress, + abortSignal, + }: DownloadOptionsType): Promise { + const { cdn, key } = await this.server.getTransferArchive({ + abortSignal, + }); + + return this.server.getEphemeralBackupStream({ + cdn, + key, + downloadOffset, + onProgress, + abortSignal, + }); + } + public async getMediaUploadForm(): Promise { return this.server.getBackupMediaUploadForm( await this.credentials.getHeadersForToday() diff --git a/ts/services/backups/crypto.ts b/ts/services/backups/crypto.ts index c1fdcb71d2dc..8bc8ad8bd665 100644 --- a/ts/services/backups/crypto.ts +++ b/ts/services/backups/crypto.ts @@ -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); } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index b371cc63306d..63922e4bcd78 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -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 { 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 - ): Promise { + private async doDownload({ + downloadPath, + ephemeralKey, + onProgress, + }: DoDownloadOptionsType): Promise { 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, diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 7fda4b1d074c..3c1cab71927f 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -431,6 +431,7 @@ const ITEM_SPECS: Partial> = { senderCertificateNoE164: ['value.serialized'], subscriberId: ['value'], backupsSubscriberId: ['value'], + backupEphemeralKey: ['value'], usernameLink: ['value.entropy', 'value.serverId'], }; async function createOrUpdateItem( diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index 8f56ec09cd50..0d4f170ff092 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -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; diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index ed137f378052..e9c79514f4c3 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -265,3 +265,8 @@ export const getBackupMediaDownloadProgress = createSelector( downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false, }) ); + +export const getIsRestoredFromBackup = createSelector( + getItems, + (state: ItemsStateType): boolean => state.isRestoredFromBackup === true +); diff --git a/ts/state/smart/HeroRow.tsx b/ts/state/smart/HeroRow.tsx index 6803a568b336..640fe63b6241 100644 --- a/ts/state/smart/HeroRow.tsx +++ b/ts/state/smart/HeroRow.tsx @@ -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} diff --git a/ts/test-both/helpers/generateBackup.ts b/ts/test-both/helpers/generateBackup.ts index ada584344db7..779d9a250737 100644 --- a/ts/test-both/helpers/generateBackup.ts +++ b/ts/test-both/helpers/generateBackup.ts @@ -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; + 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 { 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 }, }, ], }, diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index 7b60838d522f..684caf27bb01 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -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(); + }); }); diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index 1587bbc6ca91..60f2f4e95f0f 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -116,6 +116,16 @@ export type BootstrapOptions = Readonly<{ contactPreKeyCount?: number; }>; +export type EphemeralBackupType = Readonly<{ + cdn: 3; + key: string; +}>; + +export type LinkOptionsType = Readonly<{ + extraConfig?: Partial; + ephemeralBackup?: EphemeralBackupType; +}>; + type BootstrapInternalOptions = BootstrapOptions & Readonly<{ benchmark: boolean; @@ -302,7 +312,10 @@ export class Bootstrap { ]); } - public async link(extraConfig?: Partial): Promise { + public async link({ + extraConfig, + ephemeralBackup, + }: LinkOptionsType = {}): Promise { 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(); diff --git a/ts/test-node/util/signalRoutes_test.ts b/ts/test-node/util/signalRoutes_test.ts index 823725b2000a..f273d2d19a4b 100644 --- a/ts/test-node/util/signalRoutes_test.ts +++ b/ts/test-node/util/signalRoutes_test.ts @@ -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'; diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index f05c07826206..d2cdc6c952c1 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -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())); } diff --git a/ts/textsecure/Provisioner.ts b/ts/textsecure/Provisioner.ts index ec8a99065c91..1b462dd04081 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -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(); diff --git a/ts/textsecure/ProvisioningCipher.ts b/ts/textsecure/ProvisioningCipher.ts index 8f291f3fd3aa..c1294c9e3c71 100644 --- a/ts/textsecure/ProvisioningCipher.ts +++ b/ts/textsecure/ProvisioningCipher.ts @@ -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, }; } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 43265ad7a90f..ab65eb2241ef 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -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([ '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; + +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; getBackupStream: (options: GetBackupStreamOptionsType) => Promise; + getEphemeralBackupStream: ( + options: GetEphemeralBackupStreamOptionsType + ) => Promise; getBackupUploadForm: ( headers: BackupPresentationHeadersType ) => Promise; @@ -1439,6 +1469,9 @@ export type WebAPIType = { getBackupCDNCredentials: ( options: GetBackupCDNCredentialsOptionsType ) => Promise; + getTransferArchive: ( + options: GetTransferArchiveOptionsType + ) => Promise; setBackupId: (options: SetBackupIdOptionsType) => Promise; 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; + function _ajax( + param: AjaxOptionsType & { responseType: 'jsonwithdetails' } + ): Promise; async function _ajax(param: AjaxOptionsType): Promise { if ( @@ -2209,6 +2247,45 @@ export function initialize({ })) as ProfileType; } + async function getTransferArchive({ + timeout = durations.HOUR, + abortSignal, + }: GetTransferArchiveOptionsType): Promise { + 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 { + return _getAttachment({ + cdnNumber: cdn, + cdnPath: `/attachments/${encodeURIComponent(key)}`, + redactor: _createRedactor(key), + options: { + downloadOffset, + onProgress, + abortSignal, + }, + }); + } + async function getBackupMediaUploadForm( headers: BackupPresentationHeadersType ) { diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 6dceb815cf99..18f69e6f234e 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -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; diff --git a/ts/util/isLinkAndSyncEnabled.ts b/ts/util/isLinkAndSyncEnabled.ts new file mode 100644 index 000000000000..a7fbe6472ff0 --- /dev/null +++ b/ts/util/isLinkAndSyncEnabled.ts @@ -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); +} diff --git a/ts/util/signalRoutes.ts b/ts/util/signalRoutes.ts index 2a357210bbb7..81ff0e46cb03 100644 --- a/ts/util/signalRoutes.ts +++ b/ts/util/signalRoutes.ts @@ -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()}`); },