Automatic session reset
This commit is contained in:
parent
fe187226bb
commit
98e7e65d25
26 changed files with 803 additions and 225 deletions
|
@ -15,6 +15,7 @@ import { v4 as getGuid } from 'uuid';
|
|||
|
||||
import { SessionCipherClass, SignalProtocolAddressClass } from '../libsignal.d';
|
||||
import { BatcherType, createBatcher } from '../util/batcher';
|
||||
import { assert } from '../util/assert';
|
||||
|
||||
import EventTarget from './EventTarget';
|
||||
import { WebAPIType } from './WebAPI';
|
||||
|
@ -48,6 +49,8 @@ const GROUPV1_ID_LENGTH = 16;
|
|||
const GROUPV2_ID_LENGTH = 32;
|
||||
const RETRY_TIMEOUT = 2 * 60 * 1000;
|
||||
|
||||
type SessionResetsType = Record<string, number>;
|
||||
|
||||
declare global {
|
||||
// We want to extend `Event`, so we need an interface.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -208,6 +211,8 @@ class MessageReceiverInner extends EventTarget {
|
|||
maxSize: 30,
|
||||
processBatch: this.cacheRemoveBatch.bind(this),
|
||||
});
|
||||
|
||||
this.cleanupSessionResets();
|
||||
}
|
||||
|
||||
static stringToArrayBuffer = (string: string): ArrayBuffer =>
|
||||
|
@ -237,6 +242,7 @@ class MessageReceiverInner extends EventTarget {
|
|||
}
|
||||
|
||||
this.isEmptied = false;
|
||||
|
||||
this.hasConnected = true;
|
||||
|
||||
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
|
||||
|
@ -1089,34 +1095,120 @@ class MessageReceiverInner extends EventTarget {
|
|||
return plaintext;
|
||||
})
|
||||
.catch(async error => {
|
||||
let errorToThrow = error;
|
||||
this.removeFromCache(envelope);
|
||||
|
||||
if (error && error.message === 'Unknown identity key') {
|
||||
// create an error that the UI will pick up and ask the
|
||||
// user if they want to re-negotiate
|
||||
const buffer = window.dcodeIO.ByteBuffer.wrap(ciphertext);
|
||||
errorToThrow = new IncomingIdentityKeyError(
|
||||
address.toString(),
|
||||
buffer.toArrayBuffer(),
|
||||
error.identityKey
|
||||
const uuid = envelope.sourceUuid;
|
||||
const deviceId = envelope.sourceDevice;
|
||||
|
||||
// We don't do a light session reset if it's just a duplicated message
|
||||
if (error && error.name === 'MessageCounterError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (uuid && deviceId) {
|
||||
await this.lightSessionReset(uuid, deviceId);
|
||||
} else {
|
||||
const envelopeId = this.getEnvelopeId(envelope);
|
||||
window.log.error(
|
||||
`MessageReceiver.decrypt: Envelope ${envelopeId} missing uuid or deviceId`
|
||||
);
|
||||
}
|
||||
|
||||
if (envelope.timestamp && envelope.timestamp.toNumber) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
envelope.timestamp = envelope.timestamp.toNumber();
|
||||
}
|
||||
|
||||
const ev = new Event('error');
|
||||
ev.error = errorToThrow;
|
||||
ev.proto = envelope;
|
||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||
|
||||
const returnError = async () => Promise.reject(errorToThrow);
|
||||
return this.dispatchAndWait(ev).then(returnError, returnError);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
isOverHourIntoPast(timestamp: number): boolean {
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const now = Date.now();
|
||||
const oneHourIntoPast = now - HOUR;
|
||||
|
||||
return isNumber(timestamp) && timestamp <= oneHourIntoPast;
|
||||
}
|
||||
|
||||
// We don't lose anything if we delete keys over an hour into the past, because we only
|
||||
// change our behavior if the timestamps stored are less than an hour ago.
|
||||
cleanupSessionResets(): void {
|
||||
const sessionResets = window.storage.get(
|
||||
'sessionResets',
|
||||
{}
|
||||
) as SessionResetsType;
|
||||
|
||||
const keys = Object.keys(sessionResets);
|
||||
keys.forEach(key => {
|
||||
const timestamp = sessionResets[key];
|
||||
if (!timestamp || this.isOverHourIntoPast(timestamp)) {
|
||||
delete sessionResets[key];
|
||||
}
|
||||
});
|
||||
|
||||
window.storage.put('sessionResets', sessionResets);
|
||||
}
|
||||
|
||||
async lightSessionReset(uuid: string, deviceId: number) {
|
||||
const id = `${uuid}.${deviceId}`;
|
||||
|
||||
try {
|
||||
const sessionResets = window.storage.get(
|
||||
'sessionResets',
|
||||
{}
|
||||
) as SessionResetsType;
|
||||
const lastReset = sessionResets[id];
|
||||
|
||||
if (lastReset && !this.isOverHourIntoPast(lastReset)) {
|
||||
window.log.warn(
|
||||
`lightSessionReset: Skipping session reset for ${id}, last reset at ${lastReset}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
sessionResets[id] = Date.now();
|
||||
window.storage.put('sessionResets', sessionResets);
|
||||
|
||||
// First, fetch this conversation
|
||||
const conversationId = window.ConversationController.ensureContactIds({
|
||||
uuid,
|
||||
});
|
||||
assert(conversationId, 'lightSessionReset: missing conversationId');
|
||||
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
assert(conversation, 'lightSessionReset: missing conversation');
|
||||
|
||||
window.log.warn(`lightSessionReset: Resetting session for ${id}`);
|
||||
|
||||
// Archive open session with this device
|
||||
const address = new window.libsignal.SignalProtocolAddress(
|
||||
uuid,
|
||||
deviceId
|
||||
);
|
||||
const sessionCipher = new window.libsignal.SessionCipher(
|
||||
window.textsecure.storage.protocol,
|
||||
address
|
||||
);
|
||||
|
||||
await sessionCipher.closeOpenSessionForDevice();
|
||||
|
||||
// Send a null message with newly-created session
|
||||
const sendOptions = conversation.getSendOptions();
|
||||
await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions);
|
||||
|
||||
// Emit event for app to put item into conversation timeline
|
||||
const event = new Event('light-session-reset');
|
||||
event.senderUuid = uuid;
|
||||
await this.dispatchAndWait(event);
|
||||
} catch (error) {
|
||||
// If we failed to do the session reset, then we'll allow another attempt
|
||||
const sessionResets = window.storage.get(
|
||||
'sessionResets',
|
||||
{}
|
||||
) as SessionResetsType;
|
||||
delete sessionResets[id];
|
||||
window.storage.put('sessionResets', sessionResets);
|
||||
|
||||
const errorString = error && error.stack ? error.stack : error;
|
||||
window.log.error('lightSessionReset: Enountered error', errorString);
|
||||
}
|
||||
}
|
||||
|
||||
async decryptPreKeyWhisperMessage(
|
||||
ciphertext: ArrayBuffer,
|
||||
sessionCipher: SessionCipherClass,
|
||||
|
@ -2266,6 +2358,10 @@ export default class MessageReceiver {
|
|||
this.stopProcessing = inner.stopProcessing.bind(inner);
|
||||
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
|
||||
|
||||
// For tests
|
||||
this.isOverHourIntoPast = inner.isOverHourIntoPast.bind(inner);
|
||||
this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner);
|
||||
|
||||
inner.connect();
|
||||
}
|
||||
|
||||
|
@ -2287,6 +2383,10 @@ export default class MessageReceiver {
|
|||
|
||||
unregisterBatchers: () => void;
|
||||
|
||||
isOverHourIntoPast: (timestamp: number) => boolean;
|
||||
|
||||
cleanupSessionResets: () => void;
|
||||
|
||||
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;
|
||||
|
||||
static arrayBufferToString = MessageReceiverInner.arrayBufferToString;
|
||||
|
|
|
@ -742,12 +742,7 @@ export default class MessageSender {
|
|||
createSyncMessage(): SyncMessageClass {
|
||||
const syncMessage = new window.textsecure.protobuf.SyncMessage();
|
||||
|
||||
// Generate a random int from 1 and 512
|
||||
const buffer = window.libsignal.crypto.getRandomBytes(1);
|
||||
const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1;
|
||||
|
||||
// Generate a random padding buffer of the chosen size
|
||||
syncMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength);
|
||||
syncMessage.padding = this.getRandomPadding();
|
||||
|
||||
return syncMessage;
|
||||
}
|
||||
|
@ -1374,6 +1369,47 @@ export default class MessageSender {
|
|||
);
|
||||
}
|
||||
|
||||
getRandomPadding(): ArrayBuffer {
|
||||
// Generate a random int from 1 and 512
|
||||
const buffer = window.libsignal.crypto.getRandomBytes(2);
|
||||
const paddingLength = (new Uint16Array(buffer)[0] & 0x1ff) + 1;
|
||||
|
||||
// Generate a random padding buffer of the chosen size
|
||||
return window.libsignal.crypto.getRandomBytes(paddingLength);
|
||||
}
|
||||
|
||||
async sendNullMessage(
|
||||
{
|
||||
uuid,
|
||||
e164,
|
||||
padding,
|
||||
}: { uuid?: string; e164?: string; padding?: ArrayBuffer },
|
||||
options?: SendOptionsType
|
||||
): Promise<CallbackResultType> {
|
||||
const nullMessage = new window.textsecure.protobuf.NullMessage();
|
||||
|
||||
const identifier = uuid || e164;
|
||||
if (!identifier) {
|
||||
throw new Error('sendNullMessage: Got neither uuid nor e164!');
|
||||
}
|
||||
|
||||
nullMessage.padding = padding || this.getRandomPadding();
|
||||
|
||||
const contentMessage = new window.textsecure.protobuf.Content();
|
||||
contentMessage.nullMessage = nullMessage;
|
||||
|
||||
// We want the NullMessage to look like a normal outgoing message; not silent
|
||||
const silent = false;
|
||||
const timestamp = Date.now();
|
||||
return this.sendIndividualProto(
|
||||
identifier,
|
||||
contentMessage,
|
||||
timestamp,
|
||||
silent,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async syncVerification(
|
||||
destinationE164: string,
|
||||
destinationUuid: string,
|
||||
|
@ -1390,26 +1426,12 @@ export default class MessageSender {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Get padding which we can share between null message and verified sync
|
||||
const padding = this.getRandomPadding();
|
||||
|
||||
// First send a null message to mask the sync message.
|
||||
const nullMessage = new window.textsecure.protobuf.NullMessage();
|
||||
|
||||
// Generate a random int from 1 and 512
|
||||
const buffer = window.libsignal.crypto.getRandomBytes(1);
|
||||
const paddingLength = (new Uint8Array(buffer)[0] & 0x1ff) + 1;
|
||||
|
||||
// Generate a random padding buffer of the chosen size
|
||||
nullMessage.padding = window.libsignal.crypto.getRandomBytes(paddingLength);
|
||||
|
||||
const contentMessage = new window.textsecure.protobuf.Content();
|
||||
contentMessage.nullMessage = nullMessage;
|
||||
|
||||
// We want the NullMessage to look like a normal outgoing message; not silent
|
||||
const silent = false;
|
||||
const promise = this.sendIndividualProto(
|
||||
destinationUuid || destinationE164,
|
||||
contentMessage,
|
||||
now,
|
||||
silent,
|
||||
const promise = this.sendNullMessage(
|
||||
{ uuid: destinationUuid, e164: destinationE164, padding },
|
||||
options
|
||||
);
|
||||
|
||||
|
@ -1423,7 +1445,7 @@ export default class MessageSender {
|
|||
verified.destinationUuid = destinationUuid;
|
||||
}
|
||||
verified.identityKey = identityKey;
|
||||
verified.nullMessage = nullMessage.padding;
|
||||
verified.nullMessage = padding;
|
||||
|
||||
const syncMessage = this.createSyncMessage();
|
||||
syncMessage.verified = verified;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue