diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 61300901e367..f39cb3ef56f7 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -4300,7 +4300,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see ``` -## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.59.0, libsignal-jni 0.59.0, libsignal-jni-testing 0.59.0, libsignal-node 0.59.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0 +## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.60.1, libsignal-jni 0.60.1, libsignal-jni-testing 0.60.1, libsignal-node 0.60.1, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0 ``` GNU AFFERO GENERAL PUBLIC LICENSE @@ -5802,7 +5802,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## bindgen 0.68.1 +## bindgen 0.70.1 ``` BSD 3-Clause License @@ -6931,7 +6931,7 @@ THE SOFTWARE. ``` -## either 1.13.0, itertools 0.13.0, petgraph 0.6.5 +## either 1.13.0, itertools 0.10.5, itertools 0.13.0, petgraph 0.6.5 ``` Copyright (c) 2015 @@ -7074,7 +7074,7 @@ DEALINGS IN THE SOFTWARE. ``` -## gimli 0.31.0, heck 0.5.0, peeking_take_while 0.1.2, unicode-bidi 0.3.15, unicode-normalization 0.1.23 +## gimli 0.31.0, heck 0.5.0, unicode-bidi 0.3.15, unicode-normalization 0.1.23 ``` Copyright (c) 2015 The Rust Project Developers @@ -10476,38 +10476,6 @@ SOFTWARE. ``` -## lazycell 1.3.0 - -``` -Original work Copyright (c) 2014 The Rust Project Developers -Modified work Copyright (c) 2016-2018 Nikita Pekin and lazycell contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - -``` - ## curve25519-dalek-derive 0.1.1, adler2 2.0.0, anyhow 1.0.88, async-trait 0.1.82, atomic-waker 1.1.2, displaydoc 0.2.5, dyn-clone 1.0.17, fastrand 2.1.1, home 0.5.9, itoa 1.0.11, linkme-impl 0.3.28, linkme 0.3.28, linux-raw-sys 0.4.14, minimal-lexical 0.2.1, num_enum 0.7.3, num_enum_derive 0.7.3, once_cell 1.19.0, paste 1.0.15, pin-project-internal 1.1.5, pin-project-lite 0.2.14, pin-project 1.1.5, prettyplease 0.2.22, proc-macro-crate 3.2.0, proc-macro2 1.0.86, quote 1.0.37, rustc-hash 1.1.0, rustix 0.38.37, rustversion 1.0.17, semver 1.0.23, send_wrapper 0.6.0, serde 1.0.210, serde_derive 1.0.210, serde_json 1.0.128, syn-mid 0.6.0, syn 1.0.109, syn 2.0.77, thiserror-impl 1.0.63, thiserror 1.0.63, unicode-ident 1.0.13, utf-8 0.7.6 ``` diff --git a/package-lock.json b/package-lock.json index 04f4ac5930e4..3a0ab39289e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@react-aria/utils": "3.16.0", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "9.0.8", - "@signalapp/libsignal-client": "0.59.0", + "@signalapp/libsignal-client": "0.60.1", "@signalapp/ringrtc": "2.48.4", "@types/fabric": "4.5.3", "backbone": "1.4.0", @@ -126,7 +126,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "1.3.2", "@indutny/symbolicate-mac": "2.3.0", - "@signalapp/mock-server": "8.3.1", + "@signalapp/mock-server": "9.0.2", "@storybook/addon-a11y": "8.1.11", "@storybook/addon-actions": "8.1.11", "@storybook/addon-controls": "8.1.11", @@ -7274,9 +7274,9 @@ } }, "node_modules/@signalapp/libsignal-client": { - "version": "0.59.0", - "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.59.0.tgz", - "integrity": "sha512-L1MmlSOmcf0qGnOIXf2J7ux6BSlLwQVi0W8F31A1JUPQ9Iwpbh7q+uCVoplmKXKV52Aw/CeZX109kLMG5vWseQ==", + "version": "0.60.1", + "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.60.1.tgz", + "integrity": "sha512-euLw0lFVyqSFeA/hYwr0RHDIsFKNVPTYDMr9JT1hG4oflYdzeesgPxqsJNDMio4esQGUSKcXxtw2gjsl+Qczfg==", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -7306,14 +7306,14 @@ } }, "node_modules/@signalapp/mock-server": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-8.3.1.tgz", - "integrity": "sha512-w4zMyLwRHZc90bxbWpG7NbtPoLXhjMIcEiqA8a5IR3qDonF/Fpi6rR047iYtfs66pBYP58XsSqRNBVzvP/Pj8Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-9.0.2.tgz", + "integrity": "sha512-QMfzA4mOZi1wagq6uGLEGDdbawyr9VG8ASAofbA/+HYDNE9n/12kzwuUs2fGpIRfs+86LDw/3iYF9ONfRDFxGQ==", "dev": true, "license": "AGPL-3.0-only", "dependencies": { "@indutny/parallel-prettier": "^3.0.0", - "@signalapp/libsignal-client": "^0.58.2", + "@signalapp/libsignal-client": "^0.60.1", "@tus/file-store": "^1.4.0", "@tus/server": "^1.7.0", "debug": "^4.3.2", @@ -7330,19 +7330,6 @@ "zod": "^3.20.2" } }, - "node_modules/@signalapp/mock-server/node_modules/@signalapp/libsignal-client": { - "version": "0.58.2", - "resolved": "https://registry.npmjs.org/@signalapp/libsignal-client/-/libsignal-client-0.58.2.tgz", - "integrity": "sha512-3OF9fGmh7tz9JVfT9xTR4DWcm4HOpbQknO9k7Oj23uSsBSEcJYmYPGM3Rdm16C/z+evVgyvrLptPtjTzXXuNzA==", - "dev": true, - "hasInstallScript": true, - "license": "AGPL-3.0-only", - "dependencies": { - "node-gyp-build": "^4.8.0", - "type-fest": "^4.26.0", - "uuid": "^8.3.0" - } - }, "node_modules/@signalapp/mock-server/node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index a87205b4aa37..40f6ed001c5d 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@react-aria/utils": "3.16.0", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "9.0.8", - "@signalapp/libsignal-client": "0.59.0", + "@signalapp/libsignal-client": "0.60.1", "@signalapp/ringrtc": "2.48.4", "@types/fabric": "4.5.3", "backbone": "1.4.0", @@ -212,7 +212,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "1.3.2", "@indutny/symbolicate-mac": "2.3.0", - "@signalapp/mock-server": "8.3.1", + "@signalapp/mock-server": "9.0.2", "@storybook/addon-a11y": "8.1.11", "@storybook/addon-actions": "8.1.11", "@storybook/addon-controls": "8.1.11", diff --git a/protos/Backups.proto b/protos/Backups.proto index 4facb2cfa039..40900a3de2f6 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -9,6 +9,8 @@ option java_package = "org.thoughtcrime.securesms.backup.v2.proto"; message BackupInfo { uint64 version = 1; uint64 backupTimeMs = 2; + bytes mediaRootBackupKey = 3; // 32-byte random value generated when the + // backup is uploaded for the first time. } // Frames must follow in the following ordering rules: diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto index fad683bbfbcd..ae2e44bacbdb 100644 --- a/protos/DeviceMessages.proto +++ b/protos/DeviceMessages.proto @@ -28,6 +28,8 @@ message ProvisionMessage { optional uint32 ProvisioningVersion = 9; optional bytes masterKey = 13; optional bytes ephemeralBackupKey = 14; // 32 bytes + optional string accountEntropyPool = 15; + optional bytes mediaRootBackupKey = 16; // 32-bytes } enum ProvisioningVersion { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 3bfdcbd357bf..577ed77f43fd 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -506,7 +506,9 @@ message SyncMessage { message Keys { optional bytes storageService = 1; // deprecated: this field will be removed in a future release. - optional bytes master = 2; + optional bytes master = 2; // deprecated: this field will be removed in a future release. + optional string accountEntropyPool = 3; + optional bytes mediaRootBackupKey = 4; } message Read { diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 8266ebe9eba0..12d1b108e65c 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -57,7 +57,8 @@ message ManifestRecord { optional uint64 version = 1; optional uint32 sourceDevice = 3; repeated Identifier keys = 2; - // Next ID: 4 + optional bytes recordIkm = 4; + // Next ID: 5 } message StorageRecord { diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 3894cce91c68..ce4ce2dae79c 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -4,11 +4,12 @@ import { Buffer } from 'buffer'; import Long from 'long'; import { Aci, HKDF } from '@signalapp/libsignal-client'; +import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys'; import * as Bytes from './Bytes'; import { Crypto } from './context/Crypto'; import { calculateAgreement, generateKeyPair } from './Curve'; -import { HashType, CipherType, UUID_BYTE_SIZE } from './types/Crypto'; +import { HashType, CipherType } from './types/Crypto'; import { ProfileDecryptError } from './types/errors'; import { getBytesSubarray } from './util/uuidToBytes'; import { logPadSize } from './util/logPadding'; @@ -154,185 +155,23 @@ export function decryptDeviceName( return Bytes.toString(plaintext); } -export function deriveStorageServiceKey(masterKey: Uint8Array): Uint8Array { - return hmacSha256(masterKey, Bytes.fromString('Storage Service Encryption')); -} - -export function deriveStorageManifestKey( - storageServiceKey: Uint8Array, - version: Long = Long.fromNumber(0) -): Uint8Array { - return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`)); -} - const BACKUP_KEY_LEN = 32; -const BACKUP_KEY_INFO = '20231003_Signal_Backups_GenerateBackupKey'; - -export function deriveBackupKey(masterKey: Uint8Array): Uint8Array { - const hkdf = HKDF.new(3); - return hkdf.deriveSecrets( - BACKUP_KEY_LEN, - Buffer.from(masterKey), - Buffer.from(BACKUP_KEY_INFO), - Buffer.alloc(0) - ); -} - -const BACKUP_SIGNATURE_KEY_LEN = 32; -const BACKUP_SIGNATURE_KEY_INFO = - '20231003_Signal_Backups_GenerateBackupIdKeyPair'; - -export function deriveBackupSignatureKey( - backupKey: Uint8Array, - aciBytes: Uint8Array -): Uint8Array { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveBackupId: invalid backup key length'); - } - - if (aciBytes.byteLength !== UUID_BYTE_SIZE) { - throw new Error('deriveBackupId: invalid aci length'); - } - - const hkdf = HKDF.new(3); - return hkdf.deriveSecrets( - BACKUP_SIGNATURE_KEY_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_SIGNATURE_KEY_INFO), - Buffer.from(aciBytes) - ); -} - -const BACKUP_ID_LEN = 16; -const BACKUP_ID_INFO = '20231003_Signal_Backups_GenerateBackupId'; - -export function deriveBackupId( - backupKey: Uint8Array, - aciBytes: Uint8Array -): Uint8Array { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveBackupId: invalid backup key length'); - } - - if (aciBytes.byteLength !== UUID_BYTE_SIZE) { - throw new Error('deriveBackupId: invalid aci length'); - } - - const hkdf = HKDF.new(3); - return hkdf.deriveSecrets( - BACKUP_ID_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_ID_INFO), - Buffer.from(aciBytes) - ); -} - -export type BackupKeyMaterialType = Readonly<{ - macKey: Uint8Array; - aesKey: Uint8Array; -}>; - -export type BackupMediaKeyMaterialType = Readonly<{ - macKey: Uint8Array; - aesKey: Uint8Array; - iv: Uint8Array; -}>; - -const BACKUP_AES_KEY_LEN = 32; -const BACKUP_MAC_KEY_LEN = 32; -const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup'; - -const BACKUP_MEDIA_ID_INFO = '20231003_Signal_Backups_Media_ID'; -const BACKUP_MEDIA_ID_LEN = 15; -const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia'; const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO = - '20240513_Signal_Backups_EncryptThumbnail'; + '20241030_SIGNAL_BACKUPS_ENCRYPT_THUMBNAIL:'; const BACKUP_MEDIA_AES_KEY_LEN = 32; const BACKUP_MEDIA_MAC_KEY_LEN = 32; const BACKUP_MEDIA_IV_LEN = 16; -export function deriveBackupKeyMaterial( - backupKey: Uint8Array, - backupId: Uint8Array -): BackupKeyMaterialType { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveBackupId: invalid backup key length'); - } - - if (backupId.byteLength !== BACKUP_ID_LEN) { - throw new Error('deriveBackupId: invalid backup id length'); - } - - const hkdf = HKDF.new(3); - const material = hkdf.deriveSecrets( - BACKUP_AES_KEY_LEN + BACKUP_MAC_KEY_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_MATERIAL_INFO), - Buffer.from(backupId) - ); - - return { - macKey: material.slice(0, BACKUP_MAC_KEY_LEN), - aesKey: material.slice(BACKUP_MAC_KEY_LEN), - }; -} - -export function deriveMediaIdFromMediaName( - backupKey: Uint8Array, - mediaName: string -): Uint8Array { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveMediaIdFromMediaName: invalid backup key length'); - } - - if (!mediaName) { - throw new Error('deriveMediaIdFromMediaName: mediaName missing'); - } - - const hkdf = HKDF.new(3); - return hkdf.deriveSecrets( - BACKUP_MEDIA_ID_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_MEDIA_ID_INFO), - Buffer.from(mediaName, 'utf8') - ); -} - -export function deriveBackupMediaKeyMaterial( - backupKey: Uint8Array, - mediaId: Uint8Array -): BackupMediaKeyMaterialType { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length'); - } - - if (!mediaId.length) { - throw new Error('deriveBackupMediaKeyMaterial: mediaId missing'); - } - - const hkdf = HKDF.new(3); - const material = hkdf.deriveSecrets( - BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_MEDIA_ENCRYPT_INFO), - Buffer.from(mediaId) - ); - - return { - macKey: material.subarray(0, BACKUP_MEDIA_MAC_KEY_LEN), - aesKey: material.subarray( - BACKUP_MEDIA_MAC_KEY_LEN, - BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN - ), - iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN), - }; -} +export type BackupMediaKeyMaterialType = Readonly<{ + aesKey: Uint8Array; + macKey: Uint8Array; +}>; export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( - backupKey: Uint8Array, + mediaRootKey: Uint8Array, mediaId: Uint8Array ): BackupMediaKeyMaterialType { - if (backupKey.byteLength !== BACKUP_KEY_LEN) { + if (mediaRootKey.byteLength !== BACKUP_KEY_LEN) { throw new Error( 'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length' ); @@ -345,9 +184,12 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( const hkdf = HKDF.new(3); const material = hkdf.deriveSecrets( BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN, - Buffer.from(backupKey), - Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO), - Buffer.from(mediaId) + Buffer.from(mediaRootKey), + Buffer.concat([ + Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO), + Buffer.from(mediaId), + ]), + Buffer.alloc(0) ); return { @@ -356,15 +198,54 @@ export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( BACKUP_MEDIA_AES_KEY_LEN, BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN ), - iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN), }; } -export function deriveStorageItemKey( +export function deriveMasterKey(accountEntropyPool: string): Uint8Array { + return AccountEntropyPool.deriveSvrKey(accountEntropyPool); +} + +export function deriveStorageServiceKey(masterKey: Uint8Array): Uint8Array { + return hmacSha256(masterKey, Bytes.fromString('Storage Service Encryption')); +} + +export function deriveStorageManifestKey( storageServiceKey: Uint8Array, - itemID: string + version: Long = Long.fromNumber(0) ): Uint8Array { - return hmacSha256(storageServiceKey, Bytes.fromString(`Item_${itemID}`)); + return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`)); +} + +const STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX = + '20240801_SIGNAL_STORAGE_SERVICE_ITEM_'; +const STORAGE_SERVICE_ITEM_KEY_LEN = 32; + +export type DeriveStorageItemKeyOptionsType = Readonly<{ + storageServiceKey: Uint8Array; + recordIkm: Uint8Array | undefined; + key: Uint8Array; +}>; + +export function deriveStorageItemKey({ + storageServiceKey, + recordIkm, + key, +}: DeriveStorageItemKeyOptionsType): Uint8Array { + if (recordIkm == null) { + const itemID = Bytes.toBase64(key); + return hmacSha256(storageServiceKey, Bytes.fromString(`Item_${itemID}`)); + } + + const hkdf = HKDF.new(3); + return hkdf.deriveSecrets( + STORAGE_SERVICE_ITEM_KEY_LEN, + Buffer.from(recordIkm), + Buffer.concat([ + Buffer.from(STORAGE_SERVICE_ITEM_KEY_INFO_PREFIX), + Buffer.from(key), + ]), + Buffer.alloc(0) + ); } export function deriveAccessKey(profileKey: Uint8Array): Uint8Array { diff --git a/ts/background.ts b/ts/background.ts index 4193c9ec81c9..89dfa50a32e7 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -181,7 +181,7 @@ import { getCallIdFromEra, updateLocalGroupCallHistoryTimestamp, } from './util/callDisposition'; -import { deriveStorageServiceKey } from './Crypto'; +import { deriveStorageServiceKey, deriveMasterKey } from './Crypto'; import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager'; import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync'; import { CallMode } from './types/CallDisposition'; @@ -950,6 +950,10 @@ export async function startApp(): Promise { 'hasRegisterSupportForUnauthenticatedDelivery' ); } + + if (window.isBeforeVersion(lastVersion, 'v7.33.0-beta.1')) { + await window.storage.remove('masterKeyLastRequestTime'); + } } setAppLoadingScreenMessage( @@ -1849,6 +1853,7 @@ export async function startApp(): Promise { await server.registerCapabilities({ deleteSync: true, versionedExpirationTimer: true, + ssre2: true, }); } catch (error) { log.error( @@ -1864,21 +1869,22 @@ export async function startApp(): Promise { } if (firstRun === true && deviceId !== 1) { - if (!window.storage.get('masterKey')) { - const lastSent = window.storage.get('masterKeyLastRequestTime') ?? 0; + if (!window.storage.get('accountEntropyPool')) { + const lastSent = + window.storage.get('accountEntropyPoolLastRequestTime') ?? 0; const now = Date.now(); // If we last attempted sync one day in the past, or if we time // traveled. if (isOlderThan(lastSent, DAY) || lastSent > now) { - log.warn('connect: masterKey not captured, requesting sync'); + log.warn('connect: AEP not captured, requesting sync'); await singleProtoJobQueue.add( MessageSender.getRequestKeySyncMessage() ); - await window.storage.put('masterKeyLastRequestTime', now); + await window.storage.put('accountEntropyPoolLastRequestTime', now); } else { log.warn( - 'connect: masterKey not captured, but sync requested recently.' + + 'connect: AEP not captured, but sync requested recently.' + 'Not running' ); } @@ -3212,20 +3218,64 @@ export async function startApp(): Promise { async function onKeysSync(ev: KeysEvent) { ev.confirm(); - const { masterKey } = ev; + const { accountEntropyPool, masterKey, mediaRootBackupKey } = ev; let { storageServiceKey } = ev; - if (masterKey == null) { - log.info('onKeysSync: deleting window.masterKey'); + const prevMasterKeyBase64 = window.storage.get('masterKey'); + const prevMasterKey = prevMasterKeyBase64 + ? Bytes.fromBase64(prevMasterKeyBase64) + : undefined; + const prevAccountEntropyPool = window.storage.get('accountEntropyPool'); + + let derivedMasterKey = masterKey; + if (derivedMasterKey == null && accountEntropyPool) { + derivedMasterKey = deriveMasterKey(accountEntropyPool); + if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) { + log.info('onKeysSync: deriving master key from account entropy pool'); + } + } + + if (accountEntropyPool == null) { + if (prevAccountEntropyPool != null) { + log.warn('onKeysSync: deleting window.accountEntropyPool'); + } + await window.storage.remove('accountEntropyPool'); + } else { + if (prevAccountEntropyPool !== accountEntropyPool) { + log.info('onKeysSync: updating accountEntropyPool'); + } + await window.storage.put('accountEntropyPool', accountEntropyPool); + } + + if (derivedMasterKey == null) { + if (prevMasterKey != null) { + log.warn('onKeysSync: deleting window.masterKey'); + } await window.storage.remove('masterKey'); } else { + if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) { + log.info('onKeysSync: updating masterKey'); + } // Override provided storageServiceKey because it is deprecated. - storageServiceKey = deriveStorageServiceKey(masterKey); - await window.storage.put('masterKey', Bytes.toBase64(masterKey)); + storageServiceKey = deriveStorageServiceKey(derivedMasterKey); + await window.storage.put('masterKey', Bytes.toBase64(derivedMasterKey)); + } + + const prevMediaRootBackupKey = window.storage.get('backupMediaRootKey'); + if (mediaRootBackupKey == null) { + if (prevMediaRootBackupKey != null) { + log.warn('onKeysSync: deleting window.backupMediaRootKey'); + } + await window.storage.remove('backupMediaRootKey'); + } else { + if (!Bytes.areEqual(prevMediaRootBackupKey, mediaRootBackupKey)) { + log.info('onKeysSync: updating window.backupMediaRootKey'); + } + await window.storage.put('backupMediaRootKey', mediaRootBackupKey); } if (storageServiceKey == null) { - log.info('onKeysSync: deleting window.storageKey'); + log.warn('onKeysSync: deleting window.storageKey'); await window.storage.remove('storageKey'); } diff --git a/ts/jobs/AttachmentBackupManager.ts b/ts/jobs/AttachmentBackupManager.ts index 3a319eda26f7..5a4244900ca8 100644 --- a/ts/jobs/AttachmentBackupManager.ts +++ b/ts/jobs/AttachmentBackupManager.ts @@ -16,10 +16,6 @@ import { type JobManagerParamsType, type JobManagerJobResultType, } from './JobManager'; -import { - deriveBackupMediaKeyMaterial, - deriveBackupMediaThumbnailInnerEncryptionKeyMaterial, -} from '../Crypto'; import { strictAssert } from '../util/assert'; import { type BackupsService, backupsService } from '../services/backups'; import { @@ -29,7 +25,11 @@ import { decryptAttachmentV2ToSink, ReencryptedDigestMismatchError, } from '../AttachmentCrypto'; -import { getBackupKey } from '../services/backups/crypto'; +import { deriveBackupMediaThumbnailInnerEncryptionKeyMaterial } from '../Crypto'; +import { + getBackupMediaRootKey, + deriveBackupMediaKeyMaterial, +} from '../services/backups/crypto'; import { type AttachmentBackupJobType, type CoreAttachmentBackupJobType, @@ -61,6 +61,7 @@ import { } from '../util/GoogleChrome'; import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl'; import { findRetryAfterTimeFromError } from './helpers/findRetryAfterTimeFromError'; +import { BackupCredentialType } from '../types/backups'; import { supportsIncrementalMac } from '../types/MIME'; import type { MIMEType } from '../types/MIME'; @@ -289,7 +290,7 @@ async function backupStandardAttachment( const mediaId = getMediaIdFromMediaName(job.mediaName); const backupKeyMaterial = deriveBackupMediaKeyMaterial( - getBackupKey(), + getBackupMediaRootKey(), mediaId.bytes ); @@ -371,8 +372,9 @@ async function backupThumbnailAttachment( const logId = `AttachmentBackupManager.backupThumbnailAttachment(${jobIdForLogging})`; const mediaId = getMediaIdFromMediaName(job.mediaName); + const backupKeyMaterial = deriveBackupMediaKeyMaterial( - getBackupKey(), + getBackupMediaRootKey(), mediaId.bytes ); @@ -432,7 +434,7 @@ async function backupThumbnailAttachment( const { aesKey, macKey } = deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( - getBackupKey(), + getBackupMediaRootKey().serialize(), mediaId.bytes ); @@ -589,7 +591,6 @@ async function copyToBackupTier({ mediaId, macKey, aesKey, - iv, dependencies, }: { cdnNumber: number; @@ -598,7 +599,6 @@ async function copyToBackupTier({ mediaId: string; macKey: Uint8Array; aesKey: Uint8Array; - iv: Uint8Array; dependencies: { backupMediaBatch?: WebAPIType['backupMediaBatch']; backupsService: BackupsService; @@ -611,7 +611,9 @@ async function copyToBackupTier({ const ciphertextLength = getAttachmentCiphertextLength(size); const { responses } = await dependencies.backupMediaBatch({ - headers: await dependencies.backupsService.credentials.getHeadersForToday(), + headers: await dependencies.backupsService.credentials.getHeadersForToday( + BackupCredentialType.Media + ), items: [ { sourceAttachment: { @@ -622,7 +624,6 @@ async function copyToBackupTier({ mediaId, hmacKey: macKey, encryptionKey: aesKey, - iv, }, ], }); diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index 4b6a41ed8069..b54c3b515d20 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { type Readable } from 'node:stream'; + import { strictAssert } from '../../util/assert'; import type { WebAPIType, @@ -12,6 +13,7 @@ import type { BackupListMediaResponseType, } from '../../textsecure/WebAPI'; import type { BackupCredentials } from './credentials'; +import { BackupCredentialType } from '../../types/backups'; import { uploadFile } from '../../util/uploadAttachment'; export type DownloadOptionsType = Readonly<{ @@ -21,48 +23,54 @@ export type DownloadOptionsType = Readonly<{ }>; export class BackupAPI { - private cachedBackupInfo: GetBackupInfoResponseType | undefined; - constructor(private credentials: BackupCredentials) {} + private cachedBackupInfo = new Map< + BackupCredentialType, + GetBackupInfoResponseType + >(); + + constructor(private readonly credentials: BackupCredentials) {} public async refresh(): Promise { - await this.server.refreshBackup( - await this.credentials.getHeadersForToday() + const headers = await Promise.all( + [BackupCredentialType.Messages, BackupCredentialType.Media].map(type => + this.credentials.getHeadersForToday(type) + ) ); + await Promise.all(headers.map(h => this.server.refreshBackup(h))); } - public async getInfo(): Promise { + public async getInfo( + credentialType: BackupCredentialType + ): Promise { const backupInfo = await this.server.getBackupInfo( - await this.credentials.getHeadersForToday() + await this.credentials.getHeadersForToday(credentialType) ); - this.cachedBackupInfo = backupInfo; + this.cachedBackupInfo.set(credentialType, backupInfo); return backupInfo; } - private async getCachedInfo(): Promise { - if (this.cachedBackupInfo) { - return this.cachedBackupInfo; + private async getCachedInfo( + credentialType: BackupCredentialType + ): Promise { + const cached = this.cachedBackupInfo.get(credentialType); + if (cached) { + return cached; } - return this.getInfo(); + return this.getInfo(credentialType); } public async getMediaDir(): Promise { - return (await this.getCachedInfo()).mediaDir; + return (await this.getCachedInfo(BackupCredentialType.Media)).mediaDir; } public async getBackupDir(): Promise { - return (await this.getCachedInfo())?.backupDir; - } - - // Backup name will change whenever a new backup is created, so we don't want to cache - // it - public async getBackupName(): Promise { - return (await this.getInfo()).backupName; + return (await this.getCachedInfo(BackupCredentialType.Media))?.backupDir; } public async upload(filePath: string, fileSize: number): Promise { const form = await this.server.getBackupUploadForm( - await this.credentials.getHeadersForToday() + await this.credentials.getHeadersForToday(BackupCredentialType.Messages) ); await uploadFile({ @@ -77,8 +85,13 @@ export class BackupAPI { onProgress, abortSignal, }: DownloadOptionsType): Promise { - const { cdn, backupDir, backupName } = await this.getInfo(); - const { headers } = await this.credentials.getCDNReadCredentials(cdn); + const { cdn, backupDir, backupName } = await this.getInfo( + BackupCredentialType.Messages + ); + const { headers } = await this.credentials.getCDNReadCredentials( + cdn, + BackupCredentialType.Messages + ); return this.server.getBackupStream({ cdn, @@ -111,7 +124,7 @@ export class BackupAPI { public async getMediaUploadForm(): Promise { return this.server.getBackupMediaUploadForm( - await this.credentials.getHeadersForToday() + await this.credentials.getHeadersForToday(BackupCredentialType.Media) ); } @@ -119,7 +132,9 @@ export class BackupAPI { items: ReadonlyArray ): Promise { return this.server.backupMediaBatch({ - headers: await this.credentials.getHeadersForToday(), + headers: await this.credentials.getHeadersForToday( + BackupCredentialType.Media + ), items, }); } @@ -132,14 +147,16 @@ export class BackupAPI { limit: number; }): Promise { return this.server.backupListMedia({ - headers: await this.credentials.getHeadersForToday(), + headers: await this.credentials.getHeadersForToday( + BackupCredentialType.Media + ), cursor, limit, }); } public clearCache(): void { - this.cachedBackupInfo = undefined; + this.cachedBackupInfo.clear(); } private get server(): WebAPIType { diff --git a/ts/services/backups/credentials.ts b/ts/services/backups/credentials.ts index 6e5fb75904ed..d54c75892495 100644 --- a/ts/services/backups/credentials.ts +++ b/ts/services/backups/credentials.ts @@ -1,7 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { PrivateKey } from '@signalapp/libsignal-client'; +import { type PrivateKey } from '@signalapp/libsignal-client'; import { BackupAuthCredential, BackupAuthCredentialRequestContext, @@ -9,6 +9,7 @@ import { type BackupLevel, GenericServerPublicParams, } from '@signalapp/libsignal-client/zkgroup'; +import { type BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys'; import * as log from '../../logging/log'; import { strictAssert } from '../../util/assert'; @@ -16,11 +17,13 @@ import { drop } from '../../util/drop'; import { isMoreRecentThan, toDayMillis } from '../../util/timestamp'; import { DAY, DurationInSeconds, HOUR } from '../../util/durations'; import { BackOff, FIBONACCI_TIMEOUTS } from '../../util/BackOff'; -import type { - BackupCdnReadCredentialType, +import { missingCaseError } from '../../util/missingCaseError'; +import { + type BackupCdnReadCredentialType, + type BackupCredentialWrapperType, + type BackupPresentationHeadersType, + type BackupSignedPresentationType, BackupCredentialType, - BackupPresentationHeadersType, - BackupSignedPresentationType, } from '../../types/backups'; import { toLogFormat } from '../../types/errors'; import { HTTPError } from '../../textsecure/Errors'; @@ -28,14 +31,12 @@ import type { GetBackupCredentialsResponseType, GetBackupCDNCredentialsResponseType, } from '../../textsecure/WebAPI'; -import { getBackupKey, getBackupSignatureKey } from './crypto'; - -export function getAuthContext(): BackupAuthCredentialRequestContext { - return BackupAuthCredentialRequestContext.create( - Buffer.from(getBackupKey()), - window.storage.user.getCheckedAci() - ); -} +import { + getBackupKey, + getBackupMediaRootKey, + getBackupSignatureKey, + getBackupMediaSignatureKey, +} from './crypto'; const FETCH_INTERVAL = 3 * DAY; @@ -54,24 +55,35 @@ export class BackupCredentials { this.scheduleFetch(); } - public async getForToday(): Promise { + public async getForToday( + credentialType: BackupCredentialType + ): Promise { const now = toDayMillis(Date.now()); - const signatureKeyBytes = getBackupSignatureKey(); - const signatureKey = PrivateKey.deserialize(Buffer.from(signatureKeyBytes)); + let signatureKey: PrivateKey; + let storageKey: `setBackup${'Messages' | 'Media'}SignatureKey`; + if (credentialType === BackupCredentialType.Messages) { + signatureKey = getBackupSignatureKey(); + storageKey = 'setBackupMessagesSignatureKey'; + } else if (credentialType === BackupCredentialType.Media) { + signatureKey = getBackupMediaSignatureKey(); + storageKey = 'setBackupMediaSignatureKey'; + } else { + throw missingCaseError(credentialType); + } // Start with cache - let credentials = window.storage.get('backupCredentials') || []; + let credentials = this.getFromCache(); - let result = credentials.find(({ redemptionTimeMs }) => { - return redemptionTimeMs === now; + let result = credentials.find(({ type, redemptionTimeMs }) => { + return type === credentialType && redemptionTimeMs === now; }); if (result === undefined) { log.info(`BackupCredentials: cache miss for ${now}`); credentials = await this.fetch(); - result = credentials.find(({ redemptionTimeMs }) => { - return redemptionTimeMs === now; + result = credentials.find(({ type, redemptionTimeMs }) => { + return type === credentialType && redemptionTimeMs === now; }); strictAssert( result !== undefined, @@ -95,33 +107,36 @@ export class BackupCredentials { 'X-Signal-ZK-Auth-Signature': signature.toString('base64'), }; - if (!window.storage.get('setBackupSignatureKey')) { - log.warn('BackupCredentials: uploading signature key'); - - const { server } = window.textsecure; - strictAssert(server, 'server not available'); - - await server.setBackupSignatureKey({ - headers, - backupIdPublicKey: signatureKey.getPublicKey().serialize(), - }); - - await window.storage.put('setBackupSignatureKey', true); + const info = { headers, level: result.level }; + if (window.storage.get(storageKey)) { + return info; } - return { + log.warn(`BackupCredentials: uploading signature key (${storageKey})`); + + const { server } = window.textsecure; + strictAssert(server, 'server not available'); + + await server.setBackupSignatureKey({ headers, - level: result.level, - }; + backupIdPublicKey: signatureKey.getPublicKey().serialize(), + }); + + await window.storage.put(storageKey, true); + + return info; } - public async getHeadersForToday(): Promise { - const { headers } = await this.getForToday(); + public async getHeadersForToday( + credentialType: BackupCredentialType + ): Promise { + const { headers } = await this.getForToday(credentialType); return headers; } public async getCDNReadCredentials( - cdn: number + cdn: number, + credentialType: BackupCredentialType ): Promise { const { server } = window.textsecure; strictAssert(server, 'server not available'); @@ -140,7 +155,7 @@ export class BackupCredentials { return cachedCredentialsForThisCdn.credentials; } - const headers = await this.getHeadersForToday(); + const headers = await this.getHeadersForToday(credentialType); const retrievedAtMs = Date.now(); const newCredentials = await server.getBackupCDNCredentials({ @@ -159,7 +174,7 @@ export class BackupCredentials { private scheduleFetch(): void { const lastFetchAt = window.storage.get( - 'backupCredentialsLastRequestTime', + 'backupCombinedCredentialsLastRequestTime', 0 ); const nextFetchAt = lastFetchAt + FETCH_INTERVAL; @@ -171,10 +186,11 @@ export class BackupCredentials { private async runPeriodicFetch(): Promise { try { - log.info('BackupCredentials: fetching'); + log.info('BackupCredentials: run periodic fetch'); await this.fetch(); - await window.storage.put('backupCredentialsLastRequestTime', Date.now()); + const now = Date.now(); + await window.storage.put('backupCombinedCredentialsLastRequestTime', now); this.fetchBackoff.reset(); this.scheduleFetch(); @@ -188,7 +204,7 @@ export class BackupCredentials { } } - private async fetch(): Promise> { + private async fetch(): Promise> { if (this.activeFetch) { return this.activeFetch; } @@ -203,7 +219,7 @@ export class BackupCredentials { } } - private async doFetch(): Promise> { + private async doFetch(): Promise> { log.info('BackupCredentials: fetching'); const now = Date.now(); @@ -211,7 +227,8 @@ export class BackupCredentials { const endDayInMs = toDayMillis(now + 6 * DAY); // And fetch missing credentials - const ctx = getAuthContext(); + const messagesCtx = this.getAuthContext(BackupCredentialType.Messages); + const mediaCtx = this.getAuthContext(BackupCredentialType.Media); const { server } = window.textsecure; strictAssert(server, 'server not available'); @@ -231,11 +248,13 @@ export class BackupCredentials { } // Backup id is missing - const request = ctx.getRequest(); + const messagesRequest = messagesCtx.getRequest(); + const mediaRequest = mediaCtx.getRequest(); // Set it await server.setBackupId({ - backupAuthCredentialRequest: request.serialize(), + messagesBackupAuthCredentialRequest: messagesRequest.serialize(), + mediaBackupAuthCredentialRequest: mediaRequest.serialize(), }); // And try again! @@ -245,16 +264,41 @@ export class BackupCredentials { }); } - log.info(`BackupCredentials: got ${response.credentials.length}`); + const { messages: messageCredentials, media: mediaCredentials } = + response.credentials; + + log.info( + 'BackupCredentials: got ' + + `${messageCredentials.length}/${mediaCredentials.length}` + ); const serverPublicParams = new GenericServerPublicParams( Buffer.from(window.getBackupServerPublicParams(), 'base64') ); - const result = new Array(); + const result = new Array(); - const issuedTimes = new Set(); - for (const { credential: buf, redemptionTime } of response.credentials) { + const allCredentials = messageCredentials + .map(credential => ({ + ...credential, + ctx: messagesCtx, + type: BackupCredentialType.Messages, + })) + .concat( + mediaCredentials.map(credential => ({ + ...credential, + ctx: mediaCtx, + type: BackupCredentialType.Media, + })) + ); + + const issuedTimes = new Set<`${BackupCredentialType}:${number}`>(); + for (const { + type, + ctx, + credential: buf, + redemptionTime, + } of allCredentials) { const credentialRes = new BackupAuthCredentialResponse(Buffer.from(buf)); const redemptionTimeMs = DurationInSeconds.toMillis(redemptionTime); @@ -268,10 +312,10 @@ export class BackupCredentials { ); strictAssert( - !issuedTimes.has(redemptionTimeMs), + !issuedTimes.has(`${type}:${redemptionTimeMs}`), 'Invalid credential response redemption time, duplicate' ); - issuedTimes.add(redemptionTimeMs); + issuedTimes.add(`${type}:${redemptionTimeMs}`); const credential = ctx.receive( credentialRes, @@ -280,6 +324,7 @@ export class BackupCredentials { ); result.push({ + type, credential: credential.serialize().toString('base64'), level: credential.getBackupLevel(), redemptionTimeMs, @@ -288,40 +333,68 @@ export class BackupCredentials { // Add cached credentials that are still in the date range, and not in // the response. - const cachedCredentials = window.storage.get('backupCredentials') || []; - for (const cached of cachedCredentials) { - const { redemptionTimeMs } = cached; + for (const cached of this.getFromCache()) { + const { type, redemptionTimeMs } = cached; if ( !(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs) ) { continue; } - if (issuedTimes.has(redemptionTimeMs)) { + if (issuedTimes.has(`${type}:${redemptionTimeMs}`)) { continue; } result.push(cached); } result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs); - await window.storage.put('backupCredentials', result); + await this.updateCache(result); const startMs = result[0].redemptionTimeMs; const endMs = result[result.length - 1].redemptionTimeMs; log.info(`BackupCredentials: saved [${startMs}, ${endMs}]`); - strictAssert(result.length === 7, 'Expected one week of credentials'); + strictAssert(result.length === 14, 'Expected one week of credentials'); return result; } - public async getBackupLevel(): Promise { - return (await this.getForToday()).level; + private getAuthContext( + credentialType: BackupCredentialType + ): BackupAuthCredentialRequestContext { + let key: BackupKey; + if (credentialType === BackupCredentialType.Messages) { + key = getBackupKey(); + } else if (credentialType === BackupCredentialType.Media) { + key = getBackupMediaRootKey(); + } else { + throw missingCaseError(credentialType); + } + return BackupAuthCredentialRequestContext.create( + key.serialize(), + window.storage.user.getCheckedAci() + ); + } + + private getFromCache(): ReadonlyArray { + return window.storage.get('backupCombinedCredentials', []); + } + + private async updateCache( + values: ReadonlyArray + ): Promise { + await window.storage.put('backupCombinedCredentials', values); + } + + public async getBackupLevel( + credentialType: BackupCredentialType + ): Promise { + return (await this.getForToday(credentialType)).level; } // Called when backup tier changes or when userChanged event public async clearCache(): Promise { this.cachedCdnReadCredentials = {}; - await window.storage.put('backupCredentials', []); + await this.updateCache([]); } } diff --git a/ts/services/backups/crypto.ts b/ts/services/backups/crypto.ts index 8bc8ad8bd665..d23ccab67f86 100644 --- a/ts/services/backups/crypto.ts +++ b/ts/services/backups/crypto.ts @@ -2,53 +2,105 @@ // SPDX-License-Identifier: AGPL-3.0-only import memoizee from 'memoizee'; +import type { PrivateKey } from '@signalapp/libsignal-client'; +import { + AccountEntropyPool, + BackupKey, +} from '@signalapp/libsignal-client/dist/AccountKeys'; +import { MessageBackupKey } from '@signalapp/libsignal-client/dist/MessageBackup'; import { strictAssert } from '../../util/assert'; import type { AciString } from '../../types/ServiceId'; import { toAciObject } from '../../util/ServiceId'; -import { - deriveBackupKey, - deriveBackupSignatureKey, - deriveBackupId, - deriveBackupKeyMaterial, -} from '../../Crypto'; -import type { BackupKeyMaterialType } from '../../Crypto'; -const getMemoizedBackupKey = memoizee((masterKey: string) => { - return deriveBackupKey(Buffer.from(masterKey, 'base64')); +const getMemoizedBackupKey = memoizee((accountEntropyPool: string) => { + return AccountEntropyPool.deriveBackupKey(accountEntropyPool); }); -export function getBackupKey(): Uint8Array { - const masterKey = window.storage.get('masterKey'); - strictAssert(masterKey, 'Master key not available'); +export function getBackupKey(): BackupKey { + const accountEntropyPool = window.storage.get('accountEntropyPool'); + strictAssert(accountEntropyPool, 'Account Entropy Pool not available'); - return getMemoizedBackupKey(masterKey); + return getMemoizedBackupKey(accountEntropyPool); +} + +export function getBackupMediaRootKey(): BackupKey { + const rootKey = window.storage.get('backupMediaRootKey'); + strictAssert(rootKey, 'Media root key not available'); + + return new BackupKey(Buffer.from(rootKey)); } const getMemoizedBackupSignatureKey = memoizee( - (backupKey: Uint8Array, aci: AciString) => { - const aciBytes = toAciObject(aci).getServiceIdBinary(); - return deriveBackupSignatureKey(backupKey, aciBytes); + (backupKey: BackupKey, aci: AciString) => { + return backupKey.deriveEcKey(toAciObject(aci)); } ); -export function getBackupSignatureKey(): Uint8Array { +export function getBackupSignatureKey(): PrivateKey { const backupKey = getBackupKey(); const aci = window.storage.user.getCheckedAci(); return getMemoizedBackupSignatureKey(backupKey, aci); } -const getMemoizedKeyMaterial = memoizee( - (backupKey: Uint8Array, aci: AciString) => { - const aciBytes = toAciObject(aci).getServiceIdBinary(); - const backupId = deriveBackupId(backupKey, aciBytes); - return deriveBackupKeyMaterial(backupKey, backupId); +const getMemoizedBackupMediaSignatureKey = memoizee( + (rootKey: BackupKey, aci: AciString) => { + return rootKey.deriveEcKey(toAciObject(aci)); } ); +export function getBackupMediaSignatureKey(): PrivateKey { + const rootKey = getBackupMediaRootKey(); + const aci = window.storage.user.getCheckedAci(); + return getMemoizedBackupMediaSignatureKey(rootKey, aci); +} + +const getMemoizedKeyMaterial = memoizee( + (backupKey: BackupKey, aci: AciString) => { + const messageKey = new MessageBackupKey({ + backupKey, + backupId: backupKey.deriveBackupId(toAciObject(aci)), + }); + + return { macKey: messageKey.hmacKey, aesKey: messageKey.aesKey }; + } +); + +export type BackupKeyMaterialType = Readonly<{ + macKey: Uint8Array; + aesKey: Uint8Array; +}>; + export function getKeyMaterial( backupKey = getBackupKey() ): BackupKeyMaterialType { const aci = window.storage.user.getCheckedAci(); return getMemoizedKeyMaterial(backupKey, aci); } + +export type BackupMediaKeyMaterialType = Readonly<{ + macKey: Uint8Array; + aesKey: Uint8Array; +}>; + +const BACKUP_MEDIA_AES_KEY_LEN = 32; +const BACKUP_MEDIA_MAC_KEY_LEN = 32; + +export function deriveBackupMediaKeyMaterial( + mediaRootKey: BackupKey, + mediaId: Uint8Array +): BackupMediaKeyMaterialType { + if (!mediaId.length) { + throw new Error('deriveBackupMediaKeyMaterial: mediaId missing'); + } + + const material = mediaRootKey.deriveMediaEncryptionKey(Buffer.from(mediaId)); + + return { + macKey: material.subarray(0, BACKUP_MEDIA_MAC_KEY_LEN), + aesKey: material.subarray( + BACKUP_MEDIA_MAC_KEY_LEN, + BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + ), + }; +} diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index b96cf5be4bc8..77a2dcc75c16 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -125,6 +125,7 @@ import { getFilePointerForAttachment, maybeGetBackupJobForAttachmentAndFilePointer, } from './util/filePointers'; +import { getBackupMediaRootKey } from './crypto'; import type { CoreAttachmentBackupJobType } from '../../types/AttachmentBackup'; import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager'; import { getBackupCdnInfo } from './util/mediaId'; @@ -269,6 +270,7 @@ export class BackupExportStream extends Readable { Backups.BackupInfo.encodeDelimited({ version: Long.fromNumber(BACKUP_VERSION), backupTimeMs: this.backupTimeMs, + mediaRootBackupKey: getBackupMediaRootKey().serialize(), }).finish() ); diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index af04dafe008a..0e751f188e1b 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -73,10 +73,12 @@ import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import type { SendStateByConversationId } from '../../messages/MessageSendState'; import { SeenStatus } from '../../MessageSeenStatus'; +import { constantTimeEqual } from '../../Crypto'; import * as Bytes from '../../Bytes'; import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants'; import type { AboutMe, LocalChatStyle } from './types'; import { BackupType } from './types'; +import { getBackupMediaRootKey } from './crypto'; import type { GroupV2ChangeDetailType } from '../../groups'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { isNotNil } from '../../util/isNotNil'; @@ -251,6 +253,24 @@ export class BackupImportStream extends Writable { if (info.version?.toNumber() !== BACKUP_VERSION) { throw new Error(`Unsupported backup version: ${info.version}`); } + + if (Bytes.isEmpty(info.mediaRootBackupKey)) { + throw new Error('Missing mediaRootBackupKey'); + } + + const theirKey = info.mediaRootBackupKey; + const ourKey = getBackupMediaRootKey().serialize(); + if (!constantTimeEqual(theirKey, ourKey)) { + // Use root key from integration test + if (isTestEnvironment(getEnvironment())) { + await window.storage.put( + 'backupMediaRootKey', + info.mediaRootBackupKey + ); + } else { + throw new Error('Incorrect mediaRootBackupKey'); + } + } } else { const frame = Backups.Frame.decode(data); diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index b5e73ced3126..451f8556b5a0 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -12,6 +12,7 @@ import { createGzip, createGunzip } from 'zlib'; import { createCipheriv, createHmac, randomBytes } from 'crypto'; import { noop } from 'lodash'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; +import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys'; import { DataReader, DataWriter } from '../../sql/Client'; import * as log from '../../logging/log'; @@ -29,6 +30,7 @@ import { HOUR } from '../../util/durations'; import { CipherType, HashType } from '../../types/Crypto'; import { InstallScreenBackupStep } from '../../types/InstallScreen'; import * as Errors from '../../types/errors'; +import { BackupCredentialType } from '../../types/backups'; import { HTTPError } from '../../textsecure/Errors'; import { constantTimeEqual } from '../../Crypto'; import { measureSize } from '../../AttachmentCrypto'; @@ -177,7 +179,9 @@ export class BackupsService { const fileName = `backup-${randomBytes(32).toString('hex')}`; const filePath = join(window.BasePaths.temp, fileName); - const backupLevel = await this.credentials.getBackupLevel(); + const backupLevel = await this.credentials.getBackupLevel( + BackupCredentialType.Media + ); log.info(`exportBackup: starting, backup level: ${backupLevel}...`); try { @@ -195,7 +199,7 @@ export class BackupsService { // Test harness public async exportBackupData( - backupLevel: BackupLevel = BackupLevel.Messages, + backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext ): Promise { const sink = new PassThrough(); @@ -210,7 +214,7 @@ export class BackupsService { // Test harness public async exportToDisk( path: string, - backupLevel: BackupLevel = BackupLevel.Messages, + backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext ): Promise { const size = await this.exportBackup( @@ -276,7 +280,9 @@ export class BackupsService { try { const importStream = await BackupImportStream.create(backupType); if (backupType === BackupType.Ciphertext) { - const { aesKey, macKey } = getKeyMaterial(ephemeralKey); + const { aesKey, macKey } = getKeyMaterial( + ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined + ); // First pass - don't decrypt, only verify mac let hmac = createHmac(HashType.size256, macKey); @@ -519,7 +525,7 @@ export class BackupsService { private async exportBackup( sink: Writable, - backupLevel: BackupLevel = BackupLevel.Messages, + backupLevel: BackupLevel = BackupLevel.Free, backupType = BackupType.Ciphertext ): Promise { strictAssert(!this.isRunning, 'BackupService is already running'); diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 260508427bfd..2489ffabe65e 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -210,7 +210,7 @@ export async function getFilePointerForAttachment({ // one point in the past verified the digest). if ( isDownloadableFromBackupTier(attachment) && - backupLevel === BackupLevel.Media + backupLevel === BackupLevel.Paid ) { return { filePointer: new Backups.FilePointer({ @@ -240,7 +240,7 @@ export async function getFilePointerForAttachment({ } // The attachment is locally saved - if (backupLevel !== BackupLevel.Media) { + if (backupLevel !== BackupLevel.Paid) { // 1. If we have information to donwnload the file from the transit tier, great, let's // just create an attachmentLocator so the restorer can try to download from the // transit tier diff --git a/ts/services/backups/util/mediaId.ts b/ts/services/backups/util/mediaId.ts index fad441825428..cfbf9de3e73a 100644 --- a/ts/services/backups/util/mediaId.ts +++ b/ts/services/backups/util/mediaId.ts @@ -3,16 +3,15 @@ import { DataReader } from '../../../sql/Client'; import * as Bytes from '../../../Bytes'; -import { getBackupKey } from '../crypto'; +import { getBackupMediaRootKey } from '../crypto'; import type { AttachmentType } from '../../../types/Attachment'; -import { deriveMediaIdFromMediaName } from '../../../Crypto'; import { strictAssert } from '../../../util/assert'; export function getMediaIdFromMediaName(mediaName: string): { string: string; bytes: Uint8Array; } { - const mediaIdBytes = deriveMediaIdFromMediaName(getBackupKey(), mediaName); + const mediaIdBytes = getBackupMediaRootKey().deriveMediaId(mediaName); return { string: Bytes.toBase64url(mediaIdBytes), bytes: mediaIdBytes, @@ -51,7 +50,7 @@ export function getMediaNameFromDigest(digest: string): string { export function getMediaNameForAttachmentThumbnail( fullsizeMediaName: string -): string { +): `${string}_thumbnail` { return `${fullsizeMediaName}_thumbnail`; } diff --git a/ts/services/backups/validator.ts b/ts/services/backups/validator.ts index 889710932fc2..92fde2698f43 100644 --- a/ts/services/backups/validator.ts +++ b/ts/services/backups/validator.ts @@ -12,13 +12,14 @@ export async function validateBackup( filePath: string, fileSize: number ): Promise { - const masterKeyBase64 = window.storage.get('masterKey'); - strictAssert(masterKeyBase64, 'Master key not available'); - - const masterKey = Buffer.from(masterKeyBase64, 'base64'); + const accountEntropy = window.storage.get('accountEntropyPool'); + strictAssert(accountEntropy, 'Account Entropy Pool not available'); const aci = toAciObject(window.storage.user.getCheckedAci()); - const backupKey = new libsignal.MessageBackupKey(masterKey, aci); + const backupKey = new libsignal.MessageBackupKey({ + accountEntropy, + aci, + }); const streams = new Array(); diff --git a/ts/services/profiles.ts b/ts/services/profiles.ts index 0e0e4c511332..c5f5a2a73358 100644 --- a/ts/services/profiles.ts +++ b/ts/services/profiles.ts @@ -70,6 +70,7 @@ type JobType = { const OBSERVED_CAPABILITY_KEYS = Object.keys({ deleteSync: true, versionedExpirationTimer: true, + ssre2: true, } satisfies CapabilitiesType) as ReadonlyArray; export class ProfileService { diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 68a84641f3da..b8724260e86a 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -123,6 +123,7 @@ const conflictBackOff = new BackOff([ function encryptRecord( storageID: string | undefined, + recordIkm: Uint8Array | undefined, storageRecord: Proto.IStorageRecord ): Proto.StorageItem { const storageItem = new Proto.StorageItem(); @@ -135,11 +136,12 @@ function encryptRecord( if (!storageKeyBase64) { throw new Error('No storage key'); } - const storageKey = Bytes.fromBase64(storageKeyBase64); - const storageItemKey = deriveStorageItemKey( - storageKey, - Bytes.toBase64(storageKeyBuffer) - ); + const storageServiceKey = Bytes.fromBase64(storageKeyBase64); + const storageItemKey = deriveStorageItemKey({ + storageServiceKey, + recordIkm, + key: storageKeyBuffer, + }); const encryptedRecord = encryptProfile( Proto.StorageRecord.encode(storageRecord).finish(), @@ -158,6 +160,7 @@ function generateStorageID(): Uint8Array { type GeneratedManifestType = { postUploadUpdateFunctions: Array<() => unknown>; + recordIkm: Uint8Array | undefined; recordsByID: Map; insertKeys: Set; deleteKeys: Set; @@ -729,6 +732,7 @@ async function generateManifest( // If we have a copy of what the current remote manifest is then we run these // additional validations comparing our pending manifest to the remote // manifest: + let recordIkm: Uint8Array | undefined; if (previousManifest) { const pendingInserts: Set = new Set(); const pendingDeletes: Set = new Set(); @@ -800,11 +804,18 @@ async function generateManifest( ); } } + + if (Bytes.isNotEmpty(previousManifest.recordIkm)) { + recordIkm = previousManifest.recordIkm; + } + } else { + recordIkm = window.storage.get('manifestRecordIkm'); } return { postUploadUpdateFunctions, recordsByID, + recordIkm, insertKeys, deleteKeys, }; @@ -812,6 +823,7 @@ async function generateManifest( type EncryptManifestOptionsType = { recordsByID: Map; + recordIkm: Uint8Array | undefined; insertKeys: Set; }; @@ -822,7 +834,7 @@ type EncryptedManifestType = { async function encryptManifest( version: number, - { recordsByID, insertKeys }: EncryptManifestOptionsType + { recordsByID, recordIkm, insertKeys }: EncryptManifestOptionsType ): Promise { const manifestRecordKeys: Set = new Set(); const newItems: Set = new Set(); @@ -843,7 +855,7 @@ async function encryptManifest( let storageItem; try { - storageItem = encryptRecord(storageID, storageRecord); + storageItem = encryptRecord(storageID, recordIkm, storageRecord); } catch (err) { log.error( `storageService.upload(${version}): encrypt record failed:`, @@ -1391,7 +1403,11 @@ async function processManifest( let conflictCount = 0; if (remoteOnlyRecords.size) { - const fetchResult = await fetchRemoteRecords(version, remoteOnlyRecords); + const fetchResult = await fetchRemoteRecords( + version, + Bytes.isNotEmpty(manifest.recordIkm) ? manifest.recordIkm : undefined, + remoteOnlyRecords + ); conflictCount = await processRemoteRecords(version, fetchResult); } @@ -1614,6 +1630,7 @@ export type FetchRemoteRecordsResultType = Readonly<{ async function fetchRemoteRecords( storageVersion: number, + recordIkm: Uint8Array | undefined, remoteOnlyRecords: Map ): Promise { const storageKeyBase64 = window.storage.get('storageKey'); @@ -1678,7 +1695,11 @@ async function fetchRemoteRecords( const base64ItemID = Bytes.toBase64(key); missingKeys.delete(base64ItemID); - const storageItemKey = deriveStorageItemKey(storageKey, base64ItemID); + const storageItemKey = deriveStorageItemKey({ + storageServiceKey: storageKey, + recordIkm, + key, + }); let storageItemPlaintext; try { @@ -2071,6 +2092,11 @@ async function sync({ ); await window.storage.put('manifestVersion', version); + if (Bytes.isNotEmpty(manifest.recordIkm)) { + await window.storage.put('manifestRecordIkm', manifest.recordIkm); + } else { + await window.storage.remove('manifestRecordIkm'); + } const hasConflicts = conflictCount !== 0; if (hasConflicts && !ignoreConflicts) { @@ -2208,6 +2234,7 @@ export async function eraseAllStorageServiceState({ // First, update high-level storage service metadata await Promise.all([ window.storage.remove('manifestVersion'), + window.storage.remove('manifestRecordIkm'), keepUnknownFields ? Promise.resolve() : window.storage.remove('storage-service-unknown-records'), diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 3c1cab71927f..9e0f1eac2245 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -432,6 +432,8 @@ const ITEM_SPECS: Partial> = { subscriberId: ['value'], backupsSubscriberId: ['value'], backupEphemeralKey: ['value'], + backupMediaRootKey: ['value'], + manifestRecordIkm: ['value'], usernameLink: ['value.entropy', 'value.serverId'], }; async function createOrUpdateItem( diff --git a/ts/test-both/helpers/generateBackup.ts b/ts/test-both/helpers/generateBackup.ts index 779d9a250737..3ae566872fb3 100644 --- a/ts/test-both/helpers/generateBackup.ts +++ b/ts/test-both/helpers/generateBackup.ts @@ -6,6 +6,11 @@ import { createGzip } from 'node:zlib'; import { createCipheriv, randomBytes } from 'node:crypto'; import { Buffer } from 'node:buffer'; import Long from 'long'; +import { + AccountEntropyPool, + BackupKey, +} from '@signalapp/libsignal-client/dist/AccountKeys'; +import { MessageBackupKey } from '@signalapp/libsignal-client/dist/MessageBackup'; import type { AciString } from '../../types/ServiceId'; import { generateAci } from '../../types/ServiceId'; @@ -14,11 +19,6 @@ import { appendPaddingStream } from '../../util/logPadding'; import { prependStream } from '../../util/prependStream'; import { appendMacStream } from '../../util/appendMacStream'; import { toAciObject } from '../../util/ServiceId'; -import { - deriveBackupKey, - deriveBackupId, - deriveBackupKeyMaterial, -} from '../../Crypto'; import { BACKUP_VERSION } from '../../services/backups/constants'; import { Backups } from '../../protobuf'; @@ -29,9 +29,10 @@ export type BackupGeneratorConfigType = Readonly< conversations: number; conversationAcis?: ReadonlyArray; messages: number; + mediaRootBackupKey: Buffer; } & ( | { - masterKey: Buffer; + accountEntropyPool: string; } | { backupKey: Buffer; @@ -50,15 +51,18 @@ export function generateBackup( options: BackupGeneratorConfigType ): GenerateBackupResultType { const { aci } = options; - let backupKey: Uint8Array; - if ('masterKey' in options) { - backupKey = deriveBackupKey(options.masterKey); + let backupKey: BackupKey; + if ('accountEntropyPool' in options) { + backupKey = AccountEntropyPool.deriveBackupKey(options.accountEntropyPool); } else { - ({ backupKey } = options); + backupKey = new BackupKey(options.backupKey); } - const aciBytes = toAciObject(aci).getServiceIdBinary(); - const backupId = Buffer.from(deriveBackupId(backupKey, aciBytes)); - const { aesKey, macKey } = deriveBackupKeyMaterial(backupKey, backupId); + const aciObj = toAciObject(aci); + const backupId = backupKey.deriveBackupId(aciObj); + const { aesKey, hmacKey } = new MessageBackupKey({ + backupKey, + backupId, + }); const iv = randomBytes(IV_LENGTH); @@ -67,7 +71,7 @@ export function generateBackup( .pipe(appendPaddingStream()) .pipe(createCipheriv(CipherType.AES256CBC, aesKey, iv)) .pipe(prependStream(iv)) - .pipe(appendMacStream(macKey)); + .pipe(appendMacStream(hmacKey)); return { backupId, stream }; } @@ -87,11 +91,13 @@ function* createRecords({ conversations, conversationAcis = [], messages, + mediaRootBackupKey, }: BackupGeneratorConfigType): Iterable { yield Buffer.from( Backups.BackupInfo.encodeDelimited({ version: Long.fromNumber(BACKUP_VERSION), backupTimeMs: getTimestamp(), + mediaRootBackupKey, }).finish() ); diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index 04dab2f1ed0d..0d218f2bc1e5 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -172,7 +172,7 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); it('migration creates long-message attachment if there is a long message.body (i.e. schemaVersion < 13)', async () => { @@ -193,7 +193,7 @@ describe('backup/attachments', () => { }), ], { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, comparator: (expected, msgInDB) => { assert.deepStrictEqual( omit(expected, 'bodyAttachment'), @@ -249,7 +249,7 @@ describe('backup/attachments', () => { }), ], { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, comparator: (expected, msgInDB) => { assert.deepStrictEqual( omit(expected, 'bodyAttachment'), @@ -269,7 +269,7 @@ describe('backup/attachments', () => { }); describe('normal attachments', () => { - it('BackupLevel.Messages, roundtrips normal attachments', async () => { + it('BackupLevel.Free, roundtrips normal attachments', async () => { const attachment1 = composeAttachment(1); const attachment2 = composeAttachment(2); @@ -288,10 +288,10 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); - it('BackupLevel.Media, roundtrips normal attachments', async () => { + it('BackupLevel.Paid, roundtrips normal attachments', async () => { const attachment = composeAttachment(1); strictAssert(attachment.digest, 'digest exists'); @@ -315,7 +315,7 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); it('roundtrips voice message attachments', async () => { @@ -344,13 +344,13 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); }); describe('Preview attachments', () => { - it('BackupLevel.Messages, roundtrips preview attachments', async () => { + it('BackupLevel.Free, roundtrips preview attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); await asymmetricRoundtripHarness( @@ -371,10 +371,10 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); - it('BackupLevel.Media, roundtrips preview attachments', async () => { + it('BackupLevel.Paid, roundtrips preview attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); @@ -412,13 +412,13 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); }); describe('contact attachments', () => { - it('BackupLevel.Messages, roundtrips contact attachments', async () => { + it('BackupLevel.Free, roundtrips contact attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); await asymmetricRoundtripHarness( @@ -440,10 +440,10 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); - it('BackupLevel.Media, roundtrips contact attachments', async () => { + it('BackupLevel.Paid, roundtrips contact attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); @@ -472,13 +472,13 @@ describe('backup/attachments', () => { ], }), ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); }); describe('quotes', () => { - it('BackupLevel.Messages, roundtrips quote attachments', async () => { + it('BackupLevel.Free, roundtrips quote attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); const authorAci = generateAci(); const quotedMessage: QuotedMessageType = { @@ -512,10 +512,10 @@ describe('backup/attachments', () => { }, }), ], - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); - it('BackupLevel.Media, roundtrips quote attachments', async () => { + it('BackupLevel.Paid, roundtrips quote attachments', async () => { const attachment = composeAttachment(1, { clientUuid: undefined }); strictAssert(attachment.digest, 'digest exists'); const authorAci = generateAci(); @@ -554,7 +554,7 @@ describe('backup/attachments', () => { }, }), ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); @@ -625,7 +625,7 @@ describe('backup/attachments', () => { }, }, ], - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); @@ -673,7 +673,7 @@ describe('backup/attachments', () => { }, ], { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, comparator: (msgBefore, msgAfter) => { if (msgBefore.timestamp === originalMessage.timestamp) { return assert.deepStrictEqual(msgBefore, msgAfter); @@ -711,7 +711,7 @@ describe('backup/attachments', () => { const packKey = Bytes.toBase64(getRandomBytes(32)); describe('when copied over from sticker pack (i.e. missing encryption info)', () => { - it('BackupLevel.Media, generates new encryption info', async () => { + it('BackupLevel.Paid, generates new encryption info', async () => { await asymmetricRoundtripHarness( [ composeMessage(1, { @@ -747,7 +747,7 @@ describe('backup/attachments', () => { }), ], { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, comparator: (msgBefore, msgAfter) => { assert.deepStrictEqual( omit(msgBefore, 'sticker.data'), @@ -776,7 +776,7 @@ describe('backup/attachments', () => { } ); }); - it('BackupLevel.Messages, generates invalid attachment locator', async () => { + it('BackupLevel.Free, generates invalid attachment locator', async () => { // since we aren't re-uploading with new encryption info, we can't include this // attachment in the backup proto await asymmetricRoundtripHarness( @@ -815,7 +815,7 @@ describe('backup/attachments', () => { }), ], { - backupLevel: BackupLevel.Messages, + backupLevel: BackupLevel.Free, } ); }); @@ -853,7 +853,7 @@ describe('backup/attachments', () => { }), ], { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, } ); }); diff --git a/ts/test-electron/backup/filePointer_test.ts b/ts/test-electron/backup/filePointer_test.ts index 7619408a8d45..58c583eb7a01 100644 --- a/ts/test-electron/backup/filePointer_test.ts +++ b/ts/test-electron/backup/filePointer_test.ts @@ -18,8 +18,7 @@ import * as Bytes from '../../Bytes'; import type { AttachmentType } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId'; -import { MASTER_KEY } from './helpers'; -import { getRandomBytes } from '../../Crypto'; +import { MASTER_KEY, MEDIA_ROOT_KEY } from './helpers'; import { generateKeys, safeUnlink } from '../../AttachmentCrypto'; import { writeNewAttachmentData } from '../../windows/attachments'; @@ -275,8 +274,8 @@ async function testAttachmentToFilePointer( } if (!options?.backupLevel) { - await _doTest(BackupLevel.Messages); - await _doTest(BackupLevel.Media); + await _doTest(BackupLevel.Free); + await _doTest(BackupLevel.Paid); } else { await _doTest(options.backupLevel); } @@ -295,6 +294,9 @@ describe('getFilePointerForAttachment', () => { if (key === 'masterKey') { return MASTER_KEY; } + if (key === 'backupMediaRootKey') { + return MEDIA_ROOT_KEY; + } return undefined; }); }); @@ -357,7 +359,7 @@ describe('getFilePointerForAttachment', () => { await testAttachmentToFilePointer( undownloadedAttachmentWithBackupLocator, filePointerWithBackupLocator, - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); @@ -372,7 +374,7 @@ describe('getFilePointerForAttachment', () => { transitCdnKey: undefined, }), }), - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); @@ -380,19 +382,19 @@ describe('getFilePointerForAttachment', () => { await testAttachmentToFilePointer( undownloadedAttachmentWithBackupLocator, filePointerWithAttachmentLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); }); }); describe('downloaded locally', () => { const downloadedAttachment = composeAttachment(); - describe('BackupLevel.Messages', () => { + describe('BackupLevel.Free', () => { it('returns attachmentLocator', async () => { await testAttachmentToFilePointer( downloadedAttachment, filePointerWithAttachmentLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); it('returns invalidAttachmentLocator if missing critical locator info', async () => { @@ -402,7 +404,7 @@ describe('getFilePointerForAttachment', () => { cdnKey: undefined, }, filePointerWithInvalidLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); await testAttachmentToFilePointer( { @@ -410,7 +412,7 @@ describe('getFilePointerForAttachment', () => { cdnNumber: undefined, }, filePointerWithInvalidLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); it('returns invalidAttachmentLocator if missing critical decryption info', async () => { @@ -420,7 +422,7 @@ describe('getFilePointerForAttachment', () => { key: undefined, }, filePointerWithInvalidLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); await testAttachmentToFilePointer( { @@ -428,11 +430,11 @@ describe('getFilePointerForAttachment', () => { digest: undefined, }, filePointerWithInvalidLocator, - { backupLevel: BackupLevel.Messages } + { backupLevel: BackupLevel.Free } ); }); }); - describe('BackupLevel.Media', () => { + describe('BackupLevel.Paid', () => { describe('if missing critical decryption / encryption info', async () => { let ciphertextFilePath: string; const attachmentNeedingEncryptionInfo: AttachmentType = { @@ -481,7 +483,7 @@ describe('getFilePointerForAttachment', () => { cdnNumber: 12, }), }), - { backupLevel: BackupLevel.Media, backupCdnNumber: 12 } + { backupLevel: BackupLevel.Paid, backupCdnNumber: 12 } ); }); @@ -489,7 +491,7 @@ describe('getFilePointerForAttachment', () => { const { filePointer: result, updatedAttachment } = await getFilePointerForAttachment({ attachment: attachmentNeedingEncryptionInfo, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: notInBackupCdn, }); @@ -529,7 +531,7 @@ describe('getFilePointerForAttachment', () => { version: 1, path: plaintextFilePath, }, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: notInBackupCdn, }); @@ -567,7 +569,7 @@ describe('getFilePointerForAttachment', () => { path: 'no/file/here.png', }, filePointerWithInvalidLocator, - { backupLevel: BackupLevel.Media } + { backupLevel: BackupLevel.Paid } ); }); @@ -584,7 +586,7 @@ describe('getFilePointerForAttachment', () => { const { filePointer: result } = await getFilePointerForAttachment({ attachment: attachmentWithReencryptionInfo, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: notInBackupCdn, }); @@ -615,7 +617,7 @@ describe('getFilePointerForAttachment', () => { cdnNumber: 12, }), }), - { backupLevel: BackupLevel.Media, backupCdnNumber: 12 } + { backupLevel: BackupLevel.Paid, backupCdnNumber: 12 } ); }); @@ -624,7 +626,7 @@ describe('getFilePointerForAttachment', () => { downloadedAttachment, filePointerWithBackupLocator, { - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, updatedAttachment: downloadedAttachment, } ); @@ -635,7 +637,8 @@ describe('getFilePointerForAttachment', () => { describe('getBackupJobForAttachmentAndFilePointer', async () => { beforeEach(async () => { - await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32))); + await window.storage.put('masterKey', MASTER_KEY); + await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY); }); afterEach(async () => { await DataWriter.removeAll(); @@ -645,7 +648,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => { it('returns null if filePointer does not have backupLocator', async () => { const { filePointer } = await getFilePointerForAttachment({ attachment, - backupLevel: BackupLevel.Messages, + backupLevel: BackupLevel.Free, getBackupCdnInfo: notInBackupCdn, }); assert.strictEqual( @@ -663,7 +666,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => { const { filePointer, updatedAttachment } = await getFilePointerForAttachment({ attachment, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: notInBackupCdn, }); const attachmentToUse = updatedAttachment ?? attachment; @@ -703,7 +706,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => { }); const { filePointer } = await getFilePointerForAttachment({ attachment, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: isInBackupTier, }); assert.deepStrictEqual( @@ -730,7 +733,7 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => { }; const { filePointer } = await getFilePointerForAttachment({ attachment: attachmentWithReencryptionInfo, - backupLevel: BackupLevel.Media, + backupLevel: BackupLevel.Paid, getBackupCdnInfo: notInBackupCdn, }); diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts index c5089652a3af..17e3e8fd5d29 100644 --- a/ts/test-electron/backup/helpers.ts +++ b/ts/test-electron/backup/helpers.ts @@ -9,6 +9,7 @@ import { createReadStream } from 'fs'; import { mkdtemp, rm } from 'fs/promises'; import * as sinon from 'sinon'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; +import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys'; import type { EditHistoryType, @@ -31,6 +32,8 @@ export const OUR_ACI = generateAci(); export const OUR_PNI = generatePni(); export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32)); export const PROFILE_KEY = getRandomBytes(32); +export const ACCOUNT_ENTROPY_POOL = AccountEntropyPool.generate(); +export const MEDIA_ROOT_KEY = getRandomBytes(32); // This is preserved across data erasure const CONVO_ID_TO_STABLE_ID = new Map(); @@ -176,7 +179,7 @@ type HarnessOptionsType = { export async function symmetricRoundtripHarness( messages: Array, - options: HarnessOptionsType = { backupLevel: BackupLevel.Messages } + options: HarnessOptionsType = { backupLevel: BackupLevel.Free } ): Promise { return asymmetricRoundtripHarness(messages, messages, options); } @@ -194,7 +197,7 @@ async function updateConvoIdToTitle() { export async function asymmetricRoundtripHarness( before: Array, after: Array, - options: HarnessOptionsType = { backupLevel: BackupLevel.Messages } + options: HarnessOptionsType = { backupLevel: BackupLevel.Free } ): Promise { const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); const fetchAndSaveBackupCdnObjectMetadata = sinon.stub( @@ -247,6 +250,8 @@ export async function setupBasics(): Promise { await window.storage.put('uuid_id', `${OUR_ACI}.2`); await window.storage.put('pni', OUR_PNI); await window.storage.put('masterKey', MASTER_KEY); + await window.storage.put('accountEntropyPool', ACCOUNT_ENTROPY_POOL); + await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY); await window.storage.put('profileKey', PROFILE_KEY); await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { diff --git a/ts/test-electron/backup/integration_test.ts b/ts/test-electron/backup/integration_test.ts index 147d369c874d..6c2d5340b59e 100644 --- a/ts/test-electron/backup/integration_test.ts +++ b/ts/test-electron/backup/integration_test.ts @@ -77,7 +77,7 @@ describe('backup/integration', () => { }); const exported = await backupsService.exportBackupData( - BackupLevel.Media, + BackupLevel.Paid, BackupType.TestOnlyPlaintext ); diff --git a/ts/test-electron/services/AttachmentBackupManager_test.ts b/ts/test-electron/services/AttachmentBackupManager_test.ts index 5346a38726aa..d8948b8f4033 100644 --- a/ts/test-electron/services/AttachmentBackupManager_test.ts +++ b/ts/test-electron/services/AttachmentBackupManager_test.ts @@ -79,7 +79,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager( index: number, overrides: Partial = {} ): ThumbnailAttachmentBackupJobType { - const mediaName = `thumbnail${index}`; + const mediaName = `thumbnail${index}_thumbnail` as const; return { mediaName, @@ -116,6 +116,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager( await DataWriter.removeAll(); await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32))); + await window.storage.put('backupMediaRootKey', getRandomBytes(32)); sandbox = sinon.createSandbox(); clock = sandbox.useFakeTimers(); diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts index 3fc08577039a..c70753f540d9 100644 --- a/ts/test-electron/util/downloadAttachment_test.ts +++ b/ts/test-electron/util/downloadAttachment_test.ts @@ -12,7 +12,7 @@ import { import { MediaTier } from '../../types/AttachmentDownload'; import { HTTPError } from '../../textsecure/Errors'; import { getCdnNumberForBackupTier } from '../../textsecure/downloadAttachment'; -import { MASTER_KEY } from '../backup/helpers'; +import { MASTER_KEY, MEDIA_ROOT_KEY } from '../backup/helpers'; import { getMediaIdFromMediaName } from '../../services/backups/util/mediaId'; import { AttachmentVariant } from '../../types/Attachment'; @@ -255,6 +255,9 @@ describe('getCdnNumberForBackupTier', () => { if (key === 'masterKey') { return MASTER_KEY; } + if (key === 'backupMediaRootKey') { + return MEDIA_ROOT_KEY; + } return undefined; }); }); diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index 684caf27bb01..8b943296e5e4 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -68,7 +68,6 @@ describe('backups', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, readReceipts: true, hasCompletedUsernameOnboarding: true, @@ -330,6 +329,7 @@ describe('backups', function (this: Mocha.Suite) { const { stream: backupStream } = generateBackup({ aci: phone.device.aci, profileKey: phone.profileKey.serialize(), + mediaRootBackupKey: phone.mediaRootBackupKey, backupKey: ephemeralBackupKey, conversations: 2, conversationAcis: [contact1, contact2], diff --git a/ts/test-mock/benchmarks/backup_bench.ts b/ts/test-mock/benchmarks/backup_bench.ts index 08fc9f0b3bf0..9cbe1ee4fe5d 100644 --- a/ts/test-mock/benchmarks/backup_bench.ts +++ b/ts/test-mock/benchmarks/backup_bench.ts @@ -11,7 +11,8 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { const { backupId, stream: backupStream } = generateBackup({ aci: phone.device.aci, profileKey: phone.profileKey.serialize(), - masterKey: phone.masterKey, + accountEntropyPool: phone.accountEntropyPool, + mediaRootBackupKey: phone.mediaRootBackupKey, conversations: 1000, messages: 60 * 1000, }); diff --git a/ts/test-mock/benchmarks/call_history_search_bench.ts b/ts/test-mock/benchmarks/call_history_search_bench.ts index e0988c00f974..18827198707e 100644 --- a/ts/test-mock/benchmarks/call_history_search_bench.ts +++ b/ts/test-mock/benchmarks/call_history_search_bench.ts @@ -35,7 +35,6 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, readReceipts: true, hasCompletedUsernameOnboarding: true, diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index 60f2f4e95f0f..6c246a7fa941 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -114,6 +114,8 @@ export type BootstrapOptions = Readonly<{ unknownContactCount?: number; contactNames?: ReadonlyArray; contactPreKeyCount?: number; + + useLegacyStorageEncryption?: boolean; }>; export type EphemeralBackupType = Readonly<{ @@ -252,6 +254,9 @@ export class Bootstrap { contacts: this.contacts, contactsWithoutProfileKey: this.contactsWithoutProfileKey, }); + if (this.options.useLegacyStorageEncryption) { + this.privPhone.storageRecordIkm = undefined; + } this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-')); diff --git a/ts/test-mock/messaging/expire_timer_version_test.ts b/ts/test-mock/messaging/expire_timer_version_test.ts index ad79da21c8ae..fceaecd31cf8 100644 --- a/ts/test-mock/messaging/expire_timer_version_test.ts +++ b/ts/test-mock/messaging/expire_timer_version_test.ts @@ -53,7 +53,6 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact(stranger, { diff --git a/ts/test-mock/messaging/relink_test.ts b/ts/test-mock/messaging/relink_test.ts index b38cfe4f6409..fc2a9916e84e 100644 --- a/ts/test-mock/messaging/relink_test.ts +++ b/ts/test-mock/messaging/relink_test.ts @@ -30,7 +30,6 @@ describe('messaging/relink', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, hasSetMyStoriesPrivacy: true, }); diff --git a/ts/test-mock/messaging/safety_number_test.ts b/ts/test-mock/messaging/safety_number_test.ts index 01a2026c36da..2da0866d40fe 100644 --- a/ts/test-mock/messaging/safety_number_test.ts +++ b/ts/test-mock/messaging/safety_number_test.ts @@ -36,7 +36,6 @@ describe('safety number', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, hasSetMyStoriesPrivacy: true, }); diff --git a/ts/test-mock/messaging/sender_key_test.ts b/ts/test-mock/messaging/sender_key_test.ts index dbe4d3bcaddd..9b4cacdde034 100644 --- a/ts/test-mock/messaging/sender_key_test.ts +++ b/ts/test-mock/messaging/sender_key_test.ts @@ -34,7 +34,6 @@ describe('senderKey', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, }); diff --git a/ts/test-mock/messaging/stories_test.ts b/ts/test-mock/messaging/stories_test.ts index d8c4b1665cb4..79b9d684f39b 100644 --- a/ts/test-mock/messaging/stories_test.ts +++ b/ts/test-mock/messaging/stories_test.ts @@ -38,7 +38,6 @@ describe('story/messaging', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, hasSetMyStoriesPrivacy: true, }); diff --git a/ts/test-mock/pnp/accept_gv2_invite_test.ts b/ts/test-mock/pnp/accept_gv2_invite_test.ts index 5c0004b41403..5496a98c06e3 100644 --- a/ts/test-mock/pnp/accept_gv2_invite_test.ts +++ b/ts/test-mock/pnp/accept_gv2_invite_test.ts @@ -46,7 +46,6 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact( diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index 576a654b14ba..228e2a0e7298 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -49,7 +49,6 @@ describe('pnp/merge', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact( diff --git a/ts/test-mock/pnp/phone_discovery_test.ts b/ts/test-mock/pnp/phone_discovery_test.ts index 30a47cd86375..9f6c15c662b4 100644 --- a/ts/test-mock/pnp/phone_discovery_test.ts +++ b/ts/test-mock/pnp/phone_discovery_test.ts @@ -46,7 +46,6 @@ describe('pnp/phone discovery', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact( diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index d97d0e391f13..ff9d7b8f5a6a 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -46,7 +46,6 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact( diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index 6d91f15c50c7..97f4b955c8ea 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -49,7 +49,6 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); // Add my story diff --git a/ts/test-mock/pnp/send_gv2_invite_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts index f57a8a7227cc..a1cc1e479d7c 100644 --- a/ts/test-mock/pnp/send_gv2_invite_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -36,7 +36,6 @@ describe('pnp/send gv2 invite', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); aciContact = await server.createPrimaryDevice({ diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 721c835bf83d..19585840d760 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -48,7 +48,6 @@ describe('pnp/username', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, }); state = state.addContact(usernameContact, { diff --git a/ts/test-mock/rate-limit/story_test.ts b/ts/test-mock/rate-limit/story_test.ts index 102b4664e34a..9119928976ad 100644 --- a/ts/test-mock/rate-limit/story_test.ts +++ b/ts/test-mock/rate-limit/story_test.ts @@ -34,7 +34,6 @@ describe('story/no-sender-key', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, hasSetMyStoriesPrivacy: true, }); diff --git a/ts/test-mock/rate-limit/viewed_test.ts b/ts/test-mock/rate-limit/viewed_test.ts index 4ad4de62676e..e1b667fb0e3c 100644 --- a/ts/test-mock/rate-limit/viewed_test.ts +++ b/ts/test-mock/rate-limit/viewed_test.ts @@ -43,7 +43,6 @@ describe('challenge/receipts', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, readReceipts: true, }); diff --git a/ts/test-mock/storage/call_links_test.ts b/ts/test-mock/storage/call_links_test.ts index 3c79f47b3473..7ff4569948d9 100644 --- a/ts/test-mock/storage/call_links_test.ts +++ b/ts/test-mock/storage/call_links_test.ts @@ -29,7 +29,6 @@ describe('storage service', function (this: Mocha.Suite) { state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, }); diff --git a/ts/test-mock/storage/fixtures.ts b/ts/test-mock/storage/fixtures.ts index 0a741a471f3b..36d46b532169 100644 --- a/ts/test-mock/storage/fixtures.ts +++ b/ts/test-mock/storage/fixtures.ts @@ -71,7 +71,6 @@ export async function initStorage( state = state.updateAccount({ profileKey: phone.profileKey.serialize(), - e164: phone.device.number, givenName: phone.profileName, }); diff --git a/ts/test-mock/storage/pin_unpin_test.ts b/ts/test-mock/storage/pin_unpin_test.ts index 1bc8bf6382a3..95f452775b3f 100644 --- a/ts/test-mock/storage/pin_unpin_test.ts +++ b/ts/test-mock/storage/pin_unpin_test.ts @@ -17,10 +17,6 @@ describe('storage service', function (this: Mocha.Suite) { let app: App; let group: Group; - beforeEach(async () => { - ({ bootstrap, app, group } = await initStorage()); - }); - afterEach(async function (this: Mocha.Context) { if (!bootstrap) { return; @@ -31,76 +27,41 @@ describe('storage service', function (this: Mocha.Suite) { await bootstrap.teardown(); }); - it('should pin/unpin groups', async () => { - const { phone, desktop, contacts } = bootstrap; + for (const useLegacyStorageEncryption of [false, true]) { + const suffix = `useLegacyStorageEncryption=${useLegacyStorageEncryption}`; + // eslint-disable-next-line no-loop-func + it(`should pin/unpin groups ${suffix}`, async () => { + ({ bootstrap, app, group } = await initStorage({ + useLegacyStorageEncryption, + })); - const window = await app.getWindow(); + const { phone, desktop, contacts } = bootstrap; - const leftPane = window.locator('#LeftPane'); - const conversationStack = window.locator('.Inbox__conversation-stack'); + const window = await app.getWindow(); - debug('Verifying that the group is pinned on startup'); - await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); - - debug('Unpinning group via storage service'); - { - const state = await phone.expectStorageState('initial state'); - - await phone.setStorageState(state.unpinGroup(group)); - await phone.sendFetchStorage({ - timestamp: bootstrap.getTimestamp(), - }); + const leftPane = window.locator('#LeftPane'); + const conversationStack = window.locator('.Inbox__conversation-stack'); + debug('Verifying that the group is pinned on startup'); await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); - } - debug('Pinning group in the app'); - { - const state = await phone.expectStorageState('consistency check'); + debug('Unpinning group via storage service'); + { + const state = await phone.expectStorageState('initial state'); - const convo = leftPane.locator(`[data-testid="${group.id}"]`); - await convo.click(); - - const moreButton = conversationStack.locator( - 'button.module-ConversationHeader__button--more' - ); - await moreButton.click(); - - const pinButton = window.locator('.react-contextmenu-item >> "Pin chat"'); - await pinButton.click(); - - const newState = await phone.waitForStorageState({ - after: state, - }); - assert.isTrue(await newState.isGroupPinned(group), 'group not pinned'); - - // AccountRecord - const { added, removed } = newState.diff(state); - assert.strictEqual(added.length, 1, 'only one record must be added'); - assert.strictEqual(removed.length, 1, 'only one record must be removed'); - } - - debug('Pinning > 4 conversations'); - { - // We already have one group and first contact pinned so we need three - // more. - const toPin = contacts.slice(1, 4); - - // To do that we need them to appear in the left pane, though. - for (const [i, contact] of toPin.entries()) { - const isLast = i === toPin.length - 1; - - debug('sending a message to contact=%d', i); - await contact.sendText(desktop, 'Hello!', { + await phone.setStorageState(state.unpinGroup(group)); + await phone.sendFetchStorage({ timestamp: bootstrap.getTimestamp(), }); + await leftPane.locator(`[data-testid="${group.id}"]`).waitFor(); + } + + debug('Pinning group in the app'); + { const state = await phone.expectStorageState('consistency check'); - debug('pinning contact=%d', i); - const convo = leftPane.locator( - `[data-testid="${contact.toContact().aci}"]` - ); + const convo = leftPane.locator(`[data-testid="${group.id}"]`); await convo.click(); const moreButton = conversationStack.locator( @@ -113,19 +74,10 @@ describe('storage service', function (this: Mocha.Suite) { ); await pinButton.click(); - if (isLast) { - // Storage state shouldn't be updated because we failed to pin - await window - .locator('.Toast >> "You can only pin up to 4 chats"') - .waitFor(); - break; - } - - debug('verifying storage state change contact=%d', i); const newState = await phone.waitForStorageState({ after: state, }); - assert.isTrue(await newState.isPinned(contact), 'contact not pinned'); + assert.isTrue(await newState.isGroupPinned(group), 'group not pinned'); // AccountRecord const { added, removed } = newState.diff(state); @@ -136,11 +88,69 @@ describe('storage service', function (this: Mocha.Suite) { 'only one record must be removed' ); } - } - debug('Verifying the final manifest version'); - const finalState = await phone.expectStorageState('consistency check'); + debug('Pinning > 4 conversations'); + { + // We already have one group and first contact pinned so we need three + // more. + const toPin = contacts.slice(1, 4); - assert.strictEqual(finalState.version, 5); - }); + // To do that we need them to appear in the left pane, though. + for (const [i, contact] of toPin.entries()) { + const isLast = i === toPin.length - 1; + + debug('sending a message to contact=%d', i); + await contact.sendText(desktop, 'Hello!', { + timestamp: bootstrap.getTimestamp(), + }); + + const state = await phone.expectStorageState('consistency check'); + + debug('pinning contact=%d', i); + const convo = leftPane.locator( + `[data-testid="${contact.toContact().aci}"]` + ); + await convo.click(); + + const moreButton = conversationStack.locator( + 'button.module-ConversationHeader__button--more' + ); + await moreButton.click(); + + const pinButton = window.locator( + '.react-contextmenu-item >> "Pin chat"' + ); + await pinButton.click(); + + if (isLast) { + // Storage state shouldn't be updated because we failed to pin + await window + .locator('.Toast >> "You can only pin up to 4 chats"') + .waitFor(); + break; + } + + debug('verifying storage state change contact=%d', i); + const newState = await phone.waitForStorageState({ + after: state, + }); + assert.isTrue(await newState.isPinned(contact), 'contact not pinned'); + + // AccountRecord + const { added, removed } = newState.diff(state); + assert.strictEqual(added.length, 1, 'only one record must be added'); + assert.strictEqual( + removed.length, + 1, + 'only one record must be removed' + ); + } + } + + debug('Verifying the final manifest version'); + const finalState = await phone.expectStorageState('consistency check'); + + assert.strictEqual(finalState.version, 5); + }); + } }); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 80167912dc42..f74d6c585df9 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -4,6 +4,10 @@ import PQueue from 'p-queue'; import { isNumber, omit, orderBy } from 'lodash'; import type { KyberPreKeyRecord } from '@signalapp/libsignal-client'; +import { + AccountEntropyPool, + BackupKey, +} from '@signalapp/libsignal-client/dist/AccountKeys'; import { Readable } from 'stream'; import EventTarget from './EventTarget'; @@ -30,6 +34,7 @@ import { decryptDeviceName, deriveAccessKey, deriveStorageServiceKey, + deriveMasterKey, encryptDeviceName, generateRegistrationId, getRandomBytes, @@ -122,7 +127,8 @@ type CreateAccountSharedOptionsType = Readonly<{ aciKeyPair: KeyPairType; pniKeyPair: KeyPairType; profileKey: Uint8Array; - masterKey: Uint8Array; + masterKey: Uint8Array | undefined; + accountEntropyPool: string | undefined; // Test-only backupFile?: Uint8Array; @@ -136,6 +142,7 @@ type CreatePrimaryDeviceOptionsType = Readonly<{ ourPni?: undefined; userAgent?: undefined; ephemeralBackupKey?: undefined; + mediaRootBackupKey: Uint8Array; readReceipts: true; @@ -152,6 +159,7 @@ export type CreateLinkedDeviceOptionsType = Readonly<{ ourPni: PniString; userAgent?: string; ephemeralBackupKey: Uint8Array | undefined; + mediaRootBackupKey: Uint8Array | undefined; readReceipts: boolean; @@ -325,6 +333,8 @@ export default class AccountManager extends EventTarget { const profileKey = getRandomBytes(PROFILE_KEY_LENGTH); const accessKey = deriveAccessKey(profileKey); const masterKey = getRandomBytes(MASTER_KEY_LENGTH); + const accountEntropyPool = AccountEntropyPool.generate(); + const mediaRootBackupKey = BackupKey.generateRandom().serialize(); await this.createAccount({ type: AccountType.Primary, @@ -337,6 +347,8 @@ export default class AccountManager extends EventTarget { accessKey, masterKey, ephemeralBackupKey: undefined, + mediaRootBackupKey, + accountEntropyPool, readReceipts: true, }); }); @@ -922,11 +934,18 @@ export default class AccountManager extends EventTarget { pniKeyPair, profileKey, masterKey, + mediaRootBackupKey, readReceipts, userAgent, backupFile, + accountEntropyPool, } = options; + strictAssert( + Bytes.isNotEmpty(masterKey) || accountEntropyPool, + 'Either master key or AEP is necessary for registration' + ); + const { storage } = window.textsecure; let password = Bytes.toBase64(getRandomBytes(16)); password = password.substring(0, password.length - 2); @@ -1174,10 +1193,21 @@ export default class AccountManager extends EventTarget { if (userAgent) { await storage.put('userAgent', userAgent); } - await storage.put('masterKey', Bytes.toBase64(masterKey)); + if (accountEntropyPool) { + await storage.put('accountEntropyPool', accountEntropyPool); + } + let derivedMasterKey = masterKey; + if (derivedMasterKey == null) { + strictAssert(accountEntropyPool, 'Cannot derive master key'); + derivedMasterKey = deriveMasterKey(accountEntropyPool); + } + if (Bytes.isNotEmpty(mediaRootBackupKey)) { + await storage.put('backupMediaRootKey', mediaRootBackupKey); + } + await storage.put('masterKey', Bytes.toBase64(derivedMasterKey)); await storage.put( 'storageKey', - Bytes.toBase64(deriveStorageServiceKey(masterKey)) + Bytes.toBase64(deriveStorageServiceKey(derivedMasterKey)) ); await storage.put('read-receipt-setting', Boolean(readReceipts)); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 39bc63ccbf3f..dd6625f31da6 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -3373,6 +3373,10 @@ export default class MessageReceiver ? sync.storageService : undefined, masterKey: Bytes.isNotEmpty(sync.master) ? sync.master : undefined, + accountEntropyPool: sync.accountEntropyPool || undefined, + mediaRootBackupKey: Bytes.isNotEmpty(sync.mediaRootBackupKey) + ? sync.mediaRootBackupKey + : undefined, }, this.removeFromCache.bind(this, envelope) ); diff --git a/ts/textsecure/Provisioner.ts b/ts/textsecure/Provisioner.ts index 1b462dd04081..9f716216698f 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -176,6 +176,8 @@ export class Provisioner { userAgent, readReceipts, ephemeralBackupKey, + accountEntropyPool, + mediaRootBackupKey, } = envelope; strictAssert(number, 'prepareLinkData: missing number'); @@ -188,8 +190,8 @@ export class Provisioner { 'prepareLinkData: missing profileKey' ); strictAssert( - Bytes.isNotEmpty(masterKey), - 'prepareLinkData: missing masterKey' + Bytes.isNotEmpty(masterKey) || accountEntropyPool, + 'prepareLinkData: missing masterKey or accountEntropyPool' ); strictAssert( isUntaggedPniString(untaggedPni), @@ -220,6 +222,8 @@ export class Provisioner { readReceipts: Boolean(readReceipts), masterKey, ephemeralBackupKey, + accountEntropyPool, + mediaRootBackupKey, }; } diff --git a/ts/textsecure/ProvisioningCipher.ts b/ts/textsecure/ProvisioningCipher.ts index c1294c9e3c71..6aaf7b3d76f7 100644 --- a/ts/textsecure/ProvisioningCipher.ts +++ b/ts/textsecure/ProvisioningCipher.ts @@ -26,6 +26,8 @@ export type ProvisionDecryptResult = Readonly<{ readReceipts?: boolean; profileKey?: Uint8Array; masterKey?: Uint8Array; + accountEntropyPool: string | undefined; + mediaRootBackupKey: Uint8Array | undefined; ephemeralBackupKey: Uint8Array | undefined; }>; @@ -94,6 +96,10 @@ class ProvisioningCipherInner { ephemeralBackupKey: Bytes.isNotEmpty(provisionMessage.ephemeralBackupKey) ? provisionMessage.ephemeralBackupKey : undefined, + mediaRootBackupKey: Bytes.isNotEmpty(provisionMessage.mediaRootBackupKey) + ? provisionMessage.mediaRootBackupKey + : undefined, + accountEntropyPool: provisionMessage.accountEntropyPool || undefined, }; } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 9bd0a75c0eba..9bd963272653 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -795,10 +795,12 @@ export type WebAPIConnectType = { export type CapabilitiesType = { deleteSync: boolean; versionedExpirationTimer: boolean; + ssre2: boolean; }; export type CapabilitiesUploadType = { deleteSync: true; versionedExpirationTimer: true; + ssre2: true; }; type StickerPackManifestType = Uint8Array; @@ -1099,7 +1101,8 @@ export type RequestVerificationResultType = Readonly<{ }>; export type SetBackupIdOptionsType = Readonly<{ - backupAuthCredentialRequest: Uint8Array; + messagesBackupAuthCredentialRequest: Uint8Array; + mediaBackupAuthCredentialRequest: Uint8Array; }>; export type SetBackupSignatureKeyOptionsType = Readonly<{ @@ -1121,7 +1124,6 @@ export type BackupMediaItemType = Readonly<{ mediaId: string; hmacKey: Uint8Array; encryptionKey: Uint8Array; - iv: Uint8Array; }>; export type BackupMediaBatchOptionsType = Readonly<{ @@ -1186,15 +1188,20 @@ export type GetBackupCredentialsOptionsType = Readonly<{ endDayInMs: number; }>; +export const backupCredentialListSchema = z + .object({ + credential: z.string().transform(x => Bytes.fromBase64(x)), + redemptionTime: z + .number() + .transform(x => durations.DurationInSeconds.fromSeconds(x)), + }) + .array(); + export const getBackupCredentialsResponseSchema = z.object({ - credentials: z - .object({ - credential: z.string().transform(x => Bytes.fromBase64(x)), - redemptionTime: z - .number() - .transform(x => durations.DurationInSeconds.fromSeconds(x)), - }) - .array(), + credentials: z.object({ + messages: backupCredentialListSchema, + media: backupCredentialListSchema, + }), }); export type GetBackupCredentialsResponseType = z.infer< @@ -2752,6 +2759,7 @@ export function initialize({ const capabilities: CapabilitiesUploadType = { deleteSync: true, versionedExpirationTimer: true, + ssre2: true, }; const jsonData = { @@ -2807,6 +2815,7 @@ export function initialize({ const capabilities: CapabilitiesUploadType = { deleteSync: true, versionedExpirationTimer: true, + ssre2: true, }; const jsonData = { @@ -3113,14 +3122,18 @@ export function initialize({ } async function setBackupId({ - backupAuthCredentialRequest, + messagesBackupAuthCredentialRequest, + mediaBackupAuthCredentialRequest, }: SetBackupIdOptionsType) { await _ajax({ call: 'setBackupId', httpType: 'PUT', jsonData: { - backupAuthCredentialRequest: Bytes.toBase64( - backupAuthCredentialRequest + messagesBackupAuthCredentialRequest: Bytes.toBase64( + messagesBackupAuthCredentialRequest + ), + mediaBackupAuthCredentialRequest: Bytes.toBase64( + mediaBackupAuthCredentialRequest ), }, }); @@ -3163,7 +3176,6 @@ export function initialize({ mediaId, hmacKey, encryptionKey, - iv, } = item; return { @@ -3175,7 +3187,6 @@ export function initialize({ mediaId, hmacKey: Bytes.toBase64(hmacKey), encryptionKey: Bytes.toBase64(encryptionKey), - iv: Bytes.toBase64(iv), }; }), }, diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index 9d1d62f09fc3..11b57bcc4840 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -8,6 +8,7 @@ import type { Readable, Writable } from 'stream'; import { Transform } from 'stream'; import { pipeline } from 'stream/promises'; import { ensureFile } from 'fs-extra'; + import * as log from '../logging/log'; import * as Errors from '../types/errors'; import { strictAssert } from '../util/assert'; @@ -18,11 +19,6 @@ import { AttachmentVariant, } from '../types/Attachment'; import * as Bytes from '../Bytes'; -import { - deriveBackupMediaKeyMaterial, - type BackupMediaKeyMaterialType, - deriveBackupMediaThumbnailInnerEncryptionKeyMaterial, -} from '../Crypto'; import { getAttachmentCiphertextLength, safeUnlink, @@ -35,7 +31,11 @@ import type { ProcessedAttachment } from './Types.d'; import type { WebAPIType } from './WebAPI'; import { createName, getRelativePath } from '../util/attachmentPath'; import { MediaTier } from '../types/AttachmentDownload'; -import { getBackupKey } from '../services/backups/crypto'; +import { + getBackupMediaRootKey, + deriveBackupMediaKeyMaterial, + type BackupMediaKeyMaterialType, +} from '../services/backups/crypto'; import { backupsService } from '../services/backups'; import { getMediaIdForAttachment, @@ -44,6 +44,7 @@ import { import { MAX_BACKUP_THUMBNAIL_SIZE } from '../types/VisualAttachment'; import { missingCaseError } from '../util/missingCaseError'; import { IV_LENGTH, MAC_LENGTH } from '../types/Crypto'; +import { BackupCredentialType } from '../types/backups'; const DEFAULT_BACKUP_CDN_NUMBER = 3; @@ -57,7 +58,7 @@ function getBackupMediaOuterEncryptionKeyMaterial( attachment: AttachmentType ): BackupMediaKeyMaterialType { const mediaId = getMediaIdForAttachment(attachment); - const backupKey = getBackupKey(); + const backupKey = getBackupMediaRootKey(); return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); } @@ -65,17 +66,14 @@ function getBackupThumbnailInnerEncryptionKeyMaterial( attachment: AttachmentType ): BackupMediaKeyMaterialType { const mediaId = getMediaIdForAttachmentThumbnail(attachment); - const backupKey = getBackupKey(); - return deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( - backupKey, - mediaId.bytes - ); + const backupKey = getBackupMediaRootKey(); + return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); } function getBackupThumbnailOuterEncryptionKeyMaterial( attachment: AttachmentType ): BackupMediaKeyMaterialType { const mediaId = getMediaIdForAttachmentThumbnail(attachment); - const backupKey = getBackupKey(); + const backupKey = getBackupMediaRootKey(); return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); } @@ -188,7 +186,10 @@ export async function downloadAttachment( const cdnNumber = await getCdnNumberForBackupTier(attachment); const cdnCredentials = - await backupsService.credentials.getCDNReadCredentials(cdnNumber); + await backupsService.credentials.getCDNReadCredentials( + cdnNumber, + BackupCredentialType.Media + ); const backupDir = await backupsService.api.getBackupDir(); const mediaDir = await backupsService.api.getMediaDir(); diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 573f7cffa1c7..fab175ff059f 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -391,20 +391,31 @@ export class FetchLatestEvent extends ConfirmableEvent { export type KeysEventData = Readonly<{ storageServiceKey: Uint8Array | undefined; masterKey: Uint8Array | undefined; + accountEntropyPool: string | undefined; + mediaRootBackupKey: Uint8Array | undefined; }>; export class KeysEvent extends ConfirmableEvent { public readonly storageServiceKey: Uint8Array | undefined; public readonly masterKey: Uint8Array | undefined; + public readonly accountEntropyPool: string | undefined; + public readonly mediaRootBackupKey: Uint8Array | undefined; constructor( - { storageServiceKey, masterKey }: KeysEventData, + { + storageServiceKey, + masterKey, + accountEntropyPool, + mediaRootBackupKey, + }: KeysEventData, confirm: ConfirmCallback ) { super('keys', confirm); this.storageServiceKey = storageServiceKey; this.masterKey = masterKey; + this.accountEntropyPool = accountEntropyPool; + this.mediaRootBackupKey = mediaRootBackupKey; } } diff --git a/ts/types/AttachmentBackup.ts b/ts/types/AttachmentBackup.ts index ecd57f441607..d80c58b30cf8 100644 --- a/ts/types/AttachmentBackup.ts +++ b/ts/types/AttachmentBackup.ts @@ -34,7 +34,7 @@ export type StandardAttachmentBackupJobType = { export type ThumbnailAttachmentBackupJobType = { type: 'thumbnail'; - mediaName: string; + mediaName: `${string}_thumbnail`; receivedAt: number; data: { fullsizePath: string | null; @@ -47,6 +47,7 @@ export type ThumbnailAttachmentBackupJobType = { const standardBackupJobDataSchema = z.object({ type: z.literal('standard'), + mediaName: z.string(), data: z.object({ path: z.string(), size: z.number(), @@ -66,8 +67,15 @@ const standardBackupJobDataSchema = z.object({ }), }); +const thumbnailMediaNameSchema = z + .string() + .refine((mediaName: string): mediaName is `${string}_thumbnail` => { + return mediaName.endsWith('_thumbnail'); + }); + const thumbnailBackupJobDataSchema = z.object({ type: z.literal('thumbnail'), + mediaName: thumbnailMediaNameSchema, data: z.object({ fullsizePath: z.string(), fullsizeSize: z.number(), @@ -79,7 +87,6 @@ const thumbnailBackupJobDataSchema = z.object({ export const attachmentBackupJobSchema = z .object({ - mediaName: z.string(), receivedAt: z.number(), }) .and( @@ -101,7 +108,7 @@ export const attachmentBackupJobSchema = z >; export const thumbnailBackupJobRecordSchema = z.object({ - mediaName: z.string(), + mediaName: thumbnailMediaNameSchema, type: z.literal('standard'), json: thumbnailBackupJobDataSchema.omit({ type: true }), }); diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 1d4b0866caff..3dd732539242 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -17,7 +17,7 @@ import type { SessionResetsType, StorageServiceCredentials, } from '../textsecure/Types.d'; -import type { BackupCredentialType } from './backups'; +import type { BackupCredentialWrapperType } from './backups'; import type { ServiceIdString } from './ServiceId'; import type { RegisteredChallengeType } from '../challenge'; @@ -87,8 +87,10 @@ export type StorageAccessType = { lastResortKeyUpdateTime: number; lastResortKeyUpdateTimePNI: number; localDeleteWarningShown: boolean; + accountEntropyPool: string; masterKey: string; - masterKeyLastRequestTime: number; + + accountEntropyPoolLastRequestTime: number; maxPreKeyId: number; maxPreKeyIdPNI: number; maxKyberPreKeyId: number; @@ -129,6 +131,7 @@ export type StorageAccessType = { storageFetchComplete: boolean; avatarUrl: string | undefined; manifestVersion: number; + manifestRecordIkm: Uint8Array; storageCredentials: StorageServiceCredentials; 'storage-service-error-records': ReadonlyArray; 'storage-service-unknown-records': ReadonlyArray; @@ -141,14 +144,16 @@ export type StorageAccessType = { unidentifiedDeliveryIndicators: boolean; groupCredentials: ReadonlyArray; callLinkAuthCredentials: ReadonlyArray; - backupCredentials: ReadonlyArray; - backupCredentialsLastRequestTime: number; + backupCombinedCredentials: ReadonlyArray; + backupCombinedCredentialsLastRequestTime: number; + backupMediaRootKey: Uint8Array; backupMediaDownloadTotalBytes: number; backupMediaDownloadCompletedBytes: number; backupMediaDownloadPaused: boolean; backupMediaDownloadBannerDismissed: boolean; backupMediaDownloadIdle: boolean; - setBackupSignatureKey: boolean; + setBackupMessagesSignatureKey: boolean; + setBackupMediaSignatureKey: boolean; lastReceivedAtCounter: number; preferredReactionEmoji: ReadonlyArray; skinTone: number; @@ -185,6 +190,7 @@ export type StorageAccessType = { observedCapabilities: { deleteSync?: true; versionedExpirationTimer?: true; + ssre2?: true; // Note: Upon capability deprecation - change the value type to `never` and // remove it in `ts/background.ts` @@ -212,6 +218,7 @@ export type StorageAccessType = { sendEditWarningShown: never; formattingWarningShown: never; hasRegisterSupportForUnauthenticatedDelivery: never; + masterKeyLastRequestTime: never; }; export type StorageInterface = { diff --git a/ts/types/backups.ts b/ts/types/backups.ts index 8af723960686..2a23dbd5a200 100644 --- a/ts/types/backups.ts +++ b/ts/types/backups.ts @@ -2,9 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; +import { BackupCredentialType } from '@signalapp/libsignal-client/dist/zkgroup'; import type { GetBackupCDNCredentialsResponseType } from '../textsecure/WebAPI'; -export type BackupCredentialType = Readonly<{ +export { BackupCredentialType }; + +export type BackupCredentialWrapperType = Readonly<{ + type: BackupCredentialType; credential: string; level: BackupLevel; redemptionTimeMs: number;