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