diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f079b38d0..39568a7d7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -891,8 +891,13 @@ "description": "Confirmation dialog text that asks the user if they really wish to delete the conversation. Answer buttons use the strings 'ok' and 'cancel'. The deletion is permanent, i.e. it cannot be undone." }, + "sessionResetOngoing": { + "message": "Secure session reset in progress", + "description": + "your secure session is currently being reset, waiting for the reset acknowledgment." + }, "sessionEnded": { - "message": "Secure session reset", + "message": "Secure session reset done", "description": "This is a past tense, informational message. In other words, your secure session has been reset." }, diff --git a/js/models/conversations.js b/js/models/conversations.js index 636ed3b2f..322f19d36 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -53,6 +53,16 @@ friends: 4, }); + // Possible session reset states + const SessionResetEnum = Object.freeze({ + // No ongoing reset + none: 0, + // we initiated the session reset + initiated: 1, + // we received the session reset + request_received: 2, + }); + const COLORS = [ 'red', 'deep_orange', @@ -75,6 +85,7 @@ verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, friendRequestStatus: FriendRequestStatusEnum.none, unlockTimestamp: null, // Timestamp used for expiring friend requests. + sessionResetStatus: FriendStatusEnum.none, }; }, @@ -1421,30 +1432,77 @@ return !this.get('left'); }, - async endSession() { - if (this.isPrivate()) { - const now = Date.now(); - const message = this.messageCollection.add({ - conversationId: this.id, - type: 'outgoing', - sent_at: now, - received_at: now, - destination: this.id, - recipients: this.getRecipients(), - flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, + async onSessionResetInitiated() { + if (this.get('sessionResetStatus') === SessionResetEnum.none) { + this.set({ sessionResetStatus : SessionResetEnum.initiated }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, }); + } + }, - const id = await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, + async onSessionResetReceived() { + if (this.get('sessionResetStatus') === SessionResetEnum.none) { + this.set({ sessionResetStatus : SessionResetEnum.request_received }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, }); - message.set({ id }); + // send empty message, this will trigger the new session to propagate + // to the reset initiator. + window.libloki.sendEmptyMessage(this.id); + } + }, - const options = this.getSendOptions(); - message.send( - this.wrapSend( - textsecure.messaging.resetSession(this.id, now, options) - ) - ); + isSessionResetReceived() { + return this.get('sessionResetStatus') === SessionResetEnum.request_received; + }, + + async createAndStoreEndSessionMessage(endSessionType) { + const now = Date.now(); + const message = this.messageCollection.add({ + conversationId: this.id, + type: 'outgoing', + sent_at: now, + received_at: now, + destination: this.id, + recipients: this.getRecipients(), + flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, + endSessionType, + }); + + const id = await window.Signal.Data.saveMessage(message.attributes, { + Message: Whisper.Message, + }); + message.set({ id }); + return message; + }, + + async onNewSessionAdopted() { + if (this.get('sessionResetStatus') === SessionResetEnum.initiated) { + // send empty message to confirm that we have adopted the new session + window.libloki.sendEmptyMessage(this.id); + } + this.createAndStoreEndSessionMessage('done'); + this.set({ sessionResetStatus : SessionResetEnum.none }); + await window.Signal.Data.updateConversation(this.id, this.attributes, { + Conversation: Whisper.Conversation, + }); + }, + + async endSession() { + if (this.isPrivate()) { + // Only create a new message if we initiated the session reset. + // On the receiver side, the actual message containing the END_SESSION flag + // will ensure the "session reset" message will be added to their conversation. + if (this.get('sessionResetStatus') === SessionResetEnum.initiated) { + const message = await this.createAndStoreEndSessionMessage('ongoing'); + const options = this.getSendOptions(); + message.send( + this.wrapSend( + textsecure.messaging.resetSession(this.id, message.get('sent_at'), options) + ) + ); + } } }, diff --git a/js/models/messages.js b/js/models/messages.js index d528b524e..70a62c0f9 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -108,6 +108,12 @@ // eslint-disable-next-line no-bitwise return !!(this.get('flags') & flag); }, + getEndSessionTranslationKey() { + if (this.get('endSessionType') === 'ongoing') { + return 'sessionResetOngoing'; + } + return 'sessionEnded'; + }, isExpirationTimerUpdate() { const flag = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; @@ -174,7 +180,7 @@ return messages.join(', '); } if (this.isEndSession()) { - return i18n('sessionEnded'); + return i18n(this.getEndSessionTranslationKey()); } if (this.isIncoming() && this.hasErrors()) { return i18n('incomingError'); @@ -294,8 +300,9 @@ }; }, getPropsForResetSessionNotification() { - // It doesn't need anything right now! - return {}; + return { + sessionResetMessageKey: this.getEndSessionTranslationKey(), + }; }, async acceptFriendRequest() { @@ -1303,6 +1310,11 @@ message.get('received_at') ); } + } else { + const endSessionType = conversation.isSessionResetReceived() + ? 'ongoing' + : 'done'; + this.set({ endSessionType }); } if (type === 'incoming' || type === 'friend-request') { const readSync = Whisper.ReadSyncs.forMessage(message); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 1ccafb00e..47290fcbc 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1258,7 +1258,8 @@ } }, - endSession() { + async endSession() { + await this.model.onSessionResetInitiated(); this.model.endSession(); }, diff --git a/libloki/libloki-protocol.js b/libloki/libloki-protocol.js index 8d2a36cb5..ca3bf8c10 100644 --- a/libloki/libloki-protocol.js +++ b/libloki/libloki-protocol.js @@ -130,6 +130,9 @@ } async function sendFriendRequestAccepted(pubKey) { + return sendEmptyMessage(pubKey); + } + async function sendEmptyMessage(pubKey) { // empty content message const content = new textsecure.protobuf.Content(); @@ -161,4 +164,5 @@ window.libloki.savePreKeyBundleForNumber = savePreKeyBundleForNumber; window.libloki.removePreKeyBundleForNumber = removePreKeyBundleForNumber; window.libloki.sendFriendRequestAccepted = sendFriendRequestAccepted; + window.libloki.sendEmptyMessage = sendEmptyMessage; })(); diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index ea36300e6..77aa27b38 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -717,11 +717,70 @@ MessageReceiver.prototype.extend({ deviceId: parseInt(textsecure.storage.user.getDeviceId(), 10), }; + let conversation; + try { + conversation = await window.ConversationController.getOrCreateAndWait(envelope.source, 'private'); + } catch (e) { + window.log.info('Error getting conversation: ', envelope.source); + } + const getCurrentSessionBaseKey = async () => { + const record = await sessionCipher.getRecord(address.toString()); + if (!record) + return null; + const openSession = record.getOpenSession(); + if (!openSession) + return null; + const { baseKey } = openSession.indexInfo; + return baseKey + }; + const captureActiveSession = async () => { + this.activeSessionBaseKey = await getCurrentSessionBaseKey(sessionCipher); + }; + const restoreActiveSession = async () => { + const record = await sessionCipher.getRecord(address.toString()); + if (!record) + return + record.archiveCurrentState(); + const sessionToRestore = record.sessions[this.activeSessionBaseKey]; + record.promoteState(sessionToRestore); + record.updateSessionState(sessionToRestore); + await textsecure.storage.protocol.storeSession(address.toString(), record.serialize()); + }; + const deleteAllSessionExcept = async (sessionBaseKey) => { + const record = await sessionCipher.getRecord(address.toString()); + if (!record) + return + const sessionToKeep = record.sessions[sessionBaseKey]; + record.sessions = {} + record.updateSessionState(sessionToKeep); + await textsecure.storage.protocol.storeSession(address.toString(), record.serialize()); + }; + const handleSessionReset = async () => { + const currentSessionBaseKey = await getCurrentSessionBaseKey(sessionCipher); + // console.warn('%cdecipher session %s', 'color:red;', currentSessionBaseKey); + if (this.activeSessionBaseKey && currentSessionBaseKey !== this.activeSessionBaseKey) { + if (conversation.isSessionResetReceived()) { + restoreActiveSession(); + } else { + deleteAllSessionExcept(currentSessionBaseKey); + conversation.onNewSessionAdopted(); + } + } else if (conversation.isSessionResetReceived()) { + deleteAllSessionExcept(this.activeSessionBaseKey); + conversation.onNewSessionAdopted(); + } + }; + switch (envelope.type) { case textsecure.protobuf.Envelope.Type.CIPHERTEXT: window.log.info('message from', this.getEnvelopeId(envelope)); - promise = sessionCipher.decryptWhisperMessage(ciphertext) - .then(this.unpad); + promise = captureActiveSession() + .then(() => sessionCipher.decryptWhisperMessage(ciphertext)) + .then(this.unpad) + .then((plainText) => { + handleSessionReset(); + return plainText; + }); break; case textsecure.protobuf.Envelope.Type.FRIEND_REQUEST: { window.log.info('friend-request message from ', envelope.source); @@ -731,11 +790,16 @@ MessageReceiver.prototype.extend({ } case textsecure.protobuf.Envelope.Type.PREKEY_BUNDLE: window.log.info('prekey message from', this.getEnvelopeId(envelope)); - promise = this.decryptPreKeyWhisperMessage( - ciphertext, - sessionCipher, - address - ); + promise = captureActiveSession(sessionCipher) + .then(() => this.decryptPreKeyWhisperMessage( + ciphertext, + sessionCipher, + address + )) + .then((plainText) => { + handleSessionReset(); + return plainText; + }); break; case textsecure.protobuf.Envelope.Type.UNIDENTIFIED_SENDER: window.log.info('received unidentified sender message'); @@ -911,6 +975,9 @@ MessageReceiver.prototype.extend({ if (msg.flags & textsecure.protobuf.DataMessage.Flags.END_SESSION) { p = this.handleEndSession(envelope.source); } + const type = (envelope.type === textsecure.protobuf.Envelope.Type.FRIEND_REQUEST) + ? 'friend-request' + : 'data'; return p.then(() => this.processDecrypted(envelope, msg, envelope.source).then(message => { const groupId = message.group && message.group.id; @@ -1282,17 +1349,37 @@ MessageReceiver.prototype.extend({ async handleEndSession(number) { window.log.info('got end session'); const deviceIds = await textsecure.storage.protocol.getDeviceIds(number); + const identityKey = StringView.hexToArrayBuffer(number); + let conversation; + try { + conversation = window.ConversationController.get(number); + } catch (e) { + window.log.error('Error getting conversation: ', number); + } + + conversation.onSessionResetReceived(); return Promise.all( - deviceIds.map(deviceId => { + deviceIds.map(async deviceId => { const address = new libsignal.SignalProtocolAddress(number, deviceId); - const sessionCipher = new libsignal.SessionCipher( + // Instead of deleting the sessions now, + // we process the new prekeys and initiate a new session. + // The old sessions will get deleted once the correspondant + // has switch the the new session. + const [preKey, signedPreKey] = await Promise.all([ + textsecure.storage.protocol.loadContactPreKey(number), + textsecure.storage.protocol.loadContactSignedPreKey(number), + ]); + if (preKey === undefined || signedPreKey === undefined) { + return null; + } + const device = { identityKey, deviceId, preKey, signedPreKey, registrationId: 0 } + const builder = new libsignal.SessionBuilder( textsecure.storage.protocol, address ); - - window.log.info('deleting sessions for', address.toString()); - return sessionCipher.deleteAllSessionsForDevice(); + builder.processPreKey(device); + return null; }) ); }, diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index 0e4be306c..d0e79665d 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -119,7 +119,12 @@ OutgoingMessage.prototype = { if (device.registrationId === 0) { window.log.info('device registrationId 0!'); } - return builder.processPreKey(device).then(() => true).catch(error => { + return builder.processPreKey(device).then(async () => { + // TODO: only remove the keys that were used above! + await window.libloki.removePreKeyBundleForNumber(number); + return true; + } + ).catch(error => { if (error.message === 'Identity key changed') { // eslint-disable-next-line no-param-reassign error.timestamp = this.timestamp; @@ -285,10 +290,15 @@ OutgoingMessage.prototype = { // Check if we need to attach the preKeys let sessionCipher; - if (this.messageType === 'friend-request') { + const isFriendRequest = this.messageType === 'friend-request'; + const flags = this.message.dataMessage ? this.message.dataMessage.get_flags() : null; + const isEndSession = flags === textsecure.protobuf.DataMessage.Flags.END_SESSION; + if (isFriendRequest || isEndSession) { // Encrypt them with the fallback this.message.preKeyBundleMessage = await libloki.getPreKeyBundleForNumber(number); window.log.info('attaching prekeys to outgoing message'); + } + if (isFriendRequest) { sessionCipher = fallBackCipher; } else { sessionCipher = new libsignal.SessionCipher( diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 9eb2ec626..66c3978be 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -691,6 +691,17 @@ MessageSender.prototype = { window.log.error(prefix, error && error.stack ? error.stack : error); throw error; }; + // The actual deletion of the session now happens later + // as we need to ensure the other contact has successfully + // switch to a new session first. + return this.sendIndividualProto( + number, + proto, + timestamp, + silent, + options + ).catch(logError('resetSession/sendToContact error:')); + /* const deleteAllSessions = targetNumber => textsecure.storage.protocol.getDeviceIds(targetNumber).then(deviceIds => Promise.all( @@ -741,6 +752,7 @@ MessageSender.prototype = { ).catch(logError('resetSession/sendSync error:')); return Promise.all([sendToContact, sendSync]); + */ }, sendMessageToGroup( diff --git a/ts/components/conversation/ResetSessionNotification.tsx b/ts/components/conversation/ResetSessionNotification.tsx index 19141a869..f964a629a 100644 --- a/ts/components/conversation/ResetSessionNotification.tsx +++ b/ts/components/conversation/ResetSessionNotification.tsx @@ -4,15 +4,16 @@ import { Localizer } from '../../types/Util'; interface Props { i18n: Localizer; + sessionResetMessageKey: string; } export class ResetSessionNotification extends React.Component { public render() { - const { i18n } = this.props; + const { i18n, sessionResetMessageKey } = this.props; return (
- {i18n('sessionEnded')} + { i18n(sessionResetMessageKey) }
); }