Link-and-sync

This commit is contained in:
Fedor Indutny 2024-10-18 10:15:03 -07:00 committed by GitHub
parent 455ff88918
commit 6565daa5c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 388 additions and 59 deletions

8
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

@ -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 {

View file

@ -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() {

View file

@ -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,

View file

@ -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>

View file

@ -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']}

View file

@ -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()

View file

@ -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);
}

View file

@ -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,

View file

@ -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>(

View file

@ -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;

View file

@ -265,3 +265,8 @@ export const getBackupMediaDownloadProgress = createSelector(
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
})
);
export const getIsRestoredFromBackup = createSelector(
getItems,
(state: ItemsStateType): boolean => state.isRestoredFromBackup === true
);

View file

@ -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}

View file

@ -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 },
},
],
},

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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';

View file

@ -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()));
}

View file

@ -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();

View file

@ -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,
};
}

View file

@ -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
) {

View file

@ -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;

View 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);
}

View file

@ -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()}`);
},