Update HKDF constants for backups
This commit is contained in:
parent
ab3c18513a
commit
a338bc5a67
60 changed files with 807 additions and 611 deletions
|
@ -4300,7 +4300,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
|
|||
|
||||
```
|
||||
|
||||
## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.59.0, libsignal-jni 0.59.0, libsignal-jni-testing 0.59.0, libsignal-node 0.59.0, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
|
||||
## libsignal-account-keys 0.1.0, attest 0.1.0, libsignal-ffi 0.60.1, libsignal-jni 0.60.1, libsignal-jni-testing 0.60.1, libsignal-node 0.60.1, signal-neon-futures 0.1.0, signal-neon-futures-tests 0.1.0, libsignal-bridge 0.1.0, libsignal-bridge-macros 0.1.0, libsignal-bridge-testing 0.1.0, libsignal-bridge-types 0.1.0, libsignal-core 0.1.0, signal-crypto 0.1.0, device-transfer 0.1.0, libsignal-keytrans 0.0.1, signal-media 0.1.0, libsignal-message-backup 0.1.0, libsignal-message-backup-macros 0.1.0, libsignal-net 0.1.0, libsignal-net-infra 0.1.0, poksho 0.7.0, libsignal-protocol 0.1.0, libsignal-svr3 0.1.0, usernames 0.1.0, zkcredential 0.1.0, zkgroup 0.9.0
|
||||
|
||||
```
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
@ -5802,7 +5802,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|||
|
||||
```
|
||||
|
||||
## bindgen 0.68.1
|
||||
## bindgen 0.70.1
|
||||
|
||||
```
|
||||
BSD 3-Clause License
|
||||
|
@ -6931,7 +6931,7 @@ THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## either 1.13.0, itertools 0.13.0, petgraph 0.6.5
|
||||
## either 1.13.0, itertools 0.10.5, itertools 0.13.0, petgraph 0.6.5
|
||||
|
||||
```
|
||||
Copyright (c) 2015
|
||||
|
@ -7074,7 +7074,7 @@ DEALINGS IN THE SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## gimli 0.31.0, heck 0.5.0, peeking_take_while 0.1.2, unicode-bidi 0.3.15, unicode-normalization 0.1.23
|
||||
## gimli 0.31.0, heck 0.5.0, unicode-bidi 0.3.15, unicode-normalization 0.1.23
|
||||
|
||||
```
|
||||
Copyright (c) 2015 The Rust Project Developers
|
||||
|
@ -10476,38 +10476,6 @@ SOFTWARE.
|
|||
|
||||
```
|
||||
|
||||
## lazycell 1.3.0
|
||||
|
||||
```
|
||||
Original work Copyright (c) 2014 The Rust Project Developers
|
||||
Modified work Copyright (c) 2016-2018 Nikita Pekin and lazycell contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
```
|
||||
|
||||
## curve25519-dalek-derive 0.1.1, adler2 2.0.0, anyhow 1.0.88, async-trait 0.1.82, atomic-waker 1.1.2, displaydoc 0.2.5, dyn-clone 1.0.17, fastrand 2.1.1, home 0.5.9, itoa 1.0.11, linkme-impl 0.3.28, linkme 0.3.28, linux-raw-sys 0.4.14, minimal-lexical 0.2.1, num_enum 0.7.3, num_enum_derive 0.7.3, once_cell 1.19.0, paste 1.0.15, pin-project-internal 1.1.5, pin-project-lite 0.2.14, pin-project 1.1.5, prettyplease 0.2.22, proc-macro-crate 3.2.0, proc-macro2 1.0.86, quote 1.0.37, rustc-hash 1.1.0, rustix 0.38.37, rustversion 1.0.17, semver 1.0.23, send_wrapper 0.6.0, serde 1.0.210, serde_derive 1.0.210, serde_json 1.0.128, syn-mid 0.6.0, syn 1.0.109, syn 2.0.77, thiserror-impl 1.0.63, thiserror 1.0.63, unicode-ident 1.0.13, utf-8 0.7.6
|
||||
|
||||
```
|
||||
|
|
31
package-lock.json
generated
31
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
235
ts/Crypto.ts
235
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 {
|
||||
|
|
|
@ -181,7 +181,7 @@ import {
|
|||
getCallIdFromEra,
|
||||
updateLocalGroupCallHistoryTimestamp,
|
||||
} from './util/callDisposition';
|
||||
import { deriveStorageServiceKey } from './Crypto';
|
||||
import { deriveStorageServiceKey, deriveMasterKey } from './Crypto';
|
||||
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
|
||||
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
|
||||
import { CallMode } from './types/CallDisposition';
|
||||
|
@ -950,6 +950,10 @@ export async function startApp(): Promise<void> {
|
|||
'hasRegisterSupportForUnauthenticatedDelivery'
|
||||
);
|
||||
}
|
||||
|
||||
if (window.isBeforeVersion(lastVersion, 'v7.33.0-beta.1')) {
|
||||
await window.storage.remove('masterKeyLastRequestTime');
|
||||
}
|
||||
}
|
||||
|
||||
setAppLoadingScreenMessage(
|
||||
|
@ -1849,6 +1853,7 @@ export async function startApp(): Promise<void> {
|
|||
await server.registerCapabilities({
|
||||
deleteSync: true,
|
||||
versionedExpirationTimer: true,
|
||||
ssre2: true,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
@ -1864,21 +1869,22 @@ export async function startApp(): Promise<void> {
|
|||
}
|
||||
|
||||
if (firstRun === true && deviceId !== 1) {
|
||||
if (!window.storage.get('masterKey')) {
|
||||
const lastSent = window.storage.get('masterKeyLastRequestTime') ?? 0;
|
||||
if (!window.storage.get('accountEntropyPool')) {
|
||||
const lastSent =
|
||||
window.storage.get('accountEntropyPoolLastRequestTime') ?? 0;
|
||||
const now = Date.now();
|
||||
|
||||
// If we last attempted sync one day in the past, or if we time
|
||||
// traveled.
|
||||
if (isOlderThan(lastSent, DAY) || lastSent > now) {
|
||||
log.warn('connect: masterKey not captured, requesting sync');
|
||||
log.warn('connect: AEP not captured, requesting sync');
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getRequestKeySyncMessage()
|
||||
);
|
||||
await window.storage.put('masterKeyLastRequestTime', now);
|
||||
await window.storage.put('accountEntropyPoolLastRequestTime', now);
|
||||
} else {
|
||||
log.warn(
|
||||
'connect: masterKey not captured, but sync requested recently.' +
|
||||
'connect: AEP not captured, but sync requested recently.' +
|
||||
'Not running'
|
||||
);
|
||||
}
|
||||
|
@ -3212,20 +3218,64 @@ export async function startApp(): Promise<void> {
|
|||
async function onKeysSync(ev: KeysEvent) {
|
||||
ev.confirm();
|
||||
|
||||
const { masterKey } = ev;
|
||||
const { accountEntropyPool, masterKey, mediaRootBackupKey } = ev;
|
||||
let { storageServiceKey } = ev;
|
||||
|
||||
if (masterKey == null) {
|
||||
log.info('onKeysSync: deleting window.masterKey');
|
||||
const prevMasterKeyBase64 = window.storage.get('masterKey');
|
||||
const prevMasterKey = prevMasterKeyBase64
|
||||
? Bytes.fromBase64(prevMasterKeyBase64)
|
||||
: undefined;
|
||||
const prevAccountEntropyPool = window.storage.get('accountEntropyPool');
|
||||
|
||||
let derivedMasterKey = masterKey;
|
||||
if (derivedMasterKey == null && accountEntropyPool) {
|
||||
derivedMasterKey = deriveMasterKey(accountEntropyPool);
|
||||
if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) {
|
||||
log.info('onKeysSync: deriving master key from account entropy pool');
|
||||
}
|
||||
}
|
||||
|
||||
if (accountEntropyPool == null) {
|
||||
if (prevAccountEntropyPool != null) {
|
||||
log.warn('onKeysSync: deleting window.accountEntropyPool');
|
||||
}
|
||||
await window.storage.remove('accountEntropyPool');
|
||||
} else {
|
||||
if (prevAccountEntropyPool !== accountEntropyPool) {
|
||||
log.info('onKeysSync: updating accountEntropyPool');
|
||||
}
|
||||
await window.storage.put('accountEntropyPool', accountEntropyPool);
|
||||
}
|
||||
|
||||
if (derivedMasterKey == null) {
|
||||
if (prevMasterKey != null) {
|
||||
log.warn('onKeysSync: deleting window.masterKey');
|
||||
}
|
||||
await window.storage.remove('masterKey');
|
||||
} else {
|
||||
if (!Bytes.areEqual(derivedMasterKey, prevMasterKey)) {
|
||||
log.info('onKeysSync: updating masterKey');
|
||||
}
|
||||
// Override provided storageServiceKey because it is deprecated.
|
||||
storageServiceKey = deriveStorageServiceKey(masterKey);
|
||||
await window.storage.put('masterKey', Bytes.toBase64(masterKey));
|
||||
storageServiceKey = deriveStorageServiceKey(derivedMasterKey);
|
||||
await window.storage.put('masterKey', Bytes.toBase64(derivedMasterKey));
|
||||
}
|
||||
|
||||
const prevMediaRootBackupKey = window.storage.get('backupMediaRootKey');
|
||||
if (mediaRootBackupKey == null) {
|
||||
if (prevMediaRootBackupKey != null) {
|
||||
log.warn('onKeysSync: deleting window.backupMediaRootKey');
|
||||
}
|
||||
await window.storage.remove('backupMediaRootKey');
|
||||
} else {
|
||||
if (!Bytes.areEqual(prevMediaRootBackupKey, mediaRootBackupKey)) {
|
||||
log.info('onKeysSync: updating window.backupMediaRootKey');
|
||||
}
|
||||
await window.storage.put('backupMediaRootKey', mediaRootBackupKey);
|
||||
}
|
||||
|
||||
if (storageServiceKey == null) {
|
||||
log.info('onKeysSync: deleting window.storageKey');
|
||||
log.warn('onKeysSync: deleting window.storageKey');
|
||||
await window.storage.remove('storageKey');
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { type Readable } from 'node:stream';
|
||||
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type {
|
||||
WebAPIType,
|
||||
|
@ -12,6 +13,7 @@ import type {
|
|||
BackupListMediaResponseType,
|
||||
} from '../../textsecure/WebAPI';
|
||||
import type { BackupCredentials } from './credentials';
|
||||
import { BackupCredentialType } from '../../types/backups';
|
||||
import { uploadFile } from '../../util/uploadAttachment';
|
||||
|
||||
export type DownloadOptionsType = Readonly<{
|
||||
|
@ -21,48 +23,54 @@ export type DownloadOptionsType = Readonly<{
|
|||
}>;
|
||||
|
||||
export class BackupAPI {
|
||||
private cachedBackupInfo: GetBackupInfoResponseType | undefined;
|
||||
constructor(private credentials: BackupCredentials) {}
|
||||
private cachedBackupInfo = new Map<
|
||||
BackupCredentialType,
|
||||
GetBackupInfoResponseType
|
||||
>();
|
||||
|
||||
constructor(private readonly credentials: BackupCredentials) {}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
await this.server.refreshBackup(
|
||||
await this.credentials.getHeadersForToday()
|
||||
const headers = await Promise.all(
|
||||
[BackupCredentialType.Messages, BackupCredentialType.Media].map(type =>
|
||||
this.credentials.getHeadersForToday(type)
|
||||
)
|
||||
);
|
||||
await Promise.all(headers.map(h => this.server.refreshBackup(h)));
|
||||
}
|
||||
|
||||
public async getInfo(): Promise<GetBackupInfoResponseType> {
|
||||
public async getInfo(
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<GetBackupInfoResponseType> {
|
||||
const backupInfo = await this.server.getBackupInfo(
|
||||
await this.credentials.getHeadersForToday()
|
||||
await this.credentials.getHeadersForToday(credentialType)
|
||||
);
|
||||
this.cachedBackupInfo = backupInfo;
|
||||
this.cachedBackupInfo.set(credentialType, backupInfo);
|
||||
return backupInfo;
|
||||
}
|
||||
|
||||
private async getCachedInfo(): Promise<GetBackupInfoResponseType> {
|
||||
if (this.cachedBackupInfo) {
|
||||
return this.cachedBackupInfo;
|
||||
private async getCachedInfo(
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<GetBackupInfoResponseType> {
|
||||
const cached = this.cachedBackupInfo.get(credentialType);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.getInfo();
|
||||
return this.getInfo(credentialType);
|
||||
}
|
||||
|
||||
public async getMediaDir(): Promise<string> {
|
||||
return (await this.getCachedInfo()).mediaDir;
|
||||
return (await this.getCachedInfo(BackupCredentialType.Media)).mediaDir;
|
||||
}
|
||||
|
||||
public async getBackupDir(): Promise<string> {
|
||||
return (await this.getCachedInfo())?.backupDir;
|
||||
}
|
||||
|
||||
// Backup name will change whenever a new backup is created, so we don't want to cache
|
||||
// it
|
||||
public async getBackupName(): Promise<string> {
|
||||
return (await this.getInfo()).backupName;
|
||||
return (await this.getCachedInfo(BackupCredentialType.Media))?.backupDir;
|
||||
}
|
||||
|
||||
public async upload(filePath: string, fileSize: number): Promise<void> {
|
||||
const form = await this.server.getBackupUploadForm(
|
||||
await this.credentials.getHeadersForToday()
|
||||
await this.credentials.getHeadersForToday(BackupCredentialType.Messages)
|
||||
);
|
||||
|
||||
await uploadFile({
|
||||
|
@ -77,8 +85,13 @@ export class BackupAPI {
|
|||
onProgress,
|
||||
abortSignal,
|
||||
}: DownloadOptionsType): Promise<Readable> {
|
||||
const { cdn, backupDir, backupName } = await this.getInfo();
|
||||
const { headers } = await this.credentials.getCDNReadCredentials(cdn);
|
||||
const { cdn, backupDir, backupName } = await this.getInfo(
|
||||
BackupCredentialType.Messages
|
||||
);
|
||||
const { headers } = await this.credentials.getCDNReadCredentials(
|
||||
cdn,
|
||||
BackupCredentialType.Messages
|
||||
);
|
||||
|
||||
return this.server.getBackupStream({
|
||||
cdn,
|
||||
|
@ -111,7 +124,7 @@ export class BackupAPI {
|
|||
|
||||
public async getMediaUploadForm(): Promise<AttachmentUploadFormResponseType> {
|
||||
return this.server.getBackupMediaUploadForm(
|
||||
await this.credentials.getHeadersForToday()
|
||||
await this.credentials.getHeadersForToday(BackupCredentialType.Media)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -119,7 +132,9 @@ export class BackupAPI {
|
|||
items: ReadonlyArray<BackupMediaItemType>
|
||||
): Promise<BackupMediaBatchResponseType> {
|
||||
return this.server.backupMediaBatch({
|
||||
headers: await this.credentials.getHeadersForToday(),
|
||||
headers: await this.credentials.getHeadersForToday(
|
||||
BackupCredentialType.Media
|
||||
),
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
@ -132,14 +147,16 @@ export class BackupAPI {
|
|||
limit: number;
|
||||
}): Promise<BackupListMediaResponseType> {
|
||||
return this.server.backupListMedia({
|
||||
headers: await this.credentials.getHeadersForToday(),
|
||||
headers: await this.credentials.getHeadersForToday(
|
||||
BackupCredentialType.Media
|
||||
),
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
public clearCache(): void {
|
||||
this.cachedBackupInfo = undefined;
|
||||
this.cachedBackupInfo.clear();
|
||||
}
|
||||
|
||||
private get server(): WebAPIType {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { PrivateKey } from '@signalapp/libsignal-client';
|
||||
import { type PrivateKey } from '@signalapp/libsignal-client';
|
||||
import {
|
||||
BackupAuthCredential,
|
||||
BackupAuthCredentialRequestContext,
|
||||
|
@ -9,6 +9,7 @@ import {
|
|||
type BackupLevel,
|
||||
GenericServerPublicParams,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
import { type BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
|
@ -16,11 +17,13 @@ import { drop } from '../../util/drop';
|
|||
import { isMoreRecentThan, toDayMillis } from '../../util/timestamp';
|
||||
import { DAY, DurationInSeconds, HOUR } from '../../util/durations';
|
||||
import { BackOff, FIBONACCI_TIMEOUTS } from '../../util/BackOff';
|
||||
import type {
|
||||
BackupCdnReadCredentialType,
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import {
|
||||
type BackupCdnReadCredentialType,
|
||||
type BackupCredentialWrapperType,
|
||||
type BackupPresentationHeadersType,
|
||||
type BackupSignedPresentationType,
|
||||
BackupCredentialType,
|
||||
BackupPresentationHeadersType,
|
||||
BackupSignedPresentationType,
|
||||
} from '../../types/backups';
|
||||
import { toLogFormat } from '../../types/errors';
|
||||
import { HTTPError } from '../../textsecure/Errors';
|
||||
|
@ -28,14 +31,12 @@ import type {
|
|||
GetBackupCredentialsResponseType,
|
||||
GetBackupCDNCredentialsResponseType,
|
||||
} from '../../textsecure/WebAPI';
|
||||
import { getBackupKey, getBackupSignatureKey } from './crypto';
|
||||
|
||||
export function getAuthContext(): BackupAuthCredentialRequestContext {
|
||||
return BackupAuthCredentialRequestContext.create(
|
||||
Buffer.from(getBackupKey()),
|
||||
window.storage.user.getCheckedAci()
|
||||
);
|
||||
}
|
||||
import {
|
||||
getBackupKey,
|
||||
getBackupMediaRootKey,
|
||||
getBackupSignatureKey,
|
||||
getBackupMediaSignatureKey,
|
||||
} from './crypto';
|
||||
|
||||
const FETCH_INTERVAL = 3 * DAY;
|
||||
|
||||
|
@ -54,24 +55,35 @@ export class BackupCredentials {
|
|||
this.scheduleFetch();
|
||||
}
|
||||
|
||||
public async getForToday(): Promise<BackupSignedPresentationType> {
|
||||
public async getForToday(
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<BackupSignedPresentationType> {
|
||||
const now = toDayMillis(Date.now());
|
||||
|
||||
const signatureKeyBytes = getBackupSignatureKey();
|
||||
const signatureKey = PrivateKey.deserialize(Buffer.from(signatureKeyBytes));
|
||||
let signatureKey: PrivateKey;
|
||||
let storageKey: `setBackup${'Messages' | 'Media'}SignatureKey`;
|
||||
if (credentialType === BackupCredentialType.Messages) {
|
||||
signatureKey = getBackupSignatureKey();
|
||||
storageKey = 'setBackupMessagesSignatureKey';
|
||||
} else if (credentialType === BackupCredentialType.Media) {
|
||||
signatureKey = getBackupMediaSignatureKey();
|
||||
storageKey = 'setBackupMediaSignatureKey';
|
||||
} else {
|
||||
throw missingCaseError(credentialType);
|
||||
}
|
||||
|
||||
// Start with cache
|
||||
let credentials = window.storage.get('backupCredentials') || [];
|
||||
let credentials = this.getFromCache();
|
||||
|
||||
let result = credentials.find(({ redemptionTimeMs }) => {
|
||||
return redemptionTimeMs === now;
|
||||
let result = credentials.find(({ type, redemptionTimeMs }) => {
|
||||
return type === credentialType && redemptionTimeMs === now;
|
||||
});
|
||||
|
||||
if (result === undefined) {
|
||||
log.info(`BackupCredentials: cache miss for ${now}`);
|
||||
credentials = await this.fetch();
|
||||
result = credentials.find(({ redemptionTimeMs }) => {
|
||||
return redemptionTimeMs === now;
|
||||
result = credentials.find(({ type, redemptionTimeMs }) => {
|
||||
return type === credentialType && redemptionTimeMs === now;
|
||||
});
|
||||
strictAssert(
|
||||
result !== undefined,
|
||||
|
@ -95,33 +107,36 @@ export class BackupCredentials {
|
|||
'X-Signal-ZK-Auth-Signature': signature.toString('base64'),
|
||||
};
|
||||
|
||||
if (!window.storage.get('setBackupSignatureKey')) {
|
||||
log.warn('BackupCredentials: uploading signature key');
|
||||
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
await server.setBackupSignatureKey({
|
||||
headers,
|
||||
backupIdPublicKey: signatureKey.getPublicKey().serialize(),
|
||||
});
|
||||
|
||||
await window.storage.put('setBackupSignatureKey', true);
|
||||
const info = { headers, level: result.level };
|
||||
if (window.storage.get(storageKey)) {
|
||||
return info;
|
||||
}
|
||||
|
||||
return {
|
||||
log.warn(`BackupCredentials: uploading signature key (${storageKey})`);
|
||||
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
await server.setBackupSignatureKey({
|
||||
headers,
|
||||
level: result.level,
|
||||
};
|
||||
backupIdPublicKey: signatureKey.getPublicKey().serialize(),
|
||||
});
|
||||
|
||||
await window.storage.put(storageKey, true);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
public async getHeadersForToday(): Promise<BackupPresentationHeadersType> {
|
||||
const { headers } = await this.getForToday();
|
||||
public async getHeadersForToday(
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<BackupPresentationHeadersType> {
|
||||
const { headers } = await this.getForToday(credentialType);
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async getCDNReadCredentials(
|
||||
cdn: number
|
||||
cdn: number,
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<GetBackupCDNCredentialsResponseType> {
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
@ -140,7 +155,7 @@ export class BackupCredentials {
|
|||
return cachedCredentialsForThisCdn.credentials;
|
||||
}
|
||||
|
||||
const headers = await this.getHeadersForToday();
|
||||
const headers = await this.getHeadersForToday(credentialType);
|
||||
|
||||
const retrievedAtMs = Date.now();
|
||||
const newCredentials = await server.getBackupCDNCredentials({
|
||||
|
@ -159,7 +174,7 @@ export class BackupCredentials {
|
|||
|
||||
private scheduleFetch(): void {
|
||||
const lastFetchAt = window.storage.get(
|
||||
'backupCredentialsLastRequestTime',
|
||||
'backupCombinedCredentialsLastRequestTime',
|
||||
0
|
||||
);
|
||||
const nextFetchAt = lastFetchAt + FETCH_INTERVAL;
|
||||
|
@ -171,10 +186,11 @@ export class BackupCredentials {
|
|||
|
||||
private async runPeriodicFetch(): Promise<void> {
|
||||
try {
|
||||
log.info('BackupCredentials: fetching');
|
||||
log.info('BackupCredentials: run periodic fetch');
|
||||
await this.fetch();
|
||||
|
||||
await window.storage.put('backupCredentialsLastRequestTime', Date.now());
|
||||
const now = Date.now();
|
||||
await window.storage.put('backupCombinedCredentialsLastRequestTime', now);
|
||||
|
||||
this.fetchBackoff.reset();
|
||||
this.scheduleFetch();
|
||||
|
@ -188,7 +204,7 @@ export class BackupCredentials {
|
|||
}
|
||||
}
|
||||
|
||||
private async fetch(): Promise<ReadonlyArray<BackupCredentialType>> {
|
||||
private async fetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
|
||||
if (this.activeFetch) {
|
||||
return this.activeFetch;
|
||||
}
|
||||
|
@ -203,7 +219,7 @@ export class BackupCredentials {
|
|||
}
|
||||
}
|
||||
|
||||
private async doFetch(): Promise<ReadonlyArray<BackupCredentialType>> {
|
||||
private async doFetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
|
||||
log.info('BackupCredentials: fetching');
|
||||
|
||||
const now = Date.now();
|
||||
|
@ -211,7 +227,8 @@ export class BackupCredentials {
|
|||
const endDayInMs = toDayMillis(now + 6 * DAY);
|
||||
|
||||
// And fetch missing credentials
|
||||
const ctx = getAuthContext();
|
||||
const messagesCtx = this.getAuthContext(BackupCredentialType.Messages);
|
||||
const mediaCtx = this.getAuthContext(BackupCredentialType.Media);
|
||||
const { server } = window.textsecure;
|
||||
strictAssert(server, 'server not available');
|
||||
|
||||
|
@ -231,11 +248,13 @@ export class BackupCredentials {
|
|||
}
|
||||
|
||||
// Backup id is missing
|
||||
const request = ctx.getRequest();
|
||||
const messagesRequest = messagesCtx.getRequest();
|
||||
const mediaRequest = mediaCtx.getRequest();
|
||||
|
||||
// Set it
|
||||
await server.setBackupId({
|
||||
backupAuthCredentialRequest: request.serialize(),
|
||||
messagesBackupAuthCredentialRequest: messagesRequest.serialize(),
|
||||
mediaBackupAuthCredentialRequest: mediaRequest.serialize(),
|
||||
});
|
||||
|
||||
// And try again!
|
||||
|
@ -245,16 +264,41 @@ export class BackupCredentials {
|
|||
});
|
||||
}
|
||||
|
||||
log.info(`BackupCredentials: got ${response.credentials.length}`);
|
||||
const { messages: messageCredentials, media: mediaCredentials } =
|
||||
response.credentials;
|
||||
|
||||
log.info(
|
||||
'BackupCredentials: got ' +
|
||||
`${messageCredentials.length}/${mediaCredentials.length}`
|
||||
);
|
||||
|
||||
const serverPublicParams = new GenericServerPublicParams(
|
||||
Buffer.from(window.getBackupServerPublicParams(), 'base64')
|
||||
);
|
||||
|
||||
const result = new Array<BackupCredentialType>();
|
||||
const result = new Array<BackupCredentialWrapperType>();
|
||||
|
||||
const issuedTimes = new Set<number>();
|
||||
for (const { credential: buf, redemptionTime } of response.credentials) {
|
||||
const allCredentials = messageCredentials
|
||||
.map(credential => ({
|
||||
...credential,
|
||||
ctx: messagesCtx,
|
||||
type: BackupCredentialType.Messages,
|
||||
}))
|
||||
.concat(
|
||||
mediaCredentials.map(credential => ({
|
||||
...credential,
|
||||
ctx: mediaCtx,
|
||||
type: BackupCredentialType.Media,
|
||||
}))
|
||||
);
|
||||
|
||||
const issuedTimes = new Set<`${BackupCredentialType}:${number}`>();
|
||||
for (const {
|
||||
type,
|
||||
ctx,
|
||||
credential: buf,
|
||||
redemptionTime,
|
||||
} of allCredentials) {
|
||||
const credentialRes = new BackupAuthCredentialResponse(Buffer.from(buf));
|
||||
|
||||
const redemptionTimeMs = DurationInSeconds.toMillis(redemptionTime);
|
||||
|
@ -268,10 +312,10 @@ export class BackupCredentials {
|
|||
);
|
||||
|
||||
strictAssert(
|
||||
!issuedTimes.has(redemptionTimeMs),
|
||||
!issuedTimes.has(`${type}:${redemptionTimeMs}`),
|
||||
'Invalid credential response redemption time, duplicate'
|
||||
);
|
||||
issuedTimes.add(redemptionTimeMs);
|
||||
issuedTimes.add(`${type}:${redemptionTimeMs}`);
|
||||
|
||||
const credential = ctx.receive(
|
||||
credentialRes,
|
||||
|
@ -280,6 +324,7 @@ export class BackupCredentials {
|
|||
);
|
||||
|
||||
result.push({
|
||||
type,
|
||||
credential: credential.serialize().toString('base64'),
|
||||
level: credential.getBackupLevel(),
|
||||
redemptionTimeMs,
|
||||
|
@ -288,40 +333,68 @@ export class BackupCredentials {
|
|||
|
||||
// Add cached credentials that are still in the date range, and not in
|
||||
// the response.
|
||||
const cachedCredentials = window.storage.get('backupCredentials') || [];
|
||||
for (const cached of cachedCredentials) {
|
||||
const { redemptionTimeMs } = cached;
|
||||
for (const cached of this.getFromCache()) {
|
||||
const { type, redemptionTimeMs } = cached;
|
||||
if (
|
||||
!(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issuedTimes.has(redemptionTimeMs)) {
|
||||
if (issuedTimes.has(`${type}:${redemptionTimeMs}`)) {
|
||||
continue;
|
||||
}
|
||||
result.push(cached);
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs);
|
||||
await window.storage.put('backupCredentials', result);
|
||||
await this.updateCache(result);
|
||||
|
||||
const startMs = result[0].redemptionTimeMs;
|
||||
const endMs = result[result.length - 1].redemptionTimeMs;
|
||||
log.info(`BackupCredentials: saved [${startMs}, ${endMs}]`);
|
||||
|
||||
strictAssert(result.length === 7, 'Expected one week of credentials');
|
||||
strictAssert(result.length === 14, 'Expected one week of credentials');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async getBackupLevel(): Promise<BackupLevel> {
|
||||
return (await this.getForToday()).level;
|
||||
private getAuthContext(
|
||||
credentialType: BackupCredentialType
|
||||
): BackupAuthCredentialRequestContext {
|
||||
let key: BackupKey;
|
||||
if (credentialType === BackupCredentialType.Messages) {
|
||||
key = getBackupKey();
|
||||
} else if (credentialType === BackupCredentialType.Media) {
|
||||
key = getBackupMediaRootKey();
|
||||
} else {
|
||||
throw missingCaseError(credentialType);
|
||||
}
|
||||
return BackupAuthCredentialRequestContext.create(
|
||||
key.serialize(),
|
||||
window.storage.user.getCheckedAci()
|
||||
);
|
||||
}
|
||||
|
||||
private getFromCache(): ReadonlyArray<BackupCredentialWrapperType> {
|
||||
return window.storage.get('backupCombinedCredentials', []);
|
||||
}
|
||||
|
||||
private async updateCache(
|
||||
values: ReadonlyArray<BackupCredentialWrapperType>
|
||||
): Promise<void> {
|
||||
await window.storage.put('backupCombinedCredentials', values);
|
||||
}
|
||||
|
||||
public async getBackupLevel(
|
||||
credentialType: BackupCredentialType
|
||||
): Promise<BackupLevel> {
|
||||
return (await this.getForToday(credentialType)).level;
|
||||
}
|
||||
|
||||
// Called when backup tier changes or when userChanged event
|
||||
public async clearCache(): Promise<void> {
|
||||
this.cachedCdnReadCredentials = {};
|
||||
await window.storage.put('backupCredentials', []);
|
||||
await this.updateCache([]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import { createGzip, createGunzip } from 'zlib';
|
|||
import { createCipheriv, createHmac, randomBytes } from 'crypto';
|
||||
import { noop } from 'lodash';
|
||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||
|
||||
import { DataReader, DataWriter } from '../../sql/Client';
|
||||
import * as log from '../../logging/log';
|
||||
|
@ -29,6 +30,7 @@ import { HOUR } from '../../util/durations';
|
|||
import { CipherType, HashType } from '../../types/Crypto';
|
||||
import { InstallScreenBackupStep } from '../../types/InstallScreen';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { BackupCredentialType } from '../../types/backups';
|
||||
import { HTTPError } from '../../textsecure/Errors';
|
||||
import { constantTimeEqual } from '../../Crypto';
|
||||
import { measureSize } from '../../AttachmentCrypto';
|
||||
|
@ -177,7 +179,9 @@ export class BackupsService {
|
|||
const fileName = `backup-${randomBytes(32).toString('hex')}`;
|
||||
const filePath = join(window.BasePaths.temp, fileName);
|
||||
|
||||
const backupLevel = await this.credentials.getBackupLevel();
|
||||
const backupLevel = await this.credentials.getBackupLevel(
|
||||
BackupCredentialType.Media
|
||||
);
|
||||
log.info(`exportBackup: starting, backup level: ${backupLevel}...`);
|
||||
|
||||
try {
|
||||
|
@ -195,7 +199,7 @@ export class BackupsService {
|
|||
|
||||
// Test harness
|
||||
public async exportBackupData(
|
||||
backupLevel: BackupLevel = BackupLevel.Messages,
|
||||
backupLevel: BackupLevel = BackupLevel.Free,
|
||||
backupType = BackupType.Ciphertext
|
||||
): Promise<Uint8Array> {
|
||||
const sink = new PassThrough();
|
||||
|
@ -210,7 +214,7 @@ export class BackupsService {
|
|||
// Test harness
|
||||
public async exportToDisk(
|
||||
path: string,
|
||||
backupLevel: BackupLevel = BackupLevel.Messages,
|
||||
backupLevel: BackupLevel = BackupLevel.Free,
|
||||
backupType = BackupType.Ciphertext
|
||||
): Promise<number> {
|
||||
const size = await this.exportBackup(
|
||||
|
@ -276,7 +280,9 @@ export class BackupsService {
|
|||
try {
|
||||
const importStream = await BackupImportStream.create(backupType);
|
||||
if (backupType === BackupType.Ciphertext) {
|
||||
const { aesKey, macKey } = getKeyMaterial(ephemeralKey);
|
||||
const { aesKey, macKey } = getKeyMaterial(
|
||||
ephemeralKey ? new BackupKey(Buffer.from(ephemeralKey)) : undefined
|
||||
);
|
||||
|
||||
// First pass - don't decrypt, only verify mac
|
||||
let hmac = createHmac(HashType.size256, macKey);
|
||||
|
@ -519,7 +525,7 @@ export class BackupsService {
|
|||
|
||||
private async exportBackup(
|
||||
sink: Writable,
|
||||
backupLevel: BackupLevel = BackupLevel.Messages,
|
||||
backupLevel: BackupLevel = BackupLevel.Free,
|
||||
backupType = BackupType.Ciphertext
|
||||
): Promise<number> {
|
||||
strictAssert(!this.isRunning, 'BackupService is already running');
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,13 +12,14 @@ export async function validateBackup(
|
|||
filePath: string,
|
||||
fileSize: number
|
||||
): Promise<void> {
|
||||
const masterKeyBase64 = window.storage.get('masterKey');
|
||||
strictAssert(masterKeyBase64, 'Master key not available');
|
||||
|
||||
const masterKey = Buffer.from(masterKeyBase64, 'base64');
|
||||
const accountEntropy = window.storage.get('accountEntropyPool');
|
||||
strictAssert(accountEntropy, 'Account Entropy Pool not available');
|
||||
|
||||
const aci = toAciObject(window.storage.user.getCheckedAci());
|
||||
const backupKey = new libsignal.MessageBackupKey(masterKey, aci);
|
||||
const backupKey = new libsignal.MessageBackupKey({
|
||||
accountEntropy,
|
||||
aci,
|
||||
});
|
||||
|
||||
const streams = new Array<FileStream>();
|
||||
|
||||
|
|
|
@ -70,6 +70,7 @@ type JobType = {
|
|||
const OBSERVED_CAPABILITY_KEYS = Object.keys({
|
||||
deleteSync: true,
|
||||
versionedExpirationTimer: true,
|
||||
ssre2: true,
|
||||
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
|
||||
|
||||
export class ProfileService {
|
||||
|
|
|
@ -123,6 +123,7 @@ const conflictBackOff = new BackOff([
|
|||
|
||||
function encryptRecord(
|
||||
storageID: string | undefined,
|
||||
recordIkm: Uint8Array | undefined,
|
||||
storageRecord: Proto.IStorageRecord
|
||||
): Proto.StorageItem {
|
||||
const storageItem = new Proto.StorageItem();
|
||||
|
@ -135,11 +136,12 @@ function encryptRecord(
|
|||
if (!storageKeyBase64) {
|
||||
throw new Error('No storage key');
|
||||
}
|
||||
const storageKey = Bytes.fromBase64(storageKeyBase64);
|
||||
const storageItemKey = deriveStorageItemKey(
|
||||
storageKey,
|
||||
Bytes.toBase64(storageKeyBuffer)
|
||||
);
|
||||
const storageServiceKey = Bytes.fromBase64(storageKeyBase64);
|
||||
const storageItemKey = deriveStorageItemKey({
|
||||
storageServiceKey,
|
||||
recordIkm,
|
||||
key: storageKeyBuffer,
|
||||
});
|
||||
|
||||
const encryptedRecord = encryptProfile(
|
||||
Proto.StorageRecord.encode(storageRecord).finish(),
|
||||
|
@ -158,6 +160,7 @@ function generateStorageID(): Uint8Array {
|
|||
|
||||
type GeneratedManifestType = {
|
||||
postUploadUpdateFunctions: Array<() => unknown>;
|
||||
recordIkm: Uint8Array | undefined;
|
||||
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
|
||||
insertKeys: Set<string>;
|
||||
deleteKeys: Set<string>;
|
||||
|
@ -729,6 +732,7 @@ async function generateManifest(
|
|||
// If we have a copy of what the current remote manifest is then we run these
|
||||
// additional validations comparing our pending manifest to the remote
|
||||
// manifest:
|
||||
let recordIkm: Uint8Array | undefined;
|
||||
if (previousManifest) {
|
||||
const pendingInserts: Set<string> = new Set();
|
||||
const pendingDeletes: Set<string> = new Set();
|
||||
|
@ -800,11 +804,18 @@ async function generateManifest(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Bytes.isNotEmpty(previousManifest.recordIkm)) {
|
||||
recordIkm = previousManifest.recordIkm;
|
||||
}
|
||||
} else {
|
||||
recordIkm = window.storage.get('manifestRecordIkm');
|
||||
}
|
||||
|
||||
return {
|
||||
postUploadUpdateFunctions,
|
||||
recordsByID,
|
||||
recordIkm,
|
||||
insertKeys,
|
||||
deleteKeys,
|
||||
};
|
||||
|
@ -812,6 +823,7 @@ async function generateManifest(
|
|||
|
||||
type EncryptManifestOptionsType = {
|
||||
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
|
||||
recordIkm: Uint8Array | undefined;
|
||||
insertKeys: Set<string>;
|
||||
};
|
||||
|
||||
|
@ -822,7 +834,7 @@ type EncryptedManifestType = {
|
|||
|
||||
async function encryptManifest(
|
||||
version: number,
|
||||
{ recordsByID, insertKeys }: EncryptManifestOptionsType
|
||||
{ recordsByID, recordIkm, insertKeys }: EncryptManifestOptionsType
|
||||
): Promise<EncryptedManifestType> {
|
||||
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
||||
const newItems: Set<Proto.IStorageItem> = new Set();
|
||||
|
@ -843,7 +855,7 @@ async function encryptManifest(
|
|||
|
||||
let storageItem;
|
||||
try {
|
||||
storageItem = encryptRecord(storageID, storageRecord);
|
||||
storageItem = encryptRecord(storageID, recordIkm, storageRecord);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`storageService.upload(${version}): encrypt record failed:`,
|
||||
|
@ -1391,7 +1403,11 @@ async function processManifest(
|
|||
|
||||
let conflictCount = 0;
|
||||
if (remoteOnlyRecords.size) {
|
||||
const fetchResult = await fetchRemoteRecords(version, remoteOnlyRecords);
|
||||
const fetchResult = await fetchRemoteRecords(
|
||||
version,
|
||||
Bytes.isNotEmpty(manifest.recordIkm) ? manifest.recordIkm : undefined,
|
||||
remoteOnlyRecords
|
||||
);
|
||||
conflictCount = await processRemoteRecords(version, fetchResult);
|
||||
}
|
||||
|
||||
|
@ -1614,6 +1630,7 @@ export type FetchRemoteRecordsResultType = Readonly<{
|
|||
|
||||
async function fetchRemoteRecords(
|
||||
storageVersion: number,
|
||||
recordIkm: Uint8Array | undefined,
|
||||
remoteOnlyRecords: Map<string, RemoteRecord>
|
||||
): Promise<FetchRemoteRecordsResultType> {
|
||||
const storageKeyBase64 = window.storage.get('storageKey');
|
||||
|
@ -1678,7 +1695,11 @@ async function fetchRemoteRecords(
|
|||
const base64ItemID = Bytes.toBase64(key);
|
||||
missingKeys.delete(base64ItemID);
|
||||
|
||||
const storageItemKey = deriveStorageItemKey(storageKey, base64ItemID);
|
||||
const storageItemKey = deriveStorageItemKey({
|
||||
storageServiceKey: storageKey,
|
||||
recordIkm,
|
||||
key,
|
||||
});
|
||||
|
||||
let storageItemPlaintext;
|
||||
try {
|
||||
|
@ -2071,6 +2092,11 @@ async function sync({
|
|||
);
|
||||
|
||||
await window.storage.put('manifestVersion', version);
|
||||
if (Bytes.isNotEmpty(manifest.recordIkm)) {
|
||||
await window.storage.put('manifestRecordIkm', manifest.recordIkm);
|
||||
} else {
|
||||
await window.storage.remove('manifestRecordIkm');
|
||||
}
|
||||
|
||||
const hasConflicts = conflictCount !== 0;
|
||||
if (hasConflicts && !ignoreConflicts) {
|
||||
|
@ -2208,6 +2234,7 @@ export async function eraseAllStorageServiceState({
|
|||
// First, update high-level storage service metadata
|
||||
await Promise.all([
|
||||
window.storage.remove('manifestVersion'),
|
||||
window.storage.remove('manifestRecordIkm'),
|
||||
keepUnknownFields
|
||||
? Promise.resolve()
|
||||
: window.storage.remove('storage-service-unknown-records'),
|
||||
|
|
|
@ -432,6 +432,8 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
|
|||
subscriberId: ['value'],
|
||||
backupsSubscriberId: ['value'],
|
||||
backupEphemeralKey: ['value'],
|
||||
backupMediaRootKey: ['value'],
|
||||
manifestRecordIkm: ['value'],
|
||||
usernameLink: ['value.entropy', 'value.serverId'],
|
||||
};
|
||||
async function createOrUpdateItem<K extends ItemKeyType>(
|
||||
|
|
|
@ -6,6 +6,11 @@ import { createGzip } from 'node:zlib';
|
|||
import { createCipheriv, randomBytes } from 'node:crypto';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import Long from 'long';
|
||||
import {
|
||||
AccountEntropyPool,
|
||||
BackupKey,
|
||||
} from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||
import { MessageBackupKey } from '@signalapp/libsignal-client/dist/MessageBackup';
|
||||
|
||||
import type { AciString } from '../../types/ServiceId';
|
||||
import { generateAci } from '../../types/ServiceId';
|
||||
|
@ -14,11 +19,6 @@ import { appendPaddingStream } from '../../util/logPadding';
|
|||
import { prependStream } from '../../util/prependStream';
|
||||
import { appendMacStream } from '../../util/appendMacStream';
|
||||
import { toAciObject } from '../../util/ServiceId';
|
||||
import {
|
||||
deriveBackupKey,
|
||||
deriveBackupId,
|
||||
deriveBackupKeyMaterial,
|
||||
} from '../../Crypto';
|
||||
import { BACKUP_VERSION } from '../../services/backups/constants';
|
||||
import { Backups } from '../../protobuf';
|
||||
|
||||
|
@ -29,9 +29,10 @@ export type BackupGeneratorConfigType = Readonly<
|
|||
conversations: number;
|
||||
conversationAcis?: ReadonlyArray<AciString>;
|
||||
messages: number;
|
||||
mediaRootBackupKey: Buffer;
|
||||
} & (
|
||||
| {
|
||||
masterKey: Buffer;
|
||||
accountEntropyPool: string;
|
||||
}
|
||||
| {
|
||||
backupKey: Buffer;
|
||||
|
@ -50,15 +51,18 @@ export function generateBackup(
|
|||
options: BackupGeneratorConfigType
|
||||
): GenerateBackupResultType {
|
||||
const { aci } = options;
|
||||
let backupKey: Uint8Array;
|
||||
if ('masterKey' in options) {
|
||||
backupKey = deriveBackupKey(options.masterKey);
|
||||
let backupKey: BackupKey;
|
||||
if ('accountEntropyPool' in options) {
|
||||
backupKey = AccountEntropyPool.deriveBackupKey(options.accountEntropyPool);
|
||||
} else {
|
||||
({ backupKey } = options);
|
||||
backupKey = new BackupKey(options.backupKey);
|
||||
}
|
||||
const aciBytes = toAciObject(aci).getServiceIdBinary();
|
||||
const backupId = Buffer.from(deriveBackupId(backupKey, aciBytes));
|
||||
const { aesKey, macKey } = deriveBackupKeyMaterial(backupKey, backupId);
|
||||
const aciObj = toAciObject(aci);
|
||||
const backupId = backupKey.deriveBackupId(aciObj);
|
||||
const { aesKey, hmacKey } = new MessageBackupKey({
|
||||
backupKey,
|
||||
backupId,
|
||||
});
|
||||
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
|
||||
|
@ -67,7 +71,7 @@ export function generateBackup(
|
|||
.pipe(appendPaddingStream())
|
||||
.pipe(createCipheriv(CipherType.AES256CBC, aesKey, iv))
|
||||
.pipe(prependStream(iv))
|
||||
.pipe(appendMacStream(macKey));
|
||||
.pipe(appendMacStream(hmacKey));
|
||||
|
||||
return { backupId, stream };
|
||||
}
|
||||
|
@ -87,11 +91,13 @@ function* createRecords({
|
|||
conversations,
|
||||
conversationAcis = [],
|
||||
messages,
|
||||
mediaRootBackupKey,
|
||||
}: BackupGeneratorConfigType): Iterable<Buffer> {
|
||||
yield Buffer.from(
|
||||
Backups.BackupInfo.encodeDelimited({
|
||||
version: Long.fromNumber(BACKUP_VERSION),
|
||||
backupTimeMs: getTimestamp(),
|
||||
mediaRootBackupKey,
|
||||
}).finish()
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { createReadStream } from 'fs';
|
|||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import * as sinon from 'sinon';
|
||||
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
|
||||
import { AccountEntropyPool } from '@signalapp/libsignal-client/dist/AccountKeys';
|
||||
|
||||
import type {
|
||||
EditHistoryType,
|
||||
|
@ -31,6 +32,8 @@ export const OUR_ACI = generateAci();
|
|||
export const OUR_PNI = generatePni();
|
||||
export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32));
|
||||
export const PROFILE_KEY = getRandomBytes(32);
|
||||
export const ACCOUNT_ENTROPY_POOL = AccountEntropyPool.generate();
|
||||
export const MEDIA_ROOT_KEY = getRandomBytes(32);
|
||||
|
||||
// This is preserved across data erasure
|
||||
const CONVO_ID_TO_STABLE_ID = new Map<string, string>();
|
||||
|
@ -176,7 +179,7 @@ type HarnessOptionsType = {
|
|||
|
||||
export async function symmetricRoundtripHarness(
|
||||
messages: Array<MessageAttributesType>,
|
||||
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
|
||||
options: HarnessOptionsType = { backupLevel: BackupLevel.Free }
|
||||
): Promise<void> {
|
||||
return asymmetricRoundtripHarness(messages, messages, options);
|
||||
}
|
||||
|
@ -194,7 +197,7 @@ async function updateConvoIdToTitle() {
|
|||
export async function asymmetricRoundtripHarness(
|
||||
before: Array<MessageAttributesType>,
|
||||
after: Array<MessageAttributesType>,
|
||||
options: HarnessOptionsType = { backupLevel: BackupLevel.Messages }
|
||||
options: HarnessOptionsType = { backupLevel: BackupLevel.Free }
|
||||
): Promise<void> {
|
||||
const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-'));
|
||||
const fetchAndSaveBackupCdnObjectMetadata = sinon.stub(
|
||||
|
@ -247,6 +250,8 @@ export async function setupBasics(): Promise<void> {
|
|||
await window.storage.put('uuid_id', `${OUR_ACI}.2`);
|
||||
await window.storage.put('pni', OUR_PNI);
|
||||
await window.storage.put('masterKey', MASTER_KEY);
|
||||
await window.storage.put('accountEntropyPool', ACCOUNT_ENTROPY_POOL);
|
||||
await window.storage.put('backupMediaRootKey', MEDIA_ROOT_KEY);
|
||||
await window.storage.put('profileKey', PROFILE_KEY);
|
||||
|
||||
await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', {
|
||||
|
|
|
@ -77,7 +77,7 @@ describe('backup/integration', () => {
|
|||
});
|
||||
|
||||
const exported = await backupsService.exportBackupData(
|
||||
BackupLevel.Media,
|
||||
BackupLevel.Paid,
|
||||
BackupType.TestOnlyPlaintext
|
||||
);
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
|
|||
index: number,
|
||||
overrides: Partial<ThumbnailAttachmentBackupJobType['data']> = {}
|
||||
): ThumbnailAttachmentBackupJobType {
|
||||
const mediaName = `thumbnail${index}`;
|
||||
const mediaName = `thumbnail${index}_thumbnail` as const;
|
||||
|
||||
return {
|
||||
mediaName,
|
||||
|
@ -116,6 +116,7 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
|
|||
await DataWriter.removeAll();
|
||||
|
||||
await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32)));
|
||||
await window.storage.put('backupMediaRootKey', getRandomBytes(32));
|
||||
|
||||
sandbox = sinon.createSandbox();
|
||||
clock = sandbox.useFakeTimers();
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -11,7 +11,8 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
|||
const { backupId, stream: backupStream } = generateBackup({
|
||||
aci: phone.device.aci,
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
masterKey: phone.masterKey,
|
||||
accountEntropyPool: phone.accountEntropyPool,
|
||||
mediaRootBackupKey: phone.mediaRootBackupKey,
|
||||
conversations: 1000,
|
||||
messages: 60 * 1000,
|
||||
});
|
||||
|
|
|
@ -35,7 +35,6 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
|
|||
|
||||
state = state.updateAccount({
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
e164: phone.device.number,
|
||||
givenName: phone.profileName,
|
||||
readReceipts: true,
|
||||
hasCompletedUsernameOnboarding: true,
|
||||
|
|
|
@ -114,6 +114,8 @@ export type BootstrapOptions = Readonly<{
|
|||
unknownContactCount?: number;
|
||||
contactNames?: ReadonlyArray<string>;
|
||||
contactPreKeyCount?: number;
|
||||
|
||||
useLegacyStorageEncryption?: boolean;
|
||||
}>;
|
||||
|
||||
export type EphemeralBackupType = Readonly<{
|
||||
|
@ -252,6 +254,9 @@ export class Bootstrap {
|
|||
contacts: this.contacts,
|
||||
contactsWithoutProfileKey: this.contactsWithoutProfileKey,
|
||||
});
|
||||
if (this.options.useLegacyStorageEncryption) {
|
||||
this.privPhone.storageRecordIkm = undefined;
|
||||
}
|
||||
|
||||
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-'));
|
||||
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -34,7 +34,6 @@ describe('senderKey', function (this: Mocha.Suite) {
|
|||
|
||||
state = state.updateAccount({
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
e164: phone.device.number,
|
||||
givenName: phone.profileName,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -71,7 +71,6 @@ export async function initStorage(
|
|||
|
||||
state = state.updateAccount({
|
||||
profileKey: phone.profileKey.serialize(),
|
||||
e164: phone.device.number,
|
||||
givenName: phone.profileName,
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ export type StandardAttachmentBackupJobType = {
|
|||
|
||||
export type ThumbnailAttachmentBackupJobType = {
|
||||
type: 'thumbnail';
|
||||
mediaName: string;
|
||||
mediaName: `${string}_thumbnail`;
|
||||
receivedAt: number;
|
||||
data: {
|
||||
fullsizePath: string | null;
|
||||
|
@ -47,6 +47,7 @@ export type ThumbnailAttachmentBackupJobType = {
|
|||
|
||||
const standardBackupJobDataSchema = z.object({
|
||||
type: z.literal('standard'),
|
||||
mediaName: z.string(),
|
||||
data: z.object({
|
||||
path: z.string(),
|
||||
size: z.number(),
|
||||
|
@ -66,8 +67,15 @@ const standardBackupJobDataSchema = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
const thumbnailMediaNameSchema = z
|
||||
.string()
|
||||
.refine((mediaName: string): mediaName is `${string}_thumbnail` => {
|
||||
return mediaName.endsWith('_thumbnail');
|
||||
});
|
||||
|
||||
const thumbnailBackupJobDataSchema = z.object({
|
||||
type: z.literal('thumbnail'),
|
||||
mediaName: thumbnailMediaNameSchema,
|
||||
data: z.object({
|
||||
fullsizePath: z.string(),
|
||||
fullsizeSize: z.number(),
|
||||
|
@ -79,7 +87,6 @@ const thumbnailBackupJobDataSchema = z.object({
|
|||
|
||||
export const attachmentBackupJobSchema = z
|
||||
.object({
|
||||
mediaName: z.string(),
|
||||
receivedAt: z.number(),
|
||||
})
|
||||
.and(
|
||||
|
@ -101,7 +108,7 @@ export const attachmentBackupJobSchema = z
|
|||
>;
|
||||
|
||||
export const thumbnailBackupJobRecordSchema = z.object({
|
||||
mediaName: z.string(),
|
||||
mediaName: thumbnailMediaNameSchema,
|
||||
type: z.literal('standard'),
|
||||
json: thumbnailBackupJobDataSchema.omit({ type: true }),
|
||||
});
|
||||
|
|
17
ts/types/Storage.d.ts
vendored
17
ts/types/Storage.d.ts
vendored
|
@ -17,7 +17,7 @@ import type {
|
|||
SessionResetsType,
|
||||
StorageServiceCredentials,
|
||||
} from '../textsecure/Types.d';
|
||||
import type { BackupCredentialType } from './backups';
|
||||
import type { BackupCredentialWrapperType } from './backups';
|
||||
import type { ServiceIdString } from './ServiceId';
|
||||
|
||||
import type { RegisteredChallengeType } from '../challenge';
|
||||
|
@ -87,8 +87,10 @@ export type StorageAccessType = {
|
|||
lastResortKeyUpdateTime: number;
|
||||
lastResortKeyUpdateTimePNI: number;
|
||||
localDeleteWarningShown: boolean;
|
||||
accountEntropyPool: string;
|
||||
masterKey: string;
|
||||
masterKeyLastRequestTime: number;
|
||||
|
||||
accountEntropyPoolLastRequestTime: number;
|
||||
maxPreKeyId: number;
|
||||
maxPreKeyIdPNI: number;
|
||||
maxKyberPreKeyId: number;
|
||||
|
@ -129,6 +131,7 @@ export type StorageAccessType = {
|
|||
storageFetchComplete: boolean;
|
||||
avatarUrl: string | undefined;
|
||||
manifestVersion: number;
|
||||
manifestRecordIkm: Uint8Array;
|
||||
storageCredentials: StorageServiceCredentials;
|
||||
'storage-service-error-records': ReadonlyArray<UnknownRecord>;
|
||||
'storage-service-unknown-records': ReadonlyArray<UnknownRecord>;
|
||||
|
@ -141,14 +144,16 @@ export type StorageAccessType = {
|
|||
unidentifiedDeliveryIndicators: boolean;
|
||||
groupCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
backupCredentials: ReadonlyArray<BackupCredentialType>;
|
||||
backupCredentialsLastRequestTime: number;
|
||||
backupCombinedCredentials: ReadonlyArray<BackupCredentialWrapperType>;
|
||||
backupCombinedCredentialsLastRequestTime: number;
|
||||
backupMediaRootKey: Uint8Array;
|
||||
backupMediaDownloadTotalBytes: number;
|
||||
backupMediaDownloadCompletedBytes: number;
|
||||
backupMediaDownloadPaused: boolean;
|
||||
backupMediaDownloadBannerDismissed: boolean;
|
||||
backupMediaDownloadIdle: boolean;
|
||||
setBackupSignatureKey: boolean;
|
||||
setBackupMessagesSignatureKey: boolean;
|
||||
setBackupMediaSignatureKey: boolean;
|
||||
lastReceivedAtCounter: number;
|
||||
preferredReactionEmoji: ReadonlyArray<string>;
|
||||
skinTone: number;
|
||||
|
@ -185,6 +190,7 @@ export type StorageAccessType = {
|
|||
observedCapabilities: {
|
||||
deleteSync?: true;
|
||||
versionedExpirationTimer?: true;
|
||||
ssre2?: true;
|
||||
|
||||
// Note: Upon capability deprecation - change the value type to `never` and
|
||||
// remove it in `ts/background.ts`
|
||||
|
@ -212,6 +218,7 @@ export type StorageAccessType = {
|
|||
sendEditWarningShown: never;
|
||||
formattingWarningShown: never;
|
||||
hasRegisterSupportForUnauthenticatedDelivery: never;
|
||||
masterKeyLastRequestTime: never;
|
||||
};
|
||||
|
||||
export type StorageInterface = {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue