Automatic session reset

This commit is contained in:
Scott Nonnenberg 2021-02-18 08:40:26 -08:00 committed by Josh Perez
parent fe187226bb
commit 98e7e65d25
26 changed files with 803 additions and 225 deletions

View file

@ -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 doesnt affect your chats 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"

View file

@ -0,0 +1,12 @@
<svg width="200" height="110" viewBox="0 0 200 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.3 15.1L12.9 15C9.1 15 6 18.1 6 21.9L5.9 88C5.9 91.8 9 94.9 12.8 94.9L39.1 95C42.9 95 46 91.9 46 88.1L46.2 22C46.2 18.2 43.1 15.1 39.3 15.1ZM42.8 88.1C42.8 90.1 41.1 91.8 39.1 91.8L12.7 91.7C10.7 91.7 9 90 9 88L9.1 21.9C9.1 19.9 10.8 18.2 12.8 18.2L39.2 18.3C41.2 18.3 42.9 20 42.9 22L42.8 88.1Z" fill="#C6C6C6"/>
<path d="M187.3 15.1L160.9 15C157.1 15 154 18.1 154 21.9L153.9 88C153.9 91.8 157 94.9 160.8 94.9L187.2 95C191 95 194.1 91.9 194.1 88.1L194.2 22C194.2 18.2 191.1 15.1 187.3 15.1ZM190.8 88.1C190.8 90.1 189.1 91.8 187.1 91.8L160.7 91.7C158.7 91.7 157 90 157 88L157.1 21.9C157.1 19.9 158.8 18.2 160.8 18.2L187.2 18.3C189.2 18.3 190.9 20 190.9 22L190.8 88.1Z" fill="#C6C6C6"/>
<circle cx="126" cy="56" r="2" fill="#848484"/>
<circle cx="136" cy="56" r="2" fill="#848484"/>
<circle cx="146" cy="56" r="2" fill="#848484"/>
<circle cx="54" cy="56" r="2" fill="#848484"/>
<circle cx="64" cy="56" r="2" fill="#848484"/>
<circle cx="74" cy="56" r="2" fill="#848484"/>
<circle cx="100" cy="56" r="18" fill="#2C6BED"/>
<path d="M109.38 56C109.393 58.5029 108.413 60.9088 106.655 62.6898C104.896 64.4708 102.503 65.4815 100 65.5C98.4434 65.4794 96.9157 65.0765 95.5513 64.3269C94.1869 63.5773 93.0276 62.5038 92.1755 61.201C91.3233 59.8982 90.8044 58.4059 90.6643 56.8555C90.5243 55.305 90.7674 53.7439 91.3723 52.3095C91.9772 50.875 92.9254 49.6112 94.1333 48.6292C95.3413 47.6473 96.7721 46.9772 98.2998 46.678C99.8276 46.3787 101.405 46.4596 102.895 46.9133C104.384 47.367 105.739 48.1797 106.84 49.28L107.84 50.28L107.49 48.98V46.56H109V52.5H103V51H105.39L106.81 51.38L105.81 50.38C104.695 49.252 103.27 48.4808 101.716 48.1644C100.162 47.8479 98.549 48.0006 97.0819 48.6028C95.6147 49.2051 94.3597 50.2299 93.4762 51.547C92.5928 52.8641 92.1207 54.414 92.12 56C92.12 57.0348 92.3238 58.0595 92.7198 59.0155C93.1158 59.9716 93.6963 60.8403 94.428 61.572C95.1597 62.3037 96.0284 62.8842 96.9845 63.2802C97.9405 63.6762 98.9652 63.88 100 63.88C101.035 63.88 102.059 63.6762 103.016 63.2802C103.972 62.8842 104.84 62.3037 105.572 61.572C106.304 60.8403 106.884 59.9716 107.28 59.0155C107.676 58.0595 107.88 57.0348 107.88 56H109.38Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,3 @@
<svg id="Export" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M13.46,8A5.46,5.46,0,1,1,2.55,8,5.54,5.54,0,0,1,12,4.06l.44.44-1.13-.33H10.17v1.5h4.49V1.18h-1.5V2.3l.31,1.09L13.08,3a7,7,0,0,0-12,5,7,7,0,0,0,7,7,7,7,0,0,0,7-7Zm.45-3.06Z"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -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,

View file

@ -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(),
};

View file

@ -24,6 +24,7 @@
<script type="text/javascript" src="../libsignal-protocol.js"></script>
<script type="text/javascript" src="../protobufs.js" data-cover></script>
<script type="text/javascript" src="../storage/user.js" data-cover></script>
<script type="text/javascript" src="../storage/unprocessed.js" data-cover></script>
<script type="text/javascript" src="../protocol_wrapper.js" data-cover></script>
<script type="text/javascript" src="../../js/libphonenumber-util.js"></script>
@ -31,10 +32,7 @@
<script type="text/javascript" src="../../js/signal_protocol_store.js" data-cover></script>
<script type="text/javascript" src="../../js/storage.js" data-cover></script>
<script type="text/javascript" src="../../js/models/blockedNumbers.js" data-cover></script>
<script type="text/javascript" src="../../js/models/messages.js" data-cover></script>
<script type="text/javascript" src="../../js/models/conversations.js" data-cover></script>
<script type="text/javascript" src="errors_test.js"></script>
<script type="text/javascript" src="helpers_test.js"></script>
<script type="text/javascript" src="storage_test.js"></script>
<script type="text/javascript" src="crypto_test.js"></script>
@ -44,6 +42,7 @@
<script type="text/javascript" src="websocket-resources_test.js"></script>
<script type="text/javascript" src="task_with_timeout_test.js"></script>
<script type="text/javascript" src="account_manager_test.js"></script>
<script type="text/javascript" src="message_receiver_test.js"></script>
<script type="text/javascript" src="sendmessage_test.js"></script>
<!-- Comment out to turn off code coverage. Useful for getting real callstacks. -->

