Backup Server APIs

This commit is contained in:
Fedor Indutny 2024-04-22 16:11:36 +02:00 committed by GitHub
parent 77aea40a63
commit 3eb0e30a23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 991 additions and 201 deletions

View file

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

View file

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

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

View 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', []);
}
}

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

View file

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

View file

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

View file

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