Prechádzať zdrojové kódy

Implement handshake procedure for session resumption

Bump SaltyRTC client version
Add session error dialog in case of an error
Add Future class to resolve a Promise outside of its executor and retrieve its status
Add an arraysAreEqual function to compare two Uint8Arrays
Add ChunkCache which keeps chunks and their sequence number until they are being acknowledged or transferred to a new connection
Update WebClientService procedures to do the new connectionInfo handshake
Refactor the stopping procedure in WebClientService, resolves #533
Lennart Grahl 7 rokov pred
rodič
commit
3fab1fb182

+ 1 - 0
index.html

@@ -121,6 +121,7 @@
     <script src="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
+    <script src="libs/future.js?v=[[VERSION]]"></script>
 
     <!-- Translation -->
     <script src="node_modules/messageformat/messageformat.min.js?v=[[VERSION]]"></script>

+ 2 - 1
public/i18n/de.json

@@ -326,6 +326,7 @@
         "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
         "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
         "WEBCLIENT_DISABLED": "Threema Web wurde auf Ihrem Gerät deaktiviert.",
-        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben."
+        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben.",
+        "SESSION_ERROR": "Die Sitzung wurde auf Grund eines Protokollfehlers beendet."
     }
 }

+ 2 - 1
public/i18n/en.json

@@ -325,6 +325,7 @@
         "SESSION_STOPPED": "The session was stopped on your device.",
         "SESSION_DELETED": "The session was deleted on your device.",
         "WEBCLIENT_DISABLED": "Threema Web was disabled on your device.",
-        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window."
+        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window.",
+        "SESSION_ERROR": "The session was stopped due to a protocol error."
     }
 }

+ 45 - 0
public/libs/future.js

@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+class Future extends Promise {
+    constructor(executor) {
+        let resolve, reject;
+        super((resolve_, reject_) => {
+            resolve = resolve_;
+            reject = reject_;
+            if (executor) {
+                return executor(resolve_, reject_);
+            }
+        });
+
+        this._done = false;
+        this._resolve = resolve;
+        this._reject = reject;
+    }
+
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    get done() {
+        return this._done;
+    }
+
+    /**
+     * Resolve the future.
+     */
+    resolve(...args) {
+        this._done = true;
+        return this._resolve(...args);
+    }
+
+    /**
+     * Reject the future.
+     */
+    reject(...args) {
+        this._done = true;
+        return this._reject(...args);
+    }
+}

+ 7 - 9
src/controllers/status.ts

@@ -197,10 +197,8 @@ export class StatusController {
 
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
-            const resetPush = false;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
+            this.webClientService.stop(threema.DisconnectReason.SessionStopped, true, false, false);
+            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, true);
             this.webClientService.start().then(
                 () => {
                     // Cancel timeout
@@ -264,15 +262,15 @@ export class StatusController {
             });
         };
 
-        const resetPush = false;
         const skipPush = true;
-        const redirect = false;
-        const startTimeout = 500; // Delay connecting a bit to wait for old websocket to close
+        // Delay connecting a bit to wait for old websocket to close
+        // TODO: Make this more robust and hopefully faster
+        const startTimeout = 500;
         this.$log.debug(this.logTag, 'Stopping old connection');
