diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3fe43aaff..fdd9ac3ad 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1077,6 +1077,22 @@ "message": "Secure session reset", "description": "This is a past tense, informational message. In other words, your secure session has been reset." }, + "ChatRefresh--notification": { + "message": "Chat session refreshed", + "description": "Shown in timeline when a error happened, and the session was automatically reset." + }, + "ChatRefresh--learnMore": { + "message": "Learn More", + "description": "Shown in timeline when session is automatically reset, to provide access to a popup info dialog" + }, + "ChatRefresh--summary": { + "message": "Signal uses end-to-end encryption and it may need to refresh your chat session sometimes. This doesn’t affect your chat’s security but you may have missed a message from this contact and you can ask them to resend it.", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, + "ChatRefresh--contactSupport": { + "message": "Contact Support", + "description": "Shown on explainer dialog available from chat session refreshed timeline events" + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" diff --git a/images/chat-session-refresh.svg b/images/chat-session-refresh.svg new file mode 100644 index 000000000..be9410200 --- /dev/null +++ b/images/chat-session-refresh.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/images/icons/v2/refresh-16.svg b/images/icons/v2/refresh-16.svg new file mode 100644 index 000000000..9d417e1a6 --- /dev/null +++ b/images/icons/v2/refresh-16.svg @@ -0,0 +1,3 @@ + + + diff --git a/libtextsecure/test/fake_web_api.js b/libtextsecure/test/fake_web_api.js index e906c3baa..3c9864ca8 100644 --- a/libtextsecure/test/fake_web_api.js +++ b/libtextsecure/test/fake_web_api.js @@ -14,7 +14,7 @@ const fakeAPI = { getAvatar: fakeCall, getDevices: fakeCall, // getKeysForIdentifier : fakeCall, - getMessageSocket: fakeCall, + getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'), getMyKeys: fakeCall, getProfile: fakeCall, getProvisioningSocket: fakeCall, diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js index f2eb6427a..801f726f1 100644 --- a/libtextsecure/test/in_memory_signal_protocol_store.js +++ b/libtextsecure/test/in_memory_signal_protocol_store.js @@ -166,4 +166,15 @@ SignalProtocolStore.prototype = { resolve(deviceIds); }); }, + + getUnprocessedCount: () => Promise.resolve(0), + getAllUnprocessed: () => Promise.resolve([]), + getUnprocessedById: () => Promise.resolve(null), + addUnprocessed: () => Promise.resolve(), + addMultipleUnprocessed: () => Promise.resolve(), + updateUnprocessedAttempts: () => Promise.resolve(), + updateUnprocessedWithData: () => Promise.resolve(), + updateUnprocessedsWithData: () => Promise.resolve(), + removeUnprocessed: () => Promise.resolve(), + removeAllUnprocessed: () => Promise.resolve(), }; diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 0f6b58901..5fe7f3dcf 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -24,6 +24,7 @@ + @@ -31,10 +32,7 @@ - - - @@ -44,6 +42,7 @@ + diff --git a/libtextsecure/test/message_receiver_test.js b/libtextsecure/test/message_receiver_test.js index 73b606deb..0ded6a0c2 100644 --- a/libtextsecure/test/message_receiver_test.js +++ b/libtextsecure/test/message_receiver_test.js @@ -1,10 +1,9 @@ // Copyright 2015-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* global libsignal, textsecure, SignalProtocolStore */ +/* global libsignal, textsecure */ describe('MessageReceiver', () => { - textsecure.storage.impl = new SignalProtocolStore(); const { WebSocket } = window; const number = '+19999999999'; const uuid = 'AAAAAAAA-BBBB-4CCC-9DDD-EEEEEEEEEEEE'; @@ -12,6 +11,7 @@ describe('MessageReceiver', () => { const signalingKey = libsignal.crypto.getRandomBytes(32 + 20); before(() => { + localStorage.clear(); window.WebSocket = MockSocket; textsecure.storage.user.setNumberAndDeviceId(number, deviceId, 'name'); textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); @@ -19,94 +19,126 @@ describe('MessageReceiver', () => { textsecure.storage.put('signaling_key', signalingKey); }); after(() => { + localStorage.clear(); window.WebSocket = WebSocket; }); describe('connecting', () => { - const attrs = { - type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, - source: number, - sourceUuid: uuid, - sourceDevice: deviceId, - timestamp: Date.now(), - }; - const websocketmessage = new textsecure.protobuf.WebSocketMessage({ - type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, - request: { verb: 'PUT', path: '/messages' }, + let attrs; + let websocketmessage; + + before(() => { + attrs = { + type: textsecure.protobuf.Envelope.Type.CIPHERTEXT, + source: number, + sourceUuid: uuid, + sourceDevice: deviceId, + timestamp: Date.now(), + content: libsignal.crypto.getRandomBytes(200), + }; + const body = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); + + websocketmessage = new textsecure.protobuf.WebSocketMessage({ + type: textsecure.protobuf.WebSocketMessage.Type.REQUEST, + request: { verb: 'PUT', path: '/api/v1/message', body }, + }); }); - before(done => { - const signal = new textsecure.protobuf.Envelope(attrs).toArrayBuffer(); - - const aesKey = signalingKey.slice(0, 32); - const macKey = signalingKey.slice(32, 32 + 20); - - window.crypto.subtle - .importKey('raw', aesKey, { name: 'AES-CBC' }, false, ['encrypt']) - .then(key => { - const iv = libsignal.crypto.getRandomBytes(16); - window.crypto.subtle - .encrypt({ name: 'AES-CBC', iv: new Uint8Array(iv) }, key, signal) - .then(ciphertext => { - window.crypto.subtle - .importKey( - 'raw', - macKey, - { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, - ['sign'] - ) - .then(innerKey => { - window.crypto.subtle - .sign({ name: 'HMAC', hash: 'SHA-256' }, innerKey, signal) - .then(mac => { - const version = new Uint8Array([1]); - const message = dcodeIO.ByteBuffer.concat([ - version, - iv, - ciphertext, - mac, - ]); - websocketmessage.request.body = message.toArrayBuffer(); - done(); - }); - }); - }); - }); - }); - - it('connects', done => { - const mockServer = new MockServer( - `ws://localhost:8080/v1/websocket/?login=${encodeURIComponent( - uuid - )}.1&password=password` - ); + it('generates light-session-reset event when it cannot decrypt', done => { + const mockServer = new MockServer('ws://localhost:8081/'); mockServer.on('connection', server => { - server.send(new Blob([websocketmessage.toArrayBuffer()])); + setTimeout(() => { + server.send(new Blob([websocketmessage.toArrayBuffer()])); + }, 1); }); - window.addEventListener('textsecure:message', ev => { - const signal = ev.proto; - const keys = Object.keys(attrs); - - for (let i = 0, max = keys.length; i < max; i += 1) { - const key = keys[i]; - assert.strictEqual(attrs[key], signal[key]); - } - assert.strictEqual(signal.message.body, 'hello'); - mockServer.close(); - - done(); - }); - - window.messageReceiver = new textsecure.MessageReceiver( + const messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', 'username', 'password', - 'signalingKey' - // 'ws://localhost:8080', - // window, + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } ); + + messageReceiver.addEventListener('light-session-reset', done()); + }); + }); + + describe('methods', () => { + let messageReceiver; + let mockServer; + + beforeEach(() => { + // Necessary to populate the server property inside of MockSocket. Without it, we + // crash when doing any number of things to a MockSocket instance. + mockServer = new MockServer('ws://localhost:8081'); + + messageReceiver = new textsecure.MessageReceiver( + 'oldUsername', + 'username', + 'password', + 'signalingKey', + { + serverTrustRoot: 'AAAAAAAA', + } + ); + }); + afterEach(() => { + mockServer.close(); + }); + + describe('#isOverHourIntoPast', () => { + it('returns false for now', () => { + assert.isFalse(messageReceiver.isOverHourIntoPast(Date.now())); + }); + it('returns false for 5 minutes ago', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + assert.isFalse(messageReceiver.isOverHourIntoPast(fiveMinutesAgo)); + }); + it('returns true for 65 minutes ago', () => { + const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000; + assert.isTrue(messageReceiver.isOverHourIntoPast(sixtyFiveMinutesAgo)); + }); + }); + + describe('#cleanupSessionResets', () => { + it('leaves empty object alone', () => { + window.storage.put('sessionResets', {}); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = {}; + assert.deepEqual(actual, expected); + }); + it('filters out any timestamp older than one hour', () => { + const startValue = { + one: Date.now() - 1, + two: Date.now(), + three: Date.now() - 65 * 60 * 1000, + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['one', 'two']); + assert.deepEqual(actual, expected); + }); + it('filters out falsey items', () => { + const startValue = { + one: 0, + two: false, + three: Date.now(), + }; + window.storage.put('sessionResets', startValue); + messageReceiver.cleanupSessionResets(); + const actual = window.storage.get('sessionResets'); + + const expected = window._.pick(startValue, ['three']); + assert.deepEqual(actual, expected); + }); }); }); }); diff --git a/preload.js b/preload.js index 581f47e12..dadfe1d4f 100644 --- a/preload.js +++ b/preload.js @@ -45,6 +45,7 @@ try { window.platform = process.platform; window.getTitle = () => title; + window.getLocale = () => config.locale; window.getEnvironment = getEnvironment; window.getAppInstance = () => config.appInstance; window.getVersion = () => config.version; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b0b0e733b..97dc97c29 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -11453,6 +11453,88 @@ $contact-modal-padding: 18px; } } +// Module: Chat Session Refreshed Notification + +.module-chat-session-refreshed-notification { + @include font-body-2; + display: flex; + flex-direction: column; + align-items: center; +} + +.module-chat-session-refreshed-notification__first-line { + margin-bottom: 12px; + display: flex; + flex-direction: row; + align-items: center; + + margin-left: auto; + margin-right: auto; +} +.module-chat-session-refreshed-notification__icon { + height: 16px; + width: 16px; + display: inline-block; + margin-right: 8px; + + @include light-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/icons/v2/refresh-16.svg', $color-gray-25); + } +} +.module-chat-session-refreshed-notification__button { + @include button-reset; + @include button-light-blue-text; + @include button-small; + + @include font-body-2; + padding: 5px 12px; +} + +// Module: Chat Session Refreshed Dialog + +.module-chat-session-refreshed-dialog { + width: 360px; + padding: 16px; + padding-top: 28px; + border-radius: 8px; + margin-left: auto; + margin-right: auto; + + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-95; + } +} +.module-chat-session-refreshed-dialog__image { + text-align: center; +} +.module-chat-session-refreshed-dialog__title { + @include font-body-1-bold; + margin-top: 10px; + margin-bottom: 3px; +} +.module-chat-session-refreshed-dialog__buttons { + text-align: right; + margin-top: 20px; +} +.module-chat-session-refreshed-dialog__button { + @include font-body-1-bold; + @include button-reset; + @include button-primary; + + border-radius: 4px; + padding: 7px 14px; + margin-left: 12px; +} +.module-chat-session-refreshed-dialog__button--secondary { + @include button-secondary; +} + /* Third-party module: react-contextmenu*/ .react-contextmenu { diff --git a/test/index.html b/test/index.html index a95268288..540786f11 100644 --- a/test/index.html +++ b/test/index.html @@ -337,15 +337,12 @@ - - - @@ -353,20 +350,15 @@ - - - - - diff --git a/ts/background.ts b/ts/background.ts index 1469ad78d..5fd76106c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1937,6 +1937,7 @@ type WhatIsThis = import('./window.d').WhatIsThis; addQueuedEventListener('read', onReadReceipt); addQueuedEventListener('verified', onVerified); addQueuedEventListener('error', onError); + addQueuedEventListener('light-session-reset', onLightSessionReset); addQueuedEventListener('empty', onEmpty); addQueuedEventListener('reconnect', onReconnect); addQueuedEventListener('configuration', onConfiguration); @@ -3121,7 +3122,8 @@ type WhatIsThis = import('./window.d').WhatIsThis; error.name === 'HTTPError' && (error.code === 401 || error.code === 403) ) { - return unlinkAndDisconnect(); + unlinkAndDisconnect(); + return; } if ( @@ -3136,101 +3138,40 @@ type WhatIsThis = import('./window.d').WhatIsThis; window.Whisper.events.trigger('reconnectTimer'); } - return Promise.resolve(); + return; } - if (ev.proto) { - if (error && error.name === 'MessageCounterError') { - if (ev.confirm) { - ev.confirm(); - } - // Ignore this message. It is likely a duplicate delivery - // because the server lost our ack the first time. - return Promise.resolve(); - } - const envelope = ev.proto; - const id = window.ConversationController.ensureContactIds({ - e164: envelope.source, - uuid: envelope.sourceUuid, - }); - if (!id) { - throw new Error('onError: ensureContactIds returned falsey id!'); - } - const message = initIncomingMessage(envelope, { - type: Message.PRIVATE, - id, - }); + window.log.warn('background onError: Doing nothing with incoming error'); + } - const conversationId = message.get('conversationId'); - const conversation = window.ConversationController.get(conversationId); + type LightSessionResetEventType = { + senderUuid: string; + }; - if (!conversation) { - window.log.warn( - 'onError: No conversation id, cannot save error bubble' - ); - ev.confirm(); - return Promise.resolve(); - } + function onLightSessionReset(event: LightSessionResetEventType) { + const conversationId = window.ConversationController.ensureContactIds({ + uuid: event.senderUuid, + }); - // This matches the queueing behavior used in Message.handleDataMessage - conversation.queueJob(async () => { - const existingMessage = await window.Signal.Data.getMessageBySender( - message.attributes, - { - Message: window.Whisper.Message, - } - ); - if (existingMessage) { - ev.confirm(); - window.log.warn( - `Got duplicate error for message ${message.idForLogging()}` - ); - return; - } + if (!conversationId) { + window.log.warn( + 'onLightSessionReset: No conversation id, cannot add message to timeline' + ); + return; + } + const conversation = window.ConversationController.get(conversationId); - const model = new window.Whisper.Message({ - ...message.attributes, - id: window.getGuid(), - }); - await model.saveErrors(error || new Error('Error was null'), { - skipSave: true, - }); - - window.MessageController.register(model.id, model); - await window.Signal.Data.saveMessage(model.attributes, { - Message: window.Whisper.Message, - forceSave: true, - }); - - conversation.set({ - active_at: Date.now(), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - unreadCount: conversation.get('unreadCount')! + 1, - }); - - const conversationTimestamp = conversation.get('timestamp'); - const messageTimestamp = model.get('timestamp'); - if ( - !conversationTimestamp || - messageTimestamp > conversationTimestamp - ) { - conversation.set({ timestamp: model.get('sent_at') }); - } - - conversation.trigger('newmessage', model); - conversation.notify(model); - - window.Whisper.events.trigger('incrementProgress'); - - if (ev.confirm) { - ev.confirm(); - } - - window.Signal.Data.updateConversation(conversation.attributes); - }); + if (!conversation) { + window.log.warn( + 'onLightSessionReset: No conversation, cannot add message to timeline' + ); + return; } - throw error; + const receivedAt = Date.now(); + conversation.queueJob(async () => { + conversation.addChatSessionRefreshed(receivedAt); + }); } async function onViewSync(ev: WhatIsThis) { diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx new file mode 100644 index 000000000..b08bad13b --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.stories.tsx @@ -0,0 +1,25 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +const i18n = setupI18n('en', enMessages); + +storiesOf('Components/Conversation/ChatSessionRefreshedDialog', module).add( + 'Default', + () => { + return ( + + ); + } +); diff --git a/ts/components/conversation/ChatSessionRefreshedDialog.tsx b/ts/components/conversation/ChatSessionRefreshedDialog.tsx new file mode 100644 index 000000000..cb73ab4cd --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedDialog.tsx @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import classNames from 'classnames'; + +import { LocalizerType } from '../../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + contactSupport: () => unknown; + onClose: () => unknown; +}; + +export function ChatSessionRefreshedDialog( + props: PropsType +): React.ReactElement { + const { i18n, contactSupport, onClose } = props; + + return ( +
+
+ +
+
+ {i18n('ChatRefresh--notification')} +
+
+ {i18n('ChatRefresh--summary')} +
+
+ + +
+
+ ); +} diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx new file mode 100644 index 000000000..80c14cb5e --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.stories.tsx @@ -0,0 +1,24 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { ChatSessionRefreshedNotification } from './ChatSessionRefreshedNotification'; + +const i18n = setupI18n('en', enMessages); + +storiesOf( + 'Components/Conversation/ChatSessionRefreshedNotification', + module +).add('Default', () => { + return ( + + ); +}); diff --git a/ts/components/conversation/ChatSessionRefreshedNotification.tsx b/ts/components/conversation/ChatSessionRefreshedNotification.tsx new file mode 100644 index 000000000..cf5e31674 --- /dev/null +++ b/ts/components/conversation/ChatSessionRefreshedNotification.tsx @@ -0,0 +1,63 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useState, ReactElement } from 'react'; + +import { LocalizerType } from '../../types/Util'; + +import { ModalHost } from '../ModalHost'; +import { ChatSessionRefreshedDialog } from './ChatSessionRefreshedDialog'; + +type PropsHousekeepingType = { + i18n: LocalizerType; +}; + +export type PropsActionsType = { + contactSupport: () => unknown; +}; + +export type PropsType = PropsHousekeepingType & PropsActionsType; + +export function ChatSessionRefreshedNotification( + props: PropsType +): ReactElement { + const { contactSupport, i18n } = props; + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsDialogOpen(true); + }, [setIsDialogOpen]); + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + }, [setIsDialogOpen]); + + const wrappedContactSupport = useCallback(() => { + setIsDialogOpen(false); + contactSupport(); + }, [contactSupport, setIsDialogOpen]); + + return ( +
+
+ + {i18n('ChatRefresh--notification')} +
+ + {isDialogOpen ? ( + + + + ) : null} +
+ ); +} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index b62f4707f..f0eb3fa82 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -252,6 +252,8 @@ const actions = () => ({ messageSizeChanged: action('messageSizeChanged'), startCallingLobby: action('startCallingLobby'), returnToActiveCall: action('returnToActiveCall'), + + contactSupport: action('contactSupport'), }); const renderItem = (id: string) => ( diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 03b2fe5aa..d9800da50 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -41,6 +41,7 @@ const getDefaultProps = () => ({ selectMessage: action('selectMessage'), reactToMessage: action('reactToMessage'), clearSelectedMessage: action('clearSelectedMessage'), + contactSupport: action('contactSupport'), replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), deleteMessage: action('deleteMessage'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 409914b0a..ec6959d3e 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -10,11 +10,14 @@ import { PropsActions as MessageActionsType, PropsData as MessageProps, } from './Message'; - import { CallingNotification, PropsActionsType as CallingNotificationActionsType, } from './CallingNotification'; +import { + ChatSessionRefreshedNotification, + PropsActionsType as PropsChatSessionRefreshedActionsType, +} from './ChatSessionRefreshedNotification'; import { CallingNotificationType } from '../../util/callingNotification'; import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import { @@ -58,6 +61,10 @@ type CallHistoryType = { type: 'callHistory'; data: CallingNotificationType; }; +type ChatSessionRefreshedType = { + type: 'chatSessionRefreshed'; + data: null; +}; type LinkNotificationType = { type: 'linkNotification'; data: null; @@ -105,6 +112,7 @@ type ProfileChangeNotificationType = { export type TimelineItemType = | CallHistoryType + | ChatSessionRefreshedType | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType @@ -131,6 +139,7 @@ type PropsLocalType = { type PropsActionsType = MessageActionsType & CallingNotificationActionsType & + PropsChatSessionRefreshedActionsType & UnsupportedMessageActionsType & SafetyNumberActionsType; @@ -184,6 +193,14 @@ export class TimelineItem extends React.PureComponent { {...item.data} /> ); + } else if (item.type === 'chatSessionRefreshed') { + notification = ( + + ); } else if (item.type === 'linkNotification') { notification = (
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d24da65d7..129ec242c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2277,6 +2277,35 @@ export class ConversationModel extends window.Backbone.Model< return this.setVerified(); } + async addChatSessionRefreshed(receivedAt: number): Promise { + window.log.info( + `addChatSessionRefreshed: adding for ${this.idForLogging()}` + ); + + const message = ({ + conversationId: this.id, + type: 'chat-session-refreshed', + sent_at: receivedAt, + received_at: receivedAt, + unread: 1, + // TODO: DESKTOP-722 + // this type does not fully implement the interface it is expected to + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + async addKeyChange(keyChangedId: string): Promise { window.log.info( 'adding key change advisory for', diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 9c54d33fd..8f340a1f4 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -201,6 +201,7 @@ export class MessageModel extends window.Backbone.Model { isNormalBubble(): boolean { return ( !this.isCallHistory() && + !this.isChatSessionRefreshed() && !this.isEndSession() && !this.isExpirationTimerUpdate() && !this.isGroupUpdate() && @@ -282,6 +283,12 @@ export class MessageModel extends window.Backbone.Model { data: this.getPropsForProfileChange(), }; } + if (this.isChatSessionRefreshed()) { + return { + type: 'chatSessionRefreshed', + data: null, + }; + } return { type: 'message', @@ -461,6 +468,10 @@ export class MessageModel extends window.Backbone.Model { return this.get('type') === 'call-history'; } + isChatSessionRefreshed(): boolean { + return this.get('type') === 'chat-session-refreshed'; + } + isProfileChange(): boolean { return this.get('type') === 'profile-change'; } @@ -1178,6 +1189,13 @@ export class MessageModel extends window.Backbone.Model { } getNotificationData(): { emoji?: string; text: string } { + if (this.isChatSessionRefreshed()) { + return { + emoji: '🔁', + text: window.i18n('ChatRefresh--notification'), + }; + } + if (this.isUnsupportedMessage()) { return { text: window.i18n('message--getDescription--unsupported-message'), @@ -1695,6 +1713,7 @@ export class MessageModel extends window.Backbone.Model { // Rendered sync messages const isCallHistory = this.isCallHistory(); + const isChatSessionRefreshed = this.isChatSessionRefreshed(); const isGroupUpdate = this.isGroupUpdate(); const isGroupV2Change = this.isGroupV2Change(); const isEndSession = this.isEndSession(); @@ -1723,6 +1742,7 @@ export class MessageModel extends window.Backbone.Model { isSticker || // Rendered sync messages isCallHistory || + isChatSessionRefreshed || isGroupUpdate || isGroupV2Change || isEndSession || diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 52e9a7892..1eeea3a05 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -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; + 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; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index cb276ff45..9b5c7e48d 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -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 { + 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; diff --git a/ts/util/index.ts b/ts/util/index.ts index 380327b74..b8c53db37 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -24,6 +24,7 @@ import { parseRemoteClientExpiration } from './parseRemoteClientExpiration'; import { sleep } from './sleep'; import { longRunningTaskWrapper } from './longRunningTaskWrapper'; import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64'; +import { mapToSupportLocale } from './mapToSupportLocale'; import * as zkgroup from './zkgroup'; export { @@ -44,6 +45,7 @@ export { isFileDangerous, longRunningTaskWrapper, makeLookup, + mapToSupportLocale, missingCaseError, parseRemoteClientExpiration, Registration, diff --git a/ts/util/mapToSupportLocale.ts b/ts/util/mapToSupportLocale.ts new file mode 100644 index 000000000..5a00a7d26 --- /dev/null +++ b/ts/util/mapToSupportLocale.ts @@ -0,0 +1,115 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type SupportLocaleType = + | 'ar' + | 'de' + | 'en-us' + | 'es' + | 'fr' + | 'it' + | 'ja' + | 'pl' + | 'pt-br' + | 'ru' + | 'sq' + | 'zh-tw'; + +export type ElectronLocaleType = + | 'af' + | 'ar' + | 'bg' + | 'bn' + | 'ca' + | 'cs' + | 'cy' + | 'da' + | 'de' + | 'el' + | 'en' + | 'eo' + | 'es' + | 'es_419' + | 'et' + | 'eu' + | 'fa' + | 'fi' + | 'fr' + | 'he' + | 'hi' + | 'hr' + | 'hu' + | 'id' + | 'it' + | 'ja' + | 'km' + | 'kn' + | 'ko' + | 'lt' + | 'mk' + | 'mr' + | 'ms' + | 'nb' + | 'nl' + | 'nn' + | 'no' + | 'pl' + | 'pt_BR' + | 'pt_PT' + | 'ro' + | 'ru' + | 'sk' + | 'sl' + | 'sq' + | 'sr' + | 'sv' + | 'sw' + | 'ta' + | 'te' + | 'th' + | 'tr' + | 'uk' + | 'ur' + | 'vi' + | 'zh_CN' + | 'zh_TW'; + +export function mapToSupportLocale( + ourLocale: ElectronLocaleType +): SupportLocaleType { + if (ourLocale === 'ar') { + return ourLocale; + } + if (ourLocale === 'de') { + return ourLocale; + } + if (ourLocale === 'es') { + return ourLocale; + } + if (ourLocale === 'fr') { + return ourLocale; + } + if (ourLocale === 'it') { + return ourLocale; + } + if (ourLocale === 'ja') { + return ourLocale; + } + if (ourLocale === 'pl') { + return ourLocale; + } + if (ourLocale === 'pt_BR') { + return 'pt-br'; + } + if (ourLocale === 'ru') { + return ourLocale; + } + if (ourLocale === 'sq') { + return ourLocale; + } + if (ourLocale === 'zh_TW') { + return 'zh-tw'; + } + + return 'en-us'; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 1b2877a8c..046b5eeac 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -774,6 +774,15 @@ Whisper.ConversationView = Whisper.View.extend({ const showExpiredOutgoingTapToViewToast = () => { this.showToast(Whisper.TapToViewExpiredOutgoingToast); }; + const contactSupport = () => { + const baseUrl = + 'https://support.signal.org/hc/LOCALE/requests/new?desktop&chat_refreshed'; + const locale = window.getLocale(); + const supportLocale = window.Signal.Util.mapToSupportLocale(locale); + const url = baseUrl.replace('LOCALE', supportLocale); + + this.navigateTo(url); + }; const scrollToQuotedMessage = async (options: any) => { const { authorId, sentAt } = options; @@ -928,6 +937,7 @@ Whisper.ConversationView = Whisper.View.extend({ JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, { id, + contactSupport, deleteMessage, deleteMessageForEveryone, displayTapToViewMessage, diff --git a/ts/window.d.ts b/ts/window.d.ts index 36993f772..414fdbea5 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -92,6 +92,7 @@ import { ProgressModal } from './components/ProgressModal'; import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { MIMEType } from './types/MIME'; +import { ElectronLocaleType } from './util/mapToSupportLocale'; export { Long } from 'long'; @@ -148,6 +149,7 @@ declare global { getInboxCollection: () => ConversationModelCollectionType; getIncomingCallNotification: () => Promise; getInteractionMode: () => 'mouse' | 'keyboard'; + getLocale: () => ElectronLocaleType; getMediaCameraPermissions: () => Promise; getMediaPermissions: () => Promise; getNodeVersion: () => string;