View file

@ -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);
});
});
});
});

View file

@ -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;

View file

@ -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 {

View file

@ -337,15 +337,12 @@
<script type="text/javascript" src="../js/reliable_trigger.js" data-cover></script>
<script type="text/javascript" src="test.js"></script>
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
<script type="text/javascript" src="../js/database.js" data-cover></script>
<script type="text/javascript" src="../js/storage.js" data-cover></script>
<script type="text/javascript" src="../js/signal_protocol_store.js" data-cover></script>
<script type="text/javascript" src="../js/libtextsecure.js" data-cover></script>
<script type="text/javascript" src="../js/libphonenumber-util.js"></script>
<script type="text/javascript" src="../js/models/messages.js" data-cover></script>
<script type="text/javascript" src="../js/models/conversations.js" data-cover></script>
<script type="text/javascript" src="../js/models/blockedNumbers.js" data-cover></script>
<script type="text/javascript" src="../js/message_controller.js" data-cover></script>
<script type="text/javascript" src="../js/keychange_listener.js" data-cover></script>
@ -353,20 +350,15 @@
<script type='text/javascript' src='../js/expiring_tap_to_view_messages.js' data-cover></script>
<script type='text/javascript' src='../js/notifications.js' data-cover></script>
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
<script type='text/javascript' src='../js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='../js/views/whisper_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/debug_log_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/contact_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/conversation_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/inbox_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/confirmation_dialog_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/identicon_svg_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script>

View file

@ -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) {

View file

@ -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 (
<ChatSessionRefreshedDialog
contactSupport={action('contactSupport')}
onClose={action('onClose')}
i18n={i18n}
/>
);
}
);

View file

@ -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 (
<div className="module-chat-session-refreshed-dialog">
<div className="module-chat-session-refreshed-dialog__image">
<img
src="images/chat-session-refresh.svg"
height="110"
width="200"
alt=""
/>
</div>
<div className="module-chat-session-refreshed-dialog__title">
{i18n('ChatRefresh--notification')}
</div>
<div className="module-chat-session-refreshed-dialog__description">
{i18n('ChatRefresh--summary')}
</div>
<div className="module-chat-session-refreshed-dialog__buttons">
<button
type="button"
onClick={contactSupport}
className={classNames(
'module-chat-session-refreshed-dialog__button',
'module-chat-session-refreshed-dialog__button--secondary'
)}
>
{i18n('ChatRefresh--contactSupport')}
</button>
<button
type="button"
onClick={onClose}
className="module-chat-session-refreshed-dialog__button"
>
{i18n('Confirmation--confirm')}
</button>
</div>
</div>
);
}

View file

@ -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 (
<ChatSessionRefreshedNotification
contactSupport={action('contactSupport')}
i18n={i18n}
/>
);
});

View file

@ -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<boolean>(false);
const openDialog = useCallback(() => {
setIsDialogOpen(true);
}, [setIsDialogOpen]);
const closeDialog = useCallback(() => {
setIsDialogOpen(false);
}, [setIsDialogOpen]);
const wrappedContactSupport = useCallback(() => {
setIsDialogOpen(false);
contactSupport();
}, [contactSupport, setIsDialogOpen]);
return (
<div className="module-chat-session-refreshed-notification">
<div className="module-chat-session-refreshed-notification__first-line">
<span className="module-chat-session-refreshed-notification__icon" />
{i18n('ChatRefresh--notification')}
</div>
<button
type="button"
onClick={openDialog}
className="module-chat-session-refreshed-notification__button"
>
{i18n('ChatRefresh--learnMore')}
</button>
{isDialogOpen ? (
<ModalHost onClose={closeDialog}>
<ChatSessionRefreshedDialog
onClose={closeDialog}
contactSupport={wrappedContactSupport}
i18n={i18n}
/>
</ModalHost>
) : null}
</div>
);
}

View file

@ -252,6 +252,8 @@ const actions = () => ({
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
contactSupport: action('contactSupport'),
});
const renderItem = (id: string) => (

View file

@ -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'),

View file

@ -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<PropsType> {
{...item.data}
/>
);
} else if (item.type === 'chatSessionRefreshed') {
notification = (
<ChatSessionRefreshedNotification
{...this.props}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'linkNotification') {
notification = (
<div className="module-message-unsynced">

View file

@ -2277,6 +2277,35 @@ export class ConversationModel extends window.Backbone.Model<
return this.setVerified();
}
async addChatSessionRefreshed(receivedAt: number): Promise<void> {
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<void> {
window.log.info(
'adding key change advisory for',

View file

@ -201,6 +201,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
isNormalBubble(): boolean {
return (
!this.isCallHistory() &&
!this.isChatSessionRefreshed() &&
!this.isEndSession() &&
!this.isExpirationTimerUpdate() &&
!this.isGroupUpdate() &&
@ -282,6 +283,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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<MessageAttributesType> {
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<MessageAttributesType> {
}
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<MessageAttributesType> {
// 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<MessageAttributesType> {
isSticker ||
// Rendered sync messages
isCallHistory ||
isChatSessionRefreshed ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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';
}

View file

@ -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,

2
ts/window.d.ts vendored
View file

@ -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<boolean>;
getInteractionMode: () => 'mouse' | 'keyboard';
getLocale: () => ElectronLocaleType;
getMediaCameraPermissions: () => Promise<boolean>;
getMediaPermissions: () => Promise<boolean>;
getNodeVersion: () => string;