-        this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
+        this.webClientService.stop(threema.DisconnectReason.SessionStopped, true, false, false);
         this.$timeout(() => {
             this.$log.debug(this.logTag, 'Starting new connection');
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
+            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, true);
             this.webClientService.start(skipPush).then(
                 () => { /* ok */ },
                 (error) => {

+ 16 - 0
src/helpers.ts

@@ -338,3 +338,19 @@ export function hasValue(val: any): boolean {
 export function sleep(ms: number): Promise<void> {
     return new Promise((resolve) => setTimeout(resolve, ms));
 }
+
+/**
+ * Compare two Uint8Array instances. Return true if all elements are equal
+ * (compared using ===).
+ */
+export function arraysAreEqual(a1: Uint8Array, a2: Uint8Array): boolean {
+    if (a1.length !== a2.length) {
+        return false;
+    }
+    for (let i = 0; i < a1.length; i++) {
+        if (a1[i] !== a2[i]) {
+            return false;
+        }
+    }
+    return true;
+}

+ 2 - 6
src/partials/messenger.ts

@@ -1003,9 +1003,7 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
+            this.webClientService.stop(threema.DisconnectReason.SessionStopped, true, true, true);
             this.receiverService.setActive(undefined);
         }, () => {
             // do nothing
@@ -1023,9 +1021,7 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
+            this.webClientService.stop(threema.DisconnectReason.SessionDeleted, true, true, true);
             this.receiverService.setActive(undefined);
         }, () => {
             // do nothing

+ 1 - 3
src/partials/welcome.ts

@@ -476,9 +476,7 @@ class WelcomeController {
 
         this.$mdDialog.show(confirm).then(() =>  {
             // Force-stop the webclient
-            const resetPush = true;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
+            this.webClientService.stop(threema.DisconnectReason.SessionDeleted, true, true, false);
 
             // Reset state
             this.stateService.updateConnectionBuildupState('new');

+ 92 - 0
src/protocol/cache.ts

@@ -0,0 +1,92 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+export type CachedChunk = Uint8Array | null;
+
+/**
+ * Contains messages that have not yet been acknowledged,
+ */
+export class ChunkCache {
+    private readonly sequenceNumberMax: number;
+    private _sequenceNumber = 0;
+    private cache: CachedChunk[] = [];
+
+    constructor(sequenceNumberMax: number) {
+        this.sequenceNumberMax = sequenceNumberMax;
+    }
+
+    /**
+     * Get the current sequence number (e.g. of the **next** chunk to be added).
+     */
+    public get sequenceNumber(): number {
+        return this._sequenceNumber;
+    }
+
+    /**
+     * Get the currently cached chunks.
+     */
+    public get chunks(): CachedChunk[] {
+        return this.cache;
+    }
+
+    /**
+     * Transfer an array of cached chunks to this cache instance.
+     */
+    public transfer(cache: CachedChunk[]): void {
+        // Add chunks but remove all which are blacklisted
+        for (const chunk of cache) {
+            if (chunk !== null) {
+                this.append(chunk);
+            }
+        }
+    }
+
+    /**
+     * Append a chunk to the chunk cache.
+     */
+    public append(chunk: CachedChunk): void {
+        // Check if the sequence number would overflow
+        if (this._sequenceNumber >= this.sequenceNumberMax) {
+            throw Error('Sequence number overflow');
+        }
+
+        // Update sequence number & append chunk
+        ++this._sequenceNumber;
+        this.cache.push(chunk);
+    }
+
+    /**
+     * Acknowledge cached chunks and remove those from the cache.
+     */
+    public acknowledge(theirSequenceNumber: number): void {
+        if (theirSequenceNumber < 0 || theirSequenceNumber > this.sequenceNumberMax) {
+            throw new Error(`Remote sent us an invalid sequence number: ${theirSequenceNumber}`);
+        }
+
+        // Calculate the slice start index for the chunk cache
+        // Important: Our sequence number is one chunk ahead!
+        const endOffset = theirSequenceNumber + 1 - this._sequenceNumber;
+        if (endOffset > 0) {
+            throw new Error('Remote travelled through time and acknowledged a chunk which is in the future');
+        } else if (-endOffset > this.cache.length) {
+            throw new Error('Remote travelled back in time and acknowledged a chunk it has already acknowledged');
+        }
+
+        // Slice our cache
+        this.cache = endOffset === 0 ? [] : this.cache.slice(endOffset);
+    }
+}

+ 325 - 93
src/services/webclient.ts

@@ -22,11 +22,8 @@
 import {StateService as UiStateService} from '@uirouter/angularjs';
 
 import * as msgpack from 'msgpack-lite';
-import {hasFeature, hasValue, hexToU8a, msgpackVisualizer} from '../helpers';
-import {
-    isContactReceiver, isDistributionListReceiver, isGroupReceiver,
-    isValidDisconnectReason, isValidReceiverType,
-} from '../typeguards';
+import {arraysAreEqual, hasFeature, hasValue, hexToU8a, msgpackVisualizer, stringToUtf8a} from '../helpers';
+import {isContactReceiver, isDistributionListReceiver, isGroupReceiver, isValidReceiverType} from '../typeguards';
 import {BatteryStatusService} from './battery';
 import {BrowserService} from './browser';
 import {TrustedKeyStoreService} from './keystore';
@@ -41,17 +38,23 @@ import {StateService} from './state';
 import {TitleService} from './title';
 import {VersionService} from './version';
 
+import {ChunkCache} from '../protocol/cache';
+
 // Aliases
 import InitializationStep = threema.InitializationStep;
 import ContactReceiverFeature = threema.ContactReceiverFeature;
+import DisconnectReason = threema.DisconnectReason;
 
 /**
  * This service handles everything related to the communication with the peer.
  */
 export class WebClientService {
+    private static CHUNK_SIZE = 64 * 1024;
+    private static SEQUENCE_NUMBER_MAX = (2 ** 32) - 1;
     private static AVATAR_LOW_MAX_SIZE = 48;
     private static MAX_TEXT_LENGTH = 3500;
     private static MAX_FILE_SIZE_WEBRTC = 15 * 1024 * 1024;
+    private static CONNECTION_ID_NONCE = stringToUtf8a('connectionidconnectionid');
 
     private static TYPE_REQUEST = 'request';
     private static TYPE_RESPONSE = 'response';
@@ -84,7 +87,9 @@ export class WebClientService {
     private static SUB_TYPE_CLEAN_RECEIVER_CONVERSATION = 'cleanReceiverConversation';
     private static SUB_TYPE_CONFIRM_ACTION = 'confirmAction';
     private static SUB_TYPE_PROFILE = 'profile';
+    private static SUB_TYPE_CONNECTION_ACK = 'connectionAck';
     private static SUB_TYPE_CONNECTION_DISCONNECT = 'connectionDisconnect';
+    private static SUB_TYPE_CONNECTION_INFO = 'connectionInfo';
     private static ARGUMENT_MODE = 'mode';
     private static ARGUMENT_MODE_NEW = 'new';
     private static ARGUMENT_MODE_MODIFIED = 'modified';
@@ -154,17 +159,21 @@ export class WebClientService {
     private stateService: StateService;
     private lastPush: Date = null;
 
-    // SaltyRTC
+    // Session connection
     private saltyRtcHost: string = null;
     public salty: saltyrtc.SaltyRTC = null;
+    private connectionInfoFuture: Future<any> = null;
     private webrtcTask: saltyrtc.tasks.webrtc.WebRTCTask = null;
     private relayedDataTask: saltyrtc.tasks.relayed_data.RelayedDataTask = null;
     private secureDataChannel: saltyrtc.tasks.webrtc.SecureDataChannel = null;
     public chosenTask: threema.ChosenTask = threema.ChosenTask.None;
+    private previousConnectionId: Uint8Array = null;
+    private currentConnectionId: Uint8Array = null;
+    private previousChunkCache: ChunkCache = null;
+    private currentChunkCache: ChunkCache = null;
 
     // Message chunking
     private messageSerial = 0;
-    private messageChunkSize = 64 * 1024;
     private unchunker: chunkedDc.Unchunker = null;
 
     // Messenger data
@@ -321,10 +330,23 @@ export class WebClientService {
     /**
      * Initialize the webclient service.
      */
-    public init(keyStore?: saltyrtc.KeyStore, peerTrustedKey?: Uint8Array, resetFields = true): void {
+    public init(keyStore?: saltyrtc.KeyStore, peerTrustedKey?: Uint8Array, resumeSession = true): void {
         // Reset state
         this.stateService.reset();
 
+        // Move instances that we need to re-establish a previous session
+        // Note: Only move the previous connection's instances if the previous
+        //       connection was successful.
+        if (!this.previousChunkCache && this.currentChunkCache) {
+            this.previousConnectionId = this.currentConnectionId;
+            this.currentConnectionId = null;
+            this.previousChunkCache = this.currentChunkCache;
+            this.currentChunkCache = null;
+        }
+
+        // Create new handshake future
+        this.connectionInfoFuture = new Future();
+
         // Create WebRTC task instance
         const maxPacketSize = this.browserService.getBrowser().isFirefox(false) ? 16384 : 65536;
         this.webrtcTask = new saltyrtcTaskWebrtc.WebRTCTask(true, maxPacketSize);
@@ -367,12 +389,14 @@ export class WebClientService {
             builder = builder.withTrustedPeerKey(peerTrustedKey);
         }
         this.salty = builder.asInitiator();
-
         if (this.config.DEBUG) {
             this.$log.debug('Public key:', this.salty.permanentKeyHex);
             this.$log.debug('Auth token:', this.salty.authTokenHex);
         }
 
+        // Create chunk cache
+        this.currentChunkCache = new ChunkCache(WebClientService.SEQUENCE_NUMBER_MAX);
+
         // We want to know about new responders.
         this.salty.on('new-responder', () => {
             if (!this.startupDone) {
@@ -461,7 +485,7 @@ export class WebClientService {
 
             // Otherwise, no handover is necessary.
             } else {
-                this.onHandover(resetFields);
+                this.onHandover(resumeSession);
                 return;
             }
         });
@@ -476,7 +500,7 @@ export class WebClientService {
         // Wait for handover to be finished
         this.salty.on('handover', () => {
             this.$log.debug(this.logTag, 'Handover done');
-            this.onHandover(resetFields);
+            this.onHandover(resumeSession);
         });
 
         // Handle SaltyRTC errors
@@ -488,7 +512,6 @@ export class WebClientService {
         });
         this.salty.on('no-shared-task', (ev) => {
             this.$log.warn('No shared task found:', ev.data);
-            const requestedWebrtc = ev.data.requested.filter((t) => t.endsWith('webrtc.tasks.saltyrtc.org')).length > 0;
             const offeredWebrtc = ev.data.offered.filter((t) => t.endsWith('webrtc.tasks.saltyrtc.org')).length > 0;
             if (!this.browserService.supportsWebrtcTask() && offeredWebrtc) {
                 this.showWebrtcAndroidWarning();
@@ -517,32 +540,133 @@ export class WebClientService {
         });
     }
 
+    /**
+     * Show an alert dialog. Can be called directly after calling `.stop(...)`.
+     */
+    private showAlert(alertMessage: string): void {
+        // Note: A former stop() call above may result in a redirect, which will
+        //       in turn hide all open dialog boxes. Therefore, to avoid
+        //       immediately hiding the alert box, enqueue dialog at end of
+        //       event loop.
+        this.$timeout(() => {
+            this.$mdDialog.show(this.$mdDialog.alert()
+                .title(this.$translate.instant('connection.SESSION_CLOSED_TITLE'))
+                .textContent(this.$translate.instant(alertMessage))
+                .ok(this.$translate.instant('common.OK')));
+        }, 0);
+    }
+
+    /**
+     * Fail the session and let the remote peer know that an error occurred.
+     * A dialog will be displayed to let the user know a protocol error
+     * happened.
+     */
+    private failSession() {
+        // Stop session
+        this.stop(DisconnectReason.SessionError, true, true, true);
+
+        // Show an error dialog
+        this.showAlert('connection.SESSION_ERROR');
+    }
+
+    /**
+     * Resume a session via the previous connection's ID and chunk cache.
+     *
+     * Important: Caller must invalidate the cache and connection ID after this
+     *            function returned!
+     */
+    private resumeSession(remoteInfo: any): void {
+        // Ensure we want to resume from the same previous connection
+        if (!arraysAreEqual(this.previousConnectionId, remoteInfo.resume.id)) {
+            this.$log.info('Cannot resume session: IDs of previous connection do not match');
+            // Both sides should detect that -> recoverable
+            return;
+        }
+
+        // Acknowledge chunks that have been received by the remote side
+        try {
+            this.previousChunkCache.acknowledge(remoteInfo.resume.sequenceNumber);
+        } catch (error) {
+            // Not recoverable
+            this.$log.error(this.logTag, `Unable to resume session: ${error}`);
+            this.failSession();
+            return;
+        }
+
+        // Transfer the cache (filters blacklisted chunks)
+        this.currentChunkCache.transfer(this.previousChunkCache.chunks);
+
+        // Resend chunks
+        for (const chunk of this.currentChunkCache.chunks) {
+            this.sendChunk(chunk);
+        }
+
+        // Done, yay!
+        this.$log.debug(this.logTag, 'Session resumed');
+    }
+
     /**
      * For the WebRTC task, this is called when the DataChannel is open.
      * For the relayed data task, this is called once the connection is established.
      */
-    private onConnectionEstablished(resetFields: boolean) {
-        // Reset fields if requested
-        if (resetFields) {
-            this._resetFields();
+    private async onConnectionEstablished(resumeSession: boolean) {
+        // Send connection info
+        resumeSession = resumeSession && this.previousConnectionId !== null && this.previousChunkCache !== null;
+        this.$log.debug(this.logTag, 'Sending connection info');
+        if (resumeSession) {
+            this._sendConnectionInfo(
+                this.currentConnectionId, this.previousConnectionId, this.previousChunkCache.sequenceNumber);
+        } else {
+            this._sendConnectionInfo(this.currentConnectionId);
+        }
+
+        // Receive connection info
+        // Note: We can receive the connectionInfo message here, or
+        //       null in case the remote side does not want to resume, or
+        //       an error which should fail the connection.
+        let remoteInfo;
+        try {
+            remoteInfo = await this.connectionInfoFuture;
+        } catch (error) {
+            this.$log.error(this.logTag, error);
+            this.failSession();
+            return;
         }
+        if (remoteInfo !== null) {
+            this.$log.debug(this.logTag, 'Received connection info');
 
-        // Determine whether to request initial data
-        const requestInitialData: boolean =
-            (resetFields === true) ||
-            (this.chosenTask === threema.ChosenTask.WebRTC);
+            // Validate connection ID
+            if (!arraysAreEqual(this.currentConnectionId, remoteInfo.id)) {
+                this.$log.error(this.logTag, 'Derived connection IDs do not match!');
+                this.failSession();
+                return;
+            }
 
-        // Only request initial data if this is not a soft reconnect
-        let requiredInitializationSteps;
-        if (requestInitialData) {
-            requiredInitializationSteps = [
+            // Try to resume the session if both local and remote want to resume
+            if (resumeSession && remoteInfo.resume !== undefined) {
+                this.resumeSession(remoteInfo);
+            } else {
+                this.$log.debug(this.logTag, `No resumption (local requested: ${resumeSession ? 'yes' : 'no'}, ` +
+                    `remote requested: ${remoteInfo.resume ? 'yes' : 'no'}`);
+            }
+        } else {
+            this.$log.debug(this.logTag, 'Remote side does not want to resume');
+        }
+
+        // Invalidate the previous connection cache & id
+        this.previousConnectionId = null;
+        this.previousChunkCache = null;
+
+        // Reset fields and request initial data if not resuming the session
+        const requiredInitializationSteps = [];
+        if (!resumeSession) {
+            requiredInitializationSteps.push(
                 InitializationStep.ClientInfo,
                 InitializationStep.Conversations,
                 InitializationStep.Receivers,
                 InitializationStep.Profile,
-            ];
-        } else {
-            requiredInitializationSteps = [];
+            );
+            this._resetFields();
         }
 
         // Resolve startup promise once initialization is done
@@ -556,8 +680,8 @@ export class WebClientService {
             });
         }
 
-        // Request initial data
-        if (requestInitialData) {
+        // Request initial data if not resuming the session
+        if (!resumeSession) {
             this._requestInitialData();
         }
 
@@ -589,11 +713,16 @@ export class WebClientService {
      * This can either be a real handover to WebRTC (Android), or simply
      * when the relayed data task takes over (iOS).
      */
-    private onHandover(resetFields: boolean) {
+    private onHandover(resumeSession: boolean) {
         // Initialize NotificationService
         this.$log.debug(this.logTag, 'Initializing NotificationService...');
         this.notificationService.init();
 
+        // Derive connection ID
+        // Note: We need to make sure this is done before any ARP messages can be received
+        const box = this.salty.encryptForPeer(new Uint8Array(0), WebClientService.CONNECTION_ID_NONCE);
+        this.currentConnectionId = box.data;
+
         // If the WebRTC task was chosen, initialize the data channel
         if (this.chosenTask === threema.ChosenTask.WebRTC) {
             // Create secure data channel
@@ -602,7 +731,9 @@ export class WebClientService {
                 WebClientService.DC_LABEL,
                 (event: Event) => {
                     this.$log.debug(this.logTag, 'SecureDataChannel open');
-                    this.onConnectionEstablished(resetFields);
+                    this.onConnectionEstablished(resumeSession).catch((error) => {
+                        this.$log.error(this.logTag, 'Error during handshake:', error);
+                    });
                 },
             );
 
@@ -632,7 +763,9 @@ export class WebClientService {
             });
 
             // The communication channel is now open! Fetch initial data
-            this.onConnectionEstablished(resetFields);
+            this.onConnectionEstablished(resumeSession).catch((error) => {
+                this.$log.error(this.logTag, 'Error during handshake:', error);
+            });
         }
     }
 
@@ -717,41 +850,69 @@ export class WebClientService {
     /**
      * Stop the webclient service.
      *
-     * This is a forced stop, meaning that all channels are closed.
-     *
-     * Parameters:
+     * This is a forced stop, meaning that all connections are being closed.
      *
-     * - `requestedByUs`: Set this to `false` if the app requested to close the session.
-     * - `reason`: The disconnect reason. When this is `SessionDeleted`, the function
-     *             will clear any trusted key or push token from the keystore.
-     * - `resetPush`: Whether to reset the push service.
-     * - `redirect`: Whether to redirect to the welcome page.
-     */
-    public stop(requestedByUs: boolean,
-                reason: threema.DisconnectReason,
-                resetPush: boolean = true,
-                redirect: boolean = false): void {
+     * @reason The disconnect reason.
+     * @send will send a disconnect message to the remote peer containing the
+     *   disconnect reason if set to `true`.
+     * @close will close the session (meaning all cached data will be
+     *   invalidated) if set to `true`. Note that the session will always be
+     *   closed in case `reason` indicates that the session is to be deleted,
+     *   has been replaced, a protocol error occurred or in case `redirect` has
+     *   been set to `true`.
+     * @redirect will redirect to the welcome page if set to `true`.
+     */
+    public stop(
+        reason: DisconnectReason,
+        send: boolean,
+        close: boolean,
+        redirect: boolean,
+    ): void {
         this.$log.info(this.logTag, 'Disconnecting...');
+        let remove = false;
+
+        // A redirect to the welcome page always implies a close
+        if (redirect) {
+            close = true;
+        }
 
-        if (requestedByUs && this.stateService.state === threema.GlobalConnectionState.Ok) {
-            // Ask peer to disconnect too
+        // Session deleted: Force close and delete
+        if (reason === DisconnectReason.SessionDeleted) {
+            close = true;
+            remove = true;
+        }
+
+        // Session replaced or error'ed: Force close
+        if (reason === DisconnectReason.SessionReplaced || reason === DisconnectReason.SessionError) {
+            close = true;
+        }
+
+        // Send disconnect reason to the remote peer if requested
+        if (send && this.stateService.state === threema.GlobalConnectionState.Ok) {
             this._sendUpdate(WebClientService.SUB_TYPE_CONNECTION_DISCONNECT, undefined, {reason: reason});
         }
 
+        // Reset states
         this.stateService.reset();
 
         // Reset the unread count
         this.resetUnreadCount();
 
-        // Clear stored data (trusted key, push token, etc)
-        const deleteStoredData = reason === threema.DisconnectReason.SessionDeleted;
-        if (deleteStoredData === true) {
+        // Clear stored data (trusted key, push token, etc) if deleting the session
+        if (remove) {
             this.trustedKeyStore.clearTrustedKey();
         }
 
-        // Clear push token
-        if (resetPush === true) {
+        // Invalidate and clear caches
+        if (close) {
+            this.previousConnectionId = null;
+            this.currentConnectionId = null;
+            this.previousChunkCache = null;
+            this.currentChunkCache = null;
             this.pushService.reset();
+            this.$log.debug(this.logTag, 'Session closed (cannot be resumed)');
+        } else {
+            this.$log.debug(this.logTag, 'Session remains open (can be resumed)');
         }
 
         // Close data channel
@@ -766,27 +927,19 @@ export class WebClientService {
             this.salty.disconnect();
         }
 
-        // Function to redirect to welcome screen
-        const redirectToWelcome = () => {
-            if (redirect === true) {
-                this.$timeout(() => {
-                    this.$state.go('welcome');
-                }, 0);
-            }
-        };
-
         // Close peer connection
         if (this.pcHelper !== null) {
-            this.$log.debug(this.logTag, 'Closing peer connection');
-            this.pcHelper.close()
-                .then(
-                    () => this.$log.debug(this.logTag, 'Peer connection was closed'),
-                    (msg: string) => this.$log.warn(this.logTag, 'Peer connection could not be closed:', msg),
-                )
-                .finally(() => redirectToWelcome());
+            this.pcHelper.close();
+            this.$log.debug(this.logTag, 'Peer connection closed');
         } else {
             this.$log.debug(this.logTag, 'Peer connection was null');
-            redirectToWelcome();
+        }
+
+        // Done, redirect now if requested
+        if (redirect) {
+            this.$timeout(() => {
+                this.$state.go('welcome');
+            }, 0);
         }
     }
 
@@ -862,6 +1015,21 @@ export class WebClientService {
         this.receiverListener.push(listener);
     }
 
+    /**
+     * Send a connection info update.
+     */
+    private _sendConnectionInfo(connectionId: Uint8Array, resumeId?: Uint8Array, sequenceNumber?: number): void {
+        const args = undefined;
+        const data = {id: connectionId};
+        if (resumeId !== undefined && sequenceNumber !== undefined) {
+            (data as any).resume = {
+                id: resumeId,
+                sequenceNumber: sequenceNumber,
+            };
+        }
+        this._sendUpdate(WebClientService.SUB_TYPE_CONNECTION_INFO, args, data);
+    }
+
     /**
      * Send a client info request.
      */
@@ -1709,7 +1877,7 @@ export class WebClientService {
      * A connectionDisconnect message arrived.
      */
     private _receiveConnectionDisconnect(message: threema.WireMessage) {
-        this.$log.debug('Received connectionDisconnect from device');
+        this.$log.debug(this.logTag, 'Received connectionDisconnect from device');
 
         if (!hasValue(message.data) || !hasValue(message.data.reason)) {
             this.$log.warn(this.logTag, 'Invalid connectionDisconnect message: data or reason missing');
@@ -1721,35 +1889,72 @@ export class WebClientService {
 
         let alertMessage: string;
         switch (reason) {
-            case threema.DisconnectReason.SessionStopped:
+            case DisconnectReason.SessionStopped:
                 alertMessage = 'connection.SESSION_STOPPED';
                 break;
-            case threema.DisconnectReason.SessionDeleted:
+            case DisconnectReason.SessionDeleted:
                 alertMessage = 'connection.SESSION_DELETED';
                 break;
-            case threema.DisconnectReason.WebclientDisabled:
+            case DisconnectReason.WebclientDisabled:
                 alertMessage = 'connection.WEBCLIENT_DISABLED';
                 break;
-            case threema.DisconnectReason.SessionReplaced:
+            case DisconnectReason.SessionReplaced:
                 alertMessage = 'connection.SESSION_REPLACED';
                 break;
+            case DisconnectReason.SessionError:
+                alertMessage = 'connection.SESSION_ERROR';
+                break;
             default:
+                alertMessage = 'connection.SESSION_ERROR';
                 this.$log.error(this.logTag, 'Unknown disconnect reason:', reason);
+                break;
         }
-        const resetPush = true;
-        const redirect = true;
-        this.stop(false, reason, resetPush, redirect);
 
-        if (alertMessage !== undefined) {
-            // The stop() call above may result in a redirect, which will in
-            // turn hide all open dialog boxes.  Therefore, to avoid immediately
-            // hiding the alert box, enqueue dialog at end of event loop.
-            this.$timeout(() => {
-                this.$mdDialog.show(this.$mdDialog.alert()
-                    .title(this.$translate.instant('connection.SESSION_CLOSED_TITLE'))
-                    .textContent(this.$translate.instant(alertMessage))
-                    .ok(this.$translate.instant('common.OK')));
-            }, 0);
+        // Stop and show an alert on the welcome page
+        this.stop(reason, false, true, true);
+        this.showAlert(alertMessage);
+    }
+
+    /**
+     * A connectionInfo message arrived.
+     */
+    private _receiveConnectionInfo(message: threema.WireMessage) {
+        this.$log.debug('Received connectionInfo from device');
+        if (!hasValue(message.data)) {
+            this.connectionInfoFuture.reject('Invalid connectionInfo message: data missing');
+            return;
+        }
+        if (!hasValue(message.data.id)) {
+            this.connectionInfoFuture.reject('Invalid connectionInfo message: data.id is missing');
+            return;
+        }
+        if (!(message.data.id instanceof ArrayBuffer)) {
+            this.connectionInfoFuture.reject('Invalid connectionInfo message: data.id is of invalid type');
+            return;
+        }
+        const resume = message.data.resume;
+        if (resume !== undefined) {
+            if (!hasValue(resume.id)) {
+                this.connectionInfoFuture.reject('Invalid connectionInfo message: data.resume.id is missing');
+                return;
+            }
+            if (!hasValue(resume.sequenceNumber)) {
+                const error = 'Invalid connectionInfo message: data.resume.sequenceNumber is missing';
+                this.connectionInfoFuture.reject(error);
+                return;
+            }
+            if (!(resume.id instanceof ArrayBuffer)) {
+                this.connectionInfoFuture.reject('Invalid connectionInfo message: data.resume.id is of invalid type');
+                return;
+            }
+            if (resume.sequenceNumber < 0 || resume.sequenceNumber > WebClientService.SEQUENCE_NUMBER_MAX) {
+                const error = 'Invalid connectionInfo message: data.resume.sequenceNumber is invalid';
+                this.connectionInfoFuture.reject(error);
+                return;
+            }
+            this.connectionInfoFuture.resolve(message);
+        } else {
+            this.connectionInfoFuture.resolve(null);
         }
     }
 
@@ -2991,7 +3196,7 @@ export class WebClientService {
     }
 
     /**
-     * Send a message through the secure data channel.
+     * Send a message via the underlying transport.
      */
     private send(message: threema.WireMessage): void {
         this.$log.debug('Sending', message.type + '/' + message.subType, 'message');
@@ -3020,12 +3225,10 @@ export class WebClientService {
                         }
                     } else {
                         const bytes: Uint8Array = this.msgpackEncode(message);
-                        const chunker = new chunkedDc.Chunker(this.messageSerial, bytes, this.messageChunkSize);
+                        const chunker = new chunkedDc.Chunker(this.messageSerial, bytes, WebClientService.CHUNK_SIZE);
                         for (const chunk of chunker) {
-                            if (this.config.MSG_DEBUGGING) {
-                                this.$log.debug('[Chunk] Sending chunk:', chunk);
-                            }
-                            this.relayedDataTask.sendMessage(chunk.buffer);
+                            // TODO: Add to chunk cache!
+                            this.sendChunk(chunk);
                         }
                         this.messageSerial += 1;
                     }
@@ -3036,6 +3239,20 @@ export class WebClientService {
         }
     }
 
+    /**
+     * Send a chunk via the underlying transport.
+     */
+    private sendChunk(chunk: Uint8Array): void {
+        // TODO: Support for sending in chunks via data channels will be added later
+        if (this.chosenTask !== threema.ChosenTask.RelayedData) {
+            throw new Error(`Cannot send chunk, not supported by task: ${this.chosenTask}`);
+        }
+        if (this.config.MSG_DEBUGGING) {
+            this.$log.debug('[Chunk] Sending chunk:', chunk);
+        }
+        this.relayedDataTask.sendMessage(chunk.buffer);
+    }
+
     /**
      * Handle incoming message bytes from the SecureDataChannel.
      */
@@ -3087,6 +3304,21 @@ export class WebClientService {
      * This method runs inside the digest loop.
      */
     private receive(message: threema.WireMessage): void {
+        // Intercept handshake message
+        // TODO: Remove this after the current iOS beta has been closed
+        if (!this.connectionInfoFuture.done) {
+            if (message.type !== WebClientService.TYPE_UPDATE
+                && message.subType !== WebClientService.SUB_TYPE_CONNECTION_INFO) {
+                // We did not receive a handshake message, so we cannot resume a session
+                const warning = `Resumption cancelled, received message ${message.type}/${message.subType}`;
+                this.$log.warn(this.logTag, warning);
+                this.connectionInfoFuture.resolve(null);
+            } else {
+                this._receiveConnectionInfo(message);
+            }
+            return;
+        }
+
         // Dispatch message
         switch (message.type) {
             case WebClientService.TYPE_REQUEST:

+ 1 - 0
src/threema.d.ts

@@ -753,6 +753,7 @@ declare namespace threema {
         SessionDeleted = 'delete',
         WebclientDisabled = 'disable',
         SessionReplaced = 'replace',
+        SessionError = 'error',
     }
 
     namespace Container {

+ 45 - 0
src/types/future.d.ts

@@ -0,0 +1,45 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+interface Future<T> extends Promise<T> {
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    readonly done: boolean;
+
+    /**
+     * Resolve the future.
+     */
+    resolve(value?: T | PromiseLike<T>): void;
+
+    /**
+     * Reject the future.
+     */
+    reject(reason?: any): void;
+}
+
+interface FutureStatic {
+    new<T>(executor?: (resolveFn: (value?: T | PromiseLike<T>) => void,
+                       rejectFn: (reason?: any) => void) => void,
+    ): Future<T>
+}
+
+declare var Future: FutureStatic;