Unlink on PNI identity key mismatch
This commit is contained in:
parent
ef0a3de636
commit
58aec8b1a3
3 changed files with 191 additions and 0 deletions
134
ts/test-mock/pnp/pni_unlink_test.ts
Normal file
134
ts/test-mock/pnp/pni_unlink_test.ts
Normal file
|
@ -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<Promise<unknown>>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
30
ts/util/checkOurPniIdentityKey.ts
Normal file
30
ts/util/checkOurPniIdentityKey.ts
Normal file
|
@ -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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue