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