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

View file

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

View file

@ -9,6 +9,8 @@ option java_package = "org.thoughtcrime.securesms.backup.v2.proto";
message BackupInfo { message BackupInfo {
uint64 version = 1; uint64 version = 1;
uint64 backupTimeMs = 2; 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: // Frames must follow in the following ordering rules:

View file

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

View file

@ -506,7 +506,9 @@ message SyncMessage {
message Keys { message Keys {
optional bytes storageService = 1; // deprecated: this field will be removed in a future release. 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 { message Read {

View file

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

View file

@ -4,11 +4,12 @@
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import Long from 'long'; import Long from 'long';
import { Aci, HKDF } from '@signalapp/libsignal-client'; import { Aci, HKDF } from '@signalapp/libsignal-client';
import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { Crypto } from './context/Crypto'; import { Crypto } from './context/Crypto';
import { calculateAgreement, generateKeyPair } from './Curve'; 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 { ProfileDecryptError } from './types/errors';
import { getBytesSubarray } from './util/uuidToBytes'; import { getBytesSubarray } from './util/uuidToBytes';
import { logPadSize } from './util/logPadding'; import { logPadSize } from './util/logPadding';
@ -154,185 +155,23 @@ export function decryptDeviceName(
return Bytes.toString(plaintext); 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_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 = 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_AES_KEY_LEN = 32;
const BACKUP_MEDIA_MAC_KEY_LEN = 32; const BACKUP_MEDIA_MAC_KEY_LEN = 32;
const BACKUP_MEDIA_IV_LEN = 16; const BACKUP_MEDIA_IV_LEN = 16;
export function deriveBackupKeyMaterial( export type BackupMediaKeyMaterialType = Readonly<{
backupKey: Uint8Array, aesKey: Uint8Array;
backupId: Uint8Array macKey: 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 function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
backupKey: Uint8Array, mediaRootKey: Uint8Array,
mediaId: Uint8Array mediaId: Uint8Array
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) { if (mediaRootKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error( throw new Error(
'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length' 'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length'
); );
@ -345,9 +184,12 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
const hkdf = HKDF.new(3); const hkdf = HKDF.new(3);
const material = hkdf.deriveSecrets( const material = hkdf.deriveSecrets(
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN, BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
Buffer.from(backupKey), Buffer.from(mediaRootKey),
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO), Buffer.concat([
Buffer.from(mediaId) Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
Buffer.from(mediaId),
]),
Buffer.alloc(0)
); );
return { return {
@ -356,15 +198,54 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
BACKUP_MEDIA_AES_KEY_LEN, BACKUP_MEDIA_AES_KEY_LEN,
BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_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, storageServiceKey: Uint8Array,
itemID: string version: Long = Long.fromNumber(0)
): Uint8Array { ): 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 { export function deriveAccessKey(profileKey: Uint8Array): Uint8Array {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,53 +2,105 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import memoizee from 'memoizee'; 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 { strictAssert } from '../../util/assert';
import type { AciString } from '../../types/ServiceId'; import type { AciString } from '../../types/ServiceId';
import { toAciObject } from '../../util/ServiceId'; import { toAciObject } from '../../util/ServiceId';
import {
deriveBackupKey,
deriveBackupSignatureKey,
deriveBackupId,
deriveBackupKeyMaterial,
} from '../../Crypto';
import type { BackupKeyMaterialType } from '../../Crypto';
const getMemoizedBackupKey = memoizee((masterKey: string) => { const getMemoizedBackupKey = memoizee((accountEntropyPool: string) => {
return deriveBackupKey(Buffer.from(masterKey, 'base64')); return AccountEntropyPool.deriveBackupKey(accountEntropyPool);
}); });
export function getBackupKey(): Uint8Array { export function getBackupKey(): BackupKey {
const masterKey = window.storage.get('masterKey'); const accountEntropyPool = window.storage.get('accountEntropyPool');
strictAssert(masterKey, 'Master key not available'); 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( const getMemoizedBackupSignatureKey = memoizee(
(backupKey: Uint8Array, aci: AciString) => { (backupKey: BackupKey, aci: AciString) => {
const aciBytes = toAciObject(aci).getServiceIdBinary(); return backupKey.deriveEcKey(toAciObject(aci));
return deriveBackupSignatureKey(backupKey, aciBytes);
} }
); );
export function getBackupSignatureKey(): Uint8Array { export function getBackupSignatureKey(): PrivateKey {
const backupKey = getBackupKey(); const backupKey = getBackupKey();
const aci = window.storage.user.getCheckedAci(); const aci = window.storage.user.getCheckedAci();
return getMemoizedBackupSignatureKey(backupKey, aci); return getMemoizedBackupSignatureKey(backupKey, aci);
} }
const getMemoizedKeyMaterial = memoizee( const getMemoizedBackupMediaSignatureKey = memoizee(
(backupKey: Uint8Array, aci: AciString) => { (rootKey: BackupKey, aci: AciString) => {
const aciBytes = toAciObject(aci).getServiceIdBinary(); return rootKey.deriveEcKey(toAciObject(aci));
const backupId = deriveBackupId(backupKey, aciBytes);
return deriveBackupKeyMaterial(backupKey, backupId);
} }
); );
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( export function getKeyMaterial(
backupKey = getBackupKey() backupKey = getBackupKey()
): BackupKeyMaterialType { ): BackupKeyMaterialType {
const aci = window.storage.user.getCheckedAci(); const aci = window.storage.user.getCheckedAci();
return getMemoizedKeyMaterial(backupKey, aci); 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, getFilePointerForAttachment,
maybeGetBackupJobForAttachmentAndFilePointer, maybeGetBackupJobForAttachmentAndFilePointer,
} from './util/filePointers'; } from './util/filePointers';
import { getBackupMediaRootKey } from './crypto';
import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup'; import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup';
import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager'; import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
import { getBackupCdnInfo } from './util/mediaId'; import { getBackupCdnInfo } from './util/mediaId';
@ -269,6 +270,7 @@ export class BackupExportStream extends Readable {
Backups.BackupInfo.encodeDelimited({ Backups.BackupInfo.encodeDelimited({
version: Long.fromNumber(BACKUP_VERSION), version: Long.fromNumber(BACKUP_VERSION),
backupTimeMs: this.backupTimeMs, backupTimeMs: this.backupTimeMs,
mediaRootBackupKey: getBackupMediaRootKey().serialize(),
}).finish() }).finish()
); );

View file

@ -73,10 +73,12 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
import { SendStatus } from '../../messages/MessageSendState'; import { SendStatus } from '../../messages/MessageSendState';
import type { SendStateByConversationId } from '../../messages/MessageSendState'; import type { SendStateByConversationId } from '../../messages/MessageSendState';
import { SeenStatus } from '../../MessageSeenStatus'; import { SeenStatus } from '../../MessageSeenStatus';
import { constantTimeEqual } from '../../Crypto';
import * as Bytes from '../../Bytes'; import * as Bytes from '../../Bytes';
import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants'; import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants';
import type { AboutMe, LocalChatStyle } from './types'; import type { AboutMe, LocalChatStyle } from './types';
import { BackupType } from './types'; import { BackupType } from './types';
import { getBackupMediaRootKey } from './crypto';
import type { GroupV2ChangeDetailType } from '../../groups'; import type { GroupV2ChangeDetailType } from '../../groups';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
@ -251,6 +253,24 @@ export class BackupImportStream extends Writable {
if (info.version?.toNumber() !== BACKUP_VERSION) { if (info.version?.toNumber() !== BACKUP_VERSION) {
throw new Error(`Unsupported backup version: ${info.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 { } else {
const frame = Backups.Frame.decode(data); const frame = Backups.Frame.decode(data);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,11 @@ import { createGzip } from 'node:zlib';
import { createCipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, randomBytes } from 'node:crypto';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import Long from 'long'; 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 type { AciString } from '../../types/ServiceId';
import { generateAci } from '../../types/ServiceId'; import { generateAci } from '../../types/ServiceId';
@ -14,11 +19,6 @@ import { appendPaddingStream } from '../../util/logPadding';
import { prependStream } from '../../util/prependStream'; import { prependStream } from '../../util/prependStream';
import { appendMacStream } from '../../util/appendMacStream'; import { appendMacStream } from '../../util/appendMacStream';
import { toAciObject } from '../../util/ServiceId'; import { toAciObject } from '../../util/ServiceId';
import {
deriveBackupKey,
deriveBackupId,
deriveBackupKeyMaterial,
} from '../../Crypto';
import { BACKUP_VERSION } from '../../services/backups/constants'; import { BACKUP_VERSION } from '../../services/backups/constants';
import { Backups } from '../../protobuf'; import { Backups } from '../../protobuf';
@ -29,9 +29,10 @@ export type BackupGeneratorConfigType = Readonly<
conversations: number; conversations: number;
conversationAcis?: ReadonlyArray<AciString>; conversationAcis?: ReadonlyArray<AciString>;
messages: number; messages: number;
mediaRootBackupKey: Buffer;
} & ( } & (
| { | {
masterKey: Buffer; accountEntropyPool: string;
} }
| { | {
backupKey: Buffer; backupKey: Buffer;
@ -50,15 +51,18 @@ export function generateBackup(
options: BackupGeneratorConfigType options: BackupGeneratorConfigType
): GenerateBackupResultType { ): GenerateBackupResultType {
const { aci } = options; const { aci } = options;
let backupKey: Uint8Array; let backupKey: BackupKey;
if ('masterKey' in options) { if ('accountEntropyPool' in options) {
backupKey = deriveBackupKey(options.masterKey); backupKey = AccountEntropyPool.deriveBackupKey(options.accountEntropyPool);
} else { } else {
({ backupKey } = options); backupKey = new BackupKey(options.backupKey);
} }
const aciBytes = toAciObject(aci).getServiceIdBinary(); const aciObj = toAciObject(aci);
const backupId = Buffer.from(deriveBackupId(backupKey, aciBytes)); const backupId = backupKey.deriveBackupId(aciObj);
const { aesKey, macKey } = deriveBackupKeyMaterial(backupKey, backupId); const { aesKey, hmacKey } = new MessageBackupKey({
backupKey,
backupId,
});
const iv = randomBytes(IV_LENGTH); const iv = randomBytes(IV_LENGTH);
@ -67,7 +71,7 @@ export function generateBackup(
.pipe(appendPaddingStream()) .pipe(appendPaddingStream())
.pipe(createCipheriv(CipherType.AES256CBC, aesKey, iv)) .pipe(createCipheriv(CipherType.AES256CBC, aesKey, iv))
.pipe(prependStream(iv)) .pipe(prependStream(iv))
.pipe(appendMacStream(macKey)); .pipe(appendMacStream(hmacKey));
return { backupId, stream }; return { backupId, stream };
} }
@ -87,11 +91,13 @@ function* createRecords({
conversations, conversations,
conversationAcis = [], conversationAcis = [],
messages, messages,
mediaRootBackupKey,
}: BackupGeneratorConfigType): Iterable<Buffer> { }: BackupGeneratorConfigType): Iterable<Buffer> {
yield Buffer.from( yield Buffer.from(
Backups.BackupInfo.encodeDelimited({ Backups.BackupInfo.encodeDelimited({
version: Long.fromNumber(BACKUP_VERSION), version: Long.fromNumber(BACKUP_VERSION),
backupTimeMs: getTimestamp(), backupTimeMs: getTimestamp(),
mediaRootBackupKey,
}).finish() }).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 () => { 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) => { comparator: (expected, msgInDB) => {
assert.deepStrictEqual( assert.deepStrictEqual(
omit(expected, 'bodyAttachment'), omit(expected, 'bodyAttachment'),
@ -249,7 +249,7 @@ describe('backup/attachments', () => {
}), }),
], ],
{ {
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
comparator: (expected, msgInDB) => { comparator: (expected, msgInDB) => {
assert.deepStrictEqual( assert.deepStrictEqual(
omit(expected, 'bodyAttachment'), omit(expected, 'bodyAttachment'),
@ -269,7 +269,7 @@ describe('backup/attachments', () => {
}); });
describe('normal attachments', () => { describe('normal attachments', () => {
it('BackupLevel.Messages, roundtrips normal attachments', async () => { it('BackupLevel.Free, roundtrips normal attachments', async () => {
const attachment1 = composeAttachment(1); const attachment1 = composeAttachment(1);
const attachment2 = composeAttachment(2); 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); const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
@ -315,7 +315,7 @@ describe('backup/attachments', () => {
], ],
}), }),
], ],
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
it('roundtrips voice message attachments', async () => { it('roundtrips voice message attachments', async () => {
@ -344,13 +344,13 @@ describe('backup/attachments', () => {
], ],
}), }),
], ],
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
}); });
describe('Preview attachments', () => { describe('Preview attachments', () => {
it('BackupLevel.Messages, roundtrips preview attachments', async () => { it('BackupLevel.Free, roundtrips preview attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined }); const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness( 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 }); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
@ -412,13 +412,13 @@ describe('backup/attachments', () => {
], ],
}), }),
], ],
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
}); });
describe('contact attachments', () => { describe('contact attachments', () => {
it('BackupLevel.Messages, roundtrips contact attachments', async () => { it('BackupLevel.Free, roundtrips contact attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined }); const attachment = composeAttachment(1, { clientUuid: undefined });
await asymmetricRoundtripHarness( 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 }); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
@ -472,13 +472,13 @@ describe('backup/attachments', () => {
], ],
}), }),
], ],
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
}); });
describe('quotes', () => { describe('quotes', () => {
it('BackupLevel.Messages, roundtrips quote attachments', async () => { it('BackupLevel.Free, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined }); const attachment = composeAttachment(1, { clientUuid: undefined });
const authorAci = generateAci(); const authorAci = generateAci();
const quotedMessage: QuotedMessageType = { 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 }); const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists'); strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci(); 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) => { comparator: (msgBefore, msgAfter) => {
if (msgBefore.timestamp === originalMessage.timestamp) { if (msgBefore.timestamp === originalMessage.timestamp) {
return assert.deepStrictEqual(msgBefore, msgAfter); return assert.deepStrictEqual(msgBefore, msgAfter);
@ -711,7 +711,7 @@ describe('backup/attachments', () => {
const packKey = Bytes.toBase64(getRandomBytes(32)); const packKey = Bytes.toBase64(getRandomBytes(32));
describe('when copied over from sticker pack (i.e. missing encryption info)', () => { 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( await asymmetricRoundtripHarness(
[ [
composeMessage(1, { composeMessage(1, {
@ -747,7 +747,7 @@ describe('backup/attachments', () => {
}), }),
], ],
{ {
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
comparator: (msgBefore, msgAfter) => { comparator: (msgBefore, msgAfter) => {
assert.deepStrictEqual( assert.deepStrictEqual(
omit(msgBefore, 'sticker.data'), 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 // since we aren't re-uploading with new encryption info, we can't include this
// attachment in the backup proto // attachment in the backup proto
await asymmetricRoundtripHarness( 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 type { AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId'; import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
import { MASTER_KEY } from './helpers'; import { MASTER_KEY, MEDIA_ROOT_KEY } from './helpers';
import { getRandomBytes } from '../../Crypto';
import { generateKeys, safeUnlink } from '../../AttachmentCrypto'; import { generateKeys, safeUnlink } from '../../AttachmentCrypto';
import { writeNewAttachmentData } from '../../windows/attachments'; import { writeNewAttachmentData } from '../../windows/attachments';
@ -275,8 +274,8 @@ async function testAttachmentToFilePointer(
} }
if (!options?.backupLevel) { if (!options?.backupLevel) {
await _doTest(BackupLevel.Messages); await _doTest(BackupLevel.Free);
await _doTest(BackupLevel.Media); await _doTest(BackupLevel.Paid);
} else { } else {
await _doTest(options.backupLevel); await _doTest(options.backupLevel);
} }
@ -295,6 +294,9 @@ describe('getFilePointerForAttachment', () => {
if (key === 'masterKey') { if (key === 'masterKey') {
return MASTER_KEY; return MASTER_KEY;
} }
if (key === 'backupMediaRootKey') {
return MEDIA_ROOT_KEY;
}
return undefined; return undefined;
}); });
}); });
@ -357,7 +359,7 @@ describe('getFilePointerForAttachment', () => {
await testAttachmentToFilePointer( await testAttachmentToFilePointer(
undownloadedAttachmentWithBackupLocator, undownloadedAttachmentWithBackupLocator,
filePointerWithBackupLocator, filePointerWithBackupLocator,
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
@ -372,7 +374,7 @@ describe('getFilePointerForAttachment', () => {
transitCdnKey: undefined, transitCdnKey: undefined,
}), }),
}), }),
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
@ -380,19 +382,19 @@ describe('getFilePointerForAttachment', () => {
await testAttachmentToFilePointer( await testAttachmentToFilePointer(
undownloadedAttachmentWithBackupLocator, undownloadedAttachmentWithBackupLocator,
filePointerWithAttachmentLocator, filePointerWithAttachmentLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
}); });
}); });
}); });
describe('downloaded locally', () => { describe('downloaded locally', () => {
const downloadedAttachment = composeAttachment(); const downloadedAttachment = composeAttachment();
describe('BackupLevel.Messages', () => { describe('BackupLevel.Free', () => {
it('returns attachmentLocator', async () => { it('returns attachmentLocator', async () => {
await testAttachmentToFilePointer( await testAttachmentToFilePointer(
downloadedAttachment, downloadedAttachment,
filePointerWithAttachmentLocator, filePointerWithAttachmentLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
}); });
it('returns invalidAttachmentLocator if missing critical locator info', async () => { it('returns invalidAttachmentLocator if missing critical locator info', async () => {
@ -402,7 +404,7 @@ describe('getFilePointerForAttachment', () => {
cdnKey: undefined, cdnKey: undefined,
}, },
filePointerWithInvalidLocator, filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
await testAttachmentToFilePointer( await testAttachmentToFilePointer(
{ {
@ -410,7 +412,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: undefined, cdnNumber: undefined,
}, },
filePointerWithInvalidLocator, filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
}); });
it('returns invalidAttachmentLocator if missing critical decryption info', async () => { it('returns invalidAttachmentLocator if missing critical decryption info', async () => {
@ -420,7 +422,7 @@ describe('getFilePointerForAttachment', () => {
key: undefined, key: undefined,
}, },
filePointerWithInvalidLocator, filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
await testAttachmentToFilePointer( await testAttachmentToFilePointer(
{ {
@ -428,11 +430,11 @@ describe('getFilePointerForAttachment', () => {
digest: undefined, digest: undefined,
}, },
filePointerWithInvalidLocator, filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Messages } { backupLevel: BackupLevel.Free }
); );
}); });
}); });
describe('BackupLevel.Media', () => { describe('BackupLevel.Paid', () => {
describe('if missing critical decryption / encryption info', async () => { describe('if missing critical decryption / encryption info', async () => {
let ciphertextFilePath: string; let ciphertextFilePath: string;
const attachmentNeedingEncryptionInfo: AttachmentType = { const attachmentNeedingEncryptionInfo: AttachmentType = {
@ -481,7 +483,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: 12, cdnNumber: 12,
}), }),
}), }),
{ backupLevel: BackupLevel.Media, backupCdnNumber: 12 } { backupLevel: BackupLevel.Paid, backupCdnNumber: 12 }
); );
}); });
@ -489,7 +491,7 @@ describe('getFilePointerForAttachment', () => {
const { filePointer: result, updatedAttachment } = const { filePointer: result, updatedAttachment } =
await getFilePointerForAttachment({ await getFilePointerForAttachment({
attachment: attachmentNeedingEncryptionInfo, attachment: attachmentNeedingEncryptionInfo,
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });
@ -529,7 +531,7 @@ describe('getFilePointerForAttachment', () => {
version: 1, version: 1,
path: plaintextFilePath, path: plaintextFilePath,
}, },
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });
@ -567,7 +569,7 @@ describe('getFilePointerForAttachment', () => {
path: 'no/file/here.png', path: 'no/file/here.png',
}, },
filePointerWithInvalidLocator, filePointerWithInvalidLocator,
{ backupLevel: BackupLevel.Media } { backupLevel: BackupLevel.Paid }
); );
}); });
@ -584,7 +586,7 @@ describe('getFilePointerForAttachment', () => {
const { filePointer: result } = await getFilePointerForAttachment({ const { filePointer: result } = await getFilePointerForAttachment({
attachment: attachmentWithReencryptionInfo, attachment: attachmentWithReencryptionInfo,
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });
@ -615,7 +617,7 @@ describe('getFilePointerForAttachment', () => {
cdnNumber: 12, cdnNumber: 12,
}), }),
}), }),
{ backupLevel: BackupLevel.Media, backupCdnNumber: 12 } { backupLevel: BackupLevel.Paid, backupCdnNumber: 12 }
); );
}); });
@ -624,7 +626,7 @@ describe('getFilePointerForAttachment', () => {
downloadedAttachment, downloadedAttachment,
filePointerWithBackupLocator, filePointerWithBackupLocator,
{ {
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
updatedAttachment: downloadedAttachment, updatedAttachment: downloadedAttachment,
} }
); );
@ -635,7 +637,8 @@ describe('getFilePointerForAttachment', () => {
describe('getBackupJobForAttachmentAndFilePointer', async () => { describe('getBackupJobForAttachmentAndFilePointer', async () => {
beforeEach(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 () => { afterEach(async () => {
await DataWriter.removeAll(); await DataWriter.removeAll();
@ -645,7 +648,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
it('returns null if filePointer does not have backupLocator', async () => { it('returns null if filePointer does not have backupLocator', async () => {
const { filePointer } = await getFilePointerForAttachment({ const { filePointer } = await getFilePointerForAttachment({
attachment, attachment,
backupLevel: BackupLevel.Messages, backupLevel: BackupLevel.Free,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });
assert.strictEqual( assert.strictEqual(
@ -663,7 +666,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
const { filePointer, updatedAttachment } = const { filePointer, updatedAttachment } =
await getFilePointerForAttachment({ await getFilePointerForAttachment({
attachment, attachment,
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });
const attachmentToUse = updatedAttachment ?? attachment; const attachmentToUse = updatedAttachment ?? attachment;
@ -703,7 +706,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
}); });
const { filePointer } = await getFilePointerForAttachment({ const { filePointer } = await getFilePointerForAttachment({
attachment, attachment,
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: isInBackupTier, getBackupCdnInfo: isInBackupTier,
}); });
assert.deepStrictEqual( assert.deepStrictEqual(
@ -730,7 +733,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
}; };
const { filePointer } = await getFilePointerForAttachment({ const { filePointer } = await getFilePointerForAttachment({
attachment: attachmentWithReencryptionInfo, attachment: attachmentWithReencryptionInfo,
backupLevel: BackupLevel.Media, backupLevel: BackupLevel.Paid,
getBackupCdnInfo: notInBackupCdn, getBackupCdnInfo: notInBackupCdn,
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -114,6 +114,8 @@ export type BootstrapOptions = Readonly<{
unknownContactCount?: number; unknownContactCount?: number;
contactNames?: ReadonlyArray<string>; contactNames?: ReadonlyArray<string>;
contactPreKeyCount?: number; contactPreKeyCount?: number;
useLegacyStorageEncryption?: boolean;
}>; }>;
export type EphemeralBackupType = Readonly<{ export type EphemeralBackupType = Readonly<{
@ -252,6 +254,9 @@ export class Bootstrap {
contacts: this.contacts, contacts: this.contacts,
contactsWithoutProfileKey: this.contactsWithoutProfileKey, contactsWithoutProfileKey: this.contactsWithoutProfileKey,
}); });
if (this.options.useLegacyStorageEncryption) {
this.privPhone.storageRecordIkm = undefined;
}
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-')); 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({ state = state.updateAccount({
profileKey: phone.profileKey.serialize(), profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
}); });
state = state.addContact(stranger, { state = state.addContact(stranger, {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,10 +17,6 @@ describe('storage service', function (this: Mocha.Suite) {
let app: App; let app: App;
let group: Group; let group: Group;
beforeEach(async () => {
({ bootstrap, app, group } = await initStorage());
});
afterEach(async function (this: Mocha.Context) { afterEach(async function (this: Mocha.Context) {
if (!bootstrap) { if (!bootstrap) {
return; return;
@ -31,76 +27,41 @@ describe('storage service', function (this: Mocha.Suite) {
await bootstrap.teardown(); await bootstrap.teardown();
}); });
it('should pin/unpin groups', async () => { for (const useLegacyStorageEncryption of [false, true]) {
const { phone, desktop, contacts } = bootstrap; 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 window = await app.getWindow();
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('Verifying that the group is pinned on startup'); const leftPane = window.locator('#LeftPane');
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); const conversationStack = window.locator('.Inbox__conversation-stack');
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(),
});
debug('Verifying that the group is pinned on startup');
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); await leftPane.locator(`[data-testid="${group.id}"]`).waitFor();
}
debug('Pinning group in the app'); debug('Unpinning group via storage service');
{ {
const state = await phone.expectStorageState('consistency check'); const state = await phone.expectStorageState('initial state');
const convo = leftPane.locator(`[data-testid="${group.id}"]`); await phone.setStorageState(state.unpinGroup(group));
await convo.click(); await phone.sendFetchStorage({
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!', {
timestamp: bootstrap.getTimestamp(), timestamp: bootstrap.getTimestamp(),
}); });
await leftPane.locator(`[data-testid="${group.id}"]`).waitFor();
}
debug('Pinning group in the app');
{
const state = await phone.expectStorageState('consistency check'); const state = await phone.expectStorageState('consistency check');
debug('pinning contact=%d', i); const convo = leftPane.locator(`[data-testid="${group.id}"]`);
const convo = leftPane.locator(
`[data-testid="${contact.toContact().aci}"]`
);
await convo.click(); await convo.click();
const moreButton = conversationStack.locator( const moreButton = conversationStack.locator(
@ -113,19 +74,10 @@ describe('storage service', function (this: Mocha.Suite) {
); );
await pinButton.click(); 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({ const newState = await phone.waitForStorageState({
after: state, after: state,
}); });
assert.isTrue(await newState.isPinned(contact), 'contact not pinned'); assert.isTrue(await newState.isGroupPinned(group), 'group not pinned');
// AccountRecord // AccountRecord
const { added, removed } = newState.diff(state); const { added, removed } = newState.diff(state);
@ -136,11 +88,69 @@ describe('storage service', function (this: Mocha.Suite) {
'only one record must be removed' 'only one record must be removed'
); );
} }
}
debug('Verifying the final manifest version'); debug('Pinning > 4 conversations');
const finalState = await phone.expectStorageState('consistency check'); {
// 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 PQueue from 'p-queue';
import { isNumber, omit, orderBy } from 'lodash'; import { isNumber, omit, orderBy } from 'lodash';
import type { KyberPreKeyRecord } from '@signalapp/libsignal-client'; import type { KyberPreKeyRecord } from '@signalapp/libsignal-client';
import {
AccountEntropyPool,
BackupKey,
} from '@signalapp/libsignal-client/dist/AccountKeys';
import { Readable } from 'stream'; import { Readable } from 'stream';
import EventTarget from './EventTarget'; import EventTarget from './EventTarget';
@ -30,6 +34,7 @@ import {
decryptDeviceName, decryptDeviceName,
deriveAccessKey, deriveAccessKey,
deriveStorageServiceKey, deriveStorageServiceKey,
deriveMasterKey,
encryptDeviceName, encryptDeviceName,
generateRegistrationId, generateRegistrationId,
getRandomBytes, getRandomBytes,
@ -122,7 +127,8 @@ type CreateAccountSharedOptionsType = Readonly<{
aciKeyPair: KeyPairType; aciKeyPair: KeyPairType;
pniKeyPair: KeyPairType; pniKeyPair: KeyPairType;
profileKey: Uint8Array; profileKey: Uint8Array;
masterKey: Uint8Array; masterKey: Uint8Array | undefined;
accountEntropyPool: string | undefined;
// Test-only // Test-only
backupFile?: Uint8Array; backupFile?: Uint8Array;
@ -136,6 +142,7 @@ type CreatePrimaryDeviceOptionsType = Readonly<{
ourPni?: undefined; ourPni?: undefined;
userAgent?: undefined; userAgent?: undefined;
ephemeralBackupKey?: undefined; ephemeralBackupKey?: undefined;
mediaRootBackupKey: Uint8Array;
readReceipts: true; readReceipts: true;
@ -152,6 +159,7 @@ export type CreateLinkedDeviceOptionsType = Readonly<{
ourPni: PniString; ourPni: PniString;
userAgent?: string; userAgent?: string;
ephemeralBackupKey: Uint8Array | undefined; ephemeralBackupKey: Uint8Array | undefined;
mediaRootBackupKey: Uint8Array | undefined;
readReceipts: boolean; readReceipts: boolean;
@ -325,6 +333,8 @@ export default class AccountManager extends EventTarget {
const profileKey = getRandomBytes(PROFILE_KEY_LENGTH); const profileKey = getRandomBytes(PROFILE_KEY_LENGTH);
const accessKey = deriveAccessKey(profileKey); const accessKey = deriveAccessKey(profileKey);
const masterKey = getRandomBytes(MASTER_KEY_LENGTH); const masterKey = getRandomBytes(MASTER_KEY_LENGTH);
const accountEntropyPool = AccountEntropyPool.generate();
const mediaRootBackupKey = BackupKey.generateRandom().serialize();
await this.createAccount({ await this.createAccount({
type: AccountType.Primary, type: AccountType.Primary,
@ -337,6 +347,8 @@ export default class AccountManager extends EventTarget {
accessKey, accessKey,
masterKey, masterKey,
ephemeralBackupKey: undefined, ephemeralBackupKey: undefined,
mediaRootBackupKey,
accountEntropyPool,
readReceipts: true, readReceipts: true,
}); });
}); });
@ -922,11 +934,18 @@ export default class AccountManager extends EventTarget {
pniKeyPair, pniKeyPair,
profileKey, profileKey,
masterKey, masterKey,
mediaRootBackupKey,
readReceipts, readReceipts,
userAgent, userAgent,
backupFile, backupFile,
accountEntropyPool,
} = options; } = options;
strictAssert(
Bytes.isNotEmpty(masterKey) || accountEntropyPool,
'Either master key or AEP is necessary for registration'
);
const { storage } = window.textsecure; const { storage } = window.textsecure;
let password = Bytes.toBase64(getRandomBytes(16)); let password = Bytes.toBase64(getRandomBytes(16));
password = password.substring(0, password.length - 2); password = password.substring(0, password.length - 2);
@ -1174,10 +1193,21 @@ export default class AccountManager extends EventTarget {
if (userAgent) { if (userAgent) {
await storage.put('userAgent', 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( await storage.put(
'storageKey', 'storageKey',
Bytes.toBase64(deriveStorageServiceKey(masterKey)) Bytes.toBase64(deriveStorageServiceKey(derivedMasterKey))
); );
await storage.put('read-receipt-setting', Boolean(readReceipts)); await storage.put('read-receipt-setting', Boolean(readReceipts));

View file

@ -3373,6 +3373,10 @@ export default class MessageReceiver
? sync.storageService ? sync.storageService
: undefined, : undefined,
masterKey: Bytes.isNotEmpty(sync.master) ? sync.master : 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) this.removeFromCache.bind(this, envelope)
); );

View file

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

View file

@ -26,6 +26,8 @@ export type ProvisionDecryptResult = Readonly<{
readReceipts?: boolean; readReceipts?: boolean;
profileKey?: Uint8Array; profileKey?: Uint8Array;
masterKey?: Uint8Array; masterKey?: Uint8Array;
accountEntropyPool: string | undefined;
mediaRootBackupKey: Uint8Array | undefined;
ephemeralBackupKey: Uint8Array | undefined; ephemeralBackupKey: Uint8Array | undefined;
}>; }>;
@ -94,6 +96,10 @@ class ProvisioningCipherInner {
ephemeralBackupKey: Bytes.isNotEmpty(provisionMessage.ephemeralBackupKey) ephemeralBackupKey: Bytes.isNotEmpty(provisionMessage.ephemeralBackupKey)
? provisionMessage.ephemeralBackupKey ? provisionMessage.ephemeralBackupKey
: undefined, : 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 = { export type CapabilitiesType = {
deleteSync: boolean; deleteSync: boolean;
versionedExpirationTimer: boolean; versionedExpirationTimer: boolean;
ssre2: boolean;
}; };
export type CapabilitiesUploadType = { export type CapabilitiesUploadType = {
deleteSync: true; deleteSync: true;
versionedExpirationTimer: true; versionedExpirationTimer: true;
ssre2: true;
}; };
type StickerPackManifestType = Uint8Array; type StickerPackManifestType = Uint8Array;
@ -1099,7 +1101,8 @@ export type RequestVerificationResultType = Readonly<{
}>; }>;
export type SetBackupIdOptionsType = Readonly<{ export type SetBackupIdOptionsType = Readonly<{
backupAuthCredentialRequest: Uint8Array; messagesBackupAuthCredentialRequest: Uint8Array;
mediaBackupAuthCredentialRequest: Uint8Array;
}>; }>;
export type SetBackupSignatureKeyOptionsType = Readonly<{ export type SetBackupSignatureKeyOptionsType = Readonly<{
@ -1121,7 +1124,6 @@ export type BackupMediaItemType = Readonly<{
mediaId: string; mediaId: string;
hmacKey: Uint8Array; hmacKey: Uint8Array;
encryptionKey: Uint8Array; encryptionKey: Uint8Array;
iv: Uint8Array;
}>; }>;
export type BackupMediaBatchOptionsType = Readonly<{ export type BackupMediaBatchOptionsType = Readonly<{
@ -1186,15 +1188,20 @@ export type GetBackupCredentialsOptionsType = Readonly<{
endDayInMs: number; 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({ export const getBackupCredentialsResponseSchema = z.object({
credentials: z credentials: z.object({
.object({ messages: backupCredentialListSchema,
credential: z.string().transform(x => Bytes.fromBase64(x)), media: backupCredentialListSchema,
redemptionTime: z }),
.number()
.transform(x => durations.DurationInSeconds.fromSeconds(x)),
})
.array(),
}); });
export type GetBackupCredentialsResponseType = z.infer< export type GetBackupCredentialsResponseType = z.infer<
@ -2752,6 +2759,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = { const capabilities: CapabilitiesUploadType = {
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true, versionedExpirationTimer: true,
ssre2: true,
}; };
const jsonData = { const jsonData = {
@ -2807,6 +2815,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = { const capabilities: CapabilitiesUploadType = {
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true, versionedExpirationTimer: true,
ssre2: true,
}; };
const jsonData = { const jsonData = {
@ -3113,14 +3122,18 @@ export function initialize({
} }
async function setBackupId({ async function setBackupId({
backupAuthCredentialRequest, messagesBackupAuthCredentialRequest,
mediaBackupAuthCredentialRequest,
}: SetBackupIdOptionsType) { }: SetBackupIdOptionsType) {
await _ajax({ await _ajax({
call: 'setBackupId', call: 'setBackupId',
httpType: 'PUT', httpType: 'PUT',
jsonData: { jsonData: {
backupAuthCredentialRequest: Bytes.toBase64( messagesBackupAuthCredentialRequest: Bytes.toBase64(
backupAuthCredentialRequest messagesBackupAuthCredentialRequest
),
mediaBackupAuthCredentialRequest: Bytes.toBase64(
mediaBackupAuthCredentialRequest
), ),
}, },
}); });
@ -3163,7 +3176,6 @@ export function initialize({
mediaId, mediaId,
hmacKey, hmacKey,
encryptionKey, encryptionKey,
iv,
} = item; } = item;
return { return {
@ -3175,7 +3187,6 @@ export function initialize({
mediaId, mediaId,
hmacKey: Bytes.toBase64(hmacKey), hmacKey: Bytes.toBase64(hmacKey),
encryptionKey: Bytes.toBase64(encryptionKey), 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 { Transform } from 'stream';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import { ensureFile } from 'fs-extra'; import { ensureFile } from 'fs-extra';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
@ -18,11 +19,6 @@ import {
AttachmentVariant, AttachmentVariant,
} from '../types/Attachment'; } from '../types/Attachment';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import {
deriveBackupMediaKeyMaterial,
type BackupMediaKeyMaterialType,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto';
import { import {
getAttachmentCiphertextLength, getAttachmentCiphertextLength,
safeUnlink, safeUnlink,
@ -35,7 +31,11 @@ import type { ProcessedAttachment } from './Types.d';
import type { WebAPIType } from './WebAPI'; import type { WebAPIType } from './WebAPI';
import { createName, getRelativePath } from '../util/attachmentPath'; import { createName, getRelativePath } from '../util/attachmentPath';
import { MediaTier } from '../types/AttachmentDownload'; 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 { backupsService } from '../services/backups';
import { import {
getMediaIdForAttachment, getMediaIdForAttachment,
@ -44,6 +44,7 @@ import {
import { MAX_BACKUP_THUMBNAIL_SIZE } from '../types/VisualAttachment'; import { MAX_BACKUP_THUMBNAIL_SIZE } from '../types/VisualAttachment';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { IV_LENGTH, MAC_LENGTH } from '../types/Crypto'; import { IV_LENGTH, MAC_LENGTH } from '../types/Crypto';
import { BackupCredentialType } from '../types/backups';
const DEFAULT_BACKUP_CDN_NUMBER = 3; const DEFAULT_BACKUP_CDN_NUMBER = 3;
@ -57,7 +58,7 @@ function getBackupMediaOuterEncryptionKeyMaterial(
attachment: AttachmentType attachment: AttachmentType
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachment(attachment); const mediaId = getMediaIdForAttachment(attachment);
const backupKey = getBackupKey(); const backupKey = getBackupMediaRootKey();
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
} }
@ -65,17 +66,14 @@ function getBackupThumbnailInnerEncryptionKeyMaterial(
attachment: AttachmentType attachment: AttachmentType
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachmentThumbnail(attachment); const mediaId = getMediaIdForAttachmentThumbnail(attachment);
const backupKey = getBackupKey(); const backupKey = getBackupMediaRootKey();
return deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
backupKey,
mediaId.bytes
);
} }
function getBackupThumbnailOuterEncryptionKeyMaterial( function getBackupThumbnailOuterEncryptionKeyMaterial(
attachment: AttachmentType attachment: AttachmentType
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachmentThumbnail(attachment); const mediaId = getMediaIdForAttachmentThumbnail(attachment);
const backupKey = getBackupKey(); const backupKey = getBackupMediaRootKey();
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes);
} }
@ -188,7 +186,10 @@ export async function downloadAttachment(
const cdnNumber = await getCdnNumberForBackupTier(attachment); const cdnNumber = await getCdnNumberForBackupTier(attachment);
const cdnCredentials = const cdnCredentials =
await backupsService.credentials.getCDNReadCredentials(cdnNumber); await backupsService.credentials.getCDNReadCredentials(
cdnNumber,
BackupCredentialType.Media
);
const backupDir = await backupsService.api.getBackupDir(); const backupDir = await backupsService.api.getBackupDir();
const mediaDir = await backupsService.api.getMediaDir(); const mediaDir = await backupsService.api.getMediaDir();

View file

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

View file

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

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

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

View file

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