/** * 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 . */ // tslint:disable:no-reference /// import { StateParams as UiStateParams, StateProvider as UiStateProvider, StateService as UiStateService, } from '@uirouter/angularjs'; import {BrowserInfo} from '../helpers/browser_info'; import {BrowserService} from '../services/browser'; import {ControllerService} from '../services/controller'; import {TrustedKeyStoreService} from '../services/keystore'; import {PushService} from '../services/push'; import {SettingsService} from '../services/settings'; import {StateService} from '../services/state'; import {VersionService} from '../services/version'; import {WebClientService} from '../services/webclient'; import GlobalConnectionState = threema.GlobalConnectionState; class DialogController { // TODO: This is also used in partials/messenger.ts. We could somehow // extract it into a separate file. public static $inject = ['$mdDialog']; public $mdDialog; constructor($mdDialog) { this.$mdDialog = $mdDialog; } public cancel() { this.$mdDialog.cancel(); } } interface WelcomeStateParams extends UiStateParams { initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array}; } class WelcomeController { private static REDIRECT_DELAY = 500; private logTag: string = '[WelcomeController]'; // Angular services private $scope: ng.IScope; private $timeout: ng.ITimeoutService; private $interval: ng.IIntervalService; private $log: ng.ILogService; private $window: ng.IWindowService; private $state: UiStateService; // Material design services private $mdDialog: ng.material.IDialogService; private $translate: ng.translate.ITranslateService; // Custom services private webClientService: WebClientService; private trustedKeyStore: TrustedKeyStoreService; private pushService: PushService; private stateService: StateService; private settingsService: SettingsService; private config: threema.Config; // Other public name = 'welcome'; private mode: 'scan' | 'unlock'; private qrCode; private password: string = ''; private formLocked: boolean = false; private pleaseUpdateAppMsg: string = null; private browser: BrowserInfo; private browserWarningShown: boolean = false; public static $inject = [ '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate', 'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService', 'VersionService', 'SettingsService', 'ControllerService', 'BROWSER_MIN_VERSIONS', 'CONFIG', ]; constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams, $timeout: ng.ITimeoutService, $interval: ng.IIntervalService, $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService, webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService, stateService: StateService, pushService: PushService, browserService: BrowserService, versionService: VersionService, settingsService: SettingsService, controllerService: ControllerService, minVersions: threema.BrowserMinVersions, config: threema.Config) { controllerService.setControllerName('welcome'); // Angular services this.$scope = $scope; this.$state = $state; this.$timeout = $timeout; this.$interval = $interval; this.$log = $log; this.$window = $window; this.$mdDialog = $mdDialog; this.$translate = $translate; // Own services this.webClientService = webClientService; this.trustedKeyStore = trustedKeyStore; this.stateService = stateService; this.pushService = pushService; this.settingsService = settingsService; this.config = config; // Determine whether browser warning should be shown this.browser = browserService.getBrowser(); const version = this.browser.version; $log.debug('Detected browser:', this.browser.description()); if (!this.browser.wasDetermined()) { $log.warn('Could not determine browser version'); this.showBrowserWarning(); } else if (this.browser.name === threema.BrowserName.Chrome) { if (version < minVersions.CHROME) { $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')'); this.showBrowserWarning(); } } else if (this.browser.name === threema.BrowserName.Firefox) { if (version < minVersions.FF) { $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')'); this.showBrowserWarning(); } } else if (this.browser.name === threema.BrowserName.Opera) { if (version < minVersions.OPERA) { $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')'); this.showBrowserWarning(); } } else if (this.browser.name === threema.BrowserName.Safari) { if (version < minVersions.SAFARI) { $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')'); this.showBrowserWarning(); } } else { $log.warn('Non-supported browser, please use Chrome, Firefox or Opera'); this.showBrowserWarning(); } // Clean up local storage // TODO: Remove this in future version this.settingsService.removeUntrustedKeyValuePair('v2infoShown'); // Determine whether local storage is available if (this.trustedKeyStore.blocked === true) { $log.error('Cannot access local storage. Is it being blocked by a browser add-on?'); this.showLocalStorageWarning(); } // Determine current version versionService.initVersion(); // Determine last version with previous protocol version if (this.config.PREV_PROTOCOL_LAST_VERSION !== null) { this.pleaseUpdateAppMsg = this.$translate.instant('troubleshooting.PLEASE_UPDATE_APP'); if (!this.config.SELF_HOSTED) { this.pleaseUpdateAppMsg += ' ' + this.$translate.instant('troubleshooting.USE_ARCHIVE_VERSION', { archiveUrl: `https://web.threema.ch/archive/${this.config.PREV_PROTOCOL_LAST_VERSION}/`, }); } } // Clear cache this.webClientService.clearCache(); // Determine whether trusted key is available let hasTrustedKey = null; try { hasTrustedKey = this.trustedKeyStore.hasTrustedKey(); } catch (e) { $log.error('Exception while accessing local storage:', e); this.showLocalStorageException(e); } // Determine connection mode if ($stateParams.initParams !== null) { this.mode = 'unlock'; const keyStore = $stateParams.initParams.keyStore; const peerTrustedKey = $stateParams.initParams.peerTrustedKey; this.reconnect(keyStore, peerTrustedKey); } else if (hasTrustedKey) { this.mode = 'unlock'; this.unlock(); } else { this.mode = 'scan'; this.scan(); } } /** * Whether or not to show the loading indicator. */ public get showLoadingIndicator(): boolean { switch (this.stateService.connectionBuildupState) { case 'push': case 'peer_handshake': case 'loading': case 'done': return true; default: return false; } } /** * Getter for connection buildup state. * * Only to be used by the template. */ public get state(): threema.ConnectionBuildupState { return this.stateService.connectionBuildupState; } /** * Getter for connection buildup progress. * * Only to be used by the template. */ public get progress(): number { return this.stateService.progress; } /** * Getter for slow connect status. * * Only to be used by the template. */ public get slowConnect(): boolean { return this.stateService.slowConnect; } /** * Initiate a new session by scanning a new QR code. */ private scan(): void { this.$log.info(this.logTag, 'Initialize session by scanning QR code...'); // Initialize webclient with new keystore this.webClientService.init(); // Set up the broadcast channel that checks whether we're already connected in another tab this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex); // Initialize QR code params this.$scope.$watch(() => this.password, () => { const payload = this.webClientService.buildQrCodePayload(this.password.length > 0); this.qrCode = this.buildQrCode(payload); }); // Start webclient this.start(); } /** * Initiate a new session by unlocking a trusted key. */ private unlock(): void { this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...'); } /** * Decrypt the keys and initiate the session. */ private unlockConfirm(): void { // Lock form to prevent further input this.formLocked = true; const decrypted: threema.TrustedKeyStoreData = this.trustedKeyStore.retrieveTrustedKey(this.password); if (decrypted === null) { this.formLocked = false; return this.showDecryptionFailed(); } // Instantiate new keystore const keyStore = new saltyrtcClient.KeyStore(decrypted.ownSecretKey); // Set up the broadcast channel that checks whether we're already connected in another tab this.setupBroadcastChannel(keyStore.publicKeyHex); // Initialize push service if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) { this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType); this.pushService.init(decrypted.pushToken, decrypted.pushTokenType); } // Reconnect this.reconnect(keyStore, decrypted.peerPublicKey); } /** * Set up a `BroadcastChannel` to check if there are other tabs running on * the same session. * * The `publicKeyHex` parameter is the hex-encoded public key of the keystore * used to establish the SaltyRTC connection. */ private setupBroadcastChannel(publicKeyHex: string) { if (!('BroadcastChannel' in this.$window)) { // No BroadcastChannel support in this browser this.$log.warn(this.logTag, 'BroadcastChannel not supported in this browser'); return; } // Config constants const CHANNEL_NAME = 'session-check'; const TYPE_PUBLIC_KEY = 'public-key'; const TYPE_ALREADY_OPEN = 'already-open'; // Set up new BroadcastChannel const channel = new BroadcastChannel(CHANNEL_NAME); // Register a message handler channel.onmessage = (event: MessageEvent) => { const message = JSON.parse(event.data); switch (message.type) { case TYPE_PUBLIC_KEY: // Another tab is trying to connect to a session. // Is it the same public key as the one we are using? if (message.key === publicKeyHex && (this.stateService.connectionBuildupState === 'loading' || this.stateService.connectionBuildupState === 'done')) { // Yes it is, notify them that the session is already active this.$log.debug( this.logTag, 'Another tab is trying to connect to our session. Respond with a broadcast.', ); channel.postMessage(JSON.stringify({ type: TYPE_ALREADY_OPEN, key: publicKeyHex, })); } break; case TYPE_ALREADY_OPEN: // Another tab notified us that the session we're trying to connect to // is already active. if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') { this.$log.error(this.logTag, 'Session already connected in another tab or window'); this.$timeout(() => { this.stateService.updateConnectionBuildupState('already_connected'); this.stateService.state = GlobalConnectionState.Error; }, 500); } break; default: this.$log.warn(this.logTag, 'Unknown broadcast message type:', message.type); break; } }; // Notify other tabs that we're trying to connect this.$log.debug(this.logTag, 'Checking if the session is already open in another tab or window'); channel.postMessage(JSON.stringify({ type: TYPE_PUBLIC_KEY, key: publicKeyHex, })); } /** * Reconnect using a specific keypair and peer public key. */ private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void { this.webClientService.init(keyStore, peerTrustedKey); this.start(); } /** * Show a browser warning dialog. */ private showBrowserWarning(): void { this.browserWarningShown = true; this.$translate.onReady().then(() => { const confirm = this.$mdDialog.confirm() .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED')) .htmlContent(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED_DETAILS')) .ok(this.$translate.instant('welcome.CONTINUE_ANYWAY')) .cancel(this.$translate.instant('welcome.ABORT')); this.$mdDialog.show(confirm).then(() => { // do nothing }, () => { // Redirect to Threema website window.location.replace('https://threema.ch/threema-web'); }); }); } /** * Show a dialog indicating that local storage is not available. */ private showLocalStorageWarning(): void { this.$translate.onReady().then(() => { const confirm = this.$mdDialog.alert() .title(this.$translate.instant('common.ERROR')) .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_MISSING_DETAILS')) .ok(this.$translate.instant('common.OK')); this.$mdDialog.show(confirm); }); } /** * Show a dialog indicating that local storage cannot be accessed. */ private showLocalStorageException(e: Error): void { this.$translate.onReady().then(() => { const confirm = this.$mdDialog.alert() .title(this.$translate.instant('common.ERROR')) .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_EXCEPTION_DETAILS', { errorMsg: e.name, })) .ok(this.$translate.instant('common.OK')); this.$mdDialog.show(confirm); }); } /** * Show the "decryption failed" dialog. */ private showDecryptionFailed(): void { this.$mdDialog.show({ controller: DialogController, controllerAs: 'ctrl', templateUrl: 'partials/dialog.unlockfailed.html', parent: angular.element(document.body), clickOutsideToClose: true, fullscreen: true, }); } /** * Show the "already connected" dialog. */ private showAlreadyConnected(): void { this.$translate.onReady().then(() => { const confirm = this.$mdDialog.alert() .title(this.$translate.instant('welcome.ALREADY_CONNECTED')) .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS')) .ok(this.$translate.instant('common.OK')); this.$mdDialog.show(confirm); }); } /** * Forget trusted keys. */ private deleteSession(ev) { const confirm = this.$mdDialog.confirm() .title(this.$translate.instant('common.SESSION_DELETE')) .textContent(this.$translate.instant('common.CONFIRM_DELETE_BODY')) .targetEvent(ev) .ok(this.$translate.instant('common.YES')) .cancel(this.$translate.instant('common.CANCEL')); this.$mdDialog.show(confirm).then(() => { // Force-stop the webclient this.webClientService.stop(threema.DisconnectReason.SessionDeleted, true, true, false); // Reset state this.stateService.updateConnectionBuildupState('new'); // Go back to scan mode this.mode = 'scan'; this.password = ''; this.formLocked = false; // Initiate scan this.scan(); }, () => { // do nothing }); } private buildQrCode(payload: string) { // To calculate version and error correction, refer to this table: // http://www.thonky.com/qr-code-tutorial/character-capacities // The qr generator uses byte mode, therefore for 92 characters with // error correction level 'M' we need version 6. const len = payload.length; let version: number; if (len <= 134) { version = 6; } else if (len <= 154) { version = 7; } else if (len <= 192) { version = 8; } else if (len <= 230) { version = 9; } else if (len <= 271) { version = 10; } else if (len <= 321) { version = 11; } else if (len <= 367) { version = 12; } else if (len <= 425) { version = 13; } else if (len <= 458) { version = 14; } else if (len <= 520) { version = 15; } else if (len <= 586) { version = 16; } else { this.$log.error(this.logTag, 'QR Code payload too large: Is your SaltyRTC host string huge?'); version = 40; } return { version: version, errorCorrectionLevel: 'L', size: 384, data: payload, }; } /** * Actually start the webclient. * * It must be initialized before calling this method. */ private start() { this.webClientService.start().then( // If connection buildup is done... () => { // Pass password to webclient service this.webClientService.setPassword(this.password); // Clear local password variable this.password = ''; this.formLocked = false; // Redirect to home this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY); }, // If an error occurs... (error) => { this.$log.error(this.logTag, 'Error state:', error); // TODO: should probably show an error message instead this.$timeout(() => this.$state.reload(), WelcomeController.REDIRECT_DELAY); }, // State updates (progress: threema.ConnectionBuildupStateChange) => { // Do nothing }, ); } /** * Reload the page. */ public reload() { this.$window.location.reload(); } } angular.module('3ema.welcome', []) .config(['$stateProvider', ($stateProvider: UiStateProvider) => { $stateProvider .state('welcome', { url: '/welcome', templateUrl: 'partials/welcome.html', controller: 'WelcomeController', controllerAs: 'ctrl', params: {initParams: null}, }); }]) .controller('WelcomeController', WelcomeController);