diff --git a/protos/SignalService.proto b/protos/SignalService.proto index fe55df782b..47f24ce654 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -407,6 +407,7 @@ message SyncMessage { BLOCKED = 3; CONFIGURATION = 4; KEYS = 5; + PNI_IDENTITY = 6; } optional Type type = 1; @@ -416,6 +417,11 @@ message SyncMessage { optional bytes storageService = 1; } + message PniIdentity { + optional bytes publicKey = 1; + optional bytes privateKey = 2; + } + message Read { optional string sender = 1; optional string senderUuid = 3; @@ -496,6 +502,7 @@ message SyncMessage { optional MessageRequestResponse messageRequestResponse = 14; reserved 15; // not yet added repeated Viewed viewed = 16; + optional PniIdentity pniIdentity = 17; } message AttachmentPointer { diff --git a/ts/background.ts b/ts/background.ts index 42fa29fef7..876f806801 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -68,6 +68,7 @@ import type { FetchLatestEvent, GroupEvent, KeysEvent, + PNIIdentityEvent, MessageEvent, MessageEventData, MessageRequestResponseEvent, @@ -369,6 +370,10 @@ export async function startApp(): Promise { queuedEventListener(onFetchLatestSync) ); messageReceiver.addEventListener('keys', queuedEventListener(onKeysSync)); + messageReceiver.addEventListener( + 'pniIdentity', + queuedEventListener(onPNIIdentitySync) + ); }); ourProfileKeyService.initialize(window.storage); @@ -2259,6 +2264,8 @@ export async function startApp(): Promise { window.waitForEmptyEventQueue = waitForEmptyEventQueue; async function onEmpty() { + const { storage, messaging } = window.textsecure; + await Promise.all([ window.waitForAllBatchers(), window.flushAllWaitBatchers(), @@ -2332,7 +2339,7 @@ export async function startApp(): Promise { } }); await window.Signal.Data.saveMessages(messagesToSave, { - ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + ourUuid: storage.user.getCheckedUuid().toString(), }); // Process crash reports if any @@ -2348,7 +2355,7 @@ export async function startApp(): Promise { routineProfileRefresh({ allConversations: window.ConversationController.getAll(), ourConversationId, - storage: window.storage, + storage, }); } else { assert( @@ -2356,6 +2363,17 @@ export async function startApp(): Promise { 'Failed to fetch our conversation ID. Skipping routine profile refresh' ); } + + // Make sure we have the PNI identity + + const pni = storage.user.getCheckedUuid(UUIDKind.PNI); + const pniIdentity = await storage.protocol.getIdentityKeyPair(pni); + if (!pniIdentity) { + log.info('Requesting PNI identity sync'); + await singleProtoJobQueue.add( + messaging.getRequestPniIdentitySyncMessage() + ); + } } let initialStartupCount = 0; @@ -3486,6 +3504,15 @@ export async function startApp(): Promise { } } + async function onPNIIdentitySync(ev: PNIIdentityEvent) { + ev.confirm(); + + log.info('onPNIIdentitySync: updating PNI keys'); + const manager = window.getAccountManager(); + const { privateKey: privKey, publicKey: pubKey } = ev.data; + await manager.updatePNIIdentity({ privKey, pubKey }); + } + async function onMessageRequestResponse(ev: MessageRequestResponseEvent) { ev.confirm(); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index ac81a0b946..ec9cc90d2f 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -1,15 +1,11 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable more/no-then */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import PQueue from 'p-queue'; import { omit } from 'lodash'; import EventTarget from './EventTarget'; -import type { WebAPIType } from './WebAPI'; +import type { WebAPIType, GroupCredentialType } from './WebAPI'; import { HTTPError } from './Errors'; import type { KeyPairType } from './Types.d'; import ProvisioningCipher from './ProvisioningCipher'; @@ -96,15 +92,15 @@ export default class AccountManager extends EventTarget { this.pending = Promise.resolve(); } - async requestVoiceVerification(number: string, token: string) { + async requestVoiceVerification(number: string, token: string): Promise { return this.server.requestVerificationVoice(number, token); } - async requestSMSVerification(number: string, token: string) { + async requestSMSVerification(number: string, token: string): Promise { return this.server.requestVerificationSMS(number, token); } - encryptDeviceName(name: string, identityKey: KeyPairType) { + encryptDeviceName(name: string, identityKey: KeyPairType): string | null { if (!name) { return null; } @@ -119,7 +115,7 @@ export default class AccountManager extends EventTarget { return Bytes.toBase64(bytes); } - async decryptDeviceName(base64: string) { + async decryptDeviceName(base64: string): Promise { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const identityKey = await window.textsecure.storage.protocol.getIdentityKeyPair(ourUuid); @@ -139,7 +135,7 @@ export default class AccountManager extends EventTarget { return name; } - async maybeUpdateDeviceName() { + async maybeUpdateDeviceName(): Promise { const isNameEncrypted = window.textsecure.storage.user.getDeviceNameEncrypted(); if (isNameEncrypted) { @@ -161,11 +157,14 @@ export default class AccountManager extends EventTarget { } } - async deviceNameIsEncrypted() { + async deviceNameIsEncrypted(): Promise { await window.textsecure.storage.user.setDeviceNameEncrypted(); } - async registerSingleDevice(number: string, verificationCode: string) { + async registerSingleDevice( + number: string, + verificationCode: string + ): Promise { return this.queueTask(async () => { const aciKeyPair = generateKeyPair(); const pniKeyPair = generateKeyPair(); @@ -203,9 +202,9 @@ export default class AccountManager extends EventTarget { } async registerSecondDevice( - setProvisioningUrl: (url: string) => unknown, + setProvisioningUrl: (url: string) => void, confirmNumber: (number?: string) => Promise - ) { + ): Promise { const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this); const provisioningCipher = new ProvisioningCipher(); const pubKey = await provisioningCipher.getPublicKey(); @@ -323,7 +322,7 @@ export default class AccountManager extends EventTarget { }); } - async refreshPreKeys(uuidKind: UUIDKind) { + async refreshPreKeys(uuidKind: UUIDKind): Promise { return this.queueTask(async () => { const preKeyCount = await this.server.getMyKeys(uuidKind); log.info(`prekey count ${preKeyCount}`); @@ -335,7 +334,7 @@ export default class AccountManager extends EventTarget { }); } - async rotateSignedPreKey(uuidKind: UUIDKind) { + 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); @@ -447,14 +446,14 @@ export default class AccountManager extends EventTarget { }); } - async queueTask(task: () => Promise) { + 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 cleanSignedPreKeys() { + async cleanSignedPreKeys(): Promise { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const store = window.textsecure.storage.protocol; @@ -662,7 +661,8 @@ export default class AccountManager extends EventTarget { const registrationIdMap = { ...(storage.get('registrationIdMap') || {}), [ourUuid]: registrationId, - // TODO: DESKTOP-2825 + + // TODO: DESKTOP-3318 [ourPni]: registrationId, }; @@ -683,7 +683,7 @@ export default class AccountManager extends EventTarget { await storage.protocol.hydrateCaches(); } - async clearSessionsAndPreKeys() { + async clearSessionsAndPreKeys(): Promise { const store = window.textsecure.storage.protocol; log.info('clearing all sessions, prekeys, and signed prekeys'); @@ -694,16 +694,68 @@ export default class AccountManager extends EventTarget { ]); } + async updatePNIIdentity(identityKeyPair: KeyPairType): Promise { + const { storage } = window.textsecure; + + log.info('AccountManager.updatePNIIdentity: generating new keys'); + + return this.queueTask(async () => { + const keys = await this.generateKeys( + SIGNED_KEY_GEN_BATCH_SIZE, + UUIDKind.PNI, + identityKeyPair + ); + await this.server.registerKeys(keys, UUIDKind.PNI); + await this.confirmKeys(keys, UUIDKind.PNI); + + // Server has accepted our keys which means we have the latest PNI identity + // now that doesn't conflict the PNI identity of the primary device. + log.info( + 'AccountManager.updatePNIIdentity: updating identity key ' + + 'and registration id' + ); + const { pubKey, privKey } = identityKeyPair; + + const pni = storage.user.getCheckedUuid(UUIDKind.PNI); + const identityKeyMap = { + ...(storage.get('identityKeyMap') || {}), + [pni.toString()]: { + pubKey: Bytes.toBase64(pubKey), + privKey: Bytes.toBase64(privKey), + }, + }; + + const aci = storage.user.getCheckedUuid(UUIDKind.ACI); + const oldRegistrationIdMap = storage.get('registrationIdMap') || {}; + const registrationIdMap = { + ...oldRegistrationIdMap, + + // TODO: DESKTOP-3318 + [pni.toString()]: oldRegistrationIdMap[aci.toString()], + }; + + await Promise.all([ + storage.put('identityKeyMap', identityKeyMap), + storage.put('registrationIdMap', registrationIdMap), + ]); + + await storage.protocol.hydrateCaches(); + }); + } + async getGroupCredentials( startDay: number, endDay: number, uuidKind: UUIDKind - ) { + ): Promise> { return this.server.getGroupCredentials(startDay, endDay, uuidKind); } // Takes the same object returned by generateKeys - async confirmKeys(keys: GeneratedKeysType, uuidKind: UUIDKind) { + async confirmKeys( + keys: GeneratedKeysType, + uuidKind: UUIDKind + ): Promise { const store = window.textsecure.storage.protocol; const key = keys.signedPreKey; const confirmed = true; @@ -720,10 +772,16 @@ export default class AccountManager extends EventTarget { await store.storeSignedPreKey(ourUuid, key.keyId, key.keyPair, confirmed); } - async generateKeys(count: number, uuidKind: UUIDKind) { - const startId = window.textsecure.storage.get('maxPreKeyId', 1); - const signedKeyId = window.textsecure.storage.get('signedKeyId', 1); - const ourUuid = window.textsecure.storage.user.getCheckedUuid(uuidKind); + async generateKeys( + count: number, + uuidKind: UUIDKind, + maybeIdentityKey?: KeyPairType + ): Promise { + const { storage } = window.textsecure; + + const startId = storage.get('maxPreKeyId', 1); + const signedKeyId = storage.get('signedKeyId', 1); + const ourUuid = storage.user.getCheckedUuid(uuidKind); if (typeof startId !== 'number') { throw new Error('Invalid maxPreKeyId'); @@ -732,60 +790,58 @@ export default class AccountManager extends EventTarget { throw new Error('Invalid signedKeyId'); } - const store = window.textsecure.storage.protocol; - return store.getIdentityKeyPair(ourUuid).then(async identityKey => { - if (!identityKey) { - throw new Error('generateKeys: No identity key pair!'); - } + const store = storage.protocol; + const identityKey = + maybeIdentityKey ?? (await store.getIdentityKeyPair(ourUuid)); + strictAssert(identityKey, 'generateKeys: No identity key pair!'); - const result: any = { - preKeys: [], - identityKey: identityKey.pubKey, + const result: Omit = { + preKeys: [], + identityKey: identityKey.pubKey, + }; + const promises = []; + + 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, + }); + })() + ); + } + + 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, }; - const promises = []; + })(); - for (let keyId = startId; keyId < startId + count; keyId += 1) { - promises.push( - Promise.resolve(generatePreKey(keyId)).then(async res => { - await store.storePreKey(ourUuid, res.keyId, res.keyPair); - result.preKeys.push({ - keyId: res.keyId, - publicKey: res.keyPair.pubKey, - }); - }) - ); - } + promises.push(signedPreKey); + promises.push(storage.put('maxPreKeyId', startId + count)); + promises.push(storage.put('signedKeyId', signedKeyId + 1)); - promises.push( - Promise.resolve(generateSignedPreKey(identityKey, signedKeyId)).then( - async res => { - await store.storeSignedPreKey(ourUuid, res.keyId, res.keyPair); - result.signedPreKey = { - keyId: res.keyId, - publicKey: res.keyPair.pubKey, - signature: res.signature, - // server.registerKeys doesn't use keyPair, confirmKeys does - keyPair: res.keyPair, - }; - } - ) - ); + await Promise.all(promises); - promises.push( - window.textsecure.storage.put('maxPreKeyId', startId + count) - ); - promises.push( - window.textsecure.storage.put('signedKeyId', signedKeyId + 1) - ); + // This is primarily for the signed prekey summary it logs out + this.cleanSignedPreKeys(); - return Promise.all(promises).then(async () => - // This is primarily for the signed prekey summary it logs out - this.cleanSignedPreKeys().then(() => result as GeneratedKeysType) - ); - }); + return { + ...result, + signedPreKey: await signedPreKey, + }; } - async registrationDone() { + async registrationDone(): Promise { log.info('registration done'); this.dispatchEvent(new Event('registration')); } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 4c0e45ce58..bfaa0ebe84 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -98,6 +98,7 @@ import { MessageRequestResponseEvent, FetchLatestEvent, KeysEvent, + PNIIdentityEvent, StickerPackEvent, VerifiedEvent, ReadSyncEvent, @@ -193,6 +194,8 @@ export default class MessageReceiver private stoppingProcessing?: boolean; + private pendingPNIIdentityEvent?: PNIIdentityEvent; + constructor({ server, storage, serverTrustRoot }: MessageReceiverOptions) { super(); @@ -467,6 +470,11 @@ export default class MessageReceiver handler: (ev: KeysEvent) => void ): void; + public override addEventListener( + name: 'pniIdentity', + handler: (ev: PNIIdentityEvent) => void + ): void; + public override addEventListener( name: 'sticker-pack', handler: (ev: StickerPackEvent) => void @@ -598,6 +606,13 @@ export default class MessageReceiver this.isEmptied = true; this.maybeScheduleRetryTimeout(); + + // Emit PNI identity event after processing the queue + const { pendingPNIIdentityEvent } = this; + this.pendingPNIIdentityEvent = undefined; + if (pendingPNIIdentityEvent) { + await this.dispatchAndWait(pendingPNIIdentityEvent); + } }; const waitForDecryptedQueue = async () => { @@ -2474,6 +2489,9 @@ export default class MessageReceiver if (syncMessage.keys) { return this.handleKeys(envelope, syncMessage.keys); } + if (syncMessage.pniIdentity) { + return this.handlePNIIdentity(envelope, syncMessage.pniIdentity); + } if (syncMessage.viewed && syncMessage.viewed.length) { return this.handleViewed(envelope, syncMessage.viewed); } @@ -2591,6 +2609,31 @@ export default class MessageReceiver return this.dispatchAndWait(ev); } + private async handlePNIIdentity( + envelope: ProcessedEnvelope, + { publicKey, privateKey }: Proto.SyncMessage.IPniIdentity + ): Promise { + log.info('MessageReceiver: got pni identity sync message'); + + if (!publicKey || !privateKey) { + log.warn('MessageReceiver: empty pni identity sync message'); + return undefined; + } + + const ev = new PNIIdentityEvent( + { publicKey, privateKey }, + this.removeFromCache.bind(this, envelope) + ); + + if (this.isEmptied) { + log.info('MessageReceiver: emitting pni identity sync message'); + return this.dispatchAndWait(ev); + } + + log.info('MessageReceiver: scheduling pni identity sync message'); + this.pendingPNIIdentityEvent = ev; + } + private async handleStickerPackOperation( envelope: ProcessedEnvelope, operations: Array diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index a225a561d3..02031ee479 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1266,6 +1266,29 @@ export default class MessageSender { }; } + getRequestPniIdentitySyncMessage(): SingleProtoJobData { + const myUuid = window.textsecure.storage.user.getCheckedUuid(); + + const request = new Proto.SyncMessage.Request(); + request.type = Proto.SyncMessage.Request.Type.PNI_IDENTITY; + const syncMessage = this.createSyncMessage(); + syncMessage.request = request; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + return { + contentHint: ContentHint.RESENDABLE, + identifier: myUuid.toString(), + isSyncMessage: true, + protoBase64: Bytes.toBase64( + Proto.Content.encode(contentMessage).finish() + ), + type: 'pniIdentitySyncRequest', + }; + } + getFetchManifestSyncMessage(): SingleProtoJobData { const myUuid = window.textsecure.storage.user.getCheckedUuid(); diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index dad3c88a90..889d0cc034 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -349,6 +349,20 @@ export class KeysEvent extends ConfirmableEvent { } } +export type PNIIdentityEventData = Readonly<{ + publicKey: Uint8Array; + privateKey: Uint8Array; +}>; + +export class PNIIdentityEvent extends ConfirmableEvent { + constructor( + public readonly data: PNIIdentityEventData, + confirm: ConfirmCallback + ) { + super('pniIdentity', confirm); + } +} + export type StickerPackEventData = Readonly<{ id?: string; key?: string; diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index a4758b97a3..6be6b95c60 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -18,6 +18,7 @@ const { insertSentProto, updateConversation } = dataInterface; export const sendTypesEnum = z.enum([ 'blockSyncRequest', + 'pniIdentitySyncRequest', 'callingMessage', // excluded from send log 'configurationSyncRequest', 'contactSyncRequest',