From b6445a6af068c9121787191992019d1b03016325 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 14 Jul 2023 09:53:20 -0700 Subject: [PATCH] Support for loading, storing, and using kyber keys in decryption --- package.json | 4 +- protos/SignalService.proto | 6 +- ts/Curve.ts | 35 + ts/LibSignalStores.ts | 59 +- ts/SignalProtocolStore.ts | 401 +++++++- ts/background.ts | 28 +- ts/jobs/removeStorageKeyJobQueue.ts | 7 +- ts/models/messages.ts | 12 - ts/sql/Client.ts | 38 + ts/sql/Interface.ts | 54 +- ts/sql/Server.ts | 57 +- ts/sql/migrations/85-add-kyber-keys.ts | 42 + ts/sql/migrations/index.ts | 3 + ts/sql/util.ts | 1 + ts/test-electron/SignalProtocolStore_test.ts | 10 +- .../textsecure/AccountManager_test.ts | 554 +++++++++-- .../textsecure/generate_keys_test.ts | 156 ++- ts/test-mock/bootstrap.ts | 23 +- ts/test-mock/messaging/edit_test.ts | 5 +- ts/test-mock/messaging/sender_key_test.ts | 5 +- ts/test-mock/messaging/stories_test.ts | 5 +- .../messaging/unknown_contact_test.ts | 5 +- ts/test-mock/pnp/accept_gv2_invite_test.ts | 5 +- ts/test-mock/pnp/change_number_test.ts | 5 +- ts/test-mock/pnp/merge_test.ts | 5 +- ts/test-mock/pnp/pni_change_test.ts | 5 +- ts/test-mock/pnp/pni_signature_test.ts | 5 +- ts/test-mock/pnp/send_gv2_invite_test.ts | 5 +- ts/test-mock/pnp/username_test.ts | 5 +- ts/test-mock/rate-limit/story_test.ts | 5 +- ts/test-mock/rate-limit/viewed_test.ts | 5 +- ts/test-mock/storage/archive_test.ts | 5 +- ts/test-mock/storage/drop_test.ts | 5 +- ts/test-mock/storage/max_read_keys_test.ts | 5 +- ts/test-mock/storage/message_request_test.ts | 5 +- ts/test-mock/storage/pin_unpin_test.ts | 5 +- ts/test-mock/storage/sticker_test.ts | 5 +- ts/test-node/sql_migrations_test.ts | 52 + ts/textsecure/AccountManager.ts | 918 ++++++++++++------ ts/textsecure/Errors.ts | 9 - ts/textsecure/MessageReceiver.ts | 36 +- ts/textsecure/SendMessage.ts | 149 +-- ts/textsecure/Types.d.ts | 2 + ...reKeyListener.ts => UpdateKeysListener.ts} | 61 +- ts/textsecure/WebAPI.ts | 181 ++-- ts/textsecure/getKeysForIdentifier.ts | 17 +- ts/types/Storage.d.ts | 15 +- ts/util/sendToGroup.ts | 15 +- yarn.lock | 26 +- 49 files changed, 2260 insertions(+), 806 deletions(-) create mode 100644 ts/sql/migrations/85-add-kyber-keys.ts rename ts/textsecure/{RotateSignedPreKeyListener.ts => UpdateKeysListener.ts} (56%) diff --git a/package.json b/package.json index 7be8eeed3342..b69574a0e57c 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "@popperjs/core": "2.11.6", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "8.4.3", - "@signalapp/libsignal-client": "0.22.0", + "@signalapp/libsignal-client": "0.27.0", "@signalapp/ringrtc": "2.29.0", "@types/fabric": "4.5.3", "backbone": "1.4.0", @@ -189,7 +189,7 @@ "@electron/fuses": "1.5.0", "@formatjs/intl": "2.6.7", "@mixer/parallel-prettier": "2.0.3", - "@signalapp/mock-server": "3.0.1", + "@signalapp/mock-server": "3.1.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 95021b712682..aa49b1e40a59 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -587,9 +587,11 @@ message SyncMessage { message PniChangeNumber { optional bytes identityKeyPair = 1; // Serialized libsignal-client IdentityKeyPair - optional bytes signedPreKey = 2; // Serialized libsignal-client SignedPreKeyRecord + optional bytes signedPreKey = 2; // Serialized libsignal-client SignedPreKeyRecord + optional bytes lastResortKyberPreKey = 5; // Serialized libsignal-client KyberPreKeyRecord optional uint32 registrationId = 3; - optional string newE164 = 4; // The e164 we have changed our number to + optional string newE164 = 4; // The e164 we have changed our number to + // Next ID: 6 } message CallEvent { diff --git a/ts/Curve.ts b/ts/Curve.ts index fc8b3bac35de..c3cddc7637c9 100644 --- a/ts/Curve.ts +++ b/ts/Curve.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as client from '@signalapp/libsignal-client'; +import type { KyberPreKeyRecord } from '@signalapp/libsignal-client'; import * as Bytes from './Bytes'; import { constantTimeEqual } from './Crypto'; @@ -59,6 +60,40 @@ export function generatePreKey(keyId: number): CompatPreKeyType { }; } +export function generateKyberPreKey( + identityKeyPair: KeyPairType, + keyId: number +): KyberPreKeyRecord { + if (!isNonNegativeInteger(keyId)) { + throw new TypeError( + `generateKyberPreKey: Invalid argument for keyId: ${keyId}` + ); + } + + if ( + !(identityKeyPair.privKey instanceof Uint8Array) || + identityKeyPair.privKey.byteLength !== 32 || + !(identityKeyPair.pubKey instanceof Uint8Array) || + identityKeyPair.pubKey.byteLength !== 33 + ) { + throw new TypeError( + 'generateKyberPreKey: Invalid argument for identityKeyPair' + ); + } + + const keyPair = client.KEMKeyPair.generate(); + const signature = calculateSignature( + identityKeyPair.privKey, + keyPair.getPublicKey().serialize() + ); + return client.KyberPreKeyRecord.new( + keyId, + Date.now(), + keyPair, + Buffer.from(signature) + ); +} + export function generateKeyPair(): KeyPairType { const privKey = client.PrivateKey.generate(); const pubKey = privKey.getPublicKey(); diff --git a/ts/LibSignalStores.ts b/ts/LibSignalStores.ts index 06297dc4235a..1cb8ed5cd5ec 100644 --- a/ts/LibSignalStores.ts +++ b/ts/LibSignalStores.ts @@ -7,6 +7,7 @@ import { isNumber } from 'lodash'; import type { Direction, + KyberPreKeyRecord, PreKeyRecord, ProtocolAddress, SenderKeyRecord, @@ -16,6 +17,7 @@ import type { } from '@signalapp/libsignal-client'; import { IdentityKeyStore, + KyberPreKeyStore, PreKeyStore, PrivateKey, PublicKey, @@ -23,7 +25,6 @@ import { SessionStore, SignedPreKeyStore, } from '@signalapp/libsignal-client'; -import { freezePreKey, freezeSignedPreKey } from './SignalProtocolStore'; import { Address } from './types/Address'; import { QualifiedAddress } from './types/QualifiedAddress'; import type { UUID } from './types/UUID'; @@ -187,12 +188,8 @@ export class PreKeys extends PreKeyStore { this.ourUuid = ourUuid; } - async savePreKey(id: number, record: PreKeyRecord): Promise { - await window.textsecure.storage.protocol.storePreKey( - this.ourUuid, - id, - freezePreKey(record) - ); + async savePreKey(): Promise { + throw new Error('savePreKey: Should not be called by libsignal!'); } async getPreKey(id: number): Promise { @@ -209,7 +206,41 @@ export class PreKeys extends PreKeyStore { } async removePreKey(id: number): Promise { - await window.textsecure.storage.protocol.removePreKey(this.ourUuid, id); + await window.textsecure.storage.protocol.removePreKeys(this.ourUuid, [id]); + } +} + +export class KyberPreKeys extends KyberPreKeyStore { + private readonly ourUuid: UUID; + + constructor({ ourUuid }: PreKeysOptions) { + super(); + this.ourUuid = ourUuid; + } + + async saveKyberPreKey(): Promise { + throw new Error('saveKyberPreKey: Should not be called by libsignal!'); + } + + async getKyberPreKey(id: number): Promise { + const kyberPreKey = + await window.textsecure.storage.protocol.loadKyberPreKey( + this.ourUuid, + id + ); + + if (kyberPreKey === undefined) { + throw new Error(`getKyberPreKey: KyberPreKey ${id} not found`); + } + + return kyberPreKey; + } + + async markKyberPreKeyUsed(id: number): Promise { + await window.textsecure.storage.protocol.maybeRemoveKyberPreKey( + this.ourUuid, + id + ); } } @@ -272,16 +303,8 @@ export class SignedPreKeys extends SignedPreKeyStore { this.ourUuid = ourUuid; } - async saveSignedPreKey( - id: number, - record: SignedPreKeyRecord - ): Promise { - await window.textsecure.storage.protocol.storeSignedPreKey( - this.ourUuid, - id, - freezeSignedPreKey(record), - true - ); + async saveSignedPreKey(): Promise { + throw new Error('saveSignedPreKey: Should not be called by libsignal!'); } async getSignedPreKey(id: number): Promise { diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 62ebdf11ef3f..bd2caddfead8 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -9,6 +9,7 @@ import { EventEmitter } from 'events'; import { Direction, IdentityKeyPair, + KyberPreKeyRecord, PreKeyRecord, PrivateKey, PublicKey, @@ -32,6 +33,7 @@ import type { IdentityKeyType, IdentityKeyIdType, KeyPairType, + KyberPreKeyType, OuterSignedPrekeyType, PniKeyMaterialType, PniSignatureMessageType, @@ -46,6 +48,7 @@ import type { SignedPreKeyType, UnprocessedType, UnprocessedUpdateType, + CompatPreKeyType, } from './textsecure/Types.d'; import type { RemoveAllConfiguration } from './types/RemoveAllConfiguration'; import type { UUIDStringType } from './types/UUID'; @@ -57,8 +60,13 @@ import * as log from './logging/log'; import * as Errors from './types/errors'; import { MINUTE } from './util/durations'; import { conversationJobQueue } from './jobs/conversationJobQueue'; +import { + KYBER_KEY_ID_KEY, + SIGNED_PRE_KEY_ID_KEY, +} from './textsecure/AccountManager'; const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds +const LOW_KEYS_THRESHOLD = 25; const VerifiedStatus = { DEFAULT: 0, @@ -103,6 +111,7 @@ type CacheEntryType = | { hydrated: true; fromDB: DBType; item: HydratedType }; type MapFields = + | 'kyberPreKeys' | 'identityKeys' | 'preKeys' | 'senderKeys' @@ -226,6 +235,11 @@ export class SignalProtocolStore extends EventEmitter { CacheEntryType >; + kyberPreKeys?: Map< + PreKeyIdType, + CacheEntryType + >; + senderKeys?: Map; sessions?: Map; @@ -290,6 +304,11 @@ export class SignalProtocolStore extends EventEmitter { 'identityKeys', window.Signal.Data.getAllIdentityKeys() ), + _fillCaches( + this, + 'kyberPreKeys', + window.Signal.Data.getAllKyberPreKeys() + ), _fillCaches( this, 'sessions', @@ -321,6 +340,190 @@ export class SignalProtocolStore extends EventEmitter { return this.ourRegistrationIds.get(ourUuid.toString()); } + private _getKeyId(ourUuid: UUID, keyId: number): PreKeyIdType { + return `${ourUuid.toString()}:${keyId}`; + } + + // KyberPreKeys + + private _getKyberPreKeyEntry( + id: PreKeyIdType, + logContext: string + ): + | { hydrated: true; fromDB: KyberPreKeyType; item: KyberPreKeyRecord } + | undefined { + if (!this.kyberPreKeys) { + throw new Error(`${logContext}: this.kyberPreKeys not yet cached!`); + } + + const entry = this.kyberPreKeys.get(id); + if (!entry) { + log.error(`${logContext}: Failed to fetch kyber prekey: ${id}`); + return undefined; + } + + if (entry.hydrated) { + log.info( + `${logContext}: Successfully fetched kyber prekey (cache hit): ${id}` + ); + return entry; + } + + const item = KyberPreKeyRecord.deserialize(Buffer.from(entry.fromDB.data)); + const newEntry = { + hydrated: true as const, + fromDB: entry.fromDB, + item, + }; + this.kyberPreKeys.set(id, newEntry); + + log.info( + `${logContext}: Successfully fetched kyberPreKey (cache miss): ${id}` + ); + return newEntry; + } + + async loadKyberPreKey( + ourUuid: UUID, + keyId: number + ): Promise { + const id: PreKeyIdType = this._getKeyId(ourUuid, keyId); + const entry = this._getKyberPreKeyEntry(id, 'loadKyberPreKey'); + + return entry?.item; + } + + loadKyberPreKeys( + ourUuid: UUID, + { isLastResort }: { isLastResort: boolean } + ): Array { + if (!this.kyberPreKeys) { + throw new Error('loadKyberPreKeys: this.kyberPreKeys not yet cached!'); + } + + if (arguments.length > 2) { + throw new Error('loadKyberPreKeys takes two arguments'); + } + + const entries = Array.from(this.kyberPreKeys.values()); + return entries + .map(item => item.fromDB) + .filter( + item => + item.ourUuid === ourUuid.toString() && + item.isLastResort === isLastResort + ); + } + + async confirmKyberPreKey(ourUuid: UUID, keyId: number): Promise { + const kyberPreKeyCache = this.kyberPreKeys; + if (!kyberPreKeyCache) { + throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!'); + } + + const id: PreKeyIdType = this._getKeyId(ourUuid, keyId); + const item = kyberPreKeyCache.get(id); + if (!item) { + throw new Error(`confirmKyberPreKey: missing kyber prekey ${id}!`); + } + + const confirmedItem = { + ...item, + fromDB: { + ...item.fromDB, + isConfirmed: true, + }, + }; + + await window.Signal.Data.createOrUpdateKyberPreKey(confirmedItem.fromDB); + kyberPreKeyCache.set(id, confirmedItem); + } + + async storeKyberPreKeys( + ourUuid: UUID, + keys: Array> + ): Promise { + const kyberPreKeyCache = this.kyberPreKeys; + if (!kyberPreKeyCache) { + throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!'); + } + + const toSave: Array = []; + + keys.forEach(key => { + const id: PreKeyIdType = this._getKeyId(ourUuid, key.keyId); + if (kyberPreKeyCache.has(id)) { + throw new Error(`storeKyberPreKey: kyber prekey ${id} already exists!`); + } + + const kyberPreKey = { + id, + + createdAt: key.createdAt, + data: key.data, + isConfirmed: key.isConfirmed, + isLastResort: key.isLastResort, + keyId: key.keyId, + ourUuid: ourUuid.toString(), + }; + + toSave.push(kyberPreKey); + }); + + await window.Signal.Data.bulkAddKyberPreKeys(toSave); + toSave.forEach(kyberPreKey => { + kyberPreKeyCache.set(kyberPreKey.id, { + hydrated: false, + fromDB: kyberPreKey, + }); + }); + } + + async maybeRemoveKyberPreKey(ourUuid: UUID, keyId: number): Promise { + const id: PreKeyIdType = this._getKeyId(ourUuid, keyId); + const entry = this._getKyberPreKeyEntry(id, 'maybeRemoveKyberPreKey'); + + if (!entry) { + return; + } + if (entry.fromDB.isLastResort) { + log.info( + `maybeRemoveKyberPreKey: Not removing kyber prekey ${id}; it's a last resort key` + ); + return; + } + + await this.removeKyberPreKeys(ourUuid, [keyId]); + } + + async removeKyberPreKeys( + ourUuid: UUID, + keyIds: Array + ): Promise { + const kyberPreKeyCache = this.kyberPreKeys; + if (!kyberPreKeyCache) { + throw new Error('removeKyberPreKeys: this.kyberPreKeys not yet cached!'); + } + + const ids = keyIds.map(keyId => this._getKeyId(ourUuid, keyId)); + + await window.Signal.Data.removeKyberPreKeyById(ids); + ids.forEach(id => { + kyberPreKeyCache.delete(id); + }); + + if (kyberPreKeyCache.size < LOW_KEYS_THRESHOLD) { + this.emitLowKeys(ourUuid, `removeKyberPreKeys@${kyberPreKeyCache.size}`); + } + } + + async clearKyberPreKeyStore(): Promise { + if (this.kyberPreKeys) { + this.kyberPreKeys.clear(); + } + await window.Signal.Data.removeAllKyberPreKeys(); + } + // PreKeys async loadPreKey( @@ -331,8 +534,7 @@ export class SignalProtocolStore extends EventEmitter { throw new Error('loadPreKey: this.preKeys not yet cached!'); } - const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`; - + const id: PreKeyIdType = this._getKeyId(ourUuid, keyId); const entry = this.preKeys.get(id); if (!entry) { log.error('Failed to fetch prekey:', id); @@ -354,53 +556,76 @@ export class SignalProtocolStore extends EventEmitter { return item; } - async storePreKey( - ourUuid: UUID, - keyId: number, - keyPair: KeyPairType - ): Promise { + loadPreKeys(ourUuid: UUID): Array { if (!this.preKeys) { + throw new Error('loadPreKeys: this.preKeys not yet cached!'); + } + + if (arguments.length > 1) { + throw new Error('loadPreKeys takes one argument'); + } + + const entries = Array.from(this.preKeys.values()); + return entries + .map(item => item.fromDB) + .filter(item => item.ourUuid === ourUuid.toString()); + } + + async storePreKeys( + ourUuid: UUID, + keys: Array + ): Promise { + const preKeyCache = this.preKeys; + if (!preKeyCache) { throw new Error('storePreKey: this.preKeys not yet cached!'); } - const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`; - if (this.preKeys.has(id)) { - throw new Error(`storePreKey: prekey ${id} already exists!`); - } + const now = Date.now(); + const toSave: Array = []; + keys.forEach(key => { + const id: PreKeyIdType = this._getKeyId(ourUuid, key.keyId); - const fromDB = { - id, - keyId, - ourUuid: ourUuid.toString(), - publicKey: keyPair.pubKey, - privateKey: keyPair.privKey, - }; + if (preKeyCache.has(id)) { + throw new Error(`storePreKeys: prekey ${id} already exists!`); + } - await window.Signal.Data.createOrUpdatePreKey(fromDB); - this.preKeys.set(id, { - hydrated: false, - fromDB, + const preKey = { + id, + keyId: key.keyId, + ourUuid: ourUuid.toString(), + publicKey: key.keyPair.pubKey, + privateKey: key.keyPair.privKey, + createdAt: now, + }; + + toSave.push(preKey); + }); + + await window.Signal.Data.bulkAddPreKeys(toSave); + toSave.forEach(preKey => { + preKeyCache.set(preKey.id, { + hydrated: false, + fromDB: preKey, + }); }); } - async removePreKey(ourUuid: UUID, keyId: number): Promise { - if (!this.preKeys) { - throw new Error('removePreKey: this.preKeys not yet cached!'); + async removePreKeys(ourUuid: UUID, keyIds: Array): Promise { + const preKeyCache = this.preKeys; + if (!preKeyCache) { + throw new Error('removePreKeys: this.preKeys not yet cached!'); } - const id: PreKeyIdType = `${ourUuid.toString()}:${keyId}`; + const ids = keyIds.map(keyId => this._getKeyId(ourUuid, keyId)); - try { - this.emit('removePreKey', ourUuid); - } catch (error) { - log.error( - 'removePreKey error triggering removePreKey:', - Errors.toLogFormat(error) - ); + await window.Signal.Data.removePreKeyById(ids); + ids.forEach(id => { + preKeyCache.delete(id); + }); + + if (preKeyCache.size < LOW_KEYS_THRESHOLD) { + this.emitLowKeys(ourUuid, `removePreKeys@${preKeyCache.size}`); } - - this.preKeys.delete(id); - await window.Signal.Data.removePreKeyById(id); } async clearPreKeyStore(): Promise { @@ -443,9 +668,7 @@ export class SignalProtocolStore extends EventEmitter { return item; } - async loadSignedPreKeys( - ourUuid: UUID - ): Promise> { + loadSignedPreKeys(ourUuid: UUID): Array { if (!this.signedPreKeys) { throw new Error('loadSignedPreKeys: this.signedPreKeys not yet cached!'); } @@ -469,8 +692,30 @@ export class SignalProtocolStore extends EventEmitter { }); } - // Note that this is also called in update scenarios, for confirming that signed prekeys - // have indeed been accepted by the server. + async confirmSignedPreKey(ourUuid: UUID, keyId: number): Promise { + const signedPreKeyCache = this.signedPreKeys; + if (!signedPreKeyCache) { + throw new Error('storeKyberPreKey: this.signedPreKeys not yet cached!'); + } + + const id: PreKeyIdType = this._getKeyId(ourUuid, keyId); + const item = signedPreKeyCache.get(id); + if (!item) { + throw new Error(`confirmSignedPreKey: missing prekey ${id}!`); + } + + const confirmedItem = { + ...item, + fromDB: { + ...item.fromDB, + confirmed: true, + }, + }; + + await window.Signal.Data.createOrUpdateSignedPreKey(confirmedItem.fromDB); + signedPreKeyCache.set(id, confirmedItem); + } + async storeSignedPreKey( ourUuid: UUID, keyId: number, @@ -482,7 +727,7 @@ export class SignalProtocolStore extends EventEmitter { throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); } - const id: SignedPreKeyIdType = `${ourUuid.toString()}:${keyId}`; + const id: SignedPreKeyIdType = this._getKeyId(ourUuid, keyId); const fromDB = { id, @@ -501,14 +746,21 @@ export class SignalProtocolStore extends EventEmitter { }); } - async removeSignedPreKey(ourUuid: UUID, keyId: number): Promise { - if (!this.signedPreKeys) { + async removeSignedPreKeys( + ourUuid: UUID, + keyIds: Array + ): Promise { + const signedPreKeyCache = this.signedPreKeys; + if (!signedPreKeyCache) { throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); } - const id: SignedPreKeyIdType = `${ourUuid.toString()}:${keyId}`; - this.signedPreKeys.delete(id); - await window.Signal.Data.removeSignedPreKeyById(id); + const ids = keyIds.map(keyId => this._getKeyId(ourUuid, keyId)); + + await window.Signal.Data.removeSignedPreKeyById(ids); + ids.forEach(id => { + signedPreKeyCache.delete(id); + }); } async clearSignedPreKeysStore(): Promise { @@ -2187,6 +2439,13 @@ export class SignalProtocolStore extends EventEmitter { } } } + if (this.kyberPreKeys) { + for (const key of this.kyberPreKeys.keys()) { + if (key.startsWith(preKeyPrefix)) { + this.kyberPreKeys.delete(key); + } + } + } // Update database await Promise.all([ @@ -2200,6 +2459,7 @@ export class SignalProtocolStore extends EventEmitter { ), window.Signal.Data.removePreKeysByUuid(oldPni.toString()), window.Signal.Data.removeSignedPreKeysByUuid(oldPni.toString()), + window.Signal.Data.removeKyberPreKeysByUuid(oldPni.toString()), ]); } @@ -2207,11 +2467,13 @@ export class SignalProtocolStore extends EventEmitter { pni: UUID, { identityKeyPair: identityBytes, + lastResortKyberPreKey: lastResortKyberPreKeyBytes, signedPreKey: signedPreKeyBytes, registrationId, }: PniKeyMaterialType ): Promise { - log.info(`SignalProtocolStore.updateOurPniKeyMaterial(${pni})`); + const logId = `SignalProtocolStore.updateOurPniKeyMaterial(${pni})`; + log.info(`${logId}: starting...`); const identityKeyPair = IdentityKeyPair.deserialize( Buffer.from(identityBytes) @@ -2219,6 +2481,9 @@ export class SignalProtocolStore extends EventEmitter { const signedPreKey = SignedPreKeyRecord.deserialize( Buffer.from(signedPreKeyBytes) ); + const lastResortKyberPreKey = lastResortKyberPreKeyBytes + ? KyberPreKeyRecord.deserialize(Buffer.from(lastResortKyberPreKeyBytes)) + : undefined; const { storage } = window; @@ -2245,6 +2510,11 @@ export class SignalProtocolStore extends EventEmitter { ...(storage.get('registrationIdMap') || {}), [pni.toString()]: registrationId, }), + async () => { + const newId = signedPreKey.id() + 1; + log.warn(`${logId}: Updating next signed pre key id to ${newId}`); + await storage.put(SIGNED_PRE_KEY_ID_KEY[UUIDKind.PNI], newId); + }, this.storeSignedPreKey( pni, signedPreKey.id(), @@ -2255,6 +2525,26 @@ export class SignalProtocolStore extends EventEmitter { true, signedPreKey.timestamp() ), + async () => { + if (!lastResortKyberPreKey) { + return; + } + const newId = lastResortKyberPreKey.id() + 1; + log.warn(`${logId}: Updating next kyber pre key id to ${newId}`); + await storage.put(KYBER_KEY_ID_KEY[UUIDKind.PNI], newId); + }, + lastResortKyberPreKeyBytes && lastResortKyberPreKey + ? this.storeKyberPreKeys(pni, [ + { + createdAt: lastResortKyberPreKey.timestamp(), + data: lastResortKyberPreKeyBytes, + isConfirmed: true, + isLastResort: true, + keyId: lastResortKyberPreKey.id(), + ourUuid: pni.toString(), + }, + ]) + : undefined, ]); } @@ -2354,12 +2644,23 @@ export class SignalProtocolStore extends EventEmitter { return Array.from(union.values()); } + + private emitLowKeys(ourUuid: UUID, source: string) { + const logId = `SignalProtocolStore.emitLowKeys/${source}:`; + try { + log.info(`${logId}: Emitting event`); + this.emit('lowKeys', ourUuid); + } catch (error) { + log.error(`${logId}: Error thrown from emit`, Errors.toLogFormat(error)); + } + } + // // EventEmitter types // public override on( - name: 'removePreKey', + name: 'lowKeys', handler: (ourUuid: UUID) => unknown ): this; @@ -2378,7 +2679,7 @@ export class SignalProtocolStore extends EventEmitter { return super.on(eventName, listener); } - public override emit(name: 'removePreKey', ourUuid: UUID): boolean; + public override emit(name: 'lowKeys', ourUuid: UUID): boolean; public override emit( name: 'keychange', diff --git a/ts/background.ts b/ts/background.ts index 5af76d3748e0..c26b2d7e0a2b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -107,7 +107,7 @@ import type { } from './textsecure/messageReceiverEvents'; import type { WebAPIType } from './textsecure/WebAPI'; import * as KeyChangeListener from './textsecure/KeyChangeListener'; -import { RotateSignedPreKeyListener } from './textsecure/RotateSignedPreKeyListener'; +import { UpdateKeysListener } from './textsecure/UpdateKeysListener'; import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation'; import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff'; import { AppViewType } from './state/ducks/app'; @@ -599,10 +599,17 @@ export async function startApp(): Promise { ); KeyChangeListener.init(window.textsecure.storage.protocol); - window.textsecure.storage.protocol.on('removePreKey', (ourUuid: UUID) => { - const uuidKind = window.textsecure.storage.user.getOurUuidKind(ourUuid); - void window.getAccountManager().refreshPreKeys(uuidKind); - }); + window.textsecure.storage.protocol.on( + 'lowKeys', + throttle( + (ourUuid: UUID) => { + const uuidKind = window.textsecure.storage.user.getOurUuidKind(ourUuid); + drop(window.getAccountManager().maybeUpdateKeys(uuidKind)); + }, + durations.MINUTE, + { trailing: true, leading: false } + ) + ); window.textsecure.storage.protocol.on('removeAllData', () => { window.reduxActions.stories.removeAllStories(); @@ -876,6 +883,15 @@ export async function startApp(): Promise { await window.storage.remove('remoteBuildExpiration'); } + if (window.isBeforeVersion(lastVersion, '6.25.0-alpha')) { + await removeStorageKeyJobQueue.add({ + key: 'nextSignedKeyRotationTime', + }); + await removeStorageKeyJobQueue.add({ + key: 'signedKeyRotationRejected', + }); + } + if (window.isBeforeVersion(lastVersion, '6.22.0-alpha')) { const formattingWarningShown = window.storage.get( 'formattingWarningShown', @@ -2079,7 +2095,7 @@ export async function startApp(): Promise { window.ConversationController.onEmpty(); // Start listeners here, after we get through our queue. - RotateSignedPreKeyListener.init(window.Whisper.events, newVersion); + UpdateKeysListener.init(window.Whisper.events, newVersion); profileKeyResponseQueue.start(); lightSessionResetQueue.start(); diff --git a/ts/jobs/removeStorageKeyJobQueue.ts b/ts/jobs/removeStorageKeyJobQueue.ts index eb5ccb7528b9..8b3e86fc322c 100644 --- a/ts/jobs/removeStorageKeyJobQueue.ts +++ b/ts/jobs/removeStorageKeyJobQueue.ts @@ -7,7 +7,12 @@ import { JobQueue } from './JobQueue'; import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; const removeStorageKeyJobDataSchema = z.object({ - key: z.enum(['senderCertificateWithUuid', 'challenge:retry-message-ids']), + key: z.enum([ + 'challenge:retry-message-ids', + 'nextSignedKeyRotationTime', + 'senderCertificateWithUuid', + 'signedKeyRotationRejected', + ]), }); type RemoveStorageKeyJobData = z.infer; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 2aa22ad0d13f..a545ffc93801 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1406,7 +1406,6 @@ export class MessageModel extends window.Backbone.Model { e.name === 'OutgoingMessageError' || e.name === 'SendMessageNetworkError' || e.name === 'SendMessageChallengeError' || - e.name === 'SignedPreKeyRotationError' || e.name === 'OutgoingIdentityKeyError' ); } @@ -1472,7 +1471,6 @@ export class MessageModel extends window.Backbone.Model { e.name === 'OutgoingMessageError' || e.name === 'SendMessageNetworkError' || e.name === 'SendMessageChallengeError' || - e.name === 'SignedPreKeyRotationError' || e.name === 'OutgoingIdentityKeyError') ); this.set({ errors: errors[1] }); @@ -1591,7 +1589,6 @@ export class MessageModel extends window.Backbone.Model { // screen will show that we didn't send to these unregistered users. const errorsToSave: Array = []; - let hadSignedPreKeyRotationError = false; errors.forEach(error => { const conversation = window.ConversationController.get(error.identifier) || @@ -1616,9 +1613,6 @@ export class MessageModel extends window.Backbone.Model { let shouldSaveError = true; switch (error.name) { - case 'SignedPreKeyRotationError': - hadSignedPreKeyRotationError = true; - break; case 'OutgoingIdentityKeyError': { if (conversation) { promises.push(conversation.getProfiles()); @@ -1648,12 +1642,6 @@ export class MessageModel extends window.Backbone.Model { } }); - if (hadSignedPreKeyRotationError) { - promises.push( - window.getAccountManager().rotateSignedPreKey(UUIDKind.ACI) - ); - } - attributesToUpdate.sendStateByConversationId = sendStateByConversationId; // Only update the expirationStartTimestamp if we don't already have one set if (!this.get('expirationStartTimestamp')) { diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 0fb24ad4e47c..bc16bddff859 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -52,6 +52,8 @@ import type { SignedPreKeyIdType, SignedPreKeyType, StoredSignedPreKeyType, + KyberPreKeyType, + StoredKyberPreKeyType, } from './Interface'; import { MINUTE } from '../util/durations'; import { getMessageIdForLogging } from '../util/idForLogging'; @@ -73,6 +75,11 @@ const exclusiveInterface: ClientExclusiveInterface = { bulkAddIdentityKeys, getAllIdentityKeys, + createOrUpdateKyberPreKey, + getKyberPreKeyById, + bulkAddKyberPreKeys, + getAllKyberPreKeys, + createOrUpdatePreKey, getPreKeyById, bulkAddPreKeys, @@ -248,6 +255,37 @@ async function getAllIdentityKeys(): Promise> { return keys.map(key => specToBytes(IDENTITY_KEY_SPEC, key)); } +// Kyber Pre Keys + +const KYBER_PRE_KEY_SPEC = ['data']; +async function createOrUpdateKyberPreKey(data: KyberPreKeyType): Promise { + const updated: StoredKyberPreKeyType = specFromBytes( + KYBER_PRE_KEY_SPEC, + data + ); + await channels.createOrUpdateKyberPreKey(updated); +} +async function getKyberPreKeyById( + id: PreKeyIdType +): Promise { + const data = await channels.getPreKeyById(id); + + return specToBytes(KYBER_PRE_KEY_SPEC, data); +} +async function bulkAddKyberPreKeys( + array: Array +): Promise { + const updated: Array = map(array, data => + specFromBytes(KYBER_PRE_KEY_SPEC, data) + ); + await channels.bulkAddKyberPreKeys(updated); +} +async function getAllKyberPreKeys(): Promise> { + const keys = await channels.getAllPreKeys(); + + return keys.map(key => specToBytes(KYBER_PRE_KEY_SPEC, key)); +} + // Pre Keys async function createOrUpdatePreKey(data: PreKeyType): Promise { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 96173ac23723..43af041ba720 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -109,21 +109,35 @@ export type MessageType = MessageAttributesType; export type MessageTypeUnhydrated = { json: string; }; + +export type PreKeyIdType = `${UUIDStringType}:${number}`; +export type KyberPreKeyType = { + id: PreKeyIdType; + + createdAt: number; + data: Uint8Array; + isConfirmed: boolean; + isLastResort: boolean; + keyId: number; + ourUuid: UUIDStringType; +}; +export type StoredKyberPreKeyType = KyberPreKeyType & { + data: string; +}; export type PreKeyType = { - id: `${UUIDStringType}:${number}`; + id: PreKeyIdType; + + createdAt: number; keyId: number; ourUuid: UUIDStringType; privateKey: Uint8Array; publicKey: Uint8Array; }; -export type StoredPreKeyType = { - id: `${UUIDStringType}:${number}`; - keyId: number; - ourUuid: UUIDStringType; + +export type StoredPreKeyType = PreKeyType & { privateKey: string; publicKey: string; }; -export type PreKeyIdType = PreKeyType['id']; export type ServerSearchResultMessageType = { json: string; @@ -410,16 +424,24 @@ export type DataInterface = { removeIdentityKeyById: (id: IdentityKeyIdType) => Promise; removeAllIdentityKeys: () => Promise; - removePreKeyById: (id: PreKeyIdType) => Promise; + removeKyberPreKeyById: ( + id: PreKeyIdType | Array + ) => Promise; + removeKyberPreKeysByUuid: (uuid: UUIDStringType) => Promise; + removeAllKyberPreKeys: () => Promise; + + removePreKeyById: (id: PreKeyIdType | Array) => Promise; removePreKeysByUuid: (uuid: UUIDStringType) => Promise; removeAllPreKeys: () => Promise; - removeSignedPreKeyById: (id: SignedPreKeyIdType) => Promise; + removeSignedPreKeyById: ( + id: SignedPreKeyIdType | Array + ) => Promise; removeSignedPreKeysByUuid: (uuid: UUIDStringType) => Promise; removeAllSignedPreKeys: () => Promise; removeAllItems: () => Promise; - removeItemById: (id: ItemKeyType) => Promise; + removeItemById: (id: ItemKeyType | Array) => Promise; createOrUpdateSenderKey: (key: SenderKeyType) => Promise; getSenderKeyById: (id: SenderKeyIdType) => Promise; @@ -822,6 +844,13 @@ export type ServerInterface = DataInterface & { bulkAddIdentityKeys: (array: Array) => Promise; getAllIdentityKeys: () => Promise>; + createOrUpdateKyberPreKey: (data: StoredKyberPreKeyType) => Promise; + getKyberPreKeyById: ( + id: PreKeyIdType + ) => Promise; + bulkAddKyberPreKeys: (array: Array) => Promise; + getAllKyberPreKeys: () => Promise>; + createOrUpdatePreKey: (data: StoredPreKeyType) => Promise; getPreKeyById: (id: PreKeyIdType) => Promise; bulkAddPreKeys: (array: Array) => Promise; @@ -901,6 +930,13 @@ export type ClientExclusiveInterface = { bulkAddIdentityKeys: (array: Array) => Promise; getAllIdentityKeys: () => Promise>; + createOrUpdateKyberPreKey: (data: KyberPreKeyType) => Promise; + getKyberPreKeyById: ( + id: PreKeyIdType + ) => Promise; + bulkAddKyberPreKeys: (array: Array) => Promise; + getAllKyberPreKeys: () => Promise>; + createOrUpdatePreKey: (data: PreKeyType) => Promise; getPreKeyById: (id: PreKeyIdType) => Promise; bulkAddPreKeys: (array: Array) => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index f6912f766eeb..9a4300036be3 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -133,6 +133,7 @@ import type { UnprocessedType, UnprocessedUpdateType, GetNearbyMessageFromDeletedSetOptionsType, + StoredKyberPreKeyType, } from './Interface'; import { SeenStatus } from '../MessageSeenStatus'; import { @@ -173,6 +174,14 @@ const dataInterface: ServerInterface = { removeAllIdentityKeys, getAllIdentityKeys, + createOrUpdateKyberPreKey, + getKyberPreKeyById, + bulkAddKyberPreKeys, + removeKyberPreKeyById, + removeKyberPreKeysByUuid, + removeAllKyberPreKeys, + getAllKyberPreKeys, + createOrUpdatePreKey, getPreKeyById, bulkAddPreKeys, @@ -655,6 +664,40 @@ async function getAllIdentityKeys(): Promise> { return getAllFromTable(getInstance(), IDENTITY_KEYS_TABLE); } +const KYBER_PRE_KEYS_TABLE = 'kyberPreKeys'; +async function createOrUpdateKyberPreKey( + data: StoredKyberPreKeyType +): Promise { + return createOrUpdate(getInstance(), KYBER_PRE_KEYS_TABLE, data); +} +async function getKyberPreKeyById( + id: PreKeyIdType +): Promise { + return getById(getInstance(), KYBER_PRE_KEYS_TABLE, id); +} +async function bulkAddKyberPreKeys( + array: Array +): Promise { + return bulkAdd(getInstance(), KYBER_PRE_KEYS_TABLE, array); +} +async function removeKyberPreKeyById( + id: PreKeyIdType | Array +): Promise { + return removeById(getInstance(), KYBER_PRE_KEYS_TABLE, id); +} +async function removeKyberPreKeysByUuid(uuid: UUIDStringType): Promise { + const db = getInstance(); + db.prepare('DELETE FROM kyberPreKeys WHERE ourUuid IS $uuid;').run({ + uuid, + }); +} +async function removeAllKyberPreKeys(): Promise { + return removeAllFromTable(getInstance(), KYBER_PRE_KEYS_TABLE); +} +async function getAllKyberPreKeys(): Promise> { + return getAllFromTable(getInstance(), KYBER_PRE_KEYS_TABLE); +} + const PRE_KEYS_TABLE = 'preKeys'; async function createOrUpdatePreKey(data: StoredPreKeyType): Promise { return createOrUpdate(getInstance(), PRE_KEYS_TABLE, data); @@ -667,7 +710,9 @@ async function getPreKeyById( async function bulkAddPreKeys(array: Array): Promise { return bulkAdd(getInstance(), PRE_KEYS_TABLE, array); } -async function removePreKeyById(id: PreKeyIdType): Promise { +async function removePreKeyById( + id: PreKeyIdType | Array +): Promise { return removeById(getInstance(), PRE_KEYS_TABLE, id); } async function removePreKeysByUuid(uuid: UUIDStringType): Promise { @@ -699,7 +744,9 @@ async function bulkAddSignedPreKeys( ): Promise { return bulkAdd(getInstance(), SIGNED_PRE_KEYS_TABLE, array); } -async function removeSignedPreKeyById(id: SignedPreKeyIdType): Promise { +async function removeSignedPreKeyById( + id: SignedPreKeyIdType | Array +): Promise { return removeById(getInstance(), SIGNED_PRE_KEYS_TABLE, id); } async function removeSignedPreKeysByUuid(uuid: UUIDStringType): Promise { @@ -755,7 +802,9 @@ async function getAllItems(): Promise { return result as unknown as StoredAllItemsType; } -async function removeItemById(id: ItemKeyType): Promise { +async function removeItemById( + id: ItemKeyType | Array +): Promise { return removeById(getInstance(), ITEMS_TABLE, id); } async function removeAllItems(): Promise { @@ -4989,6 +5038,7 @@ async function removeAll(): Promise { DELETE FROM identityKeys; DELETE FROM items; DELETE FROM jobs; + DELETE FROM kyberPreKeys; DELETE FROM messages_fts; DELETE FROM messages; DELETE FROM preKeys; @@ -5024,6 +5074,7 @@ async function removeAllConfiguration( ` DELETE FROM identityKeys; DELETE FROM jobs; + DELETE FROM kyberPreKeys; DELETE FROM preKeys; DELETE FROM senderKeys; DELETE FROM sendLogMessageIds; diff --git a/ts/sql/migrations/85-add-kyber-keys.ts b/ts/sql/migrations/85-add-kyber-keys.ts new file mode 100644 index 000000000000..740388655cec --- /dev/null +++ b/ts/sql/migrations/85-add-kyber-keys.ts @@ -0,0 +1,42 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion85( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 85) { + return; + } + + db.transaction(() => { + db.exec( + `CREATE TABLE kyberPreKeys( + id STRING PRIMARY KEY NOT NULL, + json TEXT NOT NULL, + ourUuid STRING + GENERATED ALWAYS AS (json_extract(json, '$.ourUuid')) + );` + ); + + // To manage our ACI or PNI keys quickly + db.exec('CREATE INDEX kyberPreKeys_ourUuid ON kyberPreKeys (ourUuid);'); + + // Add time to all existing preKeys to allow us to expire them + const now = Date.now(); + db.exec( + `UPDATE preKeys SET + json = json_set(json, '$.createdAt', ${now}); + ` + ); + + db.pragma('user_version = 85'); + })(); + + logger.info('updateToSchemaVersion85: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 6886b5e93ab4..35907077d15c 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -60,6 +60,7 @@ import updateToSchemaVersion81 from './81-contact-removed-notification'; import updateToSchemaVersion82 from './82-edited-messages-read-index'; import updateToSchemaVersion83 from './83-mentions'; import updateToSchemaVersion84 from './84-all-mentions'; +import updateToSchemaVersion85 from './85-add-kyber-keys'; function updateToSchemaVersion1( currentVersion: number, @@ -1984,11 +1985,13 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion77, updateToSchemaVersion78, updateToSchemaVersion79, + updateToSchemaVersion80, updateToSchemaVersion81, updateToSchemaVersion82, updateToSchemaVersion83, updateToSchemaVersion84, + updateToSchemaVersion85, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/sql/util.ts b/ts/sql/util.ts index e9d85455f3c8..b55f17c1e27b 100644 --- a/ts/sql/util.ts +++ b/ts/sql/util.ts @@ -16,6 +16,7 @@ export type TableType = | 'conversations' | 'identityKeys' | 'items' + | 'kyberPreKeys' | 'messages' | 'preKeys' | 'senderKeys' diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index 5d46ca4bd6ae..1e927cb79c89 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -930,7 +930,7 @@ describe('SignalProtocolStore', () => { }); describe('storePreKey', () => { it('stores prekeys', async () => { - await store.storePreKey(ourUuid, 1, testKey); + await store.storePreKeys(ourUuid, [{ keyId: 1, keyPair: testKey }]); const key = await store.loadPreKey(ourUuid, 1); if (!key) { throw new Error('Missing key!'); @@ -947,10 +947,10 @@ describe('SignalProtocolStore', () => { }); describe('removePreKey', () => { before(async () => { - await store.storePreKey(ourUuid, 2, testKey); + await store.storePreKeys(ourUuid, [{ keyId: 2, keyPair: testKey }]); }); it('deletes prekeys', async () => { - await store.removePreKey(ourUuid, 2); + await store.removePreKeys(ourUuid, [2]); const key = await store.loadPreKey(ourUuid, 2); assert.isUndefined(key); @@ -978,7 +978,7 @@ describe('SignalProtocolStore', () => { await store.storeSignedPreKey(ourUuid, 4, testKey); }); it('deletes signed prekeys', async () => { - await store.removeSignedPreKey(ourUuid, 4); + await store.removeSignedPreKeys(ourUuid, [4]); const key = await store.loadSignedPreKey(ourUuid, 4); assert.isUndefined(key); @@ -1557,7 +1557,7 @@ describe('SignalProtocolStore', () => { }); describe('removeOurOldPni/updateOurPniKeyMaterial', () => { beforeEach(async () => { - await store.storePreKey(ourUuid, 2, testKey); + await store.storePreKeys(ourUuid, [{ keyId: 2, keyPair: testKey }]); await store.storeSignedPreKey(ourUuid, 3, testKey); }); diff --git a/ts/test-electron/textsecure/AccountManager_test.ts b/ts/test-electron/textsecure/AccountManager_test.ts index a91eeee04516..654db7ace162 100644 --- a/ts/test-electron/textsecure/AccountManager_test.ts +++ b/ts/test-electron/textsecure/AccountManager_test.ts @@ -2,84 +2,98 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { range } from 'lodash'; import { getRandomBytes } from '../../Crypto'; import AccountManager from '../../textsecure/AccountManager'; -import type { OuterSignedPrekeyType } from '../../textsecure/Types.d'; +import type { + KyberPreKeyType, + OuterSignedPrekeyType, + PreKeyType, +} from '../../textsecure/Types.d'; import { UUID, UUIDKind } from '../../types/UUID'; +import { DAY } from '../../util/durations'; /* eslint-disable @typescript-eslint/no-explicit-any */ describe('AccountManager', () => { let accountManager: AccountManager; + const ourUuid = UUID.generate(); + const identityKey = window.Signal.Curve.generateKeyPair(); + const pubKey = getRandomBytes(33); + const privKey = getRandomBytes(32); + + let originalGetIdentityKeyPair: any; + let originalGetUuid: any; + let originalGetCheckedUuid: any; + beforeEach(() => { const server: any = {}; accountManager = new AccountManager(server); + + originalGetIdentityKeyPair = + window.textsecure.storage.protocol.getIdentityKeyPair; + originalGetUuid = window.textsecure.storage.user.getUuid; + originalGetCheckedUuid = window.textsecure.storage.user.getCheckedUuid; + + window.textsecure.storage.protocol.getIdentityKeyPair = () => identityKey; + window.textsecure.storage.user.getUuid = () => ourUuid; + window.textsecure.storage.user.getCheckedUuid = () => ourUuid; }); - describe('#cleanSignedPreKeys', () => { - let originalGetIdentityKeyPair: any; + afterEach(() => { + window.textsecure.storage.protocol.getIdentityKeyPair = + originalGetIdentityKeyPair; + window.textsecure.storage.user.getUuid = originalGetUuid; + window.textsecure.storage.user.getCheckedUuid = originalGetCheckedUuid; + }); + + describe('encrypted device name', () => { + it('roundtrips', async () => { + const deviceName = 'v2.5.0 on Ubunto 20.04'; + const encrypted = accountManager.encryptDeviceName( + deviceName, + identityKey + ); + if (!encrypted) { + throw new Error('failed to encrypt!'); + } + assert.strictEqual(typeof encrypted, 'string'); + const decrypted = await accountManager.decryptDeviceName(encrypted); + + assert.strictEqual(decrypted, deviceName); + }); + + it('handles falsey deviceName', () => { + const encrypted = accountManager.encryptDeviceName('', identityKey); + assert.strictEqual(encrypted, null); + }); + }); + + describe('#_cleanSignedPreKeys', () => { let originalLoadSignedPreKeys: any; let originalRemoveSignedPreKey: any; - let originalGetUuid: any; let signedPreKeys: Array; - const DAY = 1000 * 60 * 60 * 24; - - const pubKey = getRandomBytes(33); - const privKey = getRandomBytes(32); - const identityKey = window.Signal.Curve.generateKeyPair(); beforeEach(async () => { - const ourUuid = UUID.generate(); - - originalGetUuid = window.textsecure.storage.user.getUuid; - originalGetIdentityKeyPair = - window.textsecure.storage.protocol.getIdentityKeyPair; originalLoadSignedPreKeys = window.textsecure.storage.protocol.loadSignedPreKeys; originalRemoveSignedPreKey = - window.textsecure.storage.protocol.removeSignedPreKey; + window.textsecure.storage.protocol.removeSignedPreKeys; - window.textsecure.storage.user.getUuid = () => ourUuid; - - window.textsecure.storage.protocol.getIdentityKeyPair = () => identityKey; - window.textsecure.storage.protocol.loadSignedPreKeys = async () => + window.textsecure.storage.protocol.loadSignedPreKeys = () => signedPreKeys; + // removeSignedPreKeys is updated per-test, below }); afterEach(() => { - window.textsecure.storage.user.getUuid = originalGetUuid; - window.textsecure.storage.protocol.getIdentityKeyPair = - originalGetIdentityKeyPair; window.textsecure.storage.protocol.loadSignedPreKeys = originalLoadSignedPreKeys; - window.textsecure.storage.protocol.removeSignedPreKey = + window.textsecure.storage.protocol.removeSignedPreKeys = originalRemoveSignedPreKey; }); - describe('encrypted device name', () => { - it('roundtrips', async () => { - const deviceName = 'v2.5.0 on Ubunto 20.04'; - const encrypted = accountManager.encryptDeviceName( - deviceName, - identityKey - ); - if (!encrypted) { - throw new Error('failed to encrypt!'); - } - assert.strictEqual(typeof encrypted, 'string'); - const decrypted = await accountManager.decryptDeviceName(encrypted); - - assert.strictEqual(decrypted, deviceName); - }); - - it('handles falsey deviceName', () => { - const encrypted = accountManager.encryptDeviceName('', identityKey); - assert.strictEqual(encrypted, null); - }); - }); - - it('keeps three confirmed keys even if over a month old', () => { + it('keeps no keys if five or less, even if over a month old', () => { const now = Date.now(); signedPreKeys = [ { @@ -103,10 +117,24 @@ describe('AccountManager', () => { pubKey, privKey, }, + { + keyId: 4, + created_at: now - DAY * 39, + confirmed: true, + pubKey, + privKey, + }, + { + keyId: 5, + created_at: now - DAY * 40, + confirmed: false, + pubKey, + privKey, + }, ]; // should be no calls to store.removeSignedPreKey, would cause crash - return accountManager.cleanSignedPreKeys(UUIDKind.ACI); + return accountManager._cleanSignedPreKeys(UUIDKind.ACI); }); it('eliminates oldest keys, even if recent key is unconfirmed', async () => { @@ -157,60 +185,430 @@ describe('AccountManager', () => { }, ]; - let count = 0; - window.textsecure.storage.protocol.removeSignedPreKey = async ( + let removedKeys: Array = []; + window.textsecure.storage.protocol.removeSignedPreKeys = async ( _, - keyId + keyIds ) => { - if (keyId !== 4) { - throw new Error(`Wrong keys were eliminated! ${keyId}`); - } - - count += 1; + removedKeys = removedKeys.concat(keyIds); }; - await accountManager.cleanSignedPreKeys(UUIDKind.ACI); - assert.strictEqual(count, 1); + await accountManager._cleanSignedPreKeys(UUIDKind.ACI); + assert.deepEqual(removedKeys, [4]); + }); + }); + + describe('#_cleanLastResortKeys', () => { + let originalLoadKyberPreKeys: any; + let originalRemoveKyberPreKey: any; + let kyberPreKeys: Array; + + beforeEach(async () => { + originalLoadKyberPreKeys = + window.textsecure.storage.protocol.loadKyberPreKeys; + originalRemoveKyberPreKey = + window.textsecure.storage.protocol.removeKyberPreKeys; + + window.textsecure.storage.protocol.loadKyberPreKeys = () => kyberPreKeys; + // removeKyberPreKeys is updated per-test, below + }); + afterEach(() => { + window.textsecure.storage.protocol.loadKyberPreKeys = + originalLoadKyberPreKeys; + window.textsecure.storage.protocol.removeKyberPreKeys = + originalRemoveKyberPreKey; }); - it('Removes no keys if less than five', async () => { + it('keeps five keys even if over a month old', () => { const now = Date.now(); - signedPreKeys = [ + kyberPreKeys = [ { + id: `${ourUuid.toString()}:1`, + + createdAt: now - DAY * 32, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, keyId: 1, - created_at: now - DAY * 32, - confirmed: true, - pubKey, - privKey, + ourUuid: ourUuid.toString(), }, { + id: `${ourUuid.toString()}:2`, + + createdAt: now - DAY * 34, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, keyId: 2, - created_at: now - DAY * 44, - confirmed: true, - pubKey, - privKey, + ourUuid: ourUuid.toString(), }, { + id: `${ourUuid.toString()}:3`, + + createdAt: now - DAY * 38, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, keyId: 3, - created_at: now - DAY * 36, - confirmed: false, - pubKey, - privKey, + ourUuid: ourUuid.toString(), }, { + id: `${ourUuid.toString()}:4`, + + createdAt: now - DAY * 39, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: false, keyId: 4, - created_at: now - DAY * 20, - confirmed: false, - pubKey, - privKey, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:5`, + + createdAt: now - DAY * 40, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: false, + keyId: 5, + ourUuid: ourUuid.toString(), }, ]; - window.textsecure.storage.protocol.removeSignedPreKey = async () => { - throw new Error('None should be removed!'); + // should be no calls to store.removeKyberPreKey, would cause crash + return accountManager._cleanLastResortKeys(UUIDKind.ACI); + }); + + it('eliminates oldest keys, even if recent key is unconfirmed', async () => { + const now = Date.now(); + kyberPreKeys = [ + { + id: `${ourUuid.toString()}:1`, + + createdAt: now - DAY * 32, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, + keyId: 1, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:2`, + + createdAt: now - DAY * 31, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: false, + keyId: 2, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:3`, + + createdAt: now - DAY * 24, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, + keyId: 3, + ourUuid: ourUuid.toString(), + }, + { + // Oldest, should be dropped + id: `${ourUuid.toString()}:4`, + + createdAt: now - DAY * 38, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, + keyId: 4, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:5`, + + createdAt: now - DAY * 5, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, + keyId: 5, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:6`, + + createdAt: now - DAY * 5, + data: getRandomBytes(32), + isLastResort: true, + isConfirmed: true, + keyId: 6, + ourUuid: ourUuid.toString(), + }, + ]; + + let removedKeys: Array = []; + window.textsecure.storage.protocol.removeKyberPreKeys = async ( + _, + keyIds + ) => { + removedKeys = removedKeys.concat(keyIds); }; - await accountManager.cleanSignedPreKeys(UUIDKind.ACI); + await accountManager._cleanLastResortKeys(UUIDKind.ACI); + assert.deepEqual(removedKeys, [4]); + }); + }); + + describe('#_cleanPreKeys', () => { + let originalLoadPreKeys: any; + let originalRemovePreKeys: any; + let preKeys: Array; + + beforeEach(async () => { + originalLoadPreKeys = window.textsecure.storage.protocol.loadPreKeys; + originalRemovePreKeys = window.textsecure.storage.protocol.removePreKeys; + + window.textsecure.storage.protocol.loadPreKeys = () => preKeys; + // removePreKeys is updated per-test, below + }); + afterEach(() => { + window.textsecure.storage.protocol.loadPreKeys = originalLoadPreKeys; + window.textsecure.storage.protocol.removePreKeys = originalRemovePreKeys; + }); + + it('keeps five keys even if over 90 days old, but all latest batch', () => { + const now = Date.now(); + preKeys = [ + { + id: `${ourUuid.toString()}:1`, + + createdAt: now - DAY * 92, + keyId: 1, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + { + id: `${ourUuid.toString()}:2`, + + createdAt: now - DAY * 93, + keyId: 2, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + { + id: `${ourUuid.toString()}:3`, + + createdAt: now - DAY * 93, + keyId: 3, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + { + id: `${ourUuid.toString()}:4`, + + createdAt: now - DAY * 93, + keyId: 4, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + { + id: `${ourUuid.toString()}:5`, + + createdAt: now - DAY * 94, + keyId: 5, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + ]; + + // should be no calls to store.removeKyberPreKey, would cause crash + return accountManager._cleanPreKeys(UUIDKind.ACI); + }); + + it('eliminates keys not in the 200 newest, over 90 days old', async () => { + const now = Date.now(); + preKeys = [ + // The latest batch + ...range(0, 100).map( + (id): PreKeyType => ({ + id: `${ourUuid.toString()}:${id}`, + + createdAt: now - DAY, + keyId: 1, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }) + ), + // Second-oldest batch, won't be dropped + ...range(100, 200).map( + (id): PreKeyType => ({ + id: `${ourUuid.toString()}:${id}`, + + createdAt: now - DAY * 40, + keyId: 1, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }) + ), + // Oldest batch, will be dropped + { + id: `${ourUuid.toString()}:6`, + + createdAt: now - DAY * 92, + keyId: 6, + ourUuid: ourUuid.toString(), + privateKey: privKey, + publicKey: pubKey, + }, + ]; + + let removedKeys: Array = []; + window.textsecure.storage.protocol.removePreKeys = async (_, keyIds) => { + removedKeys = removedKeys.concat(keyIds); + }; + + await accountManager._cleanPreKeys(UUIDKind.ACI); + assert.deepEqual(removedKeys, [6]); + }); + }); + + describe('#_cleanKyberPreKeys', () => { + let originalLoadKyberPreKeys: any; + let originalRemoveKyberPreKeys: any; + let kyberPreKeys: Array; + + beforeEach(async () => { + originalLoadKyberPreKeys = + window.textsecure.storage.protocol.loadKyberPreKeys; + originalRemoveKyberPreKeys = + window.textsecure.storage.protocol.removeKyberPreKeys; + + window.textsecure.storage.protocol.loadKyberPreKeys = () => kyberPreKeys; + // removeKyberPreKeys is updated per-test, below + }); + afterEach(() => { + window.textsecure.storage.protocol.loadKyberPreKeys = + originalLoadKyberPreKeys; + window.textsecure.storage.protocol.removeKyberPreKeys = + originalRemoveKyberPreKeys; + }); + + it('keeps five keys even if over 90 days old', () => { + const now = Date.now(); + kyberPreKeys = [ + { + id: `${ourUuid.toString()}:1`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 1, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:2`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 2, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:3`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 3, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:4`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 4, + ourUuid: ourUuid.toString(), + }, + { + id: `${ourUuid.toString()}:5`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 5, + ourUuid: ourUuid.toString(), + }, + ]; + + // should be no calls to store.removeKyberPreKey, would cause crash + return accountManager._cleanKyberPreKeys(UUIDKind.ACI); + }); + + it('eliminates keys not in the newest 200, over 90 days old', async () => { + const now = Date.now(); + kyberPreKeys = [ + // The latest batch + ...range(0, 100).map( + (id): KyberPreKeyType => ({ + id: `${ourUuid.toString()}:${id}`, + + createdAt: now - DAY, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 1, + ourUuid: ourUuid.toString(), + }) + ), + // Second-oldest batch, won't be dropped + ...range(100, 200).map( + (id): KyberPreKeyType => ({ + id: `${ourUuid.toString()}:${id}`, + + createdAt: now - DAY * 45, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 4, + ourUuid: ourUuid.toString(), + }) + ), + // Oldest batch, will be dropped + { + id: `${ourUuid.toString()}:6`, + + createdAt: now - DAY * 93, + data: getRandomBytes(32), + isConfirmed: false, + isLastResort: false, + keyId: 6, + ourUuid: ourUuid.toString(), + }, + ]; + + let removedKeys: Array = []; + window.textsecure.storage.protocol.removeKyberPreKeys = async ( + _, + keyIds + ) => { + removedKeys = removedKeys.concat(keyIds); + }; + + await accountManager._cleanKyberPreKeys(UUIDKind.ACI); + assert.deepEqual(removedKeys, [6]); }); }); }); diff --git a/ts/test-electron/textsecure/generate_keys_test.ts b/ts/test-electron/textsecure/generate_keys_test.ts index 0c85f8b571a5..d0b6337b4d8f 100644 --- a/ts/test-electron/textsecure/generate_keys_test.ts +++ b/ts/test-electron/textsecure/generate_keys_test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { constantTimeEqual } from '../../Crypto'; import { generateKeyPair } from '../../Curve'; -import type { GeneratedKeysType } from '../../textsecure/AccountManager'; +import type { UploadKeysType } from '../../textsecure/WebAPI'; import AccountManager from '../../textsecure/AccountManager'; import type { PreKeyType, SignedPreKeyType } from '../../textsecure/Types.d'; import { UUID, UUIDKind } from '../../types/UUID'; @@ -19,6 +19,7 @@ const assertEqualBuffers = (a: Uint8Array, b: Uint8Array) => { describe('Key generation', function thisNeeded() { const count = 10; const ourUuid = new UUID('aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee'); + let result: UploadKeysType; this.timeout(count * 2000); function itStoresPreKey(keyId: number): void { @@ -30,6 +31,15 @@ describe('Key generation', function thisNeeded() { assert(keyPair, `PreKey ${keyId} not found`); }); } + function itStoresKyberPreKey(keyId: number): void { + it(`kyber pre key ${keyId} is valid`, async () => { + const key = await textsecure.storage.protocol.loadKyberPreKey( + ourUuid, + keyId + ); + assert(key, `kyber pre key ${keyId} not found`); + }); + } function itStoresSignedPreKey(keyId: number): void { it(`signed prekey ${keyId} is valid`, async () => { const keyPair = await textsecure.storage.protocol.loadSignedPreKey( @@ -39,7 +49,8 @@ describe('Key generation', function thisNeeded() { assert(keyPair, `SignedPreKey ${keyId} not found`); }); } - async function validateResultKey( + + async function validateResultPreKey( resultKey: Pick ): Promise { const keyPair = await textsecure.storage.protocol.loadPreKey( @@ -52,8 +63,11 @@ describe('Key generation', function thisNeeded() { assertEqualBuffers(resultKey.publicKey, keyPair.publicKey().serialize()); } async function validateResultSignedKey( - resultSignedKey: Pick + resultSignedKey?: Pick ) { + if (!resultSignedKey) { + throw new Error('validateResultSignedKey: No signed prekey provided!'); + } const keyPair = await textsecure.storage.protocol.loadSignedPreKey( ourUuid, resultSignedKey.keyId @@ -68,120 +82,166 @@ describe('Key generation', function thisNeeded() { } before(async () => { + await textsecure.storage.protocol.clearPreKeyStore(); + await textsecure.storage.protocol.clearKyberPreKeyStore(); + await textsecure.storage.protocol.clearSignedPreKeysStore(); + const keyPair = generateKeyPair(); await textsecure.storage.put('identityKeyMap', { [ourUuid.toString()]: keyPair, }); await textsecure.storage.user.setUuidAndDeviceId(ourUuid.toString(), 1); + await textsecure.storage.protocol.hydrateCaches(); }); after(async () => { await textsecure.storage.protocol.clearPreKeyStore(); + await textsecure.storage.protocol.clearKyberPreKeyStore(); await textsecure.storage.protocol.clearSignedPreKeysStore(); }); describe('the first time', () => { - let result: GeneratedKeysType; - before(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountManager = new AccountManager({} as any); - result = await accountManager.generateKeys(count, UUIDKind.ACI); + result = await accountManager._generateKeys(count, UUIDKind.ACI); }); - for (let i = 1; i <= count; i += 1) { - itStoresPreKey(i); - } - itStoresSignedPreKey(1); + describe('generates the basics', () => { + for (let i = 1; i <= count; i += 1) { + itStoresPreKey(i); + } + for (let i = 1; i <= count + 1; i += 1) { + itStoresKyberPreKey(i); + } + itStoresSignedPreKey(1); + }); it(`result contains ${count} preKeys`, () => { - assert.isArray(result.preKeys); - assert.lengthOf(result.preKeys, count); + const preKeys = result.preKeys || []; + assert.isArray(preKeys); + assert.lengthOf(preKeys, count); for (let i = 0; i < count; i += 1) { - assert.isObject(result.preKeys[i]); + assert.isObject(preKeys[i]); } }); it('result contains the correct keyIds', () => { + const preKeys = result.preKeys || []; for (let i = 0; i < count; i += 1) { - assert.strictEqual(result.preKeys[i].keyId, i + 1); + assert.strictEqual(preKeys[i].keyId, i + 1); } }); it('result contains the correct public keys', async () => { - await Promise.all(result.preKeys.map(validateResultKey)); + const preKeys = result.preKeys || []; + await Promise.all(preKeys.map(validateResultPreKey)); }); it('returns a signed prekey', () => { - assert.strictEqual(result.signedPreKey.keyId, 1); - assert.instanceOf(result.signedPreKey.signature, Uint8Array); + assert.strictEqual(result.signedPreKey?.keyId, 1); + assert.instanceOf(result.signedPreKey?.signature, Uint8Array); return validateResultSignedKey(result.signedPreKey); }); }); describe('the second time', () => { - let result: GeneratedKeysType; before(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountManager = new AccountManager({} as any); - result = await accountManager.generateKeys(count, UUIDKind.ACI); + result = await accountManager._generateKeys(count, UUIDKind.ACI); + }); + + describe('generates the basics', () => { + for (let i = 1; i <= 2 * count; i += 1) { + itStoresPreKey(i); + } + for (let i = 1; i <= 2 * count + 2; i += 1) { + itStoresKyberPreKey(i); + } + itStoresSignedPreKey(1); + itStoresSignedPreKey(2); }); - for (let i = 1; i <= 2 * count; i += 1) { - itStoresPreKey(i); - } - itStoresSignedPreKey(1); - itStoresSignedPreKey(2); it(`result contains ${count} preKeys`, () => { - assert.isArray(result.preKeys); - assert.lengthOf(result.preKeys, count); + const preKeys = result.preKeys || []; + assert.isArray(preKeys); + assert.lengthOf(preKeys, count); for (let i = 0; i < count; i += 1) { - assert.isObject(result.preKeys[i]); + assert.isObject(preKeys[i]); } }); it('result contains the correct keyIds', () => { + const preKeys = result.preKeys || []; for (let i = 1; i <= count; i += 1) { - assert.strictEqual(result.preKeys[i - 1].keyId, i + count); + assert.strictEqual(preKeys[i - 1].keyId, i + count); } }); it('result contains the correct public keys', async () => { - await Promise.all(result.preKeys.map(validateResultKey)); + const preKeys = result.preKeys || []; + await Promise.all(preKeys.map(validateResultPreKey)); }); it('returns a signed prekey', () => { - assert.strictEqual(result.signedPreKey.keyId, 2); - assert.instanceOf(result.signedPreKey.signature, Uint8Array); + assert.strictEqual(result.signedPreKey?.keyId, 2); + assert.instanceOf(result.signedPreKey?.signature, Uint8Array); return validateResultSignedKey(result.signedPreKey); }); }); - describe('the third time', () => { - let result: GeneratedKeysType; + describe('the third time, after keys are confirmed', () => { before(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountManager = new AccountManager({} as any); - result = await accountManager.generateKeys(count, UUIDKind.ACI); + + await accountManager._confirmKeys(result, UUIDKind.ACI); + + result = await accountManager._generateKeys(count, UUIDKind.ACI); + }); + + describe('generates the basics', () => { + for (let i = 1; i <= 3 * count; i += 1) { + itStoresPreKey(i); + } + // Note: no new last resort kyber key generated + for (let i = 1; i <= 3 * count + 2; i += 1) { + itStoresKyberPreKey(i); + } + itStoresSignedPreKey(1); + itStoresSignedPreKey(2); }); - for (let i = 1; i <= 3 * count; i += 1) { - itStoresPreKey(i); - } - itStoresSignedPreKey(2); - itStoresSignedPreKey(3); it(`result contains ${count} preKeys`, () => { - assert.isArray(result.preKeys); - assert.lengthOf(result.preKeys, count); + const preKeys = result.preKeys || []; + assert.isArray(preKeys); + assert.lengthOf(preKeys, count); for (let i = 0; i < count; i += 1) { - assert.isObject(result.preKeys[i]); + assert.isObject(preKeys[i]); } }); it('result contains the correct keyIds', () => { + const preKeys = result.preKeys || []; for (let i = 1; i <= count; i += 1) { - assert.strictEqual(result.preKeys[i - 1].keyId, i + 2 * count); + assert.strictEqual(preKeys[i - 1].keyId, i + 2 * count); } }); it('result contains the correct public keys', async () => { - await Promise.all(result.preKeys.map(validateResultKey)); + const preKeys = result.preKeys || []; + await Promise.all(preKeys.map(validateResultPreKey)); }); - it('result contains a signed prekey', () => { - assert.strictEqual(result.signedPreKey.keyId, 3); - assert.instanceOf(result.signedPreKey.signature, Uint8Array); - return validateResultSignedKey(result.signedPreKey); + it('does not generate a third last resort prekey', async () => { + const keyId = 3 * count + 3; + const key = await textsecure.storage.protocol.loadKyberPreKey( + ourUuid, + keyId + ); + assert.isUndefined(key, `kyber pre key ${keyId} was unexpectedly found`); + }); + it('does not generate a third signed prekey', async () => { + const keyId = 3; + const keyPair = await textsecure.storage.protocol.loadSignedPreKey( + ourUuid, + keyId + ); + assert.isUndefined( + keyPair, + `SignedPreKey ${keyId} was unexpectedly found` + ); }); }); }); diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index d7ac51beeba2..4c65bd6e91c8 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -7,6 +7,7 @@ import path from 'path'; import os from 'os'; import createDebug from 'debug'; import pTimeout from 'p-timeout'; +import normalizePath from 'normalize-path'; import type { Device, PrimaryDevice } from '@signalapp/mock-server'; import { Server, UUIDKind, loadCertificates } from '@signalapp/mock-server'; @@ -289,7 +290,20 @@ export class Bootstrap { return result; } - public async saveLogs(app: App | undefined = this.lastApp): Promise { + public async maybeSaveLogs( + test?: Mocha.Test, + app: App | undefined = this.lastApp + ): Promise { + const { FORCE_ARTIFACT_SAVE } = process.env; + if (test?.state !== 'passed' || FORCE_ARTIFACT_SAVE) { + await this.saveLogs(app, test?.fullTitle()); + } + } + + public async saveLogs( + app: App | undefined = this.lastApp, + pathPrefix?: string + ): Promise { const { ARTIFACTS_DIR } = process.env; if (!ARTIFACTS_DIR) { // eslint-disable-next-line no-console @@ -299,7 +313,12 @@ export class Bootstrap { await fs.mkdir(ARTIFACTS_DIR, { recursive: true }); - const outDir = await fs.mkdtemp(path.join(ARTIFACTS_DIR, 'logs-')); + const normalizedPrefix = pathPrefix + ? `-${normalizePath(pathPrefix.replace(/[ /]/g, '-'))}-` + : ''; + const outDir = await fs.mkdtemp( + path.join(ARTIFACTS_DIR, `logs-${normalizedPrefix}`) + ); // eslint-disable-next-line no-console console.error(`Saving logs to ${outDir}`); diff --git a/ts/test-mock/messaging/edit_test.ts b/ts/test-mock/messaging/edit_test.ts index 50bc77e9624a..a5ed5feb1dbe 100644 --- a/ts/test-mock/messaging/edit_test.ts +++ b/ts/test-mock/messaging/edit_test.ts @@ -67,10 +67,7 @@ describe('editing', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/messaging/sender_key_test.ts b/ts/test-mock/messaging/sender_key_test.ts index 95c2b680ff9d..a1482090cce7 100644 --- a/ts/test-mock/messaging/sender_key_test.ts +++ b/ts/test-mock/messaging/sender_key_test.ts @@ -54,10 +54,7 @@ describe('senderKey', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/messaging/stories_test.ts b/ts/test-mock/messaging/stories_test.ts index d07814feab0c..c48773f228d4 100644 --- a/ts/test-mock/messaging/stories_test.ts +++ b/ts/test-mock/messaging/stories_test.ts @@ -118,10 +118,7 @@ describe('story/messaging', function unknownContacts() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/messaging/unknown_contact_test.ts b/ts/test-mock/messaging/unknown_contact_test.ts index a2c9d6168c25..654b144627b8 100644 --- a/ts/test-mock/messaging/unknown_contact_test.ts +++ b/ts/test-mock/messaging/unknown_contact_test.ts @@ -40,10 +40,7 @@ describe('unknown contacts', function unknownContacts() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/accept_gv2_invite_test.ts b/ts/test-mock/pnp/accept_gv2_invite_test.ts index 3b7cc809a893..8ee15984d715 100644 --- a/ts/test-mock/pnp/accept_gv2_invite_test.ts +++ b/ts/test-mock/pnp/accept_gv2_invite_test.ts @@ -56,10 +56,7 @@ describe('pnp/accept gv2 invite', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/change_number_test.ts b/ts/test-mock/pnp/change_number_test.ts index e128f6078db1..fa8c830093d8 100644 --- a/ts/test-mock/pnp/change_number_test.ts +++ b/ts/test-mock/pnp/change_number_test.ts @@ -23,10 +23,7 @@ describe('pnp/change number', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index 9464e009893d..dcdc7540d880 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -92,10 +92,7 @@ describe('pnp/merge', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index aca4c21d9b23..bc409a2ba828 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -62,10 +62,7 @@ describe('pnp/PNI Change', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index 7f01c50adbac..90b92b2bb613 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -86,10 +86,7 @@ describe('pnp/PNI Signature', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/send_gv2_invite_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts index b87dff93fb29..053c4ea213ca 100644 --- a/ts/test-mock/pnp/send_gv2_invite_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -85,10 +85,7 @@ describe('pnp/send gv2 invite', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 881f79ea4652..37a9aa3411fc 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -72,10 +72,7 @@ describe('pnp/username', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/rate-limit/story_test.ts b/ts/test-mock/rate-limit/story_test.ts index 2a4be249b728..b7582ae28079 100644 --- a/ts/test-mock/rate-limit/story_test.ts +++ b/ts/test-mock/rate-limit/story_test.ts @@ -57,10 +57,7 @@ describe('story/no-sender-key', function needsName() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/rate-limit/viewed_test.ts b/ts/test-mock/rate-limit/viewed_test.ts index b74753a90c3a..a4cdebc26a22 100644 --- a/ts/test-mock/rate-limit/viewed_test.ts +++ b/ts/test-mock/rate-limit/viewed_test.ts @@ -64,10 +64,7 @@ describe('challenge/receipts', function challengeReceiptsTest() { }); afterEach(async function after() { - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/archive_test.ts b/ts/test-mock/storage/archive_test.ts index ae26fbd21525..12cbe0923181 100644 --- a/ts/test-mock/storage/archive_test.ts +++ b/ts/test-mock/storage/archive_test.ts @@ -22,10 +22,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/drop_test.ts b/ts/test-mock/storage/drop_test.ts index a8362dd586b2..d1147b27dbf8 100644 --- a/ts/test-mock/storage/drop_test.ts +++ b/ts/test-mock/storage/drop_test.ts @@ -25,10 +25,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/max_read_keys_test.ts b/ts/test-mock/storage/max_read_keys_test.ts index 847a90314e50..882710948acd 100644 --- a/ts/test-mock/storage/max_read_keys_test.ts +++ b/ts/test-mock/storage/max_read_keys_test.ts @@ -27,10 +27,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/message_request_test.ts b/ts/test-mock/storage/message_request_test.ts index 8347dbbaf3a7..d39bb2884366 100644 --- a/ts/test-mock/storage/message_request_test.ts +++ b/ts/test-mock/storage/message_request_test.ts @@ -22,10 +22,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/pin_unpin_test.ts b/ts/test-mock/storage/pin_unpin_test.ts index 415572700825..4ab5df4dd207 100644 --- a/ts/test-mock/storage/pin_unpin_test.ts +++ b/ts/test-mock/storage/pin_unpin_test.ts @@ -26,10 +26,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-mock/storage/sticker_test.ts b/ts/test-mock/storage/sticker_test.ts index 327bd49a5e13..acf3c026ae49 100644 --- a/ts/test-mock/storage/sticker_test.ts +++ b/ts/test-mock/storage/sticker_test.ts @@ -99,10 +99,7 @@ describe('storage service', function needsName() { return; } - if (this.currentTest?.state !== 'passed') { - await bootstrap.saveLogs(app); - } - + await bootstrap.maybeSaveLogs(this.currentTest, app); await app.close(); await bootstrap.teardown(); }); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index 202fa4de1583..a1a09f892e22 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -3476,4 +3476,56 @@ describe('SQL migrations test', () => { ); }); }); + + describe('updateToSchemaVersion85', () => { + it('generates ourUuid field when JSON is inserted', () => { + updateToVersion(85); + const id = 'a1111:a2222'; + const ourUuid = 'ab3333'; + const value = { + ourUuid, + }; + const json = JSON.stringify(value); + db.prepare( + ` + INSERT INTO kyberPreKeys (id, json) VALUES + ('${id}', '${json}'); + ` + ).run(); + + const payload = db.prepare('SELECT * FROM kyberPreKeys LIMIT 1;').get(); + + assert.strictEqual(payload.id, id); + assert.strictEqual(payload.json, json); + assert.strictEqual(payload.ourUuid, ourUuid); + }); + + it('adds a createdAt to all existing prekeys', () => { + updateToVersion(84); + + const id = 'a1111:a2222'; + const ourUuid = 'ab3333'; + const value = { + ourUuid, + }; + const startingTime = Date.now(); + const json = JSON.stringify(value); + db.prepare( + ` + INSERT INTO preKeys (id, json) VALUES + ('${id}', '${json}'); + ` + ).run(); + + updateToVersion(85); + + const payload = db.prepare('SELECT * FROM preKeys LIMIT 1;').get(); + + assert.strictEqual(payload.id, id); + + const object = JSON.parse(payload.json); + assert.strictEqual(object.ourUuid, ourUuid); + assert.isAtLeast(object.createdAt, startingTime); + }); + }); }); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index a6461f55c362..9df6ea85ca24 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -2,12 +2,22 @@ // SPDX-License-Identifier: AGPL-3.0-only import PQueue from 'p-queue'; -import { omit } from 'lodash'; +import { isNumber, omit, orderBy } from 'lodash'; import EventTarget from './EventTarget'; -import type { WebAPIType } from './WebAPI'; -import { HTTPError } from './Errors'; -import type { KeyPairType, PniKeyMaterialType } from './Types.d'; +import type { + UploadKeysType, + UploadKyberPreKeyType, + UploadPreKeyType, + UploadSignedPreKeyType, + WebAPIType, +} from './WebAPI'; +import type { + CompatPreKeyType, + KeyPairType, + KyberPreKeyType, + PniKeyMaterialType, +} from './Types.d'; import ProvisioningCipher from './ProvisioningCipher'; import type { IncomingWebSocketRequest } from './WebsocketResources'; import createTaskWithTimeout from './TaskWithTimeout'; @@ -26,6 +36,7 @@ import { generateKeyPair, generateSignedPreKey, generatePreKey, + generateKyberPreKey, } from '../Curve'; import { UUID, UUIDKind } from '../types/UUID'; import { isMoreRecentThan, isOlderThan } from '../util/timestamp'; @@ -36,26 +47,56 @@ import { getProvisioningUrl } from '../util/getProvisioningUrl'; import { isNotNil } from '../util/isNotNil'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; +import type { StorageAccessType } from '../types/Storage'; + +type StorageKeyByUuidKind = { + [kind in UUIDKind]: keyof StorageAccessType; +}; const DAY = 24 * 60 * 60 * 1000; -const MINIMUM_SIGNED_PREKEYS = 5; -const ARCHIVE_AGE = 30 * DAY; -const PREKEY_ROTATION_AGE = DAY * 1.5; -const PROFILE_KEY_LENGTH = 32; -const SIGNED_KEY_GEN_BATCH_SIZE = 100; -export type GeneratedKeysType = { - preKeys: Array<{ - keyId: number; - publicKey: Uint8Array; - }>; - signedPreKey: { - keyId: number; - publicKey: Uint8Array; - signature: Uint8Array; - keyPair: KeyPairType; - }; - identityKey: Uint8Array; +const STARTING_KEY_ID = 1; +const PROFILE_KEY_LENGTH = 32; +const KEY_TOO_OLD_THRESHOLD = 14 * DAY; + +export const KYBER_KEY_ID_KEY: StorageKeyByUuidKind = { + [UUIDKind.ACI]: 'maxKyberPreKeyId', + [UUIDKind.Unknown]: 'maxKyberPreKeyId', + [UUIDKind.PNI]: 'maxKyberPreKeyIdPNI', +}; + +const LAST_RESORT_KEY_ARCHIVE_AGE = 30 * DAY; +const LAST_RESORT_KEY_ROTATION_AGE = DAY * 1.5; +const LAST_RESORT_KEY_MINIMUM = 5; +const LAST_RESORT_KEY_UPDATE_TIME_KEY: StorageKeyByUuidKind = { + [UUIDKind.ACI]: 'lastResortKeyUpdateTime', + [UUIDKind.Unknown]: 'lastResortKeyUpdateTime', + [UUIDKind.PNI]: 'lastResortKeyUpdateTimePNI', +}; + +const PRE_KEY_ARCHIVE_AGE = 90 * DAY; +const PRE_KEY_GEN_BATCH_SIZE = 100; +const PRE_KEY_MAX_COUNT = 200; +const PRE_KEY_ID_KEY: StorageKeyByUuidKind = { + [UUIDKind.ACI]: 'maxPreKeyId', + [UUIDKind.Unknown]: 'maxPreKeyId', + [UUIDKind.PNI]: 'maxPreKeyIdPNI', +}; +const PRE_KEY_MINIMUM = 10; + +const SIGNED_PRE_KEY_ARCHIVE_AGE = 30 * DAY; +export const SIGNED_PRE_KEY_ID_KEY: StorageKeyByUuidKind = { + [UUIDKind.ACI]: 'signedKeyId', + [UUIDKind.Unknown]: 'signedKeyId', + [UUIDKind.PNI]: 'signedKeyIdPNI', +}; + +const SIGNED_PRE_KEY_ROTATION_AGE = DAY * 1.5; +const SIGNED_PRE_KEY_MINIMUM = 5; +const SIGNED_PRE_KEY_UPDATE_TIME_KEY: StorageKeyByUuidKind = { + [UUIDKind.ACI]: 'signedKeyUpdateTime', + [UUIDKind.Unknown]: 'signedKeyUpdateTime', + [UUIDKind.PNI]: 'signedKeyUpdateTimePNI', }; type CreateAccountOptionsType = Readonly<{ @@ -70,6 +111,21 @@ type CreateAccountOptionsType = Readonly<{ accessKey?: Uint8Array; }>; +function getNextKeyId(kind: UUIDKind, keys: StorageKeyByUuidKind): number { + const id = window.storage.get(keys[kind]); + + if (isNumber(id)) { + return id; + } + + // For PNI ids, start with existing ACI id + if (kind === UUIDKind.PNI) { + return window.storage.get(keys[UUIDKind.ACI], STARTING_KEY_ID); + } + + return STARTING_KEY_ID; +} + export default class AccountManager extends EventTarget { pending: Promise; @@ -81,6 +137,13 @@ export default class AccountManager extends EventTarget { this.pending = Promise.resolve(); } + private async queueTask(task: () => Promise): Promise { + this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 }); + const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task'); + + return this.pendingQueue.add(taskWithTimeout); + } + async requestVoiceVerification(number: string, token: string): Promise { return this.server.requestVerificationVoice(number, token); } @@ -154,7 +217,7 @@ export default class AccountManager extends EventTarget { number: string, verificationCode: string ): Promise { - return this.queueTask(async () => { + await this.queueTask(async () => { const aciKeyPair = generateKeyPair(); const pniKeyPair = generateKeyPair(); const profileKey = getRandomBytes(PROFILE_KEY_LENGTH); @@ -171,18 +234,14 @@ export default class AccountManager extends EventTarget { accessKey, }); - await this.clearSessionsAndPreKeys(); + const uploadKeys = async (kind: UUIDKind) => { + const keys = await this._generateKeys(PRE_KEY_GEN_BATCH_SIZE, kind); + await this.server.registerKeys(keys, kind); + await this._confirmKeys(keys, kind); + }; - await Promise.all( - [UUIDKind.ACI, UUIDKind.PNI].map(async kind => { - const keys = await this.generateKeys( - SIGNED_KEY_GEN_BATCH_SIZE, - kind - ); - await this.server.registerKeys(keys, kind); - await this.confirmKeys(keys, kind); - }) - ); + await uploadKeys(UUIDKind.ACI); + await uploadKeys(UUIDKind.PNI); } finally { this.server.finishRegistration(registrationBaton); } @@ -194,7 +253,6 @@ export default class AccountManager extends EventTarget { setProvisioningUrl: (url: string) => void, confirmNumber: (number?: string) => Promise ): Promise { - const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this); const provisioningCipher = new ProvisioningCipher(); const pubKey = await provisioningCipher.getPublicKey(); @@ -285,36 +343,30 @@ export default class AccountManager extends EventTarget { userAgent: provisionMessage.userAgent, readReceipts: provisionMessage.readReceipts, }); - await clearSessionsAndPreKeys(); - const keyKinds = [UUIDKind.ACI]; - if (provisionMessage.pniKeyPair) { - keyKinds.push(UUIDKind.PNI); - } + const uploadKeys = async (kind: UUIDKind) => { + const keys = await this._generateKeys(PRE_KEY_GEN_BATCH_SIZE, kind); - await Promise.all( - keyKinds.map(async kind => { - const keys = await this.generateKeys( - SIGNED_KEY_GEN_BATCH_SIZE, - kind - ); - - try { - await this.server.registerKeys(keys, kind); - await this.confirmKeys(keys, kind); - } catch (error) { - if (kind === UUIDKind.PNI) { - log.error( - 'Failed to upload PNI prekeys. Moving on', - Errors.toLogFormat(error) - ); - return; - } - - throw error; + try { + await this.server.registerKeys(keys, kind); + await this._confirmKeys(keys, kind); + } catch (error) { + if (kind === UUIDKind.PNI) { + log.error( + 'Failed to upload PNI prekeys. Moving on', + Errors.toLogFormat(error) + ); + return; } - }) - ); + + throw error; + } + }; + + await uploadKeys(UUIDKind.ACI); + if (provisionMessage.pniKeyPair) { + await uploadKeys(UUIDKind.PNI); + } } finally { this.server.finishRegistration(registrationBaton); } @@ -323,159 +375,352 @@ export default class AccountManager extends EventTarget { }); } - async refreshPreKeys(uuidKind: UUIDKind): Promise { - return this.queueTask(async () => { - const preKeyCount = await this.server.getMyKeys(uuidKind); - log.info( - `refreshPreKeys(${uuidKind}): Server prekey count is ${preKeyCount}` + private getIdentityKeyOrThrow(ourUuid: UUID): KeyPairType { + const { storage } = window.textsecure; + const store = storage.protocol; + let identityKey: KeyPairType | undefined; + try { + identityKey = store.getIdentityKeyPair(ourUuid); + } catch (error) { + const errorText = Errors.toLogFormat(error); + throw new Error( + `generateNewKyberPreKeys: Failed to fetch identity key - ${errorText}` ); - if (preKeyCount >= 10) { - return; - } + } - const keys = await this.generateKeys(SIGNED_KEY_GEN_BATCH_SIZE, uuidKind); - await this.server.registerKeys(keys, uuidKind); + if (!identityKey) { + throw new Error('generateNewKyberPreKeys: Missing identity key'); + } - const updatedCount = await this.server.getMyKeys(uuidKind); - log.info( - `refreshPreKeys(${uuidKind}): Successfully updated; server count is now ${updatedCount}` - ); - }); + return identityKey; } - async rotateSignedPreKey(uuidKind: UUIDKind): Promise { - return this.queueTask(async () => { - const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); - const signedKeyId = window.textsecure.storage.get('signedKeyId', 1); - if (typeof signedKeyId !== 'number') { - throw new Error('Invalid signedKeyId'); - } + private async generateNewPreKeys( + uuidKind: UUIDKind, + count: number + ): Promise> { + const logId = `AccountManager.generateNewPreKeys(${uuidKind})`; + const { storage } = window.textsecure; + const store = storage.protocol; - const store = window.textsecure.storage.protocol; - const { server } = this; + const startId = getNextKeyId(uuidKind, PRE_KEY_ID_KEY); + log.info(`${logId}: Generating ${count} new keys starting at ${startId}`); - const existingKeys = await store.loadSignedPreKeys(ourUuid); - existingKeys.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); - const confirmedKeys = existingKeys.filter(key => key.confirmed); - const mostRecent = confirmedKeys[0]; - - if (isMoreRecentThan(mostRecent?.created_at || 0, PREKEY_ROTATION_AGE)) { - log.warn( - `rotateSignedPreKey(${uuidKind}): ${confirmedKeys.length} ` + - `confirmed keys, most recent was created ${mostRecent?.created_at}. Cancelling rotation.` - ); - return; - } - - let identityKey: KeyPairType | undefined; - try { - identityKey = store.getIdentityKeyPair(ourUuid); - } catch (error) { - // We swallow any error here, because we don't want to get into - // a loop of repeated retries. - log.error( - 'Failed to get identity key. Canceling key rotation.', - Errors.toLogFormat(error) - ); - return; - } - - if (!identityKey) { - // TODO: DESKTOP-2855 - if (uuidKind === UUIDKind.PNI) { - log.warn(`rotateSignedPreKey(${uuidKind}): No identity key pair!`); - return; - } - throw new Error( - `rotateSignedPreKey(${uuidKind}): No identity key pair!` - ); - } - - const res = await generateSignedPreKey(identityKey, signedKeyId); - - log.info( - `rotateSignedPreKey(${uuidKind}): Saving new signed prekey`, - res.keyId + const ourUuid = storage.user.getCheckedUuid(uuidKind); + if (typeof startId !== 'number') { + throw new Error( + `${logId}: Invalid ${PRE_KEY_ID_KEY[uuidKind]} in storage` ); + } - await Promise.all([ - window.textsecure.storage.put('signedKeyId', signedKeyId + 1), - store.storeSignedPreKey(ourUuid, res.keyId, res.keyPair), - ]); + const toSave: Array = []; + for (let keyId = startId; keyId < startId + count; keyId += 1) { + toSave.push(generatePreKey(keyId)); + } + + await store.storePreKeys(ourUuid, toSave); + await storage.put(PRE_KEY_ID_KEY[uuidKind], startId + count); + + return toSave.map(key => ({ + keyId: key.keyId, + publicKey: key.keyPair.pubKey, + })); + } + + private async generateNewKyberPreKeys( + uuidKind: UUIDKind, + count: number + ): Promise> { + const logId = `AccountManager.generateNewKyberPreKeys(${uuidKind})`; + const { storage } = window.textsecure; + const store = storage.protocol; + + const startId = getNextKeyId(uuidKind, KYBER_KEY_ID_KEY); + log.info(`${logId}: Generating ${count} new keys starting at ${startId}`); + + const ourUuid = storage.user.getCheckedUuid(uuidKind); + if (typeof startId !== 'number') { + throw new Error( + `${logId}: Invalid ${KYBER_KEY_ID_KEY[uuidKind]} in storage` + ); + } + + const identityKey = this.getIdentityKeyOrThrow(ourUuid); + + const toSave: Array> = []; + const toUpload: Array = []; + const now = Date.now(); + for (let keyId = startId; keyId < startId + count; keyId += 1) { + const record = generateKyberPreKey(identityKey, keyId); + toSave.push({ + createdAt: now, + data: record.serialize(), + isConfirmed: false, + isLastResort: false, + keyId, + ourUuid: ourUuid.toString(), + }); + toUpload.push({ + keyId, + publicKey: record.publicKey().serialize(), + signature: record.signature(), + }); + } + + await store.storeKyberPreKeys(ourUuid, toSave); + await storage.put(KYBER_KEY_ID_KEY[uuidKind], startId + count); + + return toUpload; + } + + async maybeUpdateKeys(uuidKind: UUIDKind): Promise { + const logId = `maybeUpdateKeys(${uuidKind})`; + await this.queueTask(async () => { + const { count: preKeyCount, pqCount: kyberPreKeyCount } = + await this.server.getMyKeyCounts(uuidKind); + + let preKeys: Array | undefined; + if (preKeyCount < PRE_KEY_MINIMUM) { + log.info( + `${logId}: Server prekey count is ${preKeyCount}, generating a new set` + ); + preKeys = await this.generateNewPreKeys( + uuidKind, + PRE_KEY_GEN_BATCH_SIZE + ); + } + + let pqPreKeys: Array | undefined; + if (kyberPreKeyCount < PRE_KEY_MINIMUM) { + log.info( + `${logId}: Server kyber prekey count is ${kyberPreKeyCount}, generating a new set` + ); + pqPreKeys = await this.generateNewKyberPreKeys( + uuidKind, + PRE_KEY_GEN_BATCH_SIZE + ); + } + + const pqLastResortPreKey = await this.maybeUpdateLastResortKyberKey( + uuidKind + ); + const signedPreKey = await this.maybeUpdateSignedPreKey(uuidKind); + + if ( + !preKeys?.length && + !signedPreKey && + !pqLastResortPreKey && + !pqPreKeys?.length + ) { + log.info(`${logId}: No new keys are needed; returning early`); + return; + } + + const keySummary: Array = []; + if (preKeys?.length) { + keySummary.push(`${!preKeys?.length || 0} prekeys`); + } + if (signedPreKey) { + keySummary.push('a signed prekey'); + } + if (pqLastResortPreKey) { + keySummary.push('a last-resort kyber prekey'); + } + if (pqPreKeys?.length) { + keySummary.push(`${!pqPreKeys?.length || 0} kyber prekeys`); + } + log.info(`${logId}: Uploading with ${keySummary.join(', ')}`); + + const { storage } = window.textsecure; + const ourUuid = storage.user.getCheckedUuid(uuidKind); + const identityKey = this.getIdentityKeyOrThrow(ourUuid); + const toUpload = { + identityKey: identityKey.pubKey, + preKeys, + pqPreKeys, + pqLastResortPreKey, + signedPreKey, + }; try { - await server.setSignedPreKey( - { - keyId: res.keyId, - publicKey: res.keyPair.pubKey, - signature: res.signature, - }, - uuidKind - ); + await this.server.registerKeys(toUpload, uuidKind); } catch (error) { - log.error( - `rotateSignedPrekey(${uuidKind}) error:`, - Errors.toLogFormat(error) - ); - - if ( - error instanceof HTTPError && - error.code >= 400 && - error.code <= 599 - ) { - const rejections = - 1 + window.textsecure.storage.get('signedKeyRotationRejected', 0); - await window.textsecure.storage.put( - 'signedKeyRotationRejected', - rejections - ); - log.error( - `rotateSignedPreKey(${uuidKind}): Signed key rotation rejected count:`, - rejections - ); - - return; - } + log.error(`${logId} upload error:`, Errors.toLogFormat(error)); throw error; } - const confirmed = true; - log.info('Confirming new signed prekey', res.keyId); - await Promise.all([ - window.textsecure.storage.remove('signedKeyRotationRejected'), - store.storeSignedPreKey(ourUuid, res.keyId, res.keyPair, confirmed), - ]); + await this._confirmKeys(toUpload, uuidKind); - try { - await Promise.all([ - this.cleanSignedPreKeys(UUIDKind.ACI), - this.cleanSignedPreKeys(UUIDKind.PNI), - ]); - } catch (_error) { - // Ignoring the error - } + const { count: updatedPreKeyCount, pqCount: updatedKyberPreKeyCount } = + await this.server.getMyKeyCounts(uuidKind); + log.info( + `${logId}: Successfully updated; ` + + `server prekey count: ${updatedPreKeyCount}, ` + + `server kyber prekey count: ${updatedKyberPreKeyCount}` + ); + + await this._cleanSignedPreKeys(uuidKind); + await this._cleanLastResortKeys(uuidKind); + await this._cleanPreKeys(uuidKind); + await this._cleanKyberPreKeys(uuidKind); }); } - async queueTask(task: () => Promise): Promise { - this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 }); - const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task'); + areKeysOutOfDate(uuidKind: UUIDKind): boolean { + const signedPreKeyTime = window.storage.get( + SIGNED_PRE_KEY_UPDATE_TIME_KEY[uuidKind], + 0 + ); + const lastResortKeyTime = window.storage.get( + LAST_RESORT_KEY_UPDATE_TIME_KEY[uuidKind], + 0 + ); - return this.pendingQueue.add(taskWithTimeout); + if (isOlderThan(signedPreKeyTime, KEY_TOO_OLD_THRESHOLD)) { + return true; + } + if (isOlderThan(lastResortKeyTime, KEY_TOO_OLD_THRESHOLD)) { + return true; + } + + return false; } - async cleanSignedPreKeys(uuidKind: UUIDKind): Promise { + private async maybeUpdateSignedPreKey( + uuidKind: UUIDKind + ): Promise { + const logId = `AccountManager.maybeUpdateSignedPreKey(${uuidKind})`; + const store = window.textsecure.storage.protocol; + + const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + const signedKeyId = getNextKeyId(uuidKind, SIGNED_PRE_KEY_ID_KEY); + if (typeof signedKeyId !== 'number') { + throw new Error( + `${logId}: Invalid ${SIGNED_PRE_KEY_ID_KEY[uuidKind]} in storage` + ); + } + + const keys = await store.loadSignedPreKeys(ourUuid); + const sortedKeys = orderBy(keys, ['created_at'], ['desc']); + const confirmedKeys = sortedKeys.filter(key => key.confirmed); + const mostRecent = confirmedKeys[0]; + + const lastUpdate = mostRecent?.created_at; + if (isMoreRecentThan(lastUpdate || 0, SIGNED_PRE_KEY_ROTATION_AGE)) { + log.warn( + `${logId}: ${confirmedKeys.length} confirmed keys, ` + + `most recent was created ${lastUpdate}. No need to update.` + ); + const existing = window.storage.get( + SIGNED_PRE_KEY_UPDATE_TIME_KEY[uuidKind] + ); + if (lastUpdate && !existing) { + log.warn(`${logId}: Updating last update time to ${lastUpdate}`); + await window.storage.put( + SIGNED_PRE_KEY_UPDATE_TIME_KEY[uuidKind], + lastUpdate + ); + } + return; + } + + const identityKey = this.getIdentityKeyOrThrow(ourUuid); + + const key = await generateSignedPreKey(identityKey, signedKeyId); + log.info(`${logId}: Saving new signed prekey`, key.keyId); + + await Promise.all([ + window.textsecure.storage.put( + SIGNED_PRE_KEY_ID_KEY[uuidKind], + signedKeyId + 1 + ), + store.storeSignedPreKey(ourUuid, key.keyId, key.keyPair), + ]); + + return { + keyId: key.keyId, + publicKey: key.keyPair.pubKey, + signature: key.signature, + }; + } + + private async maybeUpdateLastResortKyberKey( + uuidKind: UUIDKind + ): Promise { + const logId = `maybeUpdateLastResortKyberKey(${uuidKind})`; + const store = window.textsecure.storage.protocol; + + const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + const kyberKeyId = getNextKeyId(uuidKind, KYBER_KEY_ID_KEY); + if (typeof kyberKeyId !== 'number') { + throw new Error( + `${logId}: Invalid ${KYBER_KEY_ID_KEY[uuidKind]} in storage` + ); + } + + const keys = store.loadKyberPreKeys(ourUuid, { isLastResort: true }); + const sortedKeys = orderBy(keys, ['createdAt'], ['desc']); + const confirmedKeys = sortedKeys.filter(key => key.isConfirmed); + const mostRecent = confirmedKeys[0]; + + const lastUpdate = mostRecent?.createdAt; + if (isMoreRecentThan(lastUpdate || 0, LAST_RESORT_KEY_ROTATION_AGE)) { + log.warn( + `${logId}: ${confirmedKeys.length} confirmed keys, ` + + `most recent was created ${lastUpdate}. No need to update.` + ); + const existing = window.storage.get( + LAST_RESORT_KEY_UPDATE_TIME_KEY[uuidKind] + ); + if (lastUpdate && !existing) { + log.warn(`${logId}: Updating last update time to ${lastUpdate}`); + await window.storage.put( + LAST_RESORT_KEY_UPDATE_TIME_KEY[uuidKind], + lastUpdate + ); + } + return; + } + + const identityKey = this.getIdentityKeyOrThrow(ourUuid); + + const keyId = kyberKeyId; + const record = await generateKyberPreKey(identityKey, keyId); + log.info(`${logId}: Saving new last resort prekey`, keyId); + const key = { + createdAt: Date.now(), + data: record.serialize(), + isConfirmed: false, + isLastResort: true, + keyId, + ourUuid: ourUuid.toString(), + }; + + await Promise.all([ + window.textsecure.storage.put(KYBER_KEY_ID_KEY[uuidKind], kyberKeyId + 1), + store.storeKyberPreKeys(ourUuid, [key]), + ]); + + return { + keyId, + publicKey: record.publicKey().serialize(), + signature: record.signature(), + }; + } + + // Exposed only for tests + async _cleanSignedPreKeys(uuidKind: UUIDKind): Promise { const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); const store = window.textsecure.storage.protocol; const logId = `AccountManager.cleanSignedPreKeys(${uuidKind})`; - const allKeys = await store.loadSignedPreKeys(ourUuid); - allKeys.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); - const confirmed = allKeys.filter(key => key.confirmed); - const unconfirmed = allKeys.filter(key => !key.confirmed); + const allKeys = store.loadSignedPreKeys(ourUuid); + const sortedKeys = orderBy(allKeys, ['created_at'], ['desc']); + const confirmed = sortedKeys.filter(key => key.confirmed); + const unconfirmed = sortedKeys.filter(key => !key.confirmed); - const recent = allKeys[0] ? allKeys[0].keyId : 'none'; + const recent = sortedKeys[0] ? sortedKeys[0].keyId : 'none'; const recentConfirmed = confirmed[0] ? confirmed[0].keyId : 'none'; const recentUnconfirmed = unconfirmed[0] ? unconfirmed[0].keyId : 'none'; log.info(`${logId}: Most recent signed key: ${recent}`); @@ -485,31 +730,143 @@ export default class AccountManager extends EventTarget { ); log.info( `${logId}: Total signed key count:`, - allKeys.length, + sortedKeys.length, '-', confirmed.length, 'confirmed' ); - // Keep MINIMUM_SIGNED_PREKEYS keys, then drop if older than ARCHIVE_AGE - await Promise.all( - allKeys.map(async (key, index) => { - if (index < MINIMUM_SIGNED_PREKEYS) { - return; - } - const createdAt = key.created_at || 0; + // Keep SIGNED_PRE_KEY_MINIMUM keys, drop if older than SIGNED_PRE_KEY_ARCHIVE_AGE - if (isOlderThan(createdAt, ARCHIVE_AGE)) { - const timestamp = new Date(createdAt).toJSON(); - const confirmedText = key.confirmed ? ' (confirmed)' : ''; - log.info( - `${logId}: Removing signed prekey: ${key.keyId} with ` + - `timestamp ${timestamp}${confirmedText}` - ); - await store.removeSignedPreKey(ourUuid, key.keyId); - } - }) + const toDelete: Array = []; + sortedKeys.forEach((key, index) => { + if (index < SIGNED_PRE_KEY_MINIMUM) { + return; + } + const createdAt = key.created_at || 0; + + if (isOlderThan(createdAt, SIGNED_PRE_KEY_ARCHIVE_AGE)) { + const timestamp = new Date(createdAt).toJSON(); + const confirmedText = key.confirmed ? ' (confirmed)' : ''; + log.info( + `${logId}: Removing signed prekey: ${key.keyId} with ` + + `timestamp ${timestamp}${confirmedText}` + ); + toDelete.push(key.keyId); + } + }); + if (toDelete.length > 0) { + log.info(`${logId}: Removing ${toDelete.length} signed prekeys`); + await store.removeSignedPreKeys(ourUuid, toDelete); + } + } + + // Exposed only for tests + async _cleanLastResortKeys(uuidKind: UUIDKind): Promise { + const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + const store = window.textsecure.storage.protocol; + const logId = `AccountManager.cleanLastResortKeys(${uuidKind})`; + + const allKeys = store.loadKyberPreKeys(ourUuid, { isLastResort: true }); + const sortedKeys = orderBy(allKeys, ['createdAt'], ['desc']); + const confirmed = sortedKeys.filter(key => key.isConfirmed); + const unconfirmed = sortedKeys.filter(key => !key.isConfirmed); + + const recent = sortedKeys[0] ? sortedKeys[0].keyId : 'none'; + const recentConfirmed = confirmed[0] ? confirmed[0].keyId : 'none'; + const recentUnconfirmed = unconfirmed[0] ? unconfirmed[0].keyId : 'none'; + log.info(`${logId}: Most recent last resort key: ${recent}`); + log.info( + `${logId}: Most recent confirmed last resort key: ${recentConfirmed}` ); + log.info( + `${logId}: Most recent unconfirmed last resort key: ${recentUnconfirmed}` + ); + log.info( + `${logId}: Total last resort key count:`, + sortedKeys.length, + '-', + confirmed.length, + 'confirmed' + ); + + // Keep LAST_RESORT_KEY_MINIMUM keys, drop if older than LAST_RESORT_KEY_ARCHIVE_AGE + + const toDelete: Array = []; + sortedKeys.forEach((key, index) => { + if (index < LAST_RESORT_KEY_MINIMUM) { + return; + } + const createdAt = key.createdAt || 0; + + if (isOlderThan(createdAt, LAST_RESORT_KEY_ARCHIVE_AGE)) { + const timestamp = new Date(createdAt).toJSON(); + const confirmedText = key.isConfirmed ? ' (confirmed)' : ''; + log.info( + `${logId}: Removing last resort key: ${key.keyId} with ` + + `timestamp ${timestamp}${confirmedText}` + ); + toDelete.push(key.keyId); + } + }); + if (toDelete.length > 0) { + log.info(`${logId}: Removing ${toDelete.length} last resort keys`); + await store.removeKyberPreKeys(ourUuid, toDelete); + } + } + + async _cleanPreKeys(uuidKind: UUIDKind): Promise { + const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + const store = window.textsecure.storage.protocol; + const logId = `AccountManager.cleanPreKeys(${uuidKind})`; + + const preKeys = store.loadPreKeys(ourUuid); + const toDelete: Array = []; + const sortedKeys = orderBy(preKeys, ['createdAt'], ['desc']); + + sortedKeys.forEach((key, index) => { + if (index < PRE_KEY_MAX_COUNT) { + return; + } + const createdAt = key.createdAt || 0; + + if (isOlderThan(createdAt, PRE_KEY_ARCHIVE_AGE)) { + toDelete.push(key.keyId); + } + }); + + log.info(`${logId}: ${sortedKeys.length} total prekeys`); + if (toDelete.length > 0) { + log.info(`${logId}: Removing ${toDelete.length} obsolete prekeys`); + await store.removePreKeys(ourUuid, toDelete); + } + } + + async _cleanKyberPreKeys(uuidKind: UUIDKind): Promise { + const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + const store = window.textsecure.storage.protocol; + const logId = `AccountManager.cleanKyberPreKeys(${uuidKind})`; + + const preKeys = store.loadKyberPreKeys(ourUuid, { isLastResort: false }); + const toDelete: Array = []; + const sortedKeys = orderBy(preKeys, ['createdAt'], ['desc']); + + sortedKeys.forEach((key, index) => { + if (index < PRE_KEY_MAX_COUNT) { + return; + } + const createdAt = key.createdAt || 0; + + if (isOlderThan(createdAt, PRE_KEY_ARCHIVE_AGE)) { + toDelete.push(key.keyId); + } + }); + + log.info(`${logId}: ${sortedKeys.length} total prekeys`); + if (toDelete.length > 0) { + log.info(`${logId}: Removing ${toDelete.length} kyber keys`); + await store.removeKyberPreKeys(ourUuid, toDelete); + } } async createAccount({ @@ -691,113 +1048,95 @@ export default class AccountManager extends EventTarget { await storage.protocol.hydrateCaches(); } - async clearSessionsAndPreKeys(): Promise { - const store = window.textsecure.storage.protocol; - - log.info('clearing all sessions, prekeys, and signed prekeys'); - await Promise.all([ - store.clearPreKeyStore(), - store.clearSignedPreKeysStore(), - store.clearSessionStore(), - ]); - } - - // Takes the same object returned by generateKeys - async confirmKeys( - keys: GeneratedKeysType, + // Exposed only for testing + public async _confirmKeys( + keys: UploadKeysType, uuidKind: UUIDKind ): Promise { - const store = window.textsecure.storage.protocol; - const key = keys.signedPreKey; - const confirmed = true; + const logId = `AccountManager.confirmKeys(${uuidKind})`; + const { storage } = window.textsecure; + const store = storage.protocol; + const ourUuid = storage.user.getCheckedUuid(uuidKind); - if (!key) { - throw new Error('confirmKeys: signedPreKey is null'); + const updatedAt = Date.now(); + const { signedPreKey, pqLastResortPreKey } = keys; + if (signedPreKey) { + log.info(`${logId}: confirming signed prekey key`, signedPreKey.keyId); + await store.confirmSignedPreKey(ourUuid, signedPreKey.keyId); + await window.storage.put( + SIGNED_PRE_KEY_UPDATE_TIME_KEY[uuidKind], + updatedAt + ); + } else { + log.info(`${logId}: signedPreKey was not uploaded, not confirming`); } - log.info( - `AccountManager.confirmKeys(${uuidKind}): confirming key`, - key.keyId - ); - const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); - await store.storeSignedPreKey(ourUuid, key.keyId, key.keyPair, confirmed); + if (pqLastResortPreKey) { + log.info( + `${logId}: confirming last resort key`, + pqLastResortPreKey.keyId + ); + await store.confirmKyberPreKey(ourUuid, pqLastResortPreKey.keyId); + await window.storage.put( + LAST_RESORT_KEY_UPDATE_TIME_KEY[uuidKind], + updatedAt + ); + } else { + log.info(`${logId}: pqLastResortPreKey was not uploaded, not confirming`); + } } - async generateKeys( + // Very similar to maybeUpdateKeys, but will always generate prekeys and doesn't upload + async _generateKeys( count: number, uuidKind: UUIDKind, maybeIdentityKey?: KeyPairType - ): Promise { + ): Promise { + const logId = `AcountManager.generateKeys(${uuidKind})`; const { storage } = window.textsecure; - - const startId = storage.get('maxPreKeyId', 1); - const signedKeyId = storage.get('signedKeyId', 1); + const store = storage.protocol; const ourUuid = storage.user.getCheckedUuid(uuidKind); - if (typeof startId !== 'number') { - throw new Error('Invalid maxPreKeyId'); - } - if (typeof signedKeyId !== 'number') { - throw new Error('Invalid signedKeyId'); - } - - const store = storage.protocol; const identityKey = maybeIdentityKey ?? store.getIdentityKeyPair(ourUuid); strictAssert(identityKey, 'generateKeys: No identity key pair!'); - const result: Omit = { - preKeys: [], - identityKey: identityKey.pubKey, - }; - const promises = []; + const preKeys = await this.generateNewPreKeys(uuidKind, count); + const pqPreKeys = await this.generateNewKyberPreKeys(uuidKind, count); + const pqLastResortPreKey = await this.maybeUpdateLastResortKyberKey( + uuidKind + ); + const signedPreKey = await this.maybeUpdateSignedPreKey(uuidKind); - for (let keyId = startId; keyId < startId + count; keyId += 1) { - promises.push( - (async () => { - const res = generatePreKey(keyId); - await store.storePreKey(ourUuid, res.keyId, res.keyPair); - result.preKeys.push({ - keyId: res.keyId, - publicKey: res.keyPair.pubKey, - }); - })() - ); - } + log.info( + `${logId}: Generated ` + + `${preKeys.length} pre keys, ` + + `${pqPreKeys.length} kyber pre keys, ` + + `${pqLastResortPreKey ? 'a' : 'NO'} last resort kyber pre key, ` + + `and ${signedPreKey ? 'a' : 'NO'} signed pre key.` + ); - const signedPreKey = (async () => { - const res = generateSignedPreKey(identityKey, signedKeyId); - await store.storeSignedPreKey(ourUuid, res.keyId, res.keyPair); - return { - keyId: res.keyId, - publicKey: res.keyPair.pubKey, - signature: res.signature, - // server.registerKeys doesn't use keyPair, confirmKeys does - keyPair: res.keyPair, - }; - })(); - - promises.push(signedPreKey); - promises.push(storage.put('maxPreKeyId', startId + count)); - promises.push(storage.put('signedKeyId', signedKeyId + 1)); - - await Promise.all(promises); - - // This is primarily for the signed prekey summary it logs out - void this.cleanSignedPreKeys(UUIDKind.ACI); - void this.cleanSignedPreKeys(UUIDKind.PNI); + // These are primarily for the summaries they log out + await this._cleanPreKeys(uuidKind); + await this._cleanKyberPreKeys(uuidKind); + await this._cleanLastResortKeys(uuidKind); + await this._cleanSignedPreKeys(uuidKind); return { - ...result, - signedPreKey: await signedPreKey, + identityKey: identityKey.pubKey, + preKeys, + pqPreKeys, + pqLastResortPreKey, + signedPreKey, }; } - async registrationDone(): Promise { + private async registrationDone(): Promise { log.info('registration done'); this.dispatchEvent(new Event('registration')); } async setPni(pni: string, keyMaterial?: PniKeyMaterialType): Promise { + const logId = `AccountManager.setPni(${pni})`; const { storage } = window.textsecure; const oldPni = storage.user.getUuid(UUIDKind.PNI)?.toString(); @@ -805,7 +1144,7 @@ export default class AccountManager extends EventTarget { return; } - log.info(`AccountManager.setPni(${pni}): updating from ${oldPni}`); + log.info(`${logId}: updating from ${oldPni}`); if (oldPni) { await storage.protocol.removeOurOldPni(new UUID(oldPni)); @@ -823,15 +1162,10 @@ export default class AccountManager extends EventTarget { // of MessageReceiver. void this.queueTask(async () => { try { - const keys = await this.generateKeys( - SIGNED_KEY_GEN_BATCH_SIZE, - UUIDKind.PNI - ); - await this.server.registerKeys(keys, UUIDKind.PNI); - await this.confirmKeys(keys, UUIDKind.PNI); + await this.maybeUpdateKeys(UUIDKind.PNI); } catch (error) { log.error( - 'setPni: Failed to upload PNI prekeys. Moving on', + `${logId}: Failed to upload PNI prekeys. Moving on`, Errors.toLogFormat(error) ); } @@ -840,7 +1174,7 @@ export default class AccountManager extends EventTarget { // PNI has changed and credentials are no longer valid await storage.put('groupCredentials', []); } else { - log.warn(`AccountManager.setPni(${pni}): no key material`); + log.warn(`${logId}: no key material`); } } } diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts index 88e1ddf27267..a4dd40c2e695 100644 --- a/ts/textsecure/Errors.ts +++ b/ts/textsecure/Errors.ts @@ -251,15 +251,6 @@ export class SendMessageProtoError extends Error implements CallbackResultType { } } -export class SignedPreKeyRotationError extends ReplayableError { - constructor() { - super({ - name: 'SignedPreKeyRotationError', - message: 'Too many signed prekey rotation failures', - }); - } -} - export class MessageError extends ReplayableError { readonly httpError: HTTPError; diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 28559e2b0589..816ea1af0bf2 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -32,6 +32,7 @@ import { import { IdentityKeys, + KyberPreKeys, PreKeys, SenderKeys, Sessions, @@ -1758,6 +1759,7 @@ export default class MessageReceiver const preKeyStore = new PreKeys({ ourUuid: destinationUuid }); const signedPreKeyStore = new SignedPreKeys({ ourUuid: destinationUuid }); + const kyberPreKeyStore = new KyberPreKeys({ ourUuid: destinationUuid }); const sealedSenderIdentifier = envelope.sourceUuid; strictAssert( @@ -1786,7 +1788,8 @@ export default class MessageReceiver sessionStore, identityKeyStore, preKeyStore, - signedPreKeyStore + signedPreKeyStore, + kyberPreKeyStore ), zone ); @@ -1811,6 +1814,7 @@ export default class MessageReceiver const { destinationUuid } = envelope; const preKeyStore = new PreKeys({ ourUuid: destinationUuid }); const signedPreKeyStore = new SignedPreKeys({ ourUuid: destinationUuid }); + const kyberPreKeyStore = new KyberPreKeys({ ourUuid: destinationUuid }); strictAssert(identifier !== undefined, 'Empty identifier'); strictAssert(sourceDevice !== undefined, 'Empty source device'); @@ -1903,7 +1907,8 @@ export default class MessageReceiver sessionStore, identityKeyStore, preKeyStore, - signedPreKeyStore + signedPreKeyStore, + kyberPreKeyStore ) ), zone @@ -2105,17 +2110,18 @@ export default class MessageReceiver msg: Proto.IStoryMessage, sentMessage?: ProcessedSent ): Promise { - const logId = getEnvelopeId(envelope); + const envelopeId = getEnvelopeId(envelope); + const logId = `MessageReceiver.handleStoryMessage(${envelopeId})`; logUnexpectedUrgentValue(envelope, 'story'); if (getStoriesBlocked()) { - log.info('MessageReceiver.handleStoryMessage: dropping', logId); + log.info(`${logId}: dropping`); this.removeFromCache(envelope); return; } - log.info('MessageReceiver.handleStoryMessage', logId); + log.info(`${logId} starting`); const attachments: Array = []; let preview: ReadonlyArray | undefined; @@ -2150,11 +2156,7 @@ export default class MessageReceiver const groupV2 = msg.group ? processGroupV2Context(msg.group) : undefined; if (groupV2 && this.isGroupBlocked(groupV2.id)) { - log.warn( - `MessageReceiver.handleStoryMessage: envelope ${getEnvelopeId( - envelope - )} ignored; destined for blocked group` - ); + log.warn(`${logId}: ignored; destined for blocked group`); this.removeFromCache(envelope); return; } @@ -2165,10 +2167,7 @@ export default class MessageReceiver ); if (timeRemaining <= 0) { - log.info( - 'MessageReceiver.handleStoryMessage: story already expired', - logId - ); + log.info(`${logId}: story already expired`); this.removeFromCache(envelope); return; } @@ -2188,6 +2187,7 @@ export default class MessageReceiver }; if (sentMessage && message.groupV2) { + log.warn(`${logId}: envelope is a sent group story`); const ev = new SentEvent( { destinationUuid: { @@ -2220,6 +2220,7 @@ export default class MessageReceiver } if (sentMessage) { + log.warn(`${logId}: envelope is a sent distribution list story`); const { storyMessageRecipients } = sentMessage; const recipients = storyMessageRecipients ?? []; @@ -2248,8 +2249,7 @@ export default class MessageReceiver } else { assertDev( false, - `MessageReceiver.handleStoryMessage(${logId}): missing ` + - `distribution list id for: ${destinationUuid}` + `${logId}: missing distribution list id for: ${destinationUuid}` ); } @@ -2296,6 +2296,7 @@ export default class MessageReceiver return; } + log.warn(`${logId}: envelope is a received story`); const ev = new MessageEvent( { source: envelope.source, @@ -3241,6 +3242,7 @@ export default class MessageReceiver { identityKeyPair, signedPreKey, + lastResortKyberPreKey, registrationId, newE164, }: Proto.SyncMessage.IPniChangeNumber @@ -3255,6 +3257,7 @@ export default class MessageReceiver return; } + // TDOO: DESKTOP-5652 if ( !Bytes.isNotEmpty(identityKeyPair) || !Bytes.isNotEmpty(signedPreKey) || @@ -3268,6 +3271,7 @@ export default class MessageReceiver const manager = window.getAccountManager(); await manager.setPni(updatedPni.toString(), { identityKeyPair, + lastResortKyberPreKey: dropNull(lastResortKyberPreKey), signedPreKey, registrationId, }); diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 6d06e200d271..c09973a7f93d 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -25,7 +25,7 @@ import type { TextAttachmentType, UploadedAttachmentType, } from '../types/Attachment'; -import type { UUID, TaggedUUIDStringType } from '../types/UUID'; +import { type UUID, type TaggedUUIDStringType, UUIDKind } from '../types/UUID'; import type { ChallengeType, GetGroupLogOptionsType, @@ -53,7 +53,6 @@ import * as Bytes from '../Bytes'; import { getRandomBytes } from '../Crypto'; import { MessageError, - SignedPreKeyRotationError, SendMessageProtoError, HTTPError, NoSenderKeyError, @@ -79,6 +78,7 @@ import { numberToAddressType, } from '../types/EmbeddedContact'; import { missingCaseError } from '../util/missingCaseError'; +import { drop } from '../util/drop'; export type SendMetadataType = { [identifier: string]: { @@ -956,27 +956,31 @@ export default class MessageSender { }); return new Promise((resolve, reject) => { - this.sendMessageProto({ - callback: (res: CallbackResultType) => { - if (res.errors && res.errors.length > 0) { - reject(new SendMessageProtoError(res)); - } else { - resolve(res); - } - }, - contentHint, - groupId, - options, - proto, - recipients: messageOptions.recipients || [], - timestamp: messageOptions.timestamp, - urgent, - story, - }); + drop( + this.sendMessageProto({ + callback: (res: CallbackResultType) => { + if (res.errors && res.errors.length > 0) { + reject(new SendMessageProtoError(res)); + } else { + resolve(res); + } + }, + contentHint, + groupId, + options, + proto, + recipients: messageOptions.recipients || [], + timestamp: messageOptions.timestamp, + urgent, + story, + }) + ); }); } - sendMessageProto({ + // Note: all the other low-level sends call this, so it is a chokepoint for 1:1 sends + // The chokepoint for group sends is sendContentMessageToGroup + async sendMessageProto({ callback, contentHint, groupId, @@ -998,13 +1002,26 @@ export default class MessageSender { story?: boolean; timestamp: number; urgent: boolean; - }>): void { - const rejections = window.textsecure.storage.get( - 'signedKeyRotationRejected', - 0 - ); - if (rejections > 5) { - throw new SignedPreKeyRotationError(); + }>): Promise { + const accountManager = window.getAccountManager(); + try { + if (accountManager.areKeysOutOfDate(UUIDKind.ACI)) { + log.warn( + `sendMessageProto/${timestamp}: Keys are out of date; updating before send` + ); + await accountManager.maybeUpdateKeys(UUIDKind.ACI); + if (accountManager.areKeysOutOfDate(UUIDKind.ACI)) { + throw new Error('Keys still out of date after update'); + } + } + } catch (error) { + // TODO: DESKTOP-5642 + callback({ + dataMessage: undefined, + editMessage: undefined, + errors: [error], + }); + return; } const outgoing = new OutgoingMessage({ @@ -1022,8 +1039,10 @@ export default class MessageSender { }); recipients.forEach(identifier => { - void this.queueJobForIdentifier(identifier, async () => - outgoing.sendToIdentifier(identifier) + drop( + this.queueJobForIdentifier(identifier, async () => + outgoing.sendToIdentifier(identifier) + ) ); }); } @@ -1056,17 +1075,19 @@ export default class MessageSender { resolve(result); }; - this.sendMessageProto({ - callback, - contentHint, - groupId, - options, - proto, - recipients, - timestamp, - urgent, - story, - }); + drop( + this.sendMessageProto({ + callback, + contentHint, + groupId, + options, + proto, + recipients, + timestamp, + urgent, + story, + }) + ); }); } @@ -1096,16 +1117,18 @@ export default class MessageSender { resolve(res); } }; - this.sendMessageProto({ - callback, - contentHint, - groupId, - options, - proto, - recipients: [identifier], - timestamp, - urgent, - }); + drop( + this.sendMessageProto({ + callback, + contentHint, + groupId, + options, + proto, + recipients: [identifier], + timestamp, + urgent, + }) + ); }); } @@ -2036,18 +2059,20 @@ export default class MessageSender { } }; - this.sendMessageProto({ - callback, - contentHint, - groupId, - options, - proto, - recipients: identifiers, - sendLogCallback, - story, - timestamp, - urgent, - }); + drop( + this.sendMessageProto({ + callback, + contentHint, + groupId, + options, + proto, + recipients: identifiers, + sendLogCallback, + story, + timestamp, + urgent, + }) + ); }); } diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index f416722208f0..df0d2019eb05 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -14,6 +14,7 @@ import type { RawBodyRange } from '../types/BodyRange'; export { IdentityKeyType, IdentityKeyIdType, + KyberPreKeyType, PreKeyIdType, PreKeyType, SenderKeyIdType, @@ -286,6 +287,7 @@ export type IRequestHandler = { export type PniKeyMaterialType = Readonly<{ identityKeyPair: Uint8Array; signedPreKey: Uint8Array; + lastResortKyberPreKey?: Uint8Array; registrationId: number; }>; diff --git a/ts/textsecure/RotateSignedPreKeyListener.ts b/ts/textsecure/UpdateKeysListener.ts similarity index 56% rename from ts/textsecure/RotateSignedPreKeyListener.ts rename to ts/textsecure/UpdateKeysListener.ts index c74339f6c057..d9d26d87248d 100644 --- a/ts/textsecure/RotateSignedPreKeyListener.ts +++ b/ts/textsecure/UpdateKeysListener.ts @@ -1,13 +1,17 @@ // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isNumber } from 'lodash'; + import * as durations from '../util/durations'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import * as Registration from '../util/registration'; import { UUIDKind } from '../types/UUID'; import * as log from '../logging/log'; +import * as Errors from '../types/errors'; -const ROTATION_INTERVAL = 2 * durations.DAY; +const UPDATE_INTERVAL = 2 * durations.DAY; +const UPDATE_TIME_STORAGE_KEY = 'nextScheduledUpdateKeyTime'; export type MinimalEventsType = { on(event: 'timetravel', callback: () => void): void; @@ -15,23 +19,20 @@ export type MinimalEventsType = { let initComplete = false; -export class RotateSignedPreKeyListener { +export class UpdateKeysListener { public timeout: NodeJS.Timeout | undefined; - protected scheduleRotationForNow(): void { + protected scheduleUpdateForNow(): void { const now = Date.now(); - void window.textsecure.storage.put('nextSignedKeyRotationTime', now); + void window.textsecure.storage.put(UPDATE_TIME_STORAGE_KEY, now); } protected setTimeoutForNextRun(): void { const now = Date.now(); - const time = window.textsecure.storage.get( - 'nextSignedKeyRotationTime', - now - ); + const time = window.textsecure.storage.get(UPDATE_TIME_STORAGE_KEY, now); log.info( - 'Next signed key rotation scheduled for', + 'UpdateKeysListener: Next update scheduled for', new Date(time).toISOString() ); @@ -44,31 +45,29 @@ export class RotateSignedPreKeyListener { this.timeout = setTimeout(() => this.runWhenOnline(), waitTime); } - private scheduleNextRotation(): void { + private scheduleNextUpdate(): void { const now = Date.now(); - const nextTime = now + ROTATION_INTERVAL; - void window.textsecure.storage.put('nextSignedKeyRotationTime', nextTime); + const nextTime = now + UPDATE_INTERVAL; + void window.textsecure.storage.put(UPDATE_TIME_STORAGE_KEY, nextTime); } private async run(): Promise { - log.info('Rotating signed prekey...'); + log.info('UpdateKeysListener: Updating keys...'); try { const accountManager = window.getAccountManager(); - await Promise.all([ - accountManager.rotateSignedPreKey(UUIDKind.ACI), - accountManager.rotateSignedPreKey(UUIDKind.PNI), - ]); - // We try to update this whenever we remove a preKey; this is a fail-safe to ensure - // we're always in good shape - await Promise.all([ - accountManager.refreshPreKeys(UUIDKind.ACI), - accountManager.refreshPreKeys(UUIDKind.PNI), - ]); - this.scheduleNextRotation(); + await accountManager.maybeUpdateKeys(UUIDKind.ACI); + await accountManager.maybeUpdateKeys(UUIDKind.PNI); + + this.scheduleNextUpdate(); this.setTimeoutForNextRun(); } catch (error) { - log.error('rotateSignedPrekey() failed. Trying again in five minutes'); + const errorString = isNumber(error.code) + ? error.code.toString() + : Errors.toLogFormat(error); + log.error( + `UpdateKeysListener.run failure - trying again in five minutes ${errorString}` + ); setTimeout(() => this.setTimeoutForNextRun(), 5 * durations.MINUTE); } } @@ -77,7 +76,9 @@ export class RotateSignedPreKeyListener { if (window.navigator.onLine) { void this.run(); } else { - log.info('We are offline; keys will be rotated when we are next online'); + log.info( + 'UpdateKeysListener: We are offline; will update keys when we are next online' + ); const listener = () => { window.removeEventListener('online', listener); this.setTimeoutForNextRun(); @@ -88,17 +89,15 @@ export class RotateSignedPreKeyListener { public static init(events: MinimalEventsType, newVersion: boolean): void { if (initComplete) { - window.SignalContext.log.info( - 'Rotate signed prekey listener: Already initialized' - ); + window.SignalContext.log.info('UpdateKeysListener: Already initialized'); return; } initComplete = true; - const listener = new RotateSignedPreKeyListener(); + const listener = new UpdateKeysListener(); if (newVersion) { - listener.scheduleRotationForNow(); + listener.scheduleUpdateForNow(); } listener.setTimeoutForNextRun(); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 1fc79bd8d36e..8c365a93f3d1 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -870,6 +870,11 @@ const attachmentV3Response = z.object({ export type AttachmentV3ResponseType = z.infer; +export type ServerKeyCountType = { + count: number; + pqCount: number; +}; + export type WebAPIType = { startRegistration(): unknown; finishRegistration(baton: unknown): void; @@ -925,7 +930,7 @@ export type WebAPIType = { deviceId?: number, options?: { accessKey?: string } ) => Promise; - getMyKeys: (uuidKind: UUIDKind) => Promise; + getMyKeyCounts: (uuidKind: UUIDKind) => Promise; getOnboardingStoryManifest: () => Promise<{ version: string; languages: Record>; @@ -998,7 +1003,7 @@ export type WebAPIType = { ) => Promise; confirmUsername(options: ConfirmUsernameOptionsType): Promise; registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise; - registerKeys: (genKeys: KeysType, uuidKind: UUIDKind) => Promise; + registerKeys: (genKeys: UploadKeysType, uuidKind: UUIDKind) => Promise; registerSupportForUnauthenticatedDelivery: () => Promise; reportMessage: (options: ReportMessageOptionsType) => Promise; requestVerificationSMS: (number: string, token: string) => Promise; @@ -1032,10 +1037,6 @@ export type WebAPIType = { } ) => Promise; setPhoneNumberDiscoverability: (newValue: boolean) => Promise; - setSignedPreKey: ( - signedPreKey: SignedPreKeyType, - uuidKind: UUIDKind - ) => Promise; updateDeviceName: (deviceName: string) => Promise; uploadAvatar: ( uploadAvatarRequestHeaders: UploadAvatarHeadersType, @@ -1060,33 +1061,46 @@ export type WebAPIType = { reconnect: () => Promise; }; -export type SignedPreKeyType = { +export type UploadSignedPreKeyType = { keyId: number; publicKey: Uint8Array; signature: Uint8Array; }; +export type UploadPreKeyType = { + keyId: number; + publicKey: Uint8Array; +}; +export type UploadKyberPreKeyType = UploadSignedPreKeyType; -export type KeysType = { +export type UploadKeysType = { identityKey: Uint8Array; - signedPreKey: SignedPreKeyType; - preKeys: Array<{ - keyId: number; - publicKey: Uint8Array; - }>; + + // If a field is not provided, the server won't update its data. + preKeys?: Array; + pqPreKeys?: Array; + pqLastResortPreKey?: UploadSignedPreKeyType; + signedPreKey?: UploadSignedPreKeyType; }; export type ServerKeysType = { devices: Array<{ deviceId: number; registrationId: number; - signedPreKey: { + + // We'll get a 404 if none of these keys are provided; we'll have at least one + preKey?: { + keyId: number; + publicKey: Uint8Array; + }; + signedPreKey?: { keyId: number; publicKey: Uint8Array; signature: Uint8Array; }; - preKey?: { + pqPreKey?: { keyId: number; publicKey: Uint8Array; + signature: Uint8Array; }; }>; identityKey: Uint8Array; @@ -1293,7 +1307,7 @@ export function initialize({ getIceServers, getKeysForIdentifier, getKeysForIdentifierUnauth, - getMyKeys, + getMyKeyCounts, getOnboardingStoryManifest, getProfile, getProfileUnauth, @@ -1331,7 +1345,6 @@ export function initialize({ sendMessagesUnauth, sendWithSenderKey, setPhoneNumberDiscoverability, - setSignedPreKey, startRegistration, unregisterRequestHandler, updateDeviceName, @@ -2052,30 +2065,74 @@ export function initialize({ publicKey: string; signature: string; }; + type JSONPreKeyType = { + keyId: number; + publicKey: string; + }; + type JSONKyberPreKeyType = { + keyId: number; + publicKey: string; + signature: string; + }; type JSONKeysType = { identityKey: string; - signedPreKey: JSONSignedPreKeyType; - preKeys: Array<{ - keyId: number; - publicKey: string; - }>; + preKeys?: Array; + pqPreKeys?: Array; + pqLastResortPreKey?: JSONKyberPreKeyType; + signedPreKey?: JSONSignedPreKeyType; }; - async function registerKeys(genKeys: KeysType, uuidKind: UUIDKind) { - const preKeys = genKeys.preKeys.map(key => ({ + async function registerKeys(genKeys: UploadKeysType, uuidKind: UUIDKind) { + const preKeys = genKeys.preKeys?.map(key => ({ keyId: key.keyId, publicKey: Bytes.toBase64(key.publicKey), })); + const pqPreKeys = genKeys.pqPreKeys?.map(key => ({ + keyId: key.keyId, + publicKey: Bytes.toBase64(key.publicKey), + signature: Bytes.toBase64(key.signature), + })); + + if ( + !preKeys?.length && + !pqPreKeys?.length && + !genKeys.pqLastResortPreKey && + !genKeys.signedPreKey + ) { + throw new Error( + 'registerKeys: None of the four potential key types were provided!' + ); + } + if (preKeys && preKeys.length === 0) { + throw new Error('registerKeys: Attempting to upload zero preKeys!'); + } + if (pqPreKeys && pqPreKeys.length === 0) { + throw new Error('registerKeys: Attempting to upload zero pqPreKeys!'); + } const keys: JSONKeysType = { identityKey: Bytes.toBase64(genKeys.identityKey), - signedPreKey: { - keyId: genKeys.signedPreKey.keyId, - publicKey: Bytes.toBase64(genKeys.signedPreKey.publicKey), - signature: Bytes.toBase64(genKeys.signedPreKey.signature), - }, preKeys, + pqPreKeys, + ...(genKeys.pqLastResortPreKey + ? { + pqLastResortPreKey: { + keyId: genKeys.pqLastResortPreKey.keyId, + publicKey: Bytes.toBase64(genKeys.pqLastResortPreKey.publicKey), + signature: Bytes.toBase64(genKeys.pqLastResortPreKey.signature), + }, + } + : null), + ...(genKeys.signedPreKey + ? { + signedPreKey: { + keyId: genKeys.signedPreKey.keyId, + publicKey: Bytes.toBase64(genKeys.signedPreKey.publicKey), + signature: Bytes.toBase64(genKeys.signedPreKey.signature), + }, + } + : null), }; await _ajax({ @@ -2097,50 +2154,39 @@ export function initialize({ }); } - async function setSignedPreKey( - signedPreKey: SignedPreKeyType, + async function getMyKeyCounts( uuidKind: UUIDKind - ) { - await _ajax({ - call: 'signed', - urlParameters: `?${uuidKindToQuery(uuidKind)}`, - httpType: 'PUT', - jsonData: { - keyId: signedPreKey.keyId, - publicKey: Bytes.toBase64(signedPreKey.publicKey), - signature: Bytes.toBase64(signedPreKey.signature), - }, - }); - } - - type ServerKeyCountType = { - count: number; - }; - - async function getMyKeys(uuidKind: UUIDKind): Promise { + ): Promise { const result = (await _ajax({ call: 'keys', urlParameters: `?${uuidKindToQuery(uuidKind)}`, httpType: 'GET', responseType: 'json', - validateResponse: { count: 'number' }, + validateResponse: { count: 'number', pqCount: 'number' }, })) as ServerKeyCountType; - return result.count; + return result; } type ServerKeyResponseType = { devices: Array<{ deviceId: number; registrationId: number; - signedPreKey: { + + // We'll get a 404 if none of these keys are provided; we'll have at least one + preKey?: { + keyId: number; + publicKey: string; + }; + signedPreKey?: { keyId: number; publicKey: string; signature: string; }; - preKey?: { + pqPreKey?: { keyId: number; publicKey: string; + signature: string; }; }>; identityKey: string; @@ -2180,12 +2226,25 @@ export function initialize({ return { deviceId: device.deviceId, registrationId: device.registrationId, - preKey, - signedPreKey: { - keyId: device.signedPreKey.keyId, - publicKey: Bytes.fromBase64(device.signedPreKey.publicKey), - signature: Bytes.fromBase64(device.signedPreKey.signature), - }, + ...(preKey ? { preKey } : null), + ...(device.signedPreKey + ? { + signedPreKey: { + keyId: device.signedPreKey.keyId, + publicKey: Bytes.fromBase64(device.signedPreKey.publicKey), + signature: Bytes.fromBase64(device.signedPreKey.signature), + }, + } + : null), + ...(device.pqPreKey + ? { + pqPreKey: { + keyId: device.pqPreKey.keyId, + publicKey: Bytes.fromBase64(device.pqPreKey.publicKey), + signature: Bytes.fromBase64(device.pqPreKey.signature), + }, + } + : null), }; }); @@ -2199,7 +2258,7 @@ export function initialize({ const keys = (await _ajax({ call: 'keys', httpType: 'GET', - urlParameters: `/${identifier}/${deviceId || '*'}`, + urlParameters: `/${identifier}/${deviceId || '*'}?pq=true`, responseType: 'json', validateResponse: { identityKey: 'string', devices: 'object' }, })) as ServerKeyResponseType; @@ -2214,7 +2273,7 @@ export function initialize({ const keys = (await _ajax({ call: 'keys', httpType: 'GET', - urlParameters: `/${identifier}/${deviceId || '*'}`, + urlParameters: `/${identifier}/${deviceId || '*'}?pq=true`, responseType: 'json', validateResponse: { identityKey: 'string', devices: 'object' }, unauthenticated: true, diff --git a/ts/textsecure/getKeysForIdentifier.ts b/ts/textsecure/getKeysForIdentifier.ts index 0f2c1d75764c..cdf69f0fa6bc 100644 --- a/ts/textsecure/getKeysForIdentifier.ts +++ b/ts/textsecure/getKeysForIdentifier.ts @@ -3,6 +3,7 @@ import { ErrorCode, + KEMPublicKey, LibSignalErrorBase, PreKeyBundle, processPreKeyBundle, @@ -101,7 +102,8 @@ async function handleServerKeys( await Promise.all( response.devices.map(async device => { - const { deviceId, registrationId, preKey, signedPreKey } = device; + const { deviceId, registrationId, pqPreKey, preKey, signedPreKey } = + device; if ( devicesToUpdate !== undefined && !devicesToUpdate.includes(deviceId) @@ -135,6 +137,14 @@ async function handleServerKeys( Buffer.from(response.identityKey) ); + const pqPreKeyId = pqPreKey?.keyId || null; + const pqPreKeyPublic = pqPreKey + ? KEMPublicKey.deserialize(Buffer.from(pqPreKey.publicKey)) + : null; + const pqPreKeySignature = pqPreKey + ? Buffer.from(pqPreKey.signature) + : null; + const preKeyBundle = PreKeyBundle.new( registrationId, deviceId, @@ -143,7 +153,10 @@ async function handleServerKeys( signedPreKey.keyId, signedPreKeyObject, Buffer.from(signedPreKey.signature), - identityKey + identityKey, + pqPreKeyId, + pqPreKeyPublic, + pqPreKeySignature ); const address = new QualifiedAddress( diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 51d1c34fca20..2990dc58d99f 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -82,7 +82,12 @@ export type StorageAccessType = { lastHeartbeat: number; lastStartup: number; lastAttemptedToRefreshProfilesAt: number; + lastResortKeyUpdateTime: number; + lastResortKeyUpdateTimePNI: number; maxPreKeyId: number; + maxPreKeyIdPNI: number; + maxKyberPreKeyId: number; + maxKyberPreKeyIdPNI: number; number_id: string; password: string; profileKey: Uint8Array; @@ -94,7 +99,9 @@ export type StorageAccessType = { showStickerPickerHint: boolean; showStickersIntroduction: boolean; signedKeyId: number; - signedKeyRotationRejected: number; + signedKeyIdPNI: number; + signedKeyUpdateTime: number; + signedKeyUpdateTimePNI: number; storageKey: string; synced_at: number; userAgent: string; @@ -145,7 +152,7 @@ export type StorageAccessType = { paymentAddress: string; zoomFactor: ZoomFactorType; preferredLeftPaneWidth: number; - nextSignedKeyRotationTime: number; + nextScheduledUpdateKeyTime: number; areWeASubscriber: boolean; subscriberId: Uint8Array; subscriberCurrencyCode: string; @@ -153,9 +160,11 @@ export type StorageAccessType = { keepMutedChatsArchived: boolean; // Deprecated + 'challenge:retry-message-ids': never; + nextSignedKeyRotationTime: number; senderCertificateWithUuid: never; signaling_key: never; - 'challenge:retry-message-ids': never; + signedKeyRotationRejected: number; }; /* eslint-enable camelcase */ diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 6f5a3c643d7c..73dee6038f44 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -21,7 +21,7 @@ import { } from '../textsecure/OutgoingMessage'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; -import { UUID } from '../types/UUID'; +import { UUID, UUIDKind } from '../types/UUID'; import * as Errors from '../types/errors'; import { getValue, isEnabled } from '../RemoteConfig'; import type { UUIDStringType } from '../types/UUID'; @@ -152,6 +152,7 @@ export async function sendToGroup({ }); } +// Note: This is the group send chokepoint. The 1:1 send chokepoint is sendMessageProto. export async function sendContentMessageToGroup({ contentHint, contentMessage, @@ -180,6 +181,18 @@ export async function sendContentMessageToGroup({ urgent: boolean; }): Promise { const logId = sendTarget.idForLogging(); + + const accountManager = window.getAccountManager(); + if (accountManager.areKeysOutOfDate(UUIDKind.ACI)) { + log.warn( + `sendToGroup/${logId}: Keys are out of date; updating before send` + ); + await accountManager.maybeUpdateKeys(UUIDKind.ACI); + if (accountManager.areKeysOutOfDate(UUIDKind.ACI)) { + throw new Error('Keys still out of date after update'); + } + } + strictAssert( window.textsecure.messaging, 'sendContentMessageToGroup: textsecure.messaging not available!' diff --git a/yarn.lock b/yarn.lock index 798795ef02c5..4b368599d536 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2276,28 +2276,20 @@ bindings "^1.5.0" tar "^6.1.0" -"@signalapp/libsignal-client@0.22.0": - version "0.22.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.22.0.tgz#d57441612df46f90df68fc5d9ad45b857b9d2c44" - integrity sha512-f1PJuxpcbmhvHxzbf0BvSJhNA3sqXrwnTf2GtfFB2CQoqTEiGCRYfyFZjwUBByiFFI5mTWKER6WGAw5AvG/3+A== +"@signalapp/libsignal-client@0.27.0", "@signalapp/libsignal-client@^0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.27.0.tgz#012e254d42e4dcd752979419c048af65a3f1eed1" + integrity sha512-XinrJ9R2veJM/u3CAaL/YN5Yid+ASfsSceQiL/Qr1vKCsMori0bWG6AzOBnDUx/Bnm6dcDBc15t8w31WkXOTVw== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/libsignal-client@^0.24.0": - version "0.24.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.24.0.tgz#4c52194071f1b0f7e0ad27a3f091c881d624897a" - integrity sha512-8IjvfD1wdkKcxwwM4KC1m8yWly5NJVAUaoiGMKiok9L+sD7HnY5DKpBXb/dpgkiSfSMdr3r4219+zFG1tq2UQQ== +"@signalapp/mock-server@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.1.0.tgz#6f499cf1a396626901760b93e888bb5020983075" + integrity sha512-u6zz9PWV7NLP+RIz2hg4zl0UY34Ufj2pjQsPmIY//oVnu3PfIiyuKMIzPU7jPMSYq29uFbUQPypLJYOYP2dOiA== dependencies: - node-gyp-build "^4.2.3" - uuid "^8.3.0" - -"@signalapp/mock-server@3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.0.1.tgz#9f5e7d8ed207af191eadc33667708013a332b522" - integrity sha512-TXATTeczq6O1apT3SNPYFYG8/Z9MMRzmRfwZgl5JyyfjJcR00+b2GSW0G5qVONeVr6r6wS5zbUBDhcZ8+b04OA== - dependencies: - "@signalapp/libsignal-client" "^0.24.0" + "@signalapp/libsignal-client" "^0.27.0" debug "^4.3.2" long "^4.0.0" micro "^9.3.4"