Update HKDF constants for backups

This commit is contained in:
Fedor Indutny 2024-10-31 10:01:03 -07:00 committed by GitHub
parent ab3c18513a
commit a338bc5a67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 807 additions and 611 deletions

View file

@ -4300,7 +4300,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
```
## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.59.0, libsignal-jni 0.59.0, libsignal-jni-testing 0.59.0, libsignal-node 0.59.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.60.1, libsignal-jni 0.60.1, libsignal-jni-testing 0.60.1, libsignal-node 0.60.1, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
```
GNU AFFERO GENERAL PUBLIC LICENSE
@ -5802,7 +5802,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
## bindgen 0.68.1
## bindgen 0.70.1
```
BSD 3-Clause License
@ -6931,7 +6931,7 @@ THE SOFTWARE.
```
## either 1.13.0, itertools 0.13.0, petgraph 0.6.5
## either 1.13.0, itertools 0.10.5, itertools 0.13.0, petgraph 0.6.5
```
Copyright (c) 2015
@ -7074,7 +7074,7 @@ DEALINGS IN THE SOFTWARE.
```
## gimli 0.31.0, heck 0.5.0, peeking_take_while 0.1.2, unicode-bidi 0.3.15, unicode-normalization 0.1.23
## gimli 0.31.0, heck 0.5.0, unicode-bidi 0.3.15, unicode-normalization 0.1.23
```
Copyright (c) 2015 The Rust Project Developers
@ -10476,38 +10476,6 @@ SOFTWARE.
```
## lazycell 1.3.0
```
Original work Copyright (c) 2014 The Rust Project Developers
Modified work Copyright (c) 2016-2018 Nikita Pekin and lazycell contributors
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
```
## curve25519-dalek-derive 0.1.1, adler2 2.0.0, anyhow 1.0.88, async-trait 0.1.82, atomic-waker 1.1.2, displaydoc 0.2.5, dyn-clone 1.0.17, fastrand 2.1.1, home 0.5.9, itoa 1.0.11, linkme-impl 0.3.28, linkme 0.3.28, linux-raw-sys 0.4.14, minimal-lexical 0.2.1, num_enum 0.7.3, num_enum_derive 0.7.3, once_cell 1.19.0, paste 1.0.15, pin-project-internal 1.1.5, pin-project-lite 0.2.14, pin-project 1.1.5, prettyplease 0.2.22, proc-macro-crate 3.2.0, proc-macro2 1.0.86, quote 1.0.37, rustc-hash 1.1.0, rustix 0.38.37, rustversion 1.0.17, semver 1.0.23, send_wrapper 0.6.0, serde 1.0.210, serde_derive 1.0.210, serde_json 1.0.128, syn-mid 0.6.0, syn 1.0.109, syn 2.0.77, thiserror-impl 1.0.63, thiserror 1.0.63, unicode-ident 1.0.13, utf-8 0.7.6
```

31
package-lock.json generated
View file

@ -22,7 +22,7 @@
"@react-aria/utils": "3.16.0",
"@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "9.0.8",
"@signalapp/libsignal-client": "0.59.0",
"@signalapp/libsignal-client": "0.60.1",
"@signalapp/ringrtc": "2.48.4",
"@types/fabric": "4.5.3",
"backbone": "1.4.0",
@ -126,7 +126,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.2",
"@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "8.3.1",
"@signalapp/mock-server": "9.0.2",
"@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
@ -7274,9 +7274,9 @@
}
},
"node_modules/@signalapp/libsignal-client": {
"version": "0.59.0",
"resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.59.0.tgz",
"integrity": "sha512-L1MmlSOmcf0qGnOIXf2J7ux6BSlLwQVi0W8F31A1JUPQ9Iwpbh7q+uCVoplmKXKV52Aw/CeZX109kLMG5vWseQ==",
"version": "0.60.1",
"resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.60.1.tgz",
"integrity": "sha512-euLw0lFVyqSFeA/hYwr0RHDIsFKNVPTYDMr9JT1hG4oflYdzeesgPxqsJNDMio4esQGUSKcXxtw2gjsl+Qczfg==",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
@ -7306,14 +7306,14 @@
}
},
"node_modules/@signalapp/mock-server": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.3.1.tgz",
"integrity": "sha512-w4zMyLwRHZc90bxbWpG7NbtPoLXhjMIcEiqA8a5IR3qDonF/Fpi6rR047iYtfs66pBYP58XsSqRNBVzvP/Pj8Q==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-9.0.2.tgz",
"integrity": "sha512-QMfzA4mOZi1wagq6uGLEGDdbawyr9VG8ASAofbA/+HYDNE9n/12kzwuUs2fGpIRfs+86LDw/3iYF9ONfRDFxGQ==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
"@indutny/parallel-prettier": "^3.0.0",
"@signalapp/libsignal-client": "^0.58.2",
"@signalapp/libsignal-client": "^0.60.1",
"@tus/file-store": "^1.4.0",
"@tus/server": "^1.7.0",
"debug": "^4.3.2",
@ -7330,19 +7330,6 @@
"zod": "^3.20.2"
}
},
"node_modules/@signalapp/mock-server/node_modules/@signalapp/libsignal-client": {
"version": "0.58.2",
"resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.58.2.tgz",
"integrity": "sha512-3OF9fGmh7tz9JVfT9xTR4DWcm4HOpbQknO9k7Oj23uSsBSEcJYmYPGM3Rdm16C/z+evVgyvrLptPtjTzXXuNzA==",
"dev": true,
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
"node-gyp-build": "^4.8.0",
"type-fest": "^4.26.0",
"uuid": "^8.3.0"
}
},
"node_modules/@signalapp/mock-server/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View file

@ -108,7 +108,7 @@
"@react-aria/utils": "3.16.0",
"@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "9.0.8",
"@signalapp/libsignal-client": "0.59.0",
"@signalapp/libsignal-client": "0.60.1",
"@signalapp/ringrtc": "2.48.4",
"@types/fabric": "4.5.3",
"backbone": "1.4.0",
@ -212,7 +212,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.2",
"@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "8.3.1",
"@signalapp/mock-server": "9.0.2",
"@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",

View file

@ -9,6 +9,8 @@ option java_package = "org.thoughtcrime.securesms.backup.v2.proto";
message BackupInfo {
uint64 version = 1;
uint64 backupTimeMs = 2;
bytes mediaRootBackupKey = 3; // 32-byte random value generated when the
// backup is uploaded for the first time.
}
// Frames must follow in the following ordering rules:

View file

@ -28,6 +28,8 @@ message ProvisionMessage {
optional uint32 ProvisioningVersion = 9;
optional bytes masterKey = 13;
optional bytes ephemeralBackupKey = 14; // 32 bytes
optional string accountEntropyPool = 15;
optional bytes mediaRootBackupKey = 16; // 32-bytes
}
enum ProvisioningVersion {

View file

@ -506,7 +506,9 @@ message SyncMessage {
message Keys {
optional bytes storageService = 1; // deprecated: this field will be removed in a future release.
optional bytes master = 2;
optional bytes master = 2; // deprecated: this field will be removed in a future release.
optional string accountEntropyPool = 3;
optional bytes mediaRootBackupKey = 4;
}
message Read {

View file

@ -57,7 +57,8 @@ message ManifestRecord {
optional uint64 version = 1;
optional uint32 sourceDevice = 3;
repeated Identifier keys = 2;
// Next ID: 4
optional bytes recordIkm = 4;
// Next ID: 5
}
message StorageRecord {

View file

@ -4,11 +4,12 @@
import { Buffer } from 'buffer';
import Long from 'long';
import { Aci, HKDF } from '@signalapp/libsignal-client';
import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
import * as Bytes from './Bytes';
import { Crypto } from './context/Crypto';
import { calculateAgreement, generateKeyPair } from './Curve';
import { HashType, CipherType, UUID_BYTE_SIZE } from './types/Crypto';
import { HashType, CipherType } from './types/Crypto';
import { ProfileDecryptError } from './types/errors';
import { getBytesSubarray } from './util/uuidToBytes';
import { logPadSize } from './util/logPadding';
@ -154,185 +155,23 @@ export function decryptDeviceName(
return Bytes.toString(plaintext);
}
export function deriveStorageServiceKey(masterKey: Uint8Array): Uint8Array {
return hmacSha256(masterKey, Bytes.fromString('Storage Service Encryption'));
}
export function deriveStorageManifestKey(
storageServiceKey: Uint8Array,
version: Long = Long.fromNumber(0)
): Uint8Array {
return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`));
}
const BACKUP_KEY_LEN = 32;
const BACKUP_KEY_INFO = '20231003_Signal_Backups_GenerateBackupKey';
export function deriveBackupKey(masterKey: Uint8Array): Uint8Array {
const hkdf = HKDF.new(3);
return hkdf.deriveSecrets(
BACKUP_KEY_LEN,
Buffer.from(masterKey),
Buffer.from(BACKUP_KEY_INFO),
Buffer.alloc(0)
);
}
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';
export function deriveBackupId(
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_ID_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_ID_INFO),
Buffer.from(aciBytes)
);
}
export type BackupKeyMaterialType = Readonly<{
macKey: Uint8Array;
aesKey: Uint8Array;
}>;
export type BackupMediaKeyMaterialType = Readonly<{
macKey: Uint8Array;
aesKey: Uint8Array;
iv: Uint8Array;
}>;
const BACKUP_AES_KEY_LEN = 32;
const BACKUP_MAC_KEY_LEN = 32;
const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup';
const BACKUP_MEDIA_ID_INFO = '20231003_Signal_Backups_Media_ID';
const BACKUP_MEDIA_ID_LEN = 15;
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia';
const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO =
'20240513_Signal_Backups_EncryptThumbnail';
'20241030_SIGNAL_BACKUPS_ENCRYPT_THUMBNAIL:';
const BACKUP_MEDIA_AES_KEY_LEN = 32;
const BACKUP_MEDIA_MAC_KEY_LEN = 32;
const BACKUP_MEDIA_IV_LEN = 16;
export function deriveBackupKeyMaterial(
backupKey: Uint8Array,
backupId: Uint8Array
): BackupKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error('deriveBackupId: invalid backup key length');
}
if (backupId.byteLength !== BACKUP_ID_LEN) {
throw new Error('deriveBackupId: invalid backup id length');
}
const hkdf = HKDF.new(3);
const material = hkdf.deriveSecrets(
BACKUP_AES_KEY_LEN + BACKUP_MAC_KEY_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_MATERIAL_INFO),
Buffer.from(backupId)
);
return {
macKey: material.slice(0, BACKUP_MAC_KEY_LEN),
aesKey: material.slice(BACKUP_MAC_KEY_LEN),
};
}
export function deriveMediaIdFromMediaName(
backupKey: Uint8Array,
mediaName: string
): Uint8Array {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error('deriveMediaIdFromMediaName: invalid backup key length');
}
if (!mediaName) {
throw new Error('deriveMediaIdFromMediaName: mediaName missing');
}
const hkdf = HKDF.new(3);
return hkdf.deriveSecrets(
BACKUP_MEDIA_ID_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_MEDIA_ID_INFO),
Buffer.from(mediaName, 'utf8')
);
}
export function deriveBackupMediaKeyMaterial(
backupKey: Uint8Array,
mediaId: Uint8Array
): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length');
}
if (!mediaId.length) {
throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
}
const hkdf = HKDF.new(3);
const material = hkdf.deriveSecrets(
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_MEDIA_ENCRYPT_INFO),
Buffer.from(mediaId)
);
return {
macKey: material.subarray(0, BACKUP_MEDIA_MAC_KEY_LEN),
aesKey: material.subarray(
BACKUP_MEDIA_MAC_KEY_LEN,
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN
),
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
};
}
export type BackupMediaKeyMaterialType = Readonly<{
aesKey: Uint8Array;
macKey: Uint8Array;
}>;
export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
backupKey: Uint8Array,
mediaRootKey: Uint8Array,
mediaId: Uint8Array
): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
if (mediaRootKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error(
'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length'
);
@ -345,9 +184,12 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
const hkdf = HKDF.new(3);
const material = hkdf.deriveSecrets(
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
Buffer.from(mediaId)
Buffer.from(mediaRootKey),
Buffer.concat([
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
Buffer.from(mediaId),
]),
Buffer.alloc(0)
);
return {
@ -356,15 +198,54 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
BACKUP_MEDIA_AES_KEY_LEN,
BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN
),
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
};
}
export function deriveStorageItemKey(
export function deriveMasterKey(accountEntropyPool: string): Uint8Array {
return AccountEntropyPool.deriveSvrKey(accountEntropyPool);
}
export function deriveStorageServiceKey(masterKey: Uint8Array): Uint8Array {
return hmacSha256(masterKey, Bytes.fromString('Storage Service Encryption'));
}
export function deriveStorageManifestKey(
storageServiceKey: Uint8Array,
itemID: string
version: Long = Long.fromNumber(0)
): Uint8Array {
return hmacSha256(storageServiceKey, Bytes.fromString(`Item_${itemID}`));
return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`));
}
const STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX =
'20240801_SIGNAL_STORAGE_SERVICE_ITEM_';
const STORAGE_SERVICE_ITEM_KEY_LEN = 32;
export type DeriveStorageItemKeyOptionsType = Readonly<{
storageServiceKey: Uint8Array;
recordIkm: Uint8Array | undefined;
key: Uint8Array;
}>;
export function deriveStorageItemKey({
storageServiceKey,
recordIkm,
key,
}: DeriveStorageItemKeyOptionsType): Uint8Array {
if (recordIkm == null) {
const itemID = Bytes.toBase64(key);
return hmacSha256(storageServiceKey, Bytes.fromString(`Item_${itemID}`));
}
const hkdf = HKDF.new(3);
return hkdf.deriveSecrets(
STORAGE_SERVICE_ITEM_KEY_LEN,
Buffer.from(recordIkm),
Buffer.concat([
Buffer.from(STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX),
Buffer.from(key),
]),
Buffer.alloc(0)
);
}
export function deriveAccessKey(profileKey: Uint8Array): Uint8Array {

View file

@ -181,7 +181,7 @@ import {
getCallIdFromEra,
updateLocalGroupCallHistoryTimestamp,
} from './util/callDisposition';
import { deriveStorageServiceKey } from './Crypto';
import { deriveStorageServiceKey, deriveMasterKey } from './Crypto';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
import { CallMode } from './types/CallDisposition';
@ -950,6 +950,10 @@ export async function startApp(): Promise<void> {
'hasRegisterSupportForUnauthenticatedDelivery'
);
}
if (window.isBeforeVersion(lastVersion, 'v7.33.0-beta.1')) {
await window.storage.remove('masterKeyLastRequestTime');
}
}
setAppLoadingScreenMessage(
@ -1849,6 +1853,7 @@ export async function startApp(): Promise<void> {
await server.registerCapabilities({
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
});
} catch (error) {
log.error(
@ -1864,21 +1869,22 @@ export async function startApp(): Promise<void> {
}
if (firstRun === true && deviceId !== 1) {
if (!window.storage.get('masterKey')) {
const lastSent = window.storage.get('masterKeyLastRequestTime') ?? 0;
if (!window.storage.get('accountEntropyPool')) {
const lastSent =
window.storage.get('accountEntropyPoolLastRequestTime') ?? 0;
const now = Date.now();
// If we last attempted sync one day in the past, or if we time
// traveled.
if (isOlderThan(lastSent, DAY) || lastSent > now) {
log.warn('connect: masterKey not captured, requesting sync');
log.warn('connect: AEP not captured, requesting sync');
await singleProtoJobQueue.add(
MessageSender.getRequestKeySyncMessage()
);
await window.storage.put('masterKeyLastRequestTime', now);
await window.storage.put('accountEntropyPoolLastRequestTime', now);
} else {
log.warn(
'connect: masterKey not captured, but sync requested recently.' +
'connect: AEP not captured, but sync requested recently.' +
'Not running'
);
}
@ -3212,20 +3218,64 @@ export async function startApp(): Promise<void> {
async function onKeysSync(ev: KeysEvent) {
ev.confirm();
const { masterKey } = ev;
const { accountEntropyPool, masterKey, mediaRootBackupKey } = ev;
let { storageServiceKey } = ev;
if (masterKey == null) {
log.info('onKeysSync: deleting window.masterKey');
const prevMasterKeyBase64 = window.storage.get('masterKey');
const prevMasterKey = prevMasterKeyBase64
? Bytes.fromBase64(prevMasterKeyBase64)
: undefined;
const prevAccountEntropyPool = window.storage.get('accountEntropyPool');
let derivedMasterKey = masterKey;
if (derivedMasterKey == null && accountEntropyPool) {
derivedMasterKey = deriveMasterKey(accountEntropyPool);
if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) {
log.info('onKeysSync: deriving master key from account entropy pool');
}
}
if (accountEntropyPool == null) {
if (prevAccountEntropyPool != null) {
log.warn('onKeysSync: deleting window.accountEntropyPool');
}
await window.storage.remove('accountEntropyPool');
} else {
if (prevAccountEntropyPool !== accountEntropyPool) {
log.info('onKeysSync: updating accountEntropyPool');
}
await window.storage.put('accountEntropyPool', accountEntropyPool);
}
if (derivedMasterKey == null) {
if (prevMasterKey != null) {
log.warn('onKeysSync: deleting window.masterKey');
}
await window.storage.remove('masterKey');
} else {
if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) {
log.info('onKeysSync: updating masterKey');
}
// Override provided storageServiceKey because it is deprecated.
storageServiceKey = deriveStorageServiceKey(masterKey);
await window.storage.put('masterKey', Bytes.toBase64(masterKey));
storageServiceKey = deriveStorageServiceKey(derivedMasterKey);
await window.storage.put('masterKey', Bytes.toBase64(derivedMasterKey));
}
const prevMediaRootBackupKey = window.storage.get('backupMediaRootKey');
if (mediaRootBackupKey == null) {
if (prevMediaRootBackupKey != null) {
log.warn('onKeysSync: deleting window.backupMediaRootKey');
}
await window.storage.remove('backupMediaRootKey');
} else {
if (!Bytes.areEqual(prevMediaRootBackupKey, mediaRootBackupKey)) {
log.info('onKeysSync: updating window.backupMediaRootKey');
}
await window.storage.put('backupMediaRootKey', mediaRootBackupKey);
}
if (storageServiceKey == null) {
log.info('onKeysSync: deleting window.storageKey');
log.warn('onKeysSync: deleting window.storageKey');
await window.storage.remove('storageKey');
}

View file

@ -16,10 +16,6 @@ import {
type JobManagerParamsType,
type JobManagerJobResultType,
} from './JobManager';
import {
deriveBackupMediaKeyMaterial,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto';
import { strictAssert } from '../util/assert';
import { type BackupsService, backupsService } from '../services/backups';
import {
@ -29,7 +25,11 @@ import {
decryptAttachmentV2ToSink,
ReencryptedDigestMismatchError,
} from '../AttachmentCrypto';
import { getBackupKey } from '../services/backups/crypto';
import { deriveBackupMediaThumbnailInnerEncryptionKeyMaterial } from '../Crypto';
import {
getBackupMediaRootKey,
deriveBackupMediaKeyMaterial,
} from '../services/backups/crypto';
import {
type AttachmentBackupJobType,
type CoreAttachmentBackupJobType,
@ -61,6 +61,7 @@ import {
} from '../util/GoogleChrome';
import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl';
import { findRetryAfterTimeFromError } from './helpers/findRetryAfterTimeFromError';
import { BackupCredentialType } from '../types/backups';
import { supportsIncrementalMac } from '../types/MIME';
import type { MIMEType } from '../types/MIME';
@ -289,7 +290,7 @@ async function backupStandardAttachment(
const mediaId = getMediaIdFromMediaName(job.mediaName);
const backupKeyMaterial = deriveBackupMediaKeyMaterial(
getBackupKey(),
getBackupMediaRootKey(),
mediaId.bytes
);
@ -371,8 +372,9 @@ async function backupThumbnailAttachment(
const logId = `AttachmentBackupManager.backupThumbnailAttachment(${jobIdForLogging})`;
const mediaId = getMediaIdFromMediaName(job.mediaName);
const backupKeyMaterial = deriveBackupMediaKeyMaterial(
getBackupKey(),
getBackupMediaRootKey(),
mediaId.bytes
);
@ -432,7 +434,7 @@ async function backupThumbnailAttachment(
const { aesKey, macKey } =
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
getBackupKey(),
getBackupMediaRootKey().serialize(),
mediaId.bytes
);
@ -589,7 +591,6 @@ async function copyToBackupTier({
mediaId,
macKey,
aesKey,
iv,
dependencies,
}: {
cdnNumber: number;
@ -598,7 +599,6 @@ async function copyToBackupTier({
mediaId: string;
macKey: Uint8Array;
aesKey: Uint8Array;
iv: Uint8Array;
dependencies: {
backupMediaBatch?: WebAPIType['backupMediaBatch'];
backupsService: BackupsService;
@ -611,7 +611,9 @@ async function copyToBackupTier({
const ciphertextLength = getAttachmentCiphertextLength(size);
const { responses } = await dependencies.backupMediaBatch({
headers: await dependencies.backupsService.credentials.getHeadersForToday(),
headers: await dependencies.backupsService.credentials.getHeadersForToday(
BackupCredentialType.Media
),
items: [
{
sourceAttachment: {
@ -622,7 +624,6 @@ async function copyToBackupTier({
mediaId,
hmacKey: macKey,
encryptionKey: aesKey,
iv,
},
],
});

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { type Readable } from 'node:stream';
import { strictAssert } from '../../util/assert';
import type {
WebAPIType,
@ -12,6 +13,7 @@ import type {
BackupListMediaResponseType,
} from '../../textsecure/WebAPI';
import type { BackupCredentials } from './credentials';
import { BackupCredentialType } from '../../types/backups';
import { uploadFile } from '../../util/uploadAttachment';
export type DownloadOptionsType = Readonly<{
@ -21,48 +23,54 @@ export type DownloadOptionsType = Readonly<{
}>;
export class BackupAPI {
private cachedBackupInfo: GetBackupInfoResponseType | undefined;
constructor(private credentials: BackupCredentials) {}
private cachedBackupInfo = new Map<
BackupCredentialType,
GetBackupInfoResponseType
>();
constructor(private readonly credentials: BackupCredentials) {}
public async refresh(): Promise<void> {
await this.server.refreshBackup(
await this.credentials.getHeadersForToday()
const headers = await Promise.all(
[BackupCredentialType.Messages, BackupCredentialType.Media].map(type =>
this.credentials.getHeadersForToday(type)
)
);
await Promise.all(headers.map(h => this.server.refreshBackup(h)));
}
public async getInfo(): Promise<GetBackupInfoResponseType> {
public async getInfo(
credentialType: BackupCredentialType
): Promise<GetBackupInfoResponseType> {
const backupInfo = await this.server.getBackupInfo(
await this.credentials.getHeadersForToday()
await this.credentials.getHeadersForToday(credentialType)
);
this.cachedBackupInfo = backupInfo;
this.cachedBackupInfo.set(credentialType, backupInfo);
return backupInfo;
}
private async getCachedInfo(): Promise<GetBackupInfoResponseType> {
if (this.cachedBackupInfo) {
return this.cachedBackupInfo;
private async getCachedInfo(
credentialType: BackupCredentialType
): Promise<GetBackupInfoResponseType> {
const cached = this.cachedBackupInfo.get(credentialType);
if (cached) {
return cached;
}
return this.getInfo();
return this.getInfo(credentialType);
}
public async getMediaDir(): Promise<string> {
return (await this.getCachedInfo()).mediaDir;
return (await this.getCachedInfo(BackupCredentialType.Media)).mediaDir;
}
public async getBackupDir(): Promise<string> {
return (await this.getCachedInfo())?.backupDir;
}
// Backup name will change whenever a new backup is created, so we don't want to cache
// it
public async getBackupName(): Promise<string> {
return (await this.getInfo()).backupName;
return (await this.getCachedInfo(BackupCredentialType.Media))?.backupDir;
}
public async upload(filePath: string, fileSize: number): Promise<void> {
const form = await this.server.getBackupUploadForm(
await this.credentials.getHeadersForToday()
await this.credentials.getHeadersForToday(BackupCredentialType.Messages)
);
await uploadFile({
@ -77,8 +85,13 @@ export class BackupAPI {
onProgress,
abortSignal,
}: DownloadOptionsType): Promise<Readable> {
const { cdn, backupDir, backupName } = await this.getInfo();
const { headers } = await this.credentials.getCDNReadCredentials(cdn);
const { cdn, backupDir, backupName } = await this.getInfo(
BackupCredentialType.Messages
);
const { headers } = await this.credentials.getCDNReadCredentials(
cdn,
BackupCredentialType.Messages
);
return this.server.getBackupStream({
cdn,
@ -111,7 +124,7 @@ export class BackupAPI {
public async getMediaUploadForm(): Promise<AttachmentUploadFormResponseType> {
return this.server.getBackupMediaUploadForm(
await this.credentials.getHeadersForToday()
await this.credentials.getHeadersForToday(BackupCredentialType.Media)
);
}
@ -119,7 +132,9 @@ export class BackupAPI {
items: ReadonlyArray<BackupMediaItemType>
): Promise<BackupMediaBatchResponseType> {
return this.server.backupMediaBatch({
headers: await this.credentials.getHeadersForToday(),
headers: await this.credentials.getHeadersForToday(
BackupCredentialType.Media
),
items,
});
}
@ -132,14 +147,16 @@ export class BackupAPI {
limit: number;
}): Promise<BackupListMediaResponseType> {
return this.server.backupListMedia({
headers: await this.credentials.getHeadersForToday(),
headers: await this.credentials.getHeadersForToday(
BackupCredentialType.Media
),
cursor,
limit,
});
}
public clearCache(): void {
this.cachedBackupInfo = undefined;
this.cachedBackupInfo.clear();
}
private get server(): WebAPIType {

View file

@ -1,7 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { PrivateKey } from '@signalapp/libsignal-client';
import { type PrivateKey } from '@signalapp/libsignal-client';
import {
BackupAuthCredential,
BackupAuthCredentialRequestContext,
@ -9,6 +9,7 @@ import {
type BackupLevel,
GenericServerPublicParams,
} from '@signalapp/libsignal-client/zkgroup';
import { type BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert';
@ -16,11 +17,13 @@ import { drop } from '../../util/drop';
import { isMoreRecentThan, toDayMillis } from '../../util/timestamp';
import { DAY, DurationInSeconds, HOUR } from '../../util/durations';
import { BackOff, FIBONACCI_TIMEOUTS } from '../../util/BackOff';
import type {
BackupCdnReadCredentialType,
import { missingCaseError } from '../../util/missingCaseError';
import {
type BackupCdnReadCredentialType,
type BackupCredentialWrapperType,
type BackupPresentationHeadersType,
type BackupSignedPresentationType,
BackupCredentialType,
BackupPresentationHeadersType,
BackupSignedPresentationType,
} from '../../types/backups';
import { toLogFormat } from '../../types/errors';
import { HTTPError } from '../../textsecure/Errors';
@ -28,14 +31,12 @@ 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()
);
}
import {
getBackupKey,
getBackupMediaRootKey,
getBackupSignatureKey,
getBackupMediaSignatureKey,
} from './crypto';
const FETCH_INTERVAL = 3 * DAY;
@ -54,24 +55,35 @@ export class BackupCredentials {
this.scheduleFetch();
}
public async getForToday(): Promise<BackupSignedPresentationType> {
public async getForToday(
credentialType: BackupCredentialType
): Promise<BackupSignedPresentationType> {
const now = toDayMillis(Date.now());
const signatureKeyBytes = getBackupSignatureKey();
const signatureKey = PrivateKey.deserialize(Buffer.from(signatureKeyBytes));
let signatureKey: PrivateKey;
let storageKey: `setBackup${'Messages' | 'Media'}SignatureKey`;
if (credentialType === BackupCredentialType.Messages) {
signatureKey = getBackupSignatureKey();
storageKey = 'setBackupMessagesSignatureKey';
} else if (credentialType === BackupCredentialType.Media) {
signatureKey = getBackupMediaSignatureKey();
storageKey = 'setBackupMediaSignatureKey';
} else {
throw missingCaseError(credentialType);
}
// Start with cache
let credentials = window.storage.get('backupCredentials') || [];
let credentials = this.getFromCache();
let result = credentials.find(({ redemptionTimeMs }) => {
return redemptionTimeMs === now;
let result = credentials.find(({ type, redemptionTimeMs }) => {
return type === credentialType && redemptionTimeMs === now;
});
if (result === undefined) {
log.info(`BackupCredentials: cache miss for ${now}`);
credentials = await this.fetch();
result = credentials.find(({ redemptionTimeMs }) => {
return redemptionTimeMs === now;
result = credentials.find(({ type, redemptionTimeMs }) => {
return type === credentialType && redemptionTimeMs === now;
});
strictAssert(
result !== undefined,
@ -95,33 +107,36 @@ export class BackupCredentials {
'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);
const info = { headers, level: result.level };
if (window.storage.get(storageKey)) {
return info;
}
return {
log.warn(`BackupCredentials: uploading signature key (${storageKey})`);
const { server } = window.textsecure;
strictAssert(server, 'server not available');
await server.setBackupSignatureKey({
headers,
level: result.level,
};
backupIdPublicKey: signatureKey.getPublicKey().serialize(),
});
await window.storage.put(storageKey, true);
return info;
}
public async getHeadersForToday(): Promise<BackupPresentationHeadersType> {
const { headers } = await this.getForToday();
public async getHeadersForToday(
credentialType: BackupCredentialType
): Promise<BackupPresentationHeadersType> {
const { headers } = await this.getForToday(credentialType);
return headers;
}
public async getCDNReadCredentials(
cdn: number
cdn: number,
credentialType: BackupCredentialType
): Promise<GetBackupCDNCredentialsResponseType> {
const { server } = window.textsecure;
strictAssert(server, 'server not available');
@ -140,7 +155,7 @@ export class BackupCredentials {
return cachedCredentialsForThisCdn.credentials;
}
const headers = await this.getHeadersForToday();
const headers = await this.getHeadersForToday(credentialType);
const retrievedAtMs = Date.now();
const newCredentials = await server.getBackupCDNCredentials({
@ -159,7 +174,7 @@ export class BackupCredentials {
private scheduleFetch(): void {
const lastFetchAt = window.storage.get(
'backupCredentialsLastRequestTime',
'backupCombinedCredentialsLastRequestTime',
0
);
const nextFetchAt = lastFetchAt + FETCH_INTERVAL;
@ -171,10 +186,11 @@ export class BackupCredentials {
private async runPeriodicFetch(): Promise<void> {
try {
log.info('BackupCredentials: fetching');
log.info('BackupCredentials: run periodic fetch');
await this.fetch();
await window.storage.put('backupCredentialsLastRequestTime', Date.now());
const now = Date.now();
await window.storage.put('backupCombinedCredentialsLastRequestTime', now);
this.fetchBackoff.reset();
this.scheduleFetch();
@ -188,7 +204,7 @@ export class BackupCredentials {
}
}
private async fetch(): Promise<ReadonlyArray<BackupCredentialType>> {
private async fetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
if (this.activeFetch) {
return this.activeFetch;
}
@ -203,7 +219,7 @@ export class BackupCredentials {
}
}
private async doFetch(): Promise<ReadonlyArray<BackupCredentialType>> {
private async doFetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
log.info('BackupCredentials: fetching');
const now = Date.now();
@ -211,7 +227,8 @@ export class BackupCredentials {
const endDayInMs = toDayMillis(now + 6 * DAY);
// And fetch missing credentials
const ctx = getAuthContext();
const messagesCtx = this.getAuthContext(BackupCredentialType.Messages);
const mediaCtx = this.getAuthContext(BackupCredentialType.Media);
const { server } = window.textsecure;
strictAssert(server, 'server not available');
@ -231,11 +248,13 @@ export class BackupCredentials {
}
// Backup id is missing
const request = ctx.getRequest();
const messagesRequest = messagesCtx.getRequest();
const mediaRequest = mediaCtx.getRequest();
// Set it
await server.setBackupId({
backupAuthCredentialRequest: request.serialize(),
messagesBackupAuthCredentialRequest: messagesRequest.serialize(),
mediaBackupAuthCredentialRequest: mediaRequest.serialize(),
});
// And try again!
@ -245,16 +264,41 @@ export class BackupCredentials {
});
}
log.info(`BackupCredentials: got ${response.credentials.length}`);
const { messages: messageCredentials, media: mediaCredentials } =
response.credentials;
log.info(
'BackupCredentials: got ' +
`${messageCredentials.length}/${mediaCredentials.length}`
);
const serverPublicParams = new GenericServerPublicParams(
Buffer.from(window.getBackupServerPublicParams(), 'base64')
);
const result = new Array<BackupCredentialType>();
const result = new Array<BackupCredentialWrapperType>();
const issuedTimes = new Set<number>();
for (const { credential: buf, redemptionTime } of response.credentials) {
const allCredentials = messageCredentials
.map(credential => ({
...credential,
ctx: messagesCtx,
type: BackupCredentialType.Messages,
}))
.concat(
mediaCredentials.map(credential => ({
...credential,
ctx: mediaCtx,
type: BackupCredentialType.Media,
}))
);
const issuedTimes = new Set<`${BackupCredentialType}:${number}`>();
for (const {
type,
ctx,
credential: buf,
redemptionTime,
} of allCredentials) {
const credentialRes = new BackupAuthCredentialResponse(Buffer.from(buf));
const redemptionTimeMs = DurationInSeconds.toMillis(redemptionTime);
@ -268,10 +312,10 @@ export class BackupCredentials {
);
strictAssert(
!issuedTimes.has(redemptionTimeMs),
!issuedTimes.has(`${type}:${redemptionTimeMs}`),
'Invalid credential response redemption time, duplicate'
);
issuedTimes.add(redemptionTimeMs);
issuedTimes.add(`${type}:${redemptionTimeMs}`);
const credential = ctx.receive(
credentialRes,
@ -280,6 +324,7 @@ export class BackupCredentials {
);
result.push({
type,
credential: credential.serialize().toString('base64'),
level: credential.getBackupLevel(),
redemptionTimeMs,
@ -288,40 +333,68 @@ export class BackupCredentials {
// 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;
for (const cached of this.getFromCache()) {
const { type, redemptionTimeMs } = cached;
if (
!(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs)
) {
continue;
}
if (issuedTimes.has(redemptionTimeMs)) {
if (issuedTimes.has(`${type}:${redemptionTimeMs}`)) {
continue;
}
result.push(cached);
}
result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs);
await window.storage.put('backupCredentials', result);
await this.updateCache(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');
strictAssert(result.length === 14, 'Expected one week of credentials');
return result;
}
public async getBackupLevel(): Promise<BackupLevel> {
return (await this.getForToday()).level;
private getAuthContext(
credentialType: BackupCredentialType
): BackupAuthCredentialRequestContext {
let key: BackupKey;
if (credentialType === BackupCredentialType.Messages) {
key = getBackupKey();
} else if (credentialType === BackupCredentialType.Media) {
key = getBackupMediaRootKey();
} else {
throw missingCaseError(credentialType);
}
return BackupAuthCredentialRequestContext.create(
key.serialize(),
window.storage.user.getCheckedAci()
);
}
private getFromCache(): ReadonlyArray<BackupCredentialWrapperType> {
return window.storage.get('backupCombinedCredentials', []);
}
private async updateCache(
values: ReadonlyArray<BackupCredentialWrapperType>
): Promise<void> {
await window.storage.put('backupCombinedCredentials', values);
}
public async getBackupLevel(
credentialType: BackupCredentialType
): Promise<BackupLevel> {
return (await this.getForToday(credentialType)).level;
}
// Called when backup tier changes or when userChanged event
public async clearCache(): Promise<void> {
this.cachedCdnReadCredentials = {};
await window.storage.put('backupCredentials', []);
await this.updateCache([]);
}
}

View file

@ -2,53 +2,105 @@
// SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee';
import type { PrivateKey } from '@signalapp/libsignal-client';
import {
AccountEntropyPool,
BackupKey,
} from '@signalapp/libsignal-client/dist/AccountKeys';
import { MessageBackupKey } from '@signalapp/libsignal-client/dist/MessageBackup';
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'));
const getMemoizedBackupKey = memoizee((accountEntropyPool: string) => {
return AccountEntropyPool.deriveBackupKey(accountEntropyPool);
});
export function getBackupKey(): Uint8Array {
const masterKey = window.storage.get('masterKey');
strictAssert(masterKey, 'Master key not available');
export function getBackupKey(): BackupKey {
const accountEntropyPool = window.storage.get('accountEntropyPool');
strictAssert(accountEntropyPool, 'Account Entropy Pool not available');
return getMemoizedBackupKey(masterKey);
return getMemoizedBackupKey(accountEntropyPool);
}
export function getBackupMediaRootKey(): BackupKey {
const rootKey = window.storage.get('backupMediaRootKey');
strictAssert(rootKey, 'Media root key not available');
return new BackupKey(Buffer.from(rootKey));
}
const getMemoizedBackupSignatureKey = memoizee(
(backupKey: Uint8Array, aci: AciString) => {
const aciBytes = toAciObject(aci).getServiceIdBinary();
return deriveBackupSignatureKey(backupKey, aciBytes);
(backupKey: BackupKey, aci: AciString) => {
return backupKey.deriveEcKey(toAciObject(aci));
}
);
export function getBackupSignatureKey(): Uint8Array {
export function getBackupSignatureKey(): PrivateKey {
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);
const getMemoizedBackupMediaSignatureKey = memoizee(
(rootKey: BackupKey, aci: AciString) => {
return rootKey.deriveEcKey(toAciObject(aci));
}
);
export function getBackupMediaSignatureKey(): PrivateKey {
const rootKey = getBackupMediaRootKey();
const aci = window.storage.user.getCheckedAci();
return getMemoizedBackupMediaSignatureKey(rootKey, aci);
}
const getMemoizedKeyMaterial = memoizee(
(backupKey: BackupKey, aci: AciString) => {
const messageKey = new MessageBackupKey({
backupKey,
backupId: backupKey.deriveBackupId(toAciObject(aci)),
});
return { macKey: messageKey.hmacKey, aesKey: messageKey.aesKey };
}
);
export type BackupKeyMaterialType = Readonly<{
macKey: Uint8Array;
aesKey: Uint8Array;
}>;
export function getKeyMaterial(
backupKey = getBackupKey()
): BackupKeyMaterialType {
const aci = window.storage.user.getCheckedAci();
return getMemoizedKeyMaterial(backupKey, aci);
}
export type BackupMediaKeyMaterialType = Readonly<{
macKey: Uint8Array;
aesKey: Uint8Array;
}>;
const BACKUP_MEDIA_AES_KEY_LEN = 32;
const BACKUP_MEDIA_MAC_KEY_LEN = 32;
export function deriveBackupMediaKeyMaterial(
mediaRootKey: BackupKey,
mediaId: Uint8Array
): BackupMediaKeyMaterialType {
if (!mediaId.length) {
throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
}
const material = mediaRootKey.deriveMediaEncryptionKey(Buffer.from(mediaId));
return {
macKey: material.subarray(0, BACKUP_MEDIA_MAC_KEY_LEN),
aesKey: material.subarray(
BACKUP_MEDIA_MAC_KEY_LEN,
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN
),
};
}

View file

@ -125,6 +125,7 @@ import {
getFilePointerForAttachment,
maybeGetBackupJobForAttachmentAndFilePointer,
} from './util/filePointers';
import { getBackupMediaRootKey } from './crypto';
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
import { getBackupCdnInfo } from './util/mediaId';
@ -269,6 +270,7 @@ export class BackupExportStream extends Readable {
Backups.BackupInfo.encodeDelimited({
version: Long.fromNumber(BACKUP_VERSION),
backupTimeMs: this.backupTimeMs,
mediaRootBackupKey: getBackupMediaRootKey().serialize(),
}).finish()
);

View file

@ -73,10 +73,12 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState';
import type { SendStateByConversationId } from '../../messages/MessageSendState';
import { SeenStatus } from '../../MessageSeenStatus';
import { constantTimeEqual } from '../../Crypto';
import * as Bytes from '../../Bytes';
import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants';
import type { AboutMe, LocalChatStyle } from './types';
import { BackupType } from './types';
import { getBackupMediaRootKey } from './crypto';
import type { GroupV2ChangeDetailType } from '../../groups';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { isNotNil } from '../../util/isNotNil';
@ -251,6 +253,24 @@ export class BackupImportStream extends Writable {
if (info.version?.toNumber() !== BACKUP_VERSION) {
throw new Error(`Unsupported backup version: ${info.version}`);
}
if (Bytes.isEmpty(info.mediaRootBackupKey)) {
throw new Error('Missing mediaRootBackupKey');
}
const theirKey = info.mediaRootBackupKey;
const ourKey = getBackupMediaRootKey().serialize();
if (!constantTimeEqual(theirKey, ourKey)) {
// Use root key from integration test
if (isTestEnvironment(getEnvironment())) {
await window.storage.put(
'backupMediaRootKey',
info.mediaRootBackupKey
);
} else {
throw new Error('Incorrect mediaRootBackupKey');
}
}
} else {
const frame = Backups.Frame.decode(data);

View file

@ -12,6 +12,7 @@ import { createGzip, createGunzip } from 'zlib';
import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
import { DataReader, DataWriter } from '../../sql/Client';
import * as log from '../../logging/log';
@ -29,6 +30,7 @@ import { HOUR } from '../../util/durations';
import { CipherType, HashType } from '../../types/Crypto';
import { InstallScreenBackupStep } from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
import { BackupCredentialType } from '../../types/backups';
import { HTTPError } from '../../textsecure/Errors';
import { constantTimeEqual } from '../../Crypto';
import { measureSize } from '../../AttachmentCrypto';
@ -177,7 +179,9 @@ export class BackupsService {
const fileName = `backup-${randomBytes(32).toString('hex')}`;
const filePath = join(window.BasePaths.temp, fileName);
const backupLevel = await this.credentials.getBackupLevel();
const backupLevel = await this.credentials.getBackupLevel(
BackupCredentialType.Media
);
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
try {
@ -195,7 +199,7 @@ export class BackupsService {
// Test harness
public async exportBackupData(
backupLevel: BackupLevel = BackupLevel.Messages,
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<Uint8Array> {
const sink = new PassThrough();
@ -210,7 +214,7 @@ export class BackupsService {
// Test harness
public async exportToDisk(
path: string,
backupLevel: BackupLevel = BackupLevel.Messages,
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<number> {
const size = await this.exportBackup(
@ -276,7 +280,9 @@ export class BackupsService {
try {
const importStream = await BackupImportStream.create(backupType);
if (backupType === BackupType.Ciphertext) {
const { aesKey, macKey } = getKeyMaterial(ephemeralKey);
const { aesKey, macKey } = getKeyMaterial(
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
);
// First pass - don't decrypt, only verify mac
let hmac = createHmac(HashType.size256, macKey);
@ -519,7 +525,7 @@ export class BackupsService {
private async exportBackup(
sink: Writable,
backupLevel: BackupLevel = BackupLevel.Messages,
backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext
): Promise<number> {
strictAssert(!this.isRunning, 'BackupService is already running');

View file

@ -210,7 +210,7 @@ export async function getFilePointerForAttachment({
// one point in the past verified the digest).
if (
isDownloadableFromBackupTier(attachment) &&
backupLevel === BackupLevel.Media
backupLevel === BackupLevel.Paid
) {
return {
filePointer: new Backups.FilePointer({
@ -240,7 +240,7 @@ export async function getFilePointerForAttachment({
}
// The attachment is locally saved
if (backupLevel !== BackupLevel.Media) {
if (backupLevel !== BackupLevel.Paid) {
// 1. If we have information to donwnload the file from the transit tier, great, let's
// just create an attachmentLocator so the restorer can try to download from the
// transit tier

View file

@ -3,16 +3,15 @@
import { DataReader } from '../../../sql/Client';
import * as Bytes from '../../../Bytes';
import { getBackupKey } from '../crypto';
import { getBackupMediaRootKey } from '../crypto';
import type { AttachmentType } from '../../../types/Attachment';
import { deriveMediaIdFromMediaName } from '../../../Crypto';
import { strictAssert } from '../../../util/assert';
export function getMediaIdFromMediaName(mediaName: string): {
string: string;
bytes: Uint8Array;
} {
const mediaIdBytes = deriveMediaIdFromMediaName(getBackupKey(), mediaName);
const mediaIdBytes = getBackupMediaRootKey().deriveMediaId(mediaName);
return {
string: Bytes.toBase64url(mediaIdBytes),
bytes: mediaIdBytes,
@ -51,7 +50,7 @@ export function getMediaNameFromDigest(digest: string): string {
export function getMediaNameForAttachmentThumbnail(
fullsizeMediaName: string
): string {
): `${string}_thumbnail` {
return `${fullsizeMediaName}_thumbnail`;
}

View file

@ -12,13 +12,14 @@ export async function validateBackup(
filePath: string,
fileSize: number
): Promise<void> {
const masterKeyBase64 = window.storage.get('masterKey');
strictAssert(masterKeyBase64, 'Master key not available');
const masterKey = Buffer.from(masterKeyBase64, 'base64');
const accountEntropy = window.storage.get('accountEntropyPool');
strictAssert(accountEntropy, 'Account Entropy Pool not available');
const aci = toAciObject(window.storage.user.getCheckedAci());
const backupKey = new libsignal.MessageBackupKey(masterKey, aci);
const backupKey = new libsignal.MessageBackupKey({
accountEntropy,
aci,
});
const streams = new Array<FileStream>();

View file

@ -70,6 +70,7 @@ type JobType = {
const OBSERVED_CAPABILITY_KEYS = Object.keys({
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
export class ProfileService {

View file

@ -123,6 +123,7 @@ const conflictBackOff = new BackOff([
function encryptRecord(
storageID: string | undefined,
recordIkm: Uint8Array | undefined,
storageRecord: Proto.IStorageRecord
): Proto.StorageItem {
const storageItem = new Proto.StorageItem();
@ -135,11 +136,12 @@ function encryptRecord(
if (!storageKeyBase64) {
throw new Error('No storage key');
}
const storageKey = Bytes.fromBase64(storageKeyBase64);
const storageItemKey = deriveStorageItemKey(
storageKey,
Bytes.toBase64(storageKeyBuffer)
);
const storageServiceKey = Bytes.fromBase64(storageKeyBase64);
const storageItemKey = deriveStorageItemKey({
storageServiceKey,
recordIkm,
key: storageKeyBuffer,
});
const encryptedRecord = encryptProfile(
Proto.StorageRecord.encode(storageRecord).finish(),
@ -158,6 +160,7 @@ function generateStorageID(): Uint8Array {
type GeneratedManifestType = {
postUploadUpdateFunctions: Array<() => unknown>;
recordIkm: Uint8Array | undefined;
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
insertKeys: Set<string>;
deleteKeys: Set<string>;
@ -729,6 +732,7 @@ async function generateManifest(
// If we have a copy of what the current remote manifest is then we run these
// additional validations comparing our pending manifest to the remote
// manifest:
let recordIkm: Uint8Array | undefined;
if (previousManifest) {
const pendingInserts: Set<string> = new Set();
const pendingDeletes: Set<string> = new Set();
@ -800,11 +804,18 @@ async function generateManifest(
);
}
}
if (Bytes.isNotEmpty(previousManifest.recordIkm)) {
recordIkm = previousManifest.recordIkm;
}
} else {
recordIkm = window.storage.get('manifestRecordIkm');
}
return {
postUploadUpdateFunctions,
recordsByID,
recordIkm,
insertKeys,
deleteKeys,
};
@ -812,6 +823,7 @@ async function generateManifest(
type EncryptManifestOptionsType = {
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
recordIkm: Uint8Array | undefined;
insertKeys: Set<string>;
};
@ -822,7 +834,7 @@ type EncryptedManifestType = {
async function encryptManifest(
version: number,
{ recordsByID, insertKeys }: EncryptManifestOptionsType
{ recordsByID, recordIkm, insertKeys }: EncryptManifestOptionsType
): Promise<EncryptedManifestType> {
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
const newItems: Set<Proto.IStorageItem> = new Set();
@ -843,7 +855,7 @@ async function encryptManifest(
let storageItem;
try {
storageItem = encryptRecord(storageID, storageRecord);
storageItem = encryptRecord(storageID, recordIkm, storageRecord);
} catch (err) {
log.error(
`storageService.upload(${version}): encrypt record failed:`,
@ -1391,7 +1403,11 @@ async function processManifest(
let conflictCount = 0;
if (remoteOnlyRecords.size) {
const fetchResult = await fetchRemoteRecords(version, remoteOnlyRecords);
const fetchResult = await fetchRemoteRecords(
version,
Bytes.isNotEmpty(manifest.recordIkm) ? manifest.recordIkm : undefined,
remoteOnlyRecords
);
conflictCount = await processRemoteRecords(version, fetchResult);
}
@ -1614,6 +1630,7 @@ export type FetchRemoteRecordsResultType = Readonly<{
async function fetchRemoteRecords(
storageVersion: number,
recordIkm: Uint8Array | undefined,
remoteOnlyRecords: Map<string, RemoteRecord>
): Promise<FetchRemoteRecordsResultType> {
const storageKeyBase64 = window.storage.get('storageKey');
@ -1678,7 +1695,11 @@ async function fetchRemoteRecords(
const base64ItemID = Bytes.toBase64(key);
missingKeys.delete(base64ItemID);
const storageItemKey = deriveStorageItemKey(storageKey, base64ItemID);
const storageItemKey = deriveStorageItemKey({
storageServiceKey: storageKey,
recordIkm,
key,
});
let storageItemPlaintext;
try {
@ -2071,6 +2092,11 @@ async function sync({
);
await window.storage.put('manifestVersion', version);
if (Bytes.isNotEmpty(manifest.recordIkm)) {
await window.storage.put('manifestRecordIkm', manifest.recordIkm);
} else {
await window.storage.remove('manifestRecordIkm');
}
const hasConflicts = conflictCount !== 0;
if (hasConflicts && !ignoreConflicts) {
@ -2208,6 +2234,7 @@ export async function eraseAllStorageServiceState({
// First, update high-level storage service metadata
await Promise.all([
window.storage.remove('manifestVersion'),
window.storage.remove('manifestRecordIkm'),
keepUnknownFields
? Promise.resolve()
: window.storage.remove('storage-service-unknown-records'),

View file

@ -432,6 +432,8 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
subscriberId: ['value'],
backupsSubscriberId: ['value'],
backupEphemeralKey: ['value'],
backupMediaRootKey: ['value'],
manifestRecordIkm: ['value'],
usernameLink: ['value.entropy', 'value.serverId'],
};
async function createOrUpdateItem<K extends ItemKeyType>(

View file

@ -6,6 +6,11 @@ import { createGzip } from 'node:zlib';
import { createCipheriv, randomBytes } from 'node:crypto';
import { Buffer } from 'node:buffer';
import Long from 'long';
import {
AccountEntropyPool,
BackupKey,
} from '@signalapp/libsignal-client/dist/AccountKeys';
import { MessageBackupKey } from '@signalapp/libsignal-client/dist/MessageBackup';
import type { AciString } from '../../types/ServiceId';
import { generateAci } from '../../types/ServiceId';
@ -14,11 +19,6 @@ import { appendPaddingStream } from '../../util/logPadding';
import { prependStream } from '../../util/prependStream';
import { appendMacStream } from '../../util/appendMacStream';
import { toAciObject } from '../../util/ServiceId';
import {
deriveBackupKey,
deriveBackupId,
deriveBackupKeyMaterial,
} from '../../Crypto';
import { BACKUP_VERSION } from '../../services/backups/constants';
import { Backups } from '../../protobuf';
@ -29,9 +29,10 @@ export type BackupGeneratorConfigType = Readonly<
conversations: number;
conversationAcis?: ReadonlyArray<AciString>;
messages: number;
mediaRootBackupKey: Buffer;
} & (
| {
masterKey: Buffer;
accountEntropyPool: string;
}
| {
backupKey: Buffer;
@ -50,15 +51,18 @@ export function generateBackup(
options: BackupGeneratorConfigType
): GenerateBackupResultType {
const { aci } = options;
let backupKey: Uint8Array;
if ('masterKey' in options) {
backupKey = deriveBackupKey(options.masterKey);
let backupKey: BackupKey;
if ('accountEntropyPool' in options) {
backupKey = AccountEntropyPool.deriveBackupKey(options.accountEntropyPool);
} else {
({ backupKey } = options);
backupKey = new BackupKey(options.backupKey);
}
const aciBytes = toAciObject(aci).getServiceIdBinary();
const backupId = Buffer.from(deriveBackupId(backupKey, aciBytes));
const { aesKey, macKey } = deriveBackupKeyMaterial(backupKey, backupId);
const aciObj = toAciObject(aci);
const backupId = backupKey.deriveBackupId(aciObj);
const { aesKey, hmacKey } = new MessageBackupKey({
backupKey,
backupId,
});
const iv = randomBytes(IV_LENGTH);
@ -67,7 +71,7 @@ export function generateBackup(
.pipe(appendPaddingStream())
.pipe(createCipheriv(CipherType.AES256CBC, aesKey, iv))
.pipe(prependStream(iv))
.pipe(appendMacStream(macKey));
.pipe(appendMacStream(hmacKey));
return { backupId, stream };
}
@ -87,11 +91,13 @@ function* createRecords({
conversations,
conversationAcis = [],
messages,
mediaRootBackupKey,
}: BackupGeneratorConfigType): Iterable<Buffer> {
yield Buffer.from(
Backups.BackupInfo.encodeDelimited({
version: Long.fromNumber(BACKUP_VERSION),
backupTimeMs: getTimestamp(),
mediaRootBackupKey,
}).finish()
);

View file

@ -172,7 +172,7 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('migration creates long-message attachment if there is a long message.body (i.e. schemaVersion < 13)', async () => {
@ -193,7 +193,7 @@ describe('backup/attachments', () => {
}),
],
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(expected, 'bodyAttachment'),
@ -249,7 +249,7 @@ describe('backup/attachments', () => {
}),
],
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => {
assert.deepStrictEqual(
omit(expected, 'bodyAttachment'),
@ -269,7 +269,7 @@ describe('backup/attachments', () => {
});
describe('normal attachments', () => {
it('BackupLevel.Messages, roundtrips normal attachments', async () => {
it('BackupLevel.Free, roundtrips normal attachments', async () => {
const attachment1 = composeAttachment(1);
const attachment2 = composeAttachment(2);
@ -288,10 +288,10 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Media, roundtrips normal attachments', async () => {
it('BackupLevel.Paid, roundtrips normal attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
@ -315,7 +315,7 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
it('roundtrips voice message attachments', async () => {
@ -344,13 +344,13 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('Preview attachments', () => {
it('BackupLevel.Messages, roundtrips preview attachments', async () => {
it('BackupLevel.Free, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness(
@ -371,10 +371,10 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Media, roundtrips preview attachments', async () => {
it('BackupLevel.Paid, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
@ -412,13 +412,13 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('contact attachments', () => {
it('BackupLevel.Messages, roundtrips contact attachments', async () => {
it('BackupLevel.Free, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness(
@ -440,10 +440,10 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Media, roundtrips contact attachments', async () => {
it('BackupLevel.Paid, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
@ -472,13 +472,13 @@ describe('backup/attachments', () => {
],
}),
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
});
describe('quotes', () => {
it('BackupLevel.Messages, roundtrips quote attachments', async () => {
it('BackupLevel.Free, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
const authorAci = generateAci();
const quotedMessage: QuotedMessageType = {
@ -512,10 +512,10 @@ describe('backup/attachments', () => {
},
}),
],
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('BackupLevel.Media, roundtrips quote attachments', async () => {
it('BackupLevel.Paid, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci();
@ -554,7 +554,7 @@ describe('backup/attachments', () => {
},
}),
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
@ -625,7 +625,7 @@ describe('backup/attachments', () => {
},
},
],
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
@ -673,7 +673,7 @@ describe('backup/attachments', () => {
},
],
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
comparator: (msgBefore, msgAfter) => {
if (msgBefore.timestamp === originalMessage.timestamp) {
return assert.deepStrictEqual(msgBefore, msgAfter);
@ -711,7 +711,7 @@ describe('backup/attachments', () => {
const packKey = Bytes.toBase64(getRandomBytes(32));
describe('when copied over from sticker pack (i.e. missing encryption info)', () => {
it('BackupLevel.Media, generates new encryption info', async () => {
it('BackupLevel.Paid, generates new encryption info', async () => {
await asymmetricRoundtripHarness(
[
composeMessage(1, {
@ -747,7 +747,7 @@ describe('backup/attachments', () => {
}),
],
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
comparator: (msgBefore, msgAfter) => {
assert.deepStrictEqual(
omit(msgBefore, 'sticker.data'),
@ -776,7 +776,7 @@ describe('backup/attachments', () => {
}
);
});
it('BackupLevel.Messages, generates invalid attachment locator', async () => {
it('BackupLevel.Free, generates invalid attachment locator', async () => {
// since we aren't re-uploading with new encryption info, we can't include this
// attachment in the backup proto
await asymmetricRoundtripHarness(
@ -815,7 +815,7 @@ describe('backup/attachments', () => {
}),
],
{
backupLevel: BackupLevel.Messages,
backupLevel: BackupLevel.Free,
}
);
});
@ -853,7 +853,7 @@ describe('backup/attachments', () => {
}),
],
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
}
);
});

View file

@ -18,8 +18,7 @@ import * as Bytes from '../../Bytes';
import type { AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
import { MASTER_KEY } from './helpers';
import { getRandomBytes } from '../../Crypto';
import { MASTER_KEY, MEDIA_ROOT_KEY } from './helpers';
import { generateKeys, safeUnlink } from '../../AttachmentCrypto';
import { writeNewAttachmentData } from '../../windows/attachments';
@ -275,8 +274,8 @@ async function testAttachmentToFilePointer(
}
if (!options?.backupLevel) {
await _doTest(BackupLevel.Messages);
await _doTest(BackupLevel.Media);
await _doTest(BackupLevel.Free);
await _doTest(BackupLevel.Paid);
} else {
await _doTest(options.backupLevel);
}
@ -295,6 +294,9 @@ describe('getFilePointerForAttachment', () => {
if (key === 'masterKey') {
return MASTER_KEY;
}
if (key === 'backupMediaRootKey') {
return MEDIA_ROOT_KEY;
}
return undefined;
});
});
@ -357,7 +359,7 @@ describe('getFilePointerForAttachment', () => {
await testAttachmentToFilePointer(
undownloadedAttachmentWithBackupLocator,
filePointerWithBackupLocator,
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
@ -372,7 +374,7 @@ describe('getFilePointerForAttachment', () => {
transitCdnKey: undefined,
}),
}),
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
@ -380,19 +382,19 @@ describe('getFilePointerForAttachment', () => {
await testAttachmentToFilePointer(
undownloadedAttachmentWithBackupLocator,
filePointerWithAttachmentLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
});
});
describe('downloaded locally', () => {
const downloadedAttachment = composeAttachment();
describe('BackupLevel.Messages', () => {
describe('BackupLevel.Free', () => {
it('returns attachmentLocator', async () => {
await testAttachmentToFilePointer(
downloadedAttachment,
filePointerWithAttachmentLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('returns invalidAttachmentLocator if missing critical locator info', async () => {
@ -402,7 +404,7 @@ describe('getFilePointerForAttachment', () => {
cdnKey: undefined,
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
await testAttachmentToFilePointer(
{
@ -410,7 +412,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: undefined,
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
@ -420,7 +422,7 @@ describe('getFilePointerForAttachment', () => {
key: undefined,
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
await testAttachmentToFilePointer(
{
@ -428,11 +430,11 @@ describe('getFilePointerForAttachment', () => {
digest: undefined,
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages }
{ backupLevel: BackupLevel.Free }
);
});
});
describe('BackupLevel.Media', () => {
describe('BackupLevel.Paid', () => {
describe('if missing critical decryption / encryption info', async () => {
let ciphertextFilePath: string;
const attachmentNeedingEncryptionInfo: AttachmentType = {
@ -481,7 +483,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: 12,
}),
}),
{ backupLevel: BackupLevel.Media, backupCdnNumber: 12 }
{ backupLevel: BackupLevel.Paid, backupCdnNumber: 12 }
);
});
@ -489,7 +491,7 @@ describe('getFilePointerForAttachment', () => {
const { filePointer: result, updatedAttachment } =
await getFilePointerForAttachment({
attachment: attachmentNeedingEncryptionInfo,
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn,
});
@ -529,7 +531,7 @@ describe('getFilePointerForAttachment', () => {
version: 1,
path: plaintextFilePath,
},
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn,
});
@ -567,7 +569,7 @@ describe('getFilePointerForAttachment', () => {
path: 'no/file/here.png',
},
filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Media }
{ backupLevel: BackupLevel.Paid }
);
});
@ -584,7 +586,7 @@ describe('getFilePointerForAttachment', () => {
const { filePointer: result } = await getFilePointerForAttachment({
attachment: attachmentWithReencryptionInfo,
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn,
});
@ -615,7 +617,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: 12,
}),
}),
{ backupLevel: BackupLevel.Media, backupCdnNumber: 12 }
{ backupLevel: BackupLevel.Paid, backupCdnNumber: 12 }
);
});
@ -624,7 +626,7 @@ describe('getFilePointerForAttachment', () => {
downloadedAttachment,
filePointerWithBackupLocator,
{
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
updatedAttachment: downloadedAttachment,
}
);
@ -635,7 +637,8 @@ describe('getFilePointerForAttachment', () => {
describe('getBackupJobForAttachmentAndFilePointer', async () => {
beforeEach(async () => {
await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32)));
await window.storage.put('masterKey', MASTER_KEY);
await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY);
});
afterEach(async () => {
await DataWriter.removeAll();
@ -645,7 +648,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
it('returns null if filePointer does not have backupLocator', async () => {
const { filePointer } = await getFilePointerForAttachment({
attachment,
backupLevel: BackupLevel.Messages,
backupLevel: BackupLevel.Free,
getBackupCdnInfo: notInBackupCdn,
});
assert.strictEqual(
@ -663,7 +666,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
const { filePointer, updatedAttachment } =
await getFilePointerForAttachment({
attachment,
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn,
});
const attachmentToUse = updatedAttachment ?? attachment;
@ -703,7 +706,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
});
const { filePointer } = await getFilePointerForAttachment({
attachment,
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: isInBackupTier,
});
assert.deepStrictEqual(
@ -730,7 +733,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
};
const { filePointer } = await getFilePointerForAttachment({
attachment: attachmentWithReencryptionInfo,
backupLevel: BackupLevel.Media,
backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn,
});

View file

@ -9,6 +9,7 @@ import { createReadStream } from 'fs';
import { mkdtemp, rm } from 'fs/promises';
import * as sinon from 'sinon';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
import type {
EditHistoryType,
@ -31,6 +32,8 @@ export const OUR_ACI = generateAci();
export const OUR_PNI = generatePni();
export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32));
export const PROFILE_KEY = getRandomBytes(32);
export const ACCOUNT_ENTROPY_POOL = AccountEntropyPool.generate();
export const MEDIA_ROOT_KEY = getRandomBytes(32);
// This is preserved across data erasure
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
@ -176,7 +179,7 @@ type HarnessOptionsType = {
export async function symmetricRoundtripHarness(
messages: Array<MessageAttributesType>,
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
options: HarnessOptionsType = { backupLevel: BackupLevel.Free }
): Promise<void> {
return asymmetricRoundtripHarness(messages, messages, options);
}
@ -194,7 +197,7 @@ async function updateConvoIdToTitle() {
export async function asymmetricRoundtripHarness(
before: Array<MessageAttributesType>,
after: Array<MessageAttributesType>,
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
options: HarnessOptionsType = { backupLevel: BackupLevel.Free }
): Promise<void> {
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
@ -247,6 +250,8 @@ export async function setupBasics(): Promise<void> {
await window.storage.put('uuid_id', `${OUR_ACI}.2`);
await window.storage.put('pni', OUR_PNI);
await window.storage.put('masterKey', MASTER_KEY);
await window.storage.put('accountEntropyPool', ACCOUNT_ENTROPY_POOL);
await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY);
await window.storage.put('profileKey', PROFILE_KEY);
await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', {

View file

@ -77,7 +77,7 @@ describe('backup/integration', () => {
});
const exported = await backupsService.exportBackupData(
BackupLevel.Media,
BackupLevel.Paid,
BackupType.TestOnlyPlaintext
);

View file

@ -79,7 +79,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
index: number,
overrides: Partial<ThumbnailAttachmentBackupJobType['data']> = {}
): ThumbnailAttachmentBackupJobType {
const mediaName = `thumbnail${index}`;
const mediaName = `thumbnail${index}_thumbnail` as const;
return {
mediaName,
@ -116,6 +116,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
await DataWriter.removeAll();
await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32)));
await window.storage.put('backupMediaRootKey', getRandomBytes(32));
sandbox = sinon.createSandbox();
clock = sandbox.useFakeTimers();

View file

@ -12,7 +12,7 @@ import {
import { MediaTier } from '../../types/AttachmentDownload';
import { HTTPError } from '../../textsecure/Errors';
import { getCdnNumberForBackupTier } from '../../textsecure/downloadAttachment';
import { MASTER_KEY } from '../backup/helpers';
import { MASTER_KEY, MEDIA_ROOT_KEY } from '../backup/helpers';
import { getMediaIdFromMediaName } from '../../services/backups/util/mediaId';
import { AttachmentVariant } from '../../types/Attachment';
@ -255,6 +255,9 @@ describe('getCdnNumberForBackupTier', () => {
if (key === 'masterKey') {
return MASTER_KEY;
}
if (key === 'backupMediaRootKey') {
return MEDIA_ROOT_KEY;
}
return undefined;
});
});

View file

@ -68,7 +68,6 @@ describe('backups', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
readReceipts: true,
hasCompletedUsernameOnboarding: true,
@ -330,6 +329,7 @@ describe('backups', function (this: Mocha.Suite) {
const { stream: backupStream } = generateBackup({
aci: phone.device.aci,
profileKey: phone.profileKey.serialize(),
mediaRootBackupKey: phone.mediaRootBackupKey,
backupKey: ephemeralBackupKey,
conversations: 2,
conversationAcis: [contact1, contact2],

View file

@ -11,7 +11,8 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const { backupId, stream: backupStream } = generateBackup({
aci: phone.device.aci,
profileKey: phone.profileKey.serialize(),
masterKey: phone.masterKey,
accountEntropyPool: phone.accountEntropyPool,
mediaRootBackupKey: phone.mediaRootBackupKey,
conversations: 1000,
messages: 60 * 1000,
});

View file

@ -35,7 +35,6 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
readReceipts: true,
hasCompletedUsernameOnboarding: true,

View file

@ -114,6 +114,8 @@ export type BootstrapOptions = Readonly<{
unknownContactCount?: number;
contactNames?: ReadonlyArray<string>;
contactPreKeyCount?: number;
useLegacyStorageEncryption?: boolean;
}>;
export type EphemeralBackupType = Readonly<{
@ -252,6 +254,9 @@ export class Bootstrap {
contacts: this.contacts,
contactsWithoutProfileKey: this.contactsWithoutProfileKey,
});
if (this.options.useLegacyStorageEncryption) {
this.privPhone.storageRecordIkm = undefined;
}
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-'));

View file

@ -53,7 +53,6 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(stranger, {

View file

@ -30,7 +30,6 @@ describe('messaging/relink', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
hasSetMyStoriesPrivacy: true,
});

View file

@ -36,7 +36,6 @@ describe('safety number', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
hasSetMyStoriesPrivacy: true,
});

View file

@ -34,7 +34,6 @@ describe('senderKey', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
});

View file

@ -38,7 +38,6 @@ describe('story/messaging', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
hasSetMyStoriesPrivacy: true,
});

View file

@ -46,7 +46,6 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(

View file

@ -49,7 +49,6 @@ describe('pnp/merge', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(

View file

@ -46,7 +46,6 @@ describe('pnp/phone discovery', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(

View file

@ -46,7 +46,6 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(

View file

@ -49,7 +49,6 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
// Add my story

View file

@ -36,7 +36,6 @@ describe('pnp/send gv2 invite', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
aciContact = await server.createPrimaryDevice({

View file

@ -48,7 +48,6 @@ describe('pnp/username', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(usernameContact, {

View file

@ -34,7 +34,6 @@ describe('story/no-sender-key', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
hasSetMyStoriesPrivacy: true,
});

View file

@ -43,7 +43,6 @@ describe('challenge/receipts', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
readReceipts: true,
});

View file

@ -29,7 +29,6 @@ describe('storage service', function (this: Mocha.Suite) {
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
});

View file

@ -71,7 +71,6 @@ export async function initStorage(
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
givenName: phone.profileName,
});

View file

@ -17,10 +17,6 @@ describe('storage service', function (this: Mocha.Suite) {
let app: App;
let group: Group;
beforeEach(async () => {
({ bootstrap, app, group } = await initStorage());
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
@ -31,76 +27,41 @@ describe('storage service', function (this: Mocha.Suite) {
await bootstrap.teardown();
});
it('should pin/unpin groups', async () => {
const { phone, desktop, contacts } = bootstrap;
for (const useLegacyStorageEncryption of [false, true]) {
const suffix = `useLegacyStorageEncryption=${useLegacyStorageEncryption}`;
// eslint-disable-next-line no-loop-func
it(`should pin/unpin groups ${suffix}`, async () => {
({ bootstrap, app, group } = await initStorage({
useLegacyStorageEncryption,
}));
const window = await app.getWindow();
const { phone, desktop, contacts } = bootstrap;
const leftPane = window.locator('#LeftPane');
const conversationStack = window.locator('.Inbox__conversation-stack');
const window = await app.getWindow();
debug('Verifying that the group is pinned on startup');
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor();
debug('Unpinning group via storage service');
{
const state = await phone.expectStorageState('initial state');
await phone.setStorageState(state.unpinGroup(group));
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
const leftPane = window.locator('#LeftPane');
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('Verifying that the group is pinned on startup');
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor();
}
debug('Pinning group in the app');
{
const state = await phone.expectStorageState('consistency check');
debug('Unpinning group via storage service');
{
const state = await phone.expectStorageState('initial state');
const convo = leftPane.locator(`[data-testid="${group.id}"]`);
await convo.click();
const moreButton = conversationStack.locator(
'button.module-ConversationHeader__button--more'
);
await moreButton.click();
const pinButton = window.locator('.react-contextmenu-item >> "Pin chat"');
await pinButton.click();
const newState = await phone.waitForStorageState({
after: state,
});
assert.isTrue(await newState.isGroupPinned(group), 'group not pinned');
// AccountRecord
const { added, removed } = newState.diff(state);
assert.strictEqual(added.length, 1, 'only one record must be added');
assert.strictEqual(removed.length, 1, 'only one record must be removed');
}
debug('Pinning > 4 conversations');
{
// We already have one group and first contact pinned so we need three
// more.
const toPin = contacts.slice(1, 4);
// To do that we need them to appear in the left pane, though.
for (const [i, contact] of toPin.entries()) {
const isLast = i === toPin.length - 1;
debug('sending a message to contact=%d', i);
await contact.sendText(desktop, 'Hello!', {
await phone.setStorageState(state.unpinGroup(group));
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor();
}
debug('Pinning group in the app');
{
const state = await phone.expectStorageState('consistency check');
debug('pinning contact=%d', i);
const convo = leftPane.locator(
`[data-testid="${contact.toContact().aci}"]`
);
const convo = leftPane.locator(`[data-testid="${group.id}"]`);
await convo.click();
const moreButton = conversationStack.locator(
@ -113,19 +74,10 @@ describe('storage service', function (this: Mocha.Suite) {
);
await pinButton.click();
if (isLast) {
// Storage state shouldn't be updated because we failed to pin
await window
.locator('.Toast >> "You can only pin up to 4 chats"')
.waitFor();
break;
}
debug('verifying storage state change contact=%d', i);
const newState = await phone.waitForStorageState({
after: state,
});
assert.isTrue(await newState.isPinned(contact), 'contact not pinned');
assert.isTrue(await newState.isGroupPinned(group), 'group not pinned');
// AccountRecord
const { added, removed } = newState.diff(state);
@ -136,11 +88,69 @@ describe('storage service', function (this: Mocha.Suite) {
'only one record must be removed'
);
}
}
debug('Verifying the final manifest version');
const finalState = await phone.expectStorageState('consistency check');
debug('Pinning > 4 conversations');
{
// We already have one group and first contact pinned so we need three
// more.
const toPin = contacts.slice(1, 4);
assert.strictEqual(finalState.version, 5);
});
// To do that we need them to appear in the left pane, though.
for (const [i, contact] of toPin.entries()) {
const isLast = i === toPin.length - 1;
debug('sending a message to contact=%d', i);
await contact.sendText(desktop, 'Hello!', {
timestamp: bootstrap.getTimestamp(),
});
const state = await phone.expectStorageState('consistency check');
debug('pinning contact=%d', i);
const convo = leftPane.locator(
`[data-testid="${contact.toContact().aci}"]`
);
await convo.click();
const moreButton = conversationStack.locator(
'button.module-ConversationHeader__button--more'
);
await moreButton.click();
const pinButton = window.locator(
'.react-contextmenu-item >> "Pin chat"'
);
await pinButton.click();
if (isLast) {
// Storage state shouldn't be updated because we failed to pin
await window
.locator('.Toast >> "You can only pin up to 4 chats"')
.waitFor();
break;
}
debug('verifying storage state change contact=%d', i);
const newState = await phone.waitForStorageState({
after: state,
});
assert.isTrue(await newState.isPinned(contact), 'contact not pinned');
// AccountRecord
const { added, removed } = newState.diff(state);
assert.strictEqual(added.length, 1, 'only one record must be added');
assert.strictEqual(
removed.length,
1,
'only one record must be removed'
);
}
}
debug('Verifying the final manifest version');
const finalState = await phone.expectStorageState('consistency check');
assert.strictEqual(finalState.version, 5);
});
}
});

View file

@ -4,6 +4,10 @@
import PQueue from 'p-queue';
import { isNumber, omit, orderBy } from 'lodash';
import type { KyberPreKeyRecord } from '@signalapp/libsignal-client';
import {
AccountEntropyPool,
BackupKey,
} from '@signalapp/libsignal-client/dist/AccountKeys';
import { Readable } from 'stream';
import EventTarget from './EventTarget';
@ -30,6 +34,7 @@ import {
decryptDeviceName,
deriveAccessKey,
deriveStorageServiceKey,
deriveMasterKey,
encryptDeviceName,
generateRegistrationId,
getRandomBytes,
@ -122,7 +127,8 @@ type CreateAccountSharedOptionsType = Readonly<{
aciKeyPair: KeyPairType;
pniKeyPair: KeyPairType;
profileKey: Uint8Array;
masterKey: Uint8Array;
masterKey: Uint8Array | undefined;
accountEntropyPool: string | undefined;
// Test-only
backupFile?: Uint8Array;
@ -136,6 +142,7 @@ type CreatePrimaryDeviceOptionsType = Readonly<{
ourPni?: undefined;
userAgent?: undefined;
ephemeralBackupKey?: undefined;
mediaRootBackupKey: Uint8Array;
readReceipts: true;
@ -152,6 +159,7 @@ export type CreateLinkedDeviceOptionsType = Readonly<{
ourPni: PniString;
userAgent?: string;
ephemeralBackupKey: Uint8Array | undefined;
mediaRootBackupKey: Uint8Array | undefined;
readReceipts: boolean;
@ -325,6 +333,8 @@ export default class AccountManager extends EventTarget {
const profileKey = getRandomBytes(PROFILE_KEY_LENGTH);
const accessKey = deriveAccessKey(profileKey);
const masterKey = getRandomBytes(MASTER_KEY_LENGTH);
const accountEntropyPool = AccountEntropyPool.generate();
const mediaRootBackupKey = BackupKey.generateRandom().serialize();
await this.createAccount({
type: AccountType.Primary,
@ -337,6 +347,8 @@ export default class AccountManager extends EventTarget {
accessKey,
masterKey,
ephemeralBackupKey: undefined,
mediaRootBackupKey,
accountEntropyPool,
readReceipts: true,
});
});
@ -922,11 +934,18 @@ export default class AccountManager extends EventTarget {
pniKeyPair,
profileKey,
masterKey,
mediaRootBackupKey,
readReceipts,
userAgent,
backupFile,
accountEntropyPool,
} = options;
strictAssert(
Bytes.isNotEmpty(masterKey) || accountEntropyPool,
'Either master key or AEP is necessary for registration'
);
const { storage } = window.textsecure;
let password = Bytes.toBase64(getRandomBytes(16));
password = password.substring(0, password.length - 2);
@ -1174,10 +1193,21 @@ export default class AccountManager extends EventTarget {
if (userAgent) {
await storage.put('userAgent', userAgent);
}
await storage.put('masterKey', Bytes.toBase64(masterKey));
if (accountEntropyPool) {
await storage.put('accountEntropyPool', accountEntropyPool);
}
let derivedMasterKey = masterKey;
if (derivedMasterKey == null) {
strictAssert(accountEntropyPool, 'Cannot derive master key');
derivedMasterKey = deriveMasterKey(accountEntropyPool);
}
if (Bytes.isNotEmpty(mediaRootBackupKey)) {
await storage.put('backupMediaRootKey', mediaRootBackupKey);
}
await storage.put('masterKey', Bytes.toBase64(derivedMasterKey));
await storage.put(
'storageKey',
Bytes.toBase64(deriveStorageServiceKey(masterKey))
Bytes.toBase64(deriveStorageServiceKey(derivedMasterKey))
);
await storage.put('read-receipt-setting', Boolean(readReceipts));

View file

@ -3373,6 +3373,10 @@ export default class MessageReceiver
? sync.storageService
: undefined,
masterKey: Bytes.isNotEmpty(sync.master) ? sync.master : undefined,
accountEntropyPool: sync.accountEntropyPool || undefined,
mediaRootBackupKey: Bytes.isNotEmpty(sync.mediaRootBackupKey)
? sync.mediaRootBackupKey
: undefined,
},
this.removeFromCache.bind(this, envelope)
);

View file

@ -176,6 +176,8 @@ export class Provisioner {
userAgent,
readReceipts,
ephemeralBackupKey,
accountEntropyPool,
mediaRootBackupKey,
} = envelope;
strictAssert(number, 'prepareLinkData: missing number');
@ -188,8 +190,8 @@ export class Provisioner {
'prepareLinkData: missing profileKey'
);
strictAssert(
Bytes.isNotEmpty(masterKey),
'prepareLinkData: missing masterKey'
Bytes.isNotEmpty(masterKey) || accountEntropyPool,
'prepareLinkData: missing masterKey or accountEntropyPool'
);
strictAssert(
isUntaggedPniString(untaggedPni),
@ -220,6 +222,8 @@ export class Provisioner {
readReceipts: Boolean(readReceipts),
masterKey,
ephemeralBackupKey,
accountEntropyPool,
mediaRootBackupKey,
};
}

View file

@ -26,6 +26,8 @@ export type ProvisionDecryptResult = Readonly<{
readReceipts?: boolean;
profileKey?: Uint8Array;
masterKey?: Uint8Array;
accountEntropyPool: string | undefined;
mediaRootBackupKey: Uint8Array | undefined;
ephemeralBackupKey: Uint8Array | undefined;
}>;
@ -94,6 +96,10 @@ class ProvisioningCipherInner {
ephemeralBackupKey: Bytes.isNotEmpty(provisionMessage.ephemeralBackupKey)
? provisionMessage.ephemeralBackupKey
: undefined,
mediaRootBackupKey: Bytes.isNotEmpty(provisionMessage.mediaRootBackupKey)
? provisionMessage.mediaRootBackupKey
: undefined,
accountEntropyPool: provisionMessage.accountEntropyPool || undefined,
};
}

View file

@ -795,10 +795,12 @@ export type WebAPIConnectType = {
export type CapabilitiesType = {
deleteSync: boolean;
versionedExpirationTimer: boolean;
ssre2: boolean;
};
export type CapabilitiesUploadType = {
deleteSync: true;
versionedExpirationTimer: true;
ssre2: true;
};
type StickerPackManifestType = Uint8Array;
@ -1099,7 +1101,8 @@ export type RequestVerificationResultType = Readonly<{
}>;
export type SetBackupIdOptionsType = Readonly<{
backupAuthCredentialRequest: Uint8Array;
messagesBackupAuthCredentialRequest: Uint8Array;
mediaBackupAuthCredentialRequest: Uint8Array;
}>;
export type SetBackupSignatureKeyOptionsType = Readonly<{
@ -1121,7 +1124,6 @@ export type BackupMediaItemType = Readonly<{
mediaId: string;
hmacKey: Uint8Array;
encryptionKey: Uint8Array;
iv: Uint8Array;
}>;
export type BackupMediaBatchOptionsType = Readonly<{
@ -1186,15 +1188,20 @@ export type GetBackupCredentialsOptionsType = Readonly<{
endDayInMs: number;
}>;
export const backupCredentialListSchema = z
.object({
credential: z.string().transform(x => Bytes.fromBase64(x)),
redemptionTime: z
.number()
.transform(x => durations.DurationInSeconds.fromSeconds(x)),
})
.array();
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(),
credentials: z.object({
messages: backupCredentialListSchema,
media: backupCredentialListSchema,
}),
});
export type GetBackupCredentialsResponseType = z.infer<
@ -2752,6 +2759,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = {
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
};
const jsonData = {
@ -2807,6 +2815,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = {
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
};
const jsonData = {
@ -3113,14 +3122,18 @@ export function initialize({
}
async function setBackupId({
backupAuthCredentialRequest,
messagesBackupAuthCredentialRequest,
mediaBackupAuthCredentialRequest,
}: SetBackupIdOptionsType) {
await _ajax({
call: 'setBackupId',
httpType: 'PUT',
jsonData: {
backupAuthCredentialRequest: Bytes.toBase64(
backupAuthCredentialRequest
messagesBackupAuthCredentialRequest: Bytes.toBase64(
messagesBackupAuthCredentialRequest
),
mediaBackupAuthCredentialRequest: Bytes.toBase64(
mediaBackupAuthCredentialRequest
),
},
});
@ -3163,7 +3176,6 @@ export function initialize({
mediaId,
hmacKey,
encryptionKey,
iv,
} = item;
return {
@ -3175,7 +3187,6 @@ export function initialize({
mediaId,
hmacKey: Bytes.toBase64(hmacKey),
encryptionKey: Bytes.toBase64(encryptionKey),
iv: Bytes.toBase64(iv),
};
}),
},

View file

@ -8,6 +8,7 @@ import type { Readable, Writable } from 'stream';
import { Transform } from 'stream';
import { pipeline } from 'stream/promises';
import { ensureFile } from 'fs-extra';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { strictAssert } from '../util/assert';
@ -18,11 +19,6 @@ import {
AttachmentVariant,
} from '../types/Attachment';
import * as Bytes from '../Bytes';
import {
deriveBackupMediaKeyMaterial,
type BackupMediaKeyMaterialType,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto';
import {
getAttachmentCiphertextLength,
safeUnlink,
@ -35,7 +31,11 @@ import type { ProcessedAttachment } from './Types.d';
import type { WebAPIType } from './WebAPI';
import { createName, getRelativePath } from '../util/attachmentPath';
import { MediaTier } from '../types/AttachmentDownload';
import { getBackupKey } from '../services/backups/crypto';
import {
getBackupMediaRootKey,
deriveBackupMediaKeyMaterial,
type BackupMediaKeyMaterialType,
} from '../services/backups/crypto';
import { backupsService } from '../services/backups';
import {
getMediaIdForAttachment,
@ -44,6 +44,7 @@ import {
import { MAX_BACKUP_THUMBNAIL_SIZE } from '../types/VisualAttachment';
import { missingCaseError } from '../util/missingCaseError';
import { IV_LENGTH, MAC_LENGTH } from '../types/Crypto';
import { BackupCredentialType } from '../types/backups';
const DEFAULT_BACKUP_CDN_NUMBER = 3;
@ -57,7 +58,7 @@ function getBackupMediaOuterEncryptionKeyMaterial(
attachment: AttachmentType
): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachment(attachment);
const backupKey = getBackupKey();
const backupKey = getBackupMediaRootKey();
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
}
@ -65,17 +66,14 @@ function getBackupThumbnailInnerEncryptionKeyMaterial(
attachment: AttachmentType
): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachmentThumbnail(attachment);
const backupKey = getBackupKey();
return deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
backupKey,
mediaId.bytes
);
const backupKey = getBackupMediaRootKey();
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
}
function getBackupThumbnailOuterEncryptionKeyMaterial(
attachment: AttachmentType
): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachmentThumbnail(attachment);
const backupKey = getBackupKey();
const backupKey = getBackupMediaRootKey();
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
}
@ -188,7 +186,10 @@ export async function downloadAttachment(
const cdnNumber = await getCdnNumberForBackupTier(attachment);
const cdnCredentials =
await backupsService.credentials.getCDNReadCredentials(cdnNumber);
await backupsService.credentials.getCDNReadCredentials(
cdnNumber,
BackupCredentialType.Media
);
const backupDir = await backupsService.api.getBackupDir();
const mediaDir = await backupsService.api.getMediaDir();

View file

@ -391,20 +391,31 @@ export class FetchLatestEvent extends ConfirmableEvent {
export type KeysEventData = Readonly<{
storageServiceKey: Uint8Array | undefined;
masterKey: Uint8Array | undefined;
accountEntropyPool: string | undefined;
mediaRootBackupKey: Uint8Array | undefined;
}>;
export class KeysEvent extends ConfirmableEvent {
public readonly storageServiceKey: Uint8Array | undefined;
public readonly masterKey: Uint8Array | undefined;
public readonly accountEntropyPool: string | undefined;
public readonly mediaRootBackupKey: Uint8Array | undefined;
constructor(
{ storageServiceKey, masterKey }: KeysEventData,
{
storageServiceKey,
masterKey,
accountEntropyPool,
mediaRootBackupKey,
}: KeysEventData,
confirm: ConfirmCallback
) {
super('keys', confirm);
this.storageServiceKey = storageServiceKey;
this.masterKey = masterKey;
this.accountEntropyPool = accountEntropyPool;
this.mediaRootBackupKey = mediaRootBackupKey;
}
}

View file

@ -34,7 +34,7 @@ export type StandardAttachmentBackupJobType = {
export type ThumbnailAttachmentBackupJobType = {
type: 'thumbnail';
mediaName: string;
mediaName: `${string}_thumbnail`;
receivedAt: number;
data: {
fullsizePath: string | null;
@ -47,6 +47,7 @@ export type ThumbnailAttachmentBackupJobType = {
const standardBackupJobDataSchema = z.object({
type: z.literal('standard'),
mediaName: z.string(),
data: z.object({
path: z.string(),
size: z.number(),
@ -66,8 +67,15 @@ const standardBackupJobDataSchema = z.object({
}),
});
const thumbnailMediaNameSchema = z
.string()
.refine((mediaName: string): mediaName is `${string}_thumbnail` => {
return mediaName.endsWith('_thumbnail');
});
const thumbnailBackupJobDataSchema = z.object({
type: z.literal('thumbnail'),
mediaName: thumbnailMediaNameSchema,
data: z.object({
fullsizePath: z.string(),
fullsizeSize: z.number(),
@ -79,7 +87,6 @@ const thumbnailBackupJobDataSchema = z.object({
export const attachmentBackupJobSchema = z
.object({
mediaName: z.string(),
receivedAt: z.number(),
})
.and(
@ -101,7 +108,7 @@ export const attachmentBackupJobSchema = z
>;
export const thumbnailBackupJobRecordSchema = z.object({
mediaName: z.string(),
mediaName: thumbnailMediaNameSchema,
type: z.literal('standard'),
json: thumbnailBackupJobDataSchema.omit({ type: true }),
});

17
ts/types/Storage.d.ts vendored
View file

@ -17,7 +17,7 @@ import type {
SessionResetsType,
StorageServiceCredentials,
} from '../textsecure/Types.d';
import type { BackupCredentialType } from './backups';
import type { BackupCredentialWrapperType } from './backups';
import type { ServiceIdString } from './ServiceId';
import type { RegisteredChallengeType } from '../challenge';
@ -87,8 +87,10 @@ export type StorageAccessType = {
lastResortKeyUpdateTime: number;
lastResortKeyUpdateTimePNI: number;
localDeleteWarningShown: boolean;
accountEntropyPool: string;
masterKey: string;
masterKeyLastRequestTime: number;
accountEntropyPoolLastRequestTime: number;
maxPreKeyId: number;
maxPreKeyIdPNI: number;
maxKyberPreKeyId: number;
@ -129,6 +131,7 @@ export type StorageAccessType = {
storageFetchComplete: boolean;
avatarUrl: string | undefined;
manifestVersion: number;
manifestRecordIkm: Uint8Array;
storageCredentials: StorageServiceCredentials;
'storage-service-error-records': ReadonlyArray<UnknownRecord>;
'storage-service-unknown-records': ReadonlyArray<UnknownRecord>;
@ -141,14 +144,16 @@ export type StorageAccessType = {
unidentifiedDeliveryIndicators: boolean;
groupCredentials: ReadonlyArray<GroupCredentialType>;
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
backupCredentials: ReadonlyArray<BackupCredentialType>;
backupCredentialsLastRequestTime: number;
backupCombinedCredentials: ReadonlyArray<BackupCredentialWrapperType>;
backupCombinedCredentialsLastRequestTime: number;
backupMediaRootKey: Uint8Array;
backupMediaDownloadTotalBytes: number;
backupMediaDownloadCompletedBytes: number;
backupMediaDownloadPaused: boolean;
backupMediaDownloadBannerDismissed: boolean;
backupMediaDownloadIdle: boolean;
setBackupSignatureKey: boolean;
setBackupMessagesSignatureKey: boolean;
setBackupMediaSignatureKey: boolean;
lastReceivedAtCounter: number;
preferredReactionEmoji: ReadonlyArray<string>;
skinTone: number;
@ -185,6 +190,7 @@ export type StorageAccessType = {
observedCapabilities: {
deleteSync?: true;
versionedExpirationTimer?: true;
ssre2?: true;
// Note: Upon capability deprecation - change the value type to `never` and
// remove it in `ts/background.ts`
@ -212,6 +218,7 @@ export type StorageAccessType = {
sendEditWarningShown: never;
formattingWarningShown: never;
hasRegisterSupportForUnauthenticatedDelivery: never;
masterKeyLastRequestTime: never;
};
export type StorageInterface = {

View file

@ -2,9 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupCredentialType } from '@signalapp/libsignal-client/dist/zkgroup';
import type { GetBackupCDNCredentialsResponseType } from '../textsecure/WebAPI';
export type BackupCredentialType = Readonly<{
export { BackupCredentialType };
export type BackupCredentialWrapperType = Readonly<{
type: BackupCredentialType;
credential: string;
level: BackupLevel;
redemptionTimeMs: number;