Backup Server APIs
This commit is contained in:
parent
77aea40a63
commit
3eb0e30a23
14 changed files with 991 additions and 201 deletions
25
ts/Crypto.ts
25
ts/Crypto.ts
|
@ -154,6 +154,31 @@ export function deriveBackupKey(masterKey: Uint8Array): Uint8Array {
|
|||
);
|
||||
}
|
||||
|
||||
const BACKUP_SIGNATURE_KEY_LEN = 32;
|
||||
const BACKUP_SIGNATURE_KEY_INFO =
|
||||
'20231003_Signal_Backups_GenerateBackupIdKeyPair';
|
||||
|
||||
export function deriveBackupSignatureKey(
|
||||
backupKey: Uint8Array,
|
||||
aciBytes: Uint8Array
|
||||
): Uint8Array {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveBackupId: invalid backup key length');
|
||||
}
|
||||
|
||||
if (aciBytes.byteLength !== UUID_BYTE_SIZE) {
|
||||
throw new Error('deriveBackupId: invalid aci length');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
return hkdf.deriveSecrets(
|
||||
BACKUP_SIGNATURE_KEY_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_SIGNATURE_KEY_INFO),
|
||||
Buffer.from(aciBytes)
|
||||
);
|
||||
}
|
||||
|
||||
const BACKUP_ID_LEN = 16;
|
||||
const BACKUP_ID_INFO = '20231003_Signal_Backups_GenerateBackupId';
|
||||
|
||||
|
|
|
@ -190,6 +190,7 @@ import {
|
|||
getCallLinksForRedux,
|
||||
loadCallLinks,
|
||||
} from './services/callLinksLoader';
|
||||
import { backupsService } from './services/backups';
|
||||
import {
|
||||
getCallIdFromEra,
|
||||
updateLocalGroupCallHistoryTimestamp,
|
||||
|
@ -699,6 +700,8 @@ export async function startApp(): Promise<void> {
|
|||
storage: window.storage,
|
||||
});
|
||||
|
||||
backupsService.start();
|
||||
|
||||
areWeASubscriberService.update(window.storage, server);
|
||||
|
||||
void cleanupSessionResets();
|
||||
|
|
72
ts/services/backups/api.ts
Normal file
72
ts/services/backups/api.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type {
|
||||
WebAPIType,
|
||||
GetBackupInfoResponseType,
|
||||
GetBackupUploadFormResponseType,
|
||||
BackupMediaItemType,
|
||||
BackupMediaBatchResponseType,
|
||||
BackupListMediaResponseType,
|
||||
} from '../../textsecure/WebAPI';
|
||||
import type { BackupCredentials } from './credentials';
|
||||
|
||||
export class BackupAPI {
|
||||
constructor(private credentials: BackupCredentials) {}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
// TODO: DESKTOP-6979
|
||||
await this.server.refreshBackup(
|
||||
await this.credentials.getHeadersForToday()
|
||||
);
|
||||
}
|
||||
|
||||
public async getInfo(): Promise<GetBackupInfoResponseType> {
|
||||
return this.server.getBackupInfo(
|
||||
await this.credentials.getHeadersForToday()
|
||||
);
|
||||
}
|
||||
|
||||
public async getUploadForm(): Promise<GetBackupUploadFormResponseType> {
|
||||
return this.server.getBackupUploadForm(
|
||||
await this.credentials.getHeadersForToday()
|
||||
);
|
||||
}
|
||||
|
||||
public async getMediaUploadForm(): Promise<GetBackupUploadFormResponseType> {
|
||||
return this.server.getBackupMediaUploadForm(
|
||||
await this.credentials.getHeadersForToday()
|
||||
);
|
||||
}
|
||||
|
||||
public async backupMediaBatch(
|
||||
items: ReadonlyArray<BackupMediaItemType>
|
||||
): Promise<BackupMediaBatchResponseType> {
|
||||
return this.server.backupMediaBatch({
|
||||
headers: await this.credentials.getHeadersForToday(),
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
public async listMedia({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}): Promise<BackupListMediaResponseType> {
|
||||
return this.server.backupListMedia({
|
||||
headers: await this.credentials.getHeadersForToday(),
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
private get server(): WebAPIType {
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
288
ts/services/backups/credentials.ts
Normal file
288
ts/services/backups/credentials.ts
Normal file
|
@ -0,0 +1,288 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { PrivateKey } from '@signalapp/libsignal-client';
|
||||
import {
|
||||
BackupAuthCredential,
|
||||
BackupAuthCredentialRequestContext,
|
||||
BackupAuthCredentialResponse,
|
||||
GenericServerPublicParams,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { drop } from '../../util/drop';
|
||||
import { toDayMillis } from '../../util/timestamp';
|
||||
import { DAY, DurationInSeconds } from '../../util/durations';
|
||||
import { BackOff, FIBONACCI_TIMEOUTS } from '../../util/BackOff';
|
||||
import type {
|
||||
BackupCredentialType,
|
||||
BackupPresentationHeadersType,
|
||||
BackupSignedPresentationType,
|
||||
} from '../../types/backups';
|
||||
import { toLogFormat } from '../../types/errors';
|
||||
import { HTTPError } from '../../textsecure/Errors';
|
||||
import type {
|
||||
GetBackupCredentialsResponseType,
|
||||
GetBackupCDNCredentialsResponseType,
|
||||
} from '../../textsecure/WebAPI';
|
||||
import { getBackupKey, getBackupSignatureKey } from './crypto';
|
||||
|
||||
export function getAuthContext(): BackupAuthCredentialRequestContext {
|
||||
return BackupAuthCredentialRequestContext.create(
|
||||
Buffer.from(getBackupKey()),
|
||||
window.storage.user.getCheckedAci()
|
||||
);
|
||||
}
|
||||
|
||||
const FETCH_INTERVAL = 3 * DAY;
|
||||
|
||||
export class BackupCredentials {
|
||||
private activeFetch: ReturnType<typeof this.fetch> | undefined;
|
||||
|
||||
private readonly fetchBackoff = new BackOff(FIBONACCI_TIMEOUTS);
|
||||
|
||||
public start(): void {
|
||||
this.scheduleFetch();
|
||||
}
|
||||
|
||||
public async getForToday(): Promise<BackupSignedPresentationType> {
|
||||
const now = toDayMillis(Date.now());
|
||||
|
||||
const signatureKeyBytes = getBackupSignatureKey();
|
||||
const signatureKey = PrivateKey.deserialize(Buffer.from(signatureKeyBytes));
|
||||
|
||||
// Start with cache
|
||||
let credentials = window.storage.get('backupCredentials') || [];
|
||||
|
||||
let result = credentials.find(({ redemptionTimeMs }) => {
|
||||
return redemptionTimeMs === now;
|
||||
});
|
||||
|
||||
if (result === undefined) {
|
||||
log.info(`BackupCredentials: cache miss for ${now}`);
|
||||
credentials = await this.fetch();
|
||||
result = credentials.find(({ redemptionTimeMs }) => {
|
||||
return redemptionTimeMs === now;
|
||||
});
|
||||
strictAssert(
|
||||
result !== undefined,
|
||||
'Remote credentials do not include today'
|
||||
);
|
||||
}
|
||||
|
||||
const cred = new BackupAuthCredential(
|
||||
Buffer.from(result.credential, 'base64')
|
||||
);
|
||||
|
||||
const serverPublicParams = new GenericServerPublicParams(
|
||||
Buffer.from(window.getGenericServerPublicParams(), 'base64')
|
||||
);
|
||||
|
||||
const presentation = cred.present(serverPublicParams).serialize();
|
||||
const signature = signatureKey.sign(presentation);
|
||||
|
||||
const headers = {
|
||||
'X-Signal-ZK-Auth': presentation.toString('base64'),
|
||||
'X-Signal-ZK-Auth-Signature': signature.toString('base64'),
|
||||
};
|
||||
|
||||
if (!window.storage.get('setBackupSignatureKey')) {
|
||||
log.warn('BackupCredentials: uploading signature key');
|
||||
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
await server.setBackupSignatureKey({
|
||||
headers,
|
||||
backupIdPublicKey: signatureKey.getPublicKey().serialize(),
|
||||
});
|
||||
|
||||
await window.storage.put('setBackupSignatureKey', true);
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
level: result.level,
|
||||
};
|
||||
}
|
||||
|
||||
public async getHeadersForToday(): Promise<BackupPresentationHeadersType> {
|
||||
const { headers } = await this.getForToday();
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async getCDNCredentials(
|
||||
cdn: number
|
||||
): Promise<GetBackupCDNCredentialsResponseType> {
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
const headers = await this.getHeadersForToday();
|
||||
|
||||
return server.getBackupCDNCredentials({ headers, cdn });
|
||||
}
|
||||
|
||||
private scheduleFetch(): void {
|
||||
const lastFetchAt = window.storage.get(
|
||||
'backupCredentialsLastRequestTime',
|
||||
0
|
||||
);
|
||||
const nextFetchAt = lastFetchAt + FETCH_INTERVAL;
|
||||
const delay = Math.max(0, nextFetchAt - Date.now());
|
||||
|
||||
log.info(`BackupCredentials: scheduling fetch in ${delay}ms`);
|
||||
setTimeout(() => drop(this.runPeriodicFetch()), delay);
|
||||
}
|
||||
|
||||
private async runPeriodicFetch(): Promise<void> {
|
||||
try {
|
||||
log.info('BackupCredentials: fetching');
|
||||
await this.fetch();
|
||||
|
||||
await window.storage.put('backupCredentialsLastRequestTime', Date.now());
|
||||
|
||||
this.fetchBackoff.reset();
|
||||
this.scheduleFetch();
|
||||
} catch (error) {
|
||||
const delay = this.fetchBackoff.get();
|
||||
log.error(
|
||||
'BackupCredentials: periodic fetch failed with ' +
|
||||
`error: ${toLogFormat(error)}, retrying in ${delay}ms`
|
||||
);
|
||||
setTimeout(() => this.scheduleFetch(), delay);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(): Promise<ReadonlyArray<BackupCredentialType>> {
|
||||
if (this.activeFetch) {
|
||||
return this.activeFetch;
|
||||
}
|
||||
|
||||
const promise = this.doFetch();
|
||||
this.activeFetch = promise;
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
this.activeFetch = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async doFetch(): Promise<ReadonlyArray<BackupCredentialType>> {
|
||||
log.info('BackupCredentials: fetching');
|
||||
|
||||
const now = Date.now();
|
||||
const startDayInMs = toDayMillis(now);
|
||||
const endDayInMs = now + 6 * DAY;
|
||||
|
||||
// And fetch missing credentials
|
||||
const ctx = getAuthContext();
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
let response: GetBackupCredentialsResponseType;
|
||||
try {
|
||||
response = await server.getBackupCredentials({
|
||||
startDayInMs,
|
||||
endDayInMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof HTTPError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error.code !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Backup id is missing
|
||||
const request = ctx.getRequest();
|
||||
|
||||
// Set it
|
||||
await server.setBackupId({
|
||||
backupAuthCredentialRequest: request.serialize(),
|
||||
});
|
||||
|
||||
// And try again!
|
||||
response = await server.getBackupCredentials({
|
||||
startDayInMs,
|
||||
endDayInMs,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`BackupCredentials: got ${response.credentials.length}`);
|
||||
|
||||
const serverPublicParams = new GenericServerPublicParams(
|
||||
Buffer.from(window.getGenericServerPublicParams(), 'base64')
|
||||
);
|
||||
|
||||
const result = new Array<BackupCredentialType>();
|
||||
|
||||
const issuedTimes = new Set<number>();
|
||||
for (const { credential: buf, redemptionTime } of response.credentials) {
|
||||
const credentialRes = new BackupAuthCredentialResponse(Buffer.from(buf));
|
||||
|
||||
const redemptionTimeMs = DurationInSeconds.toMillis(redemptionTime);
|
||||
strictAssert(
|
||||
startDayInMs <= redemptionTimeMs,
|
||||
'Invalid credential response redemption time, too early'
|
||||
);
|
||||
strictAssert(
|
||||
redemptionTimeMs <= endDayInMs,
|
||||
'Invalid credential response redemption time, too late'
|
||||
);
|
||||
|
||||
strictAssert(
|
||||
!issuedTimes.has(redemptionTimeMs),
|
||||
'Invalid credential response redemption time, duplicate'
|
||||
);
|
||||
issuedTimes.add(redemptionTimeMs);
|
||||
|
||||
const credential = ctx.receive(
|
||||
credentialRes,
|
||||
redemptionTime,
|
||||
serverPublicParams
|
||||
);
|
||||
|
||||
result.push({
|
||||
credential: credential.serialize().toString('base64'),
|
||||
level: credential.getBackupLevel(),
|
||||
redemptionTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Add cached credentials that are still in the date range, and not in
|
||||
// the response.
|
||||
const cachedCredentials = window.storage.get('backupCredentials') || [];
|
||||
for (const cached of cachedCredentials) {
|
||||
const { redemptionTimeMs } = cached;
|
||||
if (
|
||||
!(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issuedTimes.has(redemptionTimeMs)) {
|
||||
continue;
|
||||
}
|
||||
result.push(cached);
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs);
|
||||
await window.storage.put('backupCredentials', result);
|
||||
|
||||
const startMs = result[0].redemptionTimeMs;
|
||||
const endMs = result[result.length - 1].redemptionTimeMs;
|
||||
log.info(`BackupCredentials: saved [${startMs}, ${endMs}]`);
|
||||
|
||||
strictAssert(result.length === 7, 'Expected one week of credentials');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Called when backup tier changes
|
||||
public async clear(): Promise<void> {
|
||||
await window.storage.put('backupCredentials', []);
|
||||
}
|
||||
}
|
53
ts/services/backups/crypto.ts
Normal file
53
ts/services/backups/crypto.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import memoizee from 'memoizee';
|
||||
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { AciString } from '../../types/ServiceId';
|
||||
import { toAciObject } from '../../util/ServiceId';
|
||||
import {
|
||||
deriveBackupKey,
|
||||
deriveBackupSignatureKey,
|
||||
deriveBackupId,
|
||||
deriveBackupKeyMaterial,
|
||||
} from '../../Crypto';
|
||||
import type { BackupKeyMaterialType } from '../../Crypto';
|
||||
|
||||
const getMemoizedBackupKey = memoizee((masterKey: string) => {
|
||||
return deriveBackupKey(Buffer.from(masterKey, 'base64'));
|
||||
});
|
||||
|
||||
export function getBackupKey(): Uint8Array {
|
||||
const masterKey = window.storage.get('masterKey');
|
||||
strictAssert(masterKey, 'Master key not available');
|
||||
|
||||
return getMemoizedBackupKey(masterKey);
|
||||
}
|
||||
|
||||
const getMemoizedBackupSignatureKey = memoizee(
|
||||
(backupKey: Uint8Array, aci: AciString) => {
|
||||
const aciBytes = toAciObject(aci).getServiceIdBinary();
|
||||
return deriveBackupSignatureKey(backupKey, aciBytes);
|
||||
}
|
||||
);
|
||||
|
||||
export function getBackupSignatureKey(): Uint8Array {
|
||||
const backupKey = getBackupKey();
|
||||
const aci = window.storage.user.getCheckedAci();
|
||||
return getMemoizedBackupSignatureKey(backupKey, aci);
|
||||
}
|
||||
|
||||
const getMemoizedKeyMaterial = memoizee(
|
||||
(backupKey: Uint8Array, aci: AciString) => {
|
||||
const aciBytes = toAciObject(aci).getServiceIdBinary();
|
||||
const backupId = deriveBackupId(backupKey, aciBytes);
|
||||
return deriveBackupKeyMaterial(backupKey, backupId);
|
||||
}
|
||||
);
|
||||
|
||||
export function getKeyMaterial(): BackupKeyMaterialType {
|
||||
const backupKey = getBackupKey();
|
||||
const aci = window.storage.user.getCheckedAci();
|
||||
return getMemoizedKeyMaterial(backupKey, aci);
|
||||
}
|
|
@ -11,47 +11,48 @@ import { noop } from 'lodash';
|
|||
|
||||
import * as log from '../../logging/log';
|
||||
import * as Bytes from '../../Bytes';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { drop } from '../../util/drop';
|
||||
import { DelimitedStream } from '../../util/DelimitedStream';
|
||||
import { appendPaddingStream } from '../../util/logPadding';
|
||||
import { prependStream } from '../../util/prependStream';
|
||||
import { appendMacStream } from '../../util/appendMacStream';
|
||||
import { toAciObject } from '../../util/ServiceId';
|
||||
import { HOUR } from '../../util/durations';
|
||||
import { CipherType, HashType } from '../../types/Crypto';
|
||||
import * as Errors from '../../types/errors';
|
||||
import {
|
||||
deriveBackupKey,
|
||||
deriveBackupId,
|
||||
deriveBackupKeyMaterial,
|
||||
constantTimeEqual,
|
||||
} from '../../Crypto';
|
||||
import type { BackupKeyMaterialType } from '../../Crypto';
|
||||
import { constantTimeEqual } from '../../Crypto';
|
||||
import { getIvAndDecipher, getMacAndUpdateHmac } from '../../AttachmentCrypto';
|
||||
import { BackupExportStream } from './export';
|
||||
import { BackupImportStream } from './import';
|
||||
import { getKeyMaterial } from './crypto';
|
||||
import { BackupCredentials } from './credentials';
|
||||
import { BackupAPI } from './api';
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getKeyMaterial(): BackupKeyMaterialType {
|
||||
const masterKey = window.storage.get('masterKey');
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not available');
|
||||
}
|
||||
|
||||
const aci = toAciObject(window.storage.user.getCheckedAci());
|
||||
const aciBytes = aci.getServiceIdBinary();
|
||||
|
||||
const backupKey = deriveBackupKey(Bytes.fromBase64(masterKey));
|
||||
const backupId = deriveBackupId(backupKey, aciBytes);
|
||||
return deriveBackupKeyMaterial(backupKey, backupId);
|
||||
}
|
||||
const BACKUP_REFRESH_INTERVAL = 24 * HOUR;
|
||||
|
||||
export class BackupsService {
|
||||
private isStarted = false;
|
||||
private isRunning = false;
|
||||
|
||||
public readonly credentials = new BackupCredentials();
|
||||
public readonly api = new BackupAPI(this.credentials);
|
||||
|
||||
public start(): void {
|
||||
strictAssert(!this.isStarted, 'Already started');
|
||||
this.isStarted = true;
|
||||
|
||||
setInterval(() => {
|
||||
drop(this.runPeriodicRefresh());
|
||||
}, BACKUP_REFRESH_INTERVAL);
|
||||
|
||||
drop(this.runPeriodicRefresh());
|
||||
this.credentials.start();
|
||||
}
|
||||
|
||||
public async exportBackup(sink: Writable): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('BackupService is already running');
|
||||
}
|
||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||
|
||||
log.info('exportBackup: starting...');
|
||||
this.isRunning = true;
|
||||
|
@ -108,9 +109,7 @@ export class BackupsService {
|
|||
}
|
||||
|
||||
public async importBackup(createBackupStream: () => Readable): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('BackupService is already running');
|
||||
}
|
||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||
|
||||
log.info('importBackup: starting...');
|
||||
this.isRunning = true;
|
||||
|
@ -134,13 +133,11 @@ export class BackupsService {
|
|||
sink
|
||||
);
|
||||
|
||||
if (theirMac == null) {
|
||||
throw new Error('importBackup: Missing MAC');
|
||||
}
|
||||
|
||||
if (!constantTimeEqual(hmac.digest(), theirMac)) {
|
||||
throw new Error('importBackup: Bad MAC');
|
||||
}
|
||||
strictAssert(theirMac != null, 'importBackup: Missing MAC');
|
||||
strictAssert(
|
||||
constantTimeEqual(hmac.digest(), theirMac),
|
||||
'importBackup: Bad MAC'
|
||||
);
|
||||
|
||||
// Second pass - decrypt (but still check the mac at the end)
|
||||
hmac = createHmac(HashType.size256, macKey);
|
||||
|
@ -154,9 +151,10 @@ export class BackupsService {
|
|||
new BackupImportStream()
|
||||
);
|
||||
|
||||
if (!constantTimeEqual(hmac.digest(), theirMac)) {
|
||||
throw new Error('importBackup: Bad MAC, second pass');
|
||||
}
|
||||
strictAssert(
|
||||
constantTimeEqual(hmac.digest(), theirMac),
|
||||
'importBackup: Bad MAC, second pass'
|
||||
);
|
||||
|
||||
log.info('importBackup: finished...');
|
||||
} catch (error) {
|
||||
|
@ -166,6 +164,15 @@ export class BackupsService {
|
|||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runPeriodicRefresh(): Promise<void> {
|
||||
try {
|
||||
await this.api.refresh();
|
||||
log.info('Backup: refreshed');
|
||||
} catch (error) {
|
||||
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const backupsService = new BackupsService();
|
||||
|
|
|
@ -49,6 +49,7 @@ import {
|
|||
untaggedPniSchema,
|
||||
} from '../types/ServiceId';
|
||||
import type { DirectoryConfigType } from '../types/RendererConfig';
|
||||
import type { BackupPresentationHeadersType } from '../types/backups';
|
||||
import * as Bytes from '../Bytes';
|
||||
import { randomInt } from '../Crypto';
|
||||
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
|
||||
|
@ -536,6 +537,10 @@ const URL_CALLS = {
|
|||
'dynamic/desktop/stories/onboarding/manifest.json',
|
||||
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||
getArtAuth: 'v1/art/auth',
|
||||
getBackupCredentials: 'v1/archives/auth',
|
||||
getBackupCDNCredentials: 'v1/archives/auth/read',
|
||||
getBackupUploadForm: 'v1/archives/upload/form',
|
||||
getBackupMediaUploadForm: 'v1/archives/media/upload/form',
|
||||
groupLog: 'v1/groups/logs',
|
||||
groupJoinedAtVersion: 'v1/groups/joined_at_version',
|
||||
groups: 'v1/groups',
|
||||
|
@ -547,9 +552,15 @@ const URL_CALLS = {
|
|||
multiRecipient: 'v1/messages/multi_recipient',
|
||||
phoneNumberDiscoverability: 'v2/accounts/phone_number_discoverability',
|
||||
profile: 'v1/profile',
|
||||
backup: 'v1/archives',
|
||||
backupMedia: 'v1/archives/media',
|
||||
backupMediaBatch: 'v1/archives/media/batch',
|
||||
backupMediaDelete: 'v1/archives/media/delete',
|
||||
registration: 'v1/registration',
|
||||
registerCapabilities: 'v1/devices/capabilities',
|
||||
reportMessage: 'v1/messages/report',
|
||||
setBackupId: 'v1/archives/backupid',
|
||||
setBackupSignatureKey: 'v1/archives/keys',
|
||||
signed: 'v2/keys/signed',
|
||||
storageManifest: 'v1/storage/manifest',
|
||||
storageModify: 'v1/storage/',
|
||||
|
@ -599,6 +610,18 @@ const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
|||
|
||||
// Account V2
|
||||
'phoneNumberDiscoverability',
|
||||
|
||||
// Backups
|
||||
'getBackupCredentials',
|
||||
'getBackupCDNCredentials',
|
||||
'getBackupMediaUploadForm',
|
||||
'getBackupUploadForm',
|
||||
'backup',
|
||||
'backupMedia',
|
||||
'backupMediaBatch',
|
||||
'backupMediaDelete',
|
||||
'setBackupId',
|
||||
'setBackupSignatureKey',
|
||||
]);
|
||||
|
||||
type InitializeOptionsType = {
|
||||
|
@ -982,6 +1005,136 @@ export type RequestVerificationResultType = Readonly<{
|
|||
sessionId: string;
|
||||
}>;
|
||||
|
||||
export type SetBackupIdOptionsType = Readonly<{
|
||||
backupAuthCredentialRequest: Uint8Array;
|
||||
}>;
|
||||
|
||||
export type SetBackupSignatureKeyOptionsType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
backupIdPublicKey: Uint8Array;
|
||||
}>;
|
||||
|
||||
export type BackupMediaItemType = Readonly<{
|
||||
sourceAttachment: Readonly<{
|
||||
cdn: number;
|
||||
key: string;
|
||||
}>;
|
||||
objectLength: number;
|
||||
mediaId: string;
|
||||
hmacKey: Uint8Array;
|
||||
encryptionKey: Uint8Array;
|
||||
iv: Uint8Array;
|
||||
}>;
|
||||
|
||||
export type BackupMediaBatchOptionsType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
items: ReadonlyArray<BackupMediaItemType>;
|
||||
}>;
|
||||
|
||||
export const backupMediaBatchResponseSchema = z.object({
|
||||
responses: z
|
||||
.object({
|
||||
status: z.number(),
|
||||
failureReason: z.string().or(z.null()).optional(),
|
||||
cdn: z.number(),
|
||||
mediaId: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type BackupMediaBatchResponseType = z.infer<
|
||||
typeof backupMediaBatchResponseSchema
|
||||
>;
|
||||
|
||||
export type BackupListMediaOptionsType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}>;
|
||||
|
||||
export const backupListMediaResponseSchema = z.object({
|
||||
storedMediaObjects: z
|
||||
.object({
|
||||
cdn: z.number(),
|
||||
mediaId: z.string(),
|
||||
objectLength: z.number(),
|
||||
})
|
||||
.array(),
|
||||
backupDir: z.string(),
|
||||
mediaDir: z.string(),
|
||||
cursor: z.string().or(z.null()).optional(),
|
||||
});
|
||||
|
||||
export type BackupListMediaResponseType = z.infer<
|
||||
typeof backupListMediaResponseSchema
|
||||
>;
|
||||
|
||||
export type BackupDeleteMediaItemType = Readonly<{
|
||||
cdn: number;
|
||||
mediaId: string;
|
||||
}>;
|
||||
|
||||
export type BackupDeleteMediaOptionsType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
mediaToDelete: ReadonlyArray<BackupDeleteMediaItemType>;
|
||||
}>;
|
||||
|
||||
export type GetBackupCredentialsOptionsType = Readonly<{
|
||||
startDayInMs: number;
|
||||
endDayInMs: number;
|
||||
}>;
|
||||
|
||||
export const getBackupCredentialsResponseSchema = z.object({
|
||||
credentials: z
|
||||
.object({
|
||||
credential: z.string().transform(x => Bytes.fromBase64(x)),
|
||||
redemptionTime: z
|
||||
.number()
|
||||
.transform(x => durations.DurationInSeconds.fromSeconds(x)),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type GetBackupCredentialsResponseType = z.infer<
|
||||
typeof getBackupCredentialsResponseSchema
|
||||
>;
|
||||
|
||||
export type GetBackupCDNCredentialsOptionsType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
cdn: number;
|
||||
}>;
|
||||
|
||||
export const getBackupCDNCredentialsResponseSchema = z.object({
|
||||
headers: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export type GetBackupCDNCredentialsResponseType = z.infer<
|
||||
typeof getBackupCDNCredentialsResponseSchema
|
||||
>;
|
||||
|
||||
export const getBackupInfoResponseSchema = z.object({
|
||||
cdn: z.number(),
|
||||
backupDir: z.string(),
|
||||
mediaDir: z.string(),
|
||||
backupName: z.string(),
|
||||
usedSpace: z.number().or(z.null()).optional(),
|
||||
});
|
||||
|
||||
export type GetBackupInfoResponseType = z.infer<
|
||||
typeof getBackupInfoResponseSchema
|
||||
>;
|
||||
|
||||
export const getBackupUploadFormResponseSchema = z.object({
|
||||
cdn: z.number(),
|
||||
key: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
signedUploadLocation: z.string(),
|
||||
});
|
||||
|
||||
export type GetBackupUploadFormResponseType = z.infer<
|
||||
typeof getBackupUploadFormResponseSchema
|
||||
>;
|
||||
|
||||
export type WebAPIType = {
|
||||
startRegistration(): unknown;
|
||||
finishRegistration(baton: unknown): void;
|
||||
|
@ -1166,6 +1319,33 @@ export type WebAPIType = {
|
|||
urgent?: boolean;
|
||||
}
|
||||
) => Promise<MultiRecipient200ResponseType>;
|
||||
getBackupInfo: (
|
||||
headers: BackupPresentationHeadersType
|
||||
) => Promise<GetBackupInfoResponseType>;
|
||||
getBackupUploadForm: (
|
||||
headers: BackupPresentationHeadersType
|
||||
) => Promise<GetBackupUploadFormResponseType>;
|
||||
getBackupMediaUploadForm: (
|
||||
headers: BackupPresentationHeadersType
|
||||
) => Promise<GetBackupUploadFormResponseType>;
|
||||
refreshBackup: (headers: BackupPresentationHeadersType) => Promise<void>;
|
||||
getBackupCredentials: (
|
||||
options: GetBackupCredentialsOptionsType
|
||||
) => Promise<GetBackupCredentialsResponseType>;
|
||||
getBackupCDNCredentials: (
|
||||
options: GetBackupCDNCredentialsOptionsType
|
||||
) => Promise<GetBackupCDNCredentialsResponseType>;
|
||||
setBackupId: (options: SetBackupIdOptionsType) => Promise<void>;
|
||||
setBackupSignatureKey: (
|
||||
options: SetBackupSignatureKeyOptionsType
|
||||
) => Promise<void>;
|
||||
backupMediaBatch: (
|
||||
options: BackupMediaBatchOptionsType
|
||||
) => Promise<BackupMediaBatchResponseType>;
|
||||
backupListMedia: (
|
||||
options: BackupListMediaOptionsType
|
||||
) => Promise<BackupListMediaResponseType>;
|
||||
backupDeleteMedia: (options: BackupDeleteMediaOptionsType) => Promise<void>;
|
||||
setPhoneNumberDiscoverability: (newValue: boolean) => Promise<void>;
|
||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||
uploadAvatar: (
|
||||
|
@ -1447,6 +1627,9 @@ export function initialize({
|
|||
// Thanks, function hoisting!
|
||||
return {
|
||||
authenticate,
|
||||
backupDeleteMedia,
|
||||
backupListMedia,
|
||||
backupMediaBatch,
|
||||
cancelInflightRequests,
|
||||
cdsLookup,
|
||||
checkAccountExistence,
|
||||
|
@ -1466,6 +1649,11 @@ export function initialize({
|
|||
getAttachment,
|
||||
getAttachmentV2,
|
||||
getAvatar,
|
||||
getBackupCredentials,
|
||||
getBackupCDNCredentials,
|
||||
getBackupInfo,
|
||||
getBackupMediaUploadForm,
|
||||
getBackupUploadForm,
|
||||
getBadgeImageFile,
|
||||
getConfig,
|
||||
getGroup,
|
||||
|
@ -1507,6 +1695,7 @@ export function initialize({
|
|||
putProfile,
|
||||
putStickers,
|
||||
reconnect,
|
||||
refreshBackup,
|
||||
registerCapabilities,
|
||||
registerKeys,
|
||||
registerRequestHandler,
|
||||
|
@ -1520,6 +1709,8 @@ export function initialize({
|
|||
sendMessages,
|
||||
sendMessagesUnauth,
|
||||
sendWithSenderKey,
|
||||
setBackupId,
|
||||
setBackupSignatureKey,
|
||||
setPhoneNumberDiscoverability,
|
||||
startRegistration,
|
||||
unregisterRequestHandler,
|
||||
|
@ -2497,6 +2688,208 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function getBackupInfo(headers: BackupPresentationHeadersType) {
|
||||
const res = await _ajax({
|
||||
call: 'backup',
|
||||
httpType: 'GET',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
return getBackupInfoResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function getBackupMediaUploadForm(
|
||||
headers: BackupPresentationHeadersType
|
||||
) {
|
||||
const res = await _ajax({
|
||||
call: 'getBackupMediaUploadForm',
|
||||
httpType: 'GET',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
return getBackupUploadFormResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function getBackupUploadForm(headers: BackupPresentationHeadersType) {
|
||||
const res = await _ajax({
|
||||
call: 'getBackupUploadForm',
|
||||
httpType: 'GET',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
return getBackupUploadFormResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function refreshBackup(headers: BackupPresentationHeadersType) {
|
||||
await _ajax({
|
||||
call: 'backup',
|
||||
httpType: 'POST',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function getBackupCredentials({
|
||||
startDayInMs,
|
||||
endDayInMs,
|
||||
}: GetBackupCredentialsOptionsType) {
|
||||
const startDayInSeconds = startDayInMs / durations.SECOND;
|
||||
const endDayInSeconds = endDayInMs / durations.SECOND;
|
||||
const res = await _ajax({
|
||||
call: 'getBackupCredentials',
|
||||
httpType: 'GET',
|
||||
urlParameters:
|
||||
`?redemptionStartSeconds=${startDayInSeconds}&` +
|
||||
`redemptionEndSeconds=${endDayInSeconds}`,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
return getBackupCredentialsResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function getBackupCDNCredentials({
|
||||
headers,
|
||||
cdn,
|
||||
}: GetBackupCDNCredentialsOptionsType) {
|
||||
const res = await _ajax({
|
||||
call: 'getBackupCDNCredentials',
|
||||
httpType: 'GET',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
urlParameters: `?cdn=${cdn}`,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
return getBackupCDNCredentialsResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function setBackupId({
|
||||
backupAuthCredentialRequest,
|
||||
}: SetBackupIdOptionsType) {
|
||||
await _ajax({
|
||||
call: 'setBackupId',
|
||||
httpType: 'PUT',
|
||||
jsonData: {
|
||||
backupAuthCredentialRequest: Bytes.toBase64(
|
||||
backupAuthCredentialRequest
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function setBackupSignatureKey({
|
||||
headers,
|
||||
backupIdPublicKey,
|
||||
}: SetBackupSignatureKeyOptionsType) {
|
||||
await _ajax({
|
||||
call: 'setBackupSignatureKey',
|
||||
httpType: 'PUT',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
jsonData: {
|
||||
backupIdPublicKey: Bytes.toBase64(backupIdPublicKey),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function backupMediaBatch({
|
||||
headers,
|
||||
items,
|
||||
}: BackupMediaBatchOptionsType) {
|
||||
const res = await _ajax({
|
||||
call: 'backupMediaBatch',
|
||||
httpType: 'PUT',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
responseType: 'json',
|
||||
jsonData: {
|
||||
items: items.map(item => {
|
||||
const {
|
||||
sourceAttachment,
|
||||
objectLength,
|
||||
mediaId,
|
||||
hmacKey,
|
||||
encryptionKey,
|
||||
iv,
|
||||
} = item;
|
||||
|
||||
return {
|
||||
sourceAttachment: {
|
||||
cdn: sourceAttachment.cdn,
|
||||
key: sourceAttachment.key,
|
||||
},
|
||||
objectLength,
|
||||
mediaId,
|
||||
hmacKey: Bytes.toBase64(hmacKey),
|
||||
encryptionKey: Bytes.toBase64(encryptionKey),
|
||||
iv: Bytes.toBase64(iv),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return backupMediaBatchResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function backupDeleteMedia({
|
||||
headers,
|
||||
mediaToDelete,
|
||||
}: BackupDeleteMediaOptionsType) {
|
||||
await _ajax({
|
||||
call: 'backupMediaDelete',
|
||||
httpType: 'POST',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
jsonData: {
|
||||
mediaToDelete: mediaToDelete.map(({ cdn, mediaId }) => {
|
||||
return {
|
||||
cdn,
|
||||
mediaId,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function backupListMedia({
|
||||
headers,
|
||||
cursor,
|
||||
limit,
|
||||
}: BackupListMediaOptionsType) {
|
||||
const params = new Array<string>();
|
||||
|
||||
if (cursor != null) {
|
||||
params.push(`cursor=${encodeURIComponent(cursor)}`);
|
||||
}
|
||||
params.push(`limit=${limit}`);
|
||||
|
||||
const res = await _ajax({
|
||||
call: 'backupMedia',
|
||||
httpType: 'GET',
|
||||
unauthenticated: true,
|
||||
accessKey: undefined,
|
||||
headers,
|
||||
responseType: 'json',
|
||||
urlParameters: `?${params.join('&')}`,
|
||||
});
|
||||
|
||||
return backupListMediaResponseSchema.parse(res);
|
||||
}
|
||||
|
||||
async function setPhoneNumberDiscoverability(newValue: boolean) {
|
||||
await _ajax({
|
||||
call: 'phoneNumberDiscoverability',
|
||||
|
|
4
ts/types/Storage.d.ts
vendored
4
ts/types/Storage.d.ts
vendored
|
@ -18,6 +18,7 @@ import type {
|
|||
SessionResetsType,
|
||||
StorageServiceCredentials,
|
||||
} from '../textsecure/Types.d';
|
||||
import type { BackupCredentialType } from './backups';
|
||||
import type { ServiceIdString } from './ServiceId';
|
||||
|
||||
import type { RegisteredChallengeType } from '../challenge';
|
||||
|
@ -134,6 +135,9 @@ export type StorageAccessType = {
|
|||
unidentifiedDeliveryIndicators: boolean;
|
||||
groupCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
backupCredentials: ReadonlyArray<BackupCredentialType>;
|
||||
backupCredentialsLastRequestTime: number;
|
||||
setBackupSignatureKey: boolean;
|
||||
lastReceivedAtCounter: number;
|
||||
preferredReactionEmoji: ReadonlyArray<string>;
|
||||
skinTone: number;
|
||||
|
|
20
ts/types/backups.ts
Normal file
20
ts/types/backups.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||
|
||||
export type BackupCredentialType = Readonly<{
|
||||
credential: string;
|
||||
level: BackupLevel;
|
||||
redemptionTimeMs: number;
|
||||
}>;
|
||||
|
||||
export type BackupPresentationHeadersType = Readonly<{
|
||||
'X-Signal-ZK-Auth': string;
|
||||
'X-Signal-ZK-Auth-Signature': string;
|
||||
}>;
|
||||
|
||||
export type BackupSignedPresentationType = Readonly<{
|
||||
headers: BackupPresentationHeadersType;
|
||||
level: BackupLevel;
|
||||
}>;
|
Loading…
Add table
Add a link
Reference in a new issue