diff --git a/ts/test-mock/pnp/pni_unlink_test.ts b/ts/test-mock/pnp/pni_unlink_test.ts new file mode 100644 index 0000000000..2df57c225d --- /dev/null +++ b/ts/test-mock/pnp/pni_unlink_test.ts @@ -0,0 +1,134 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ServiceIdKind } from '@signalapp/mock-server'; +import { + IdentityKeyPair, + PrivateKey, + SignedPreKeyRecord, + KEMKeyPair, + KyberPreKeyRecord, +} from '@signalapp/libsignal-client'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { generatePni } from '../../types/ServiceId'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; + +export const debug = createDebug('mock:test:pni-unlink'); + +describe('pnp/PNI DecryptionError unlink', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App | undefined; + + beforeEach(async () => { + bootstrap = new Bootstrap({ + contactCount: 0, + }); + await bootstrap.init(); + + await bootstrap.linkAndClose(); + }); + + afterEach(async function after() { + if (app) { + await bootstrap.maybeSaveLogs(this.currentTest, app); + await app.close(); + } + await bootstrap.teardown(); + }); + + it('unlinks desktop if PNI identity is different on server', async () => { + const { desktop, phone, server } = bootstrap; + + const badIdentity = IdentityKeyPair.generate(); + + const signedPreKey = PrivateKey.generate(); + const signedPreKeySig = badIdentity.privateKey.sign( + signedPreKey.getPublicKey().serialize() + ); + const signedPreKeyRecord = SignedPreKeyRecord.new( + 1000, + Date.now(), + signedPreKey.getPublicKey(), + signedPreKey, + signedPreKeySig + ); + + const kyberPreKey = KEMKeyPair.generate(); + const kyberPreKeySig = badIdentity.privateKey.sign( + kyberPreKey.getPublicKey().serialize() + ); + const kyberPreKeyRecord = KyberPreKeyRecord.new( + 1001, + Date.now(), + kyberPreKey, + kyberPreKeySig + ); + + debug('corrupting PNI identity key'); + const sendPromises = new Array>(); + + const pniChangeNumber = { + identityKeyPair: badIdentity.serialize(), + registrationId: desktop.getRegistrationId(ServiceIdKind.PNI), + signedPreKey: signedPreKeyRecord.serialize(), + lastResortKyberPreKey: kyberPreKeyRecord.serialize(), + newE164: desktop.number, + }; + + // The goal of these two sync messages is to update Desktop's PNI identity + // key while keeping the PNI itself the same so that the Desktop wouldn't + // drop the PNI envelope from `sendText()` below. + sendPromises.push( + phone.sendRaw( + desktop, + { + syncMessage: { + pniChangeNumber, + }, + }, + { timestamp: bootstrap.getTimestamp(), updatedPni: generatePni() } + ) + ); + sendPromises.push( + phone.sendRaw( + desktop, + { + syncMessage: { + pniChangeNumber, + }, + }, + { timestamp: bootstrap.getTimestamp(), updatedPni: desktop.pni } + ) + ); + + debug('sending a message to our PNI'); + const stranger = await server.createPrimaryDevice({ + profileName: 'Mysterious Stranger', + }); + + const ourKey = await desktop.popSingleUseKey(ServiceIdKind.PNI); + await stranger.addSingleUseKey(desktop, ourKey, ServiceIdKind.PNI); + + sendPromises.push( + stranger.sendText(desktop, 'A message to PNI', { + serviceIdKind: ServiceIdKind.PNI, + withProfileKey: true, + timestamp: bootstrap.getTimestamp(), + }) + ); + + debug('starting the app to process the queue'); + app = await bootstrap.startApp(); + + await Promise.all(sendPromises); + + const window = await app.getWindow(); + + await window.locator('.LeftPaneDialog__message >> "Unlinked"').waitFor(); + }); +}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 4897488575..ba7260df9e 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -139,6 +139,7 @@ import { inspectUnknownFieldTags } from '../util/inspectProtobufs'; import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { filterAndClean } from '../types/BodyRange'; import { getCallEventForProto } from '../util/callDisposition'; +import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey'; import { CallLogEvent } from '../types/CallDisposition'; const GROUPV2_ID_LENGTH = 32; @@ -298,6 +299,8 @@ export default class MessageReceiver private stoppingProcessing?: boolean; + private pniIdentityKeyCheckRequired?: boolean; + constructor({ server, storage, serverTrustRoot }: MessageReceiverOptions) { super(); @@ -739,6 +742,15 @@ export default class MessageReceiver this.cacheRemoveBatcher.flushAndWait(), ]); + if (this.pniIdentityKeyCheckRequired) { + log.warn( + "MessageReceiver: got 'empty' event, " + + 'running scheduled pni identity key check' + ); + drop(checkOurPniIdentityKey()); + } + this.pniIdentityKeyCheckRequired = false; + log.info("MessageReceiver: emitting 'empty' event"); this.dispatchEvent(new EmptyEvent()); this.isEmptied = true; @@ -2033,6 +2045,16 @@ export default class MessageReceiver throw error; } + if (serviceIdKind === ServiceIdKind.PNI) { + log.info( + 'MessageReceiver.decrypt: Error on PNI; no further processing; ' + + 'queueing pni identity check' + ); + this.pniIdentityKeyCheckRequired = true; + this.removeFromCache(envelope); + throw error; + } + const { cipherTextBytes, cipherTextType } = envelope; const event = new DecryptionErrorEvent( { @@ -3319,6 +3341,11 @@ export default class MessageReceiver return; } + if (this.pniIdentityKeyCheckRequired) { + log.warn('MessageReceiver: canceling pni identity key check'); + } + this.pniIdentityKeyCheckRequired = false; + const manager = window.getAccountManager(); await manager.setPni(updatedPni, { identityKeyPair, diff --git a/ts/util/checkOurPniIdentityKey.ts b/ts/util/checkOurPniIdentityKey.ts new file mode 100644 index 0000000000..3bff79f5cf --- /dev/null +++ b/ts/util/checkOurPniIdentityKey.ts @@ -0,0 +1,30 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; +import { constantTimeEqual } from '../Crypto'; +import { strictAssert } from './assert'; + +export async function checkOurPniIdentityKey(): Promise { + const { server } = window.textsecure; + strictAssert(server, 'WebAPI not ready'); + + const ourPni = window.storage.user.getCheckedPni(); + const localKeyPair = await window.storage.protocol.getIdentityKeyPair(ourPni); + if (!localKeyPair) { + log.warn( + `checkOurPniIdentityKey: no local key pair for ${ourPni}, unlinking` + ); + window.Whisper.events.trigger('unlinkAndDisconnect'); + return; + } + + const { identityKey: remoteKey } = await server.getKeysForServiceId(ourPni); + + if (!constantTimeEqual(localKeyPair.pubKey, remoteKey)) { + log.warn( + `checkOurPniIdentityKey: local/remote key mismatch for ${ourPni}, unlinking` + ); + window.Whisper.events.trigger('unlinkAndDisconnect'); + } +}