welcome.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. // tslint:disable:no-reference
  18. /// <reference path="../types/broadcastchannel.d.ts" />
  19. import {
  20. StateProvider as UiStateProvider,
  21. StateService as UiStateService,
  22. } from '@uirouter/angularjs';
  23. import {BrowserInfo} from '../helpers/browser_info';
  24. import {BrowserService} from '../services/browser';
  25. import {ControllerService} from '../services/controller';
  26. import {TrustedKeyStoreService} from '../services/keystore';
  27. import {PushService} from '../services/push';
  28. import {SettingsService} from '../services/settings';
  29. import {StateService} from '../services/state';
  30. import {VersionService} from '../services/version';
  31. import {WebClientService} from '../services/webclient';
  32. import GlobalConnectionState = threema.GlobalConnectionState;
  33. import DisconnectReason = threema.DisconnectReason;
  34. class DialogController {
  35. // TODO: This is also used in partials/messenger.ts. We could somehow
  36. // extract it into a separate file.
  37. public static $inject = ['$mdDialog'];
  38. public $mdDialog;
  39. constructor($mdDialog) {
  40. this.$mdDialog = $mdDialog;
  41. }
  42. public cancel() {
  43. this.$mdDialog.cancel();
  44. }
  45. }
  46. class WelcomeController {
  47. private static REDIRECT_DELAY = 500;
  48. private logTag: string = '[WelcomeController]';
  49. // Angular services
  50. private $scope: ng.IScope;
  51. private $timeout: ng.ITimeoutService;
  52. private $interval: ng.IIntervalService;
  53. private $log: ng.ILogService;
  54. private $window: ng.IWindowService;
  55. private $state: UiStateService;
  56. // Material design services
  57. private $mdDialog: ng.material.IDialogService;
  58. private $translate: ng.translate.ITranslateService;
  59. // Custom services
  60. private webClientService: WebClientService;
  61. private trustedKeyStore: TrustedKeyStoreService;
  62. private pushService: PushService;
  63. private stateService: StateService;
  64. private settingsService: SettingsService;
  65. private config: threema.Config;
  66. // Other
  67. public name = 'welcome';
  68. private mode: 'scan' | 'unlock';
  69. private qrCode;
  70. private password: string = '';
  71. private formLocked: boolean = false;
  72. private pleaseUpdateAppMsg: string = null;
  73. private browser: BrowserInfo;
  74. private browserWarningShown: boolean = false;
  75. public static $inject = [
  76. '$scope', '$state', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
  77. 'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
  78. 'VersionService', 'SettingsService', 'ControllerService',
  79. 'BROWSER_MIN_VERSIONS', 'CONFIG',
  80. ];
  81. constructor($scope: ng.IScope, $state: UiStateService,
  82. $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
  83. $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
  84. $translate: ng.translate.ITranslateService,
  85. webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
  86. stateService: StateService, pushService: PushService,
  87. browserService: BrowserService,
  88. versionService: VersionService,
  89. settingsService: SettingsService,
  90. controllerService: ControllerService,
  91. minVersions: threema.BrowserMinVersions,
  92. config: threema.Config) {
  93. controllerService.setControllerName('welcome');
  94. // Angular services
  95. this.$scope = $scope;
  96. this.$state = $state;
  97. this.$timeout = $timeout;
  98. this.$interval = $interval;
  99. this.$log = $log;
  100. this.$window = $window;
  101. this.$mdDialog = $mdDialog;
  102. this.$translate = $translate;
  103. // Own services
  104. this.webClientService = webClientService;
  105. this.trustedKeyStore = trustedKeyStore;
  106. this.stateService = stateService;
  107. this.pushService = pushService;
  108. this.settingsService = settingsService;
  109. this.config = config;
  110. // TODO: Allow to trigger below behaviour by using state parameters
  111. // Determine whether browser warning should be shown
  112. this.browser = browserService.getBrowser();
  113. const version = this.browser.version;
  114. $log.debug('Detected browser:', this.browser.description());
  115. if (!this.browser.wasDetermined()) {
  116. $log.warn('Could not determine browser version');
  117. this.showBrowserWarning();
  118. } else if (this.browser.name === threema.BrowserName.Chrome) {
  119. if (version < minVersions.CHROME) {
  120. $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
  121. this.showBrowserWarning();
  122. }
  123. } else if (this.browser.name === threema.BrowserName.Firefox) {
  124. if (version < minVersions.FF) {
  125. $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
  126. this.showBrowserWarning();
  127. }
  128. } else if (this.browser.name === threema.BrowserName.Opera) {
  129. if (version < minVersions.OPERA) {
  130. $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
  131. this.showBrowserWarning();
  132. }
  133. } else if (this.browser.name === threema.BrowserName.Safari) {
  134. if (version < minVersions.SAFARI) {
  135. $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
  136. this.showBrowserWarning();
  137. }
  138. } else {
  139. $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
  140. this.showBrowserWarning();
  141. }
  142. // Clean up local storage
  143. // TODO: Remove this in future version
  144. this.settingsService.removeUntrustedKeyValuePair('v2infoShown');
  145. // Determine whether local storage is available
  146. if (this.trustedKeyStore.blocked === true) {
  147. $log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
  148. this.showLocalStorageWarning();
  149. }
  150. // Determine current version
  151. versionService.initVersion();
  152. // Determine last version with previous protocol version
  153. if (this.config.PREV_PROTOCOL_LAST_VERSION !== null) {
  154. this.pleaseUpdateAppMsg = this.$translate.instant('troubleshooting.PLEASE_UPDATE_APP');
  155. if (!this.config.SELF_HOSTED) {
  156. this.pleaseUpdateAppMsg += ' ' + this.$translate.instant('troubleshooting.USE_ARCHIVE_VERSION', {
  157. archiveUrl: `https://web.threema.ch/archive/${this.config.PREV_PROTOCOL_LAST_VERSION}/`,
  158. });
  159. }
  160. }
  161. // Clear cache
  162. this.webClientService.clearCache();
  163. // Determine whether trusted key is available
  164. let hasTrustedKey = null;
  165. try {
  166. hasTrustedKey = this.trustedKeyStore.hasTrustedKey();
  167. } catch (e) {
  168. $log.error('Exception while accessing local storage:', e);
  169. this.showLocalStorageException(e);
  170. }
  171. // Determine connection mode
  172. if (hasTrustedKey) {
  173. this.mode = 'unlock';
  174. this.unlock();
  175. } else {
  176. this.mode = 'scan';
  177. this.scan();
  178. }
  179. }
  180. /**
  181. * Whether or not to show the loading indicator.
  182. */
  183. public get showLoadingIndicator(): boolean {
  184. switch (this.stateService.connectionBuildupState) {
  185. case 'push':
  186. case 'peer_handshake':
  187. case 'loading':
  188. case 'done':
  189. return true;
  190. default:
  191. return false;
  192. }
  193. }
  194. /**
  195. * Getter for connection buildup state.
  196. *
  197. * Only to be used by the template.
  198. */
  199. public get state(): threema.ConnectionBuildupState {
  200. return this.stateService.connectionBuildupState;
  201. }
  202. /**
  203. * Getter for connection buildup progress.
  204. *
  205. * Only to be used by the template.
  206. */
  207. public get progress(): number {
  208. return this.stateService.progress;
  209. }
  210. /**
  211. * Getter for slow connect status.
  212. *
  213. * Only to be used by the template.
  214. */
  215. public get slowConnect(): boolean {
  216. return this.stateService.slowConnect;
  217. }
  218. /**
  219. * Initiate a new session by scanning a new QR code.
  220. */
  221. private scan(): void {
  222. this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
  223. // Initialize webclient with new keystore
  224. this.webClientService.stop({
  225. reason: DisconnectReason.SessionStopped,
  226. send: false,
  227. close: true,
  228. redirect: false,
  229. });
  230. this.webClientService.init({
  231. resume: false,
  232. });
  233. // Set up the broadcast channel that checks whether we're already connected in another tab
  234. this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
  235. // Initialize QR code params
  236. this.$scope.$watch(() => this.password, () => {
  237. const payload = this.webClientService.buildQrCodePayload(this.password.length > 0);
  238. this.qrCode = this.buildQrCode(payload);
  239. });
  240. // Start webclient
  241. this.start();
  242. }
  243. /**
  244. * Initiate a new session by unlocking a trusted key.
  245. */
  246. private unlock(): void {
  247. this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
  248. }
  249. /**
  250. * Decrypt the keys and initiate the session.
  251. */
  252. private unlockConfirm(): void {
  253. // Lock form to prevent further input
  254. this.formLocked = true;
  255. const decrypted: threema.TrustedKeyStoreData = this.trustedKeyStore.retrieveTrustedKey(this.password);
  256. if (decrypted === null) {
  257. this.formLocked = false;
  258. return this.showDecryptionFailed();
  259. }
  260. // Instantiate new keystore
  261. const keyStore = new saltyrtcClient.KeyStore(decrypted.ownSecretKey);
  262. // Set up the broadcast channel that checks whether we're already connected in another tab
  263. this.setupBroadcastChannel(keyStore.publicKeyHex);
  264. // Initialize push service
  265. if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
  266. this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
  267. this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
  268. }
  269. // Reconnect
  270. this.reconnect(keyStore, decrypted.peerPublicKey);
  271. }
  272. /**
  273. * Set up a `BroadcastChannel` to check if there are other tabs running on
  274. * the same session.
  275. *
  276. * The `publicKeyHex` parameter is the hex-encoded public key of the keystore
  277. * used to establish the SaltyRTC connection.
  278. */
  279. private setupBroadcastChannel(publicKeyHex: string) {
  280. if (!('BroadcastChannel' in this.$window)) {
  281. // No BroadcastChannel support in this browser
  282. this.$log.warn(this.logTag, 'BroadcastChannel not supported in this browser');
  283. return;
  284. }
  285. // Config constants
  286. const CHANNEL_NAME = 'session-check';
  287. const TYPE_PUBLIC_KEY = 'public-key';
  288. const TYPE_ALREADY_OPEN = 'already-open';
  289. // Set up new BroadcastChannel
  290. const channel = new BroadcastChannel(CHANNEL_NAME);
  291. // Register a message handler
  292. channel.onmessage = (event: MessageEvent) => {
  293. const message = JSON.parse(event.data);
  294. switch (message.type) {
  295. case TYPE_PUBLIC_KEY:
  296. // Another tab is trying to connect to a session.
  297. // Is it the same public key as the one we are using?
  298. if (message.key === publicKeyHex
  299. && (this.stateService.connectionBuildupState === 'loading'
  300. || this.stateService.connectionBuildupState === 'done')) {
  301. // Yes it is, notify them that the session is already active
  302. this.$log.debug(
  303. this.logTag,
  304. 'Another tab is trying to connect to our session. Respond with a broadcast.',
  305. );
  306. channel.postMessage(JSON.stringify({
  307. type: TYPE_ALREADY_OPEN,
  308. key: publicKeyHex,
  309. }));
  310. }
  311. break;
  312. case TYPE_ALREADY_OPEN:
  313. // Another tab notified us that the session we're trying to connect to
  314. // is already active.
  315. if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
  316. this.$log.error(this.logTag, 'Session already connected in another tab or window');
  317. this.$timeout(() => {
  318. this.stateService.updateConnectionBuildupState('already_connected');
  319. this.stateService.state = GlobalConnectionState.Error;
  320. }, 500);
  321. }
  322. break;
  323. default:
  324. this.$log.warn(this.logTag, 'Unknown broadcast message type:', message.type);
  325. break;
  326. }
  327. };
  328. // Notify other tabs that we're trying to connect
  329. this.$log.debug(this.logTag, 'Checking if the session is already open in another tab or window');
  330. channel.postMessage(JSON.stringify({
  331. type: TYPE_PUBLIC_KEY,
  332. key: publicKeyHex,
  333. }));
  334. }
  335. /**
  336. * Reconnect using a specific keypair and peer public key.
  337. */
  338. private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void {
  339. this.webClientService.stop({
  340. reason: DisconnectReason.SessionStopped,
  341. send: false,
  342. close: true,
  343. redirect: false,
  344. });
  345. this.webClientService.init({
  346. keyStore: keyStore,
  347. peerTrustedKey: peerTrustedKey,
  348. resume: false,
  349. });
  350. this.start();
  351. }
  352. /**
  353. * Show a browser warning dialog.
  354. */
  355. private showBrowserWarning(): void {
  356. this.browserWarningShown = true;
  357. this.$translate.onReady().then(() => {
  358. const confirm = this.$mdDialog.confirm()
  359. .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED'))
  360. .htmlContent(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED_DETAILS'))
  361. .ok(this.$translate.instant('welcome.CONTINUE_ANYWAY'))
  362. .cancel(this.$translate.instant('welcome.ABORT'));
  363. this.$mdDialog.show(confirm).then(() => {
  364. // do nothing
  365. }, () => {
  366. // Redirect to Threema website
  367. window.location.replace('https://threema.ch/threema-web');
  368. });
  369. });
  370. }
  371. /**
  372. * Show a dialog indicating that local storage is not available.
  373. */
  374. private showLocalStorageWarning(): void {
  375. this.$translate.onReady().then(() => {
  376. const confirm = this.$mdDialog.alert()
  377. .title(this.$translate.instant('common.ERROR'))
  378. .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_MISSING_DETAILS'))
  379. .ok(this.$translate.instant('common.OK'));
  380. this.$mdDialog.show(confirm);
  381. });
  382. }
  383. /**
  384. * Show a dialog indicating that local storage cannot be accessed.
  385. */
  386. private showLocalStorageException(e: Error): void {
  387. this.$translate.onReady().then(() => {
  388. const confirm = this.$mdDialog.alert()
  389. .title(this.$translate.instant('common.ERROR'))
  390. .htmlContent(this.$translate.instant('welcome.LOCAL_STORAGE_EXCEPTION_DETAILS', {
  391. errorMsg: e.name,
  392. }))
  393. .ok(this.$translate.instant('common.OK'));
  394. this.$mdDialog.show(confirm);
  395. });
  396. }
  397. /**
  398. * Show the "decryption failed" dialog.
  399. */
  400. private showDecryptionFailed(): void {
  401. this.$mdDialog.show({
  402. controller: DialogController,
  403. controllerAs: 'ctrl',
  404. templateUrl: 'partials/dialog.unlockfailed.html',
  405. parent: angular.element(document.body),
  406. clickOutsideToClose: true,
  407. fullscreen: true,
  408. });
  409. }
  410. /**
  411. * Show the "already connected" dialog.
  412. */
  413. private showAlreadyConnected(): void {
  414. this.$translate.onReady().then(() => {
  415. const confirm = this.$mdDialog.alert()
  416. .title(this.$translate.instant('welcome.ALREADY_CONNECTED'))
  417. .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS'))
  418. .ok(this.$translate.instant('common.OK'));
  419. this.$mdDialog.show(confirm);
  420. });
  421. }
  422. /**
  423. * Forget trusted keys.
  424. */
  425. private deleteSession(ev) {
  426. const confirm = this.$mdDialog.confirm()
  427. .title(this.$translate.instant('common.SESSION_DELETE'))
  428. .textContent(this.$translate.instant('common.CONFIRM_DELETE_BODY'))
  429. .targetEvent(ev)
  430. .ok(this.$translate.instant('common.YES'))
  431. .cancel(this.$translate.instant('common.CANCEL'));
  432. this.$mdDialog.show(confirm).then(() => {
  433. // Force-stop the webclient
  434. this.webClientService.stop({
  435. reason: DisconnectReason.SessionDeleted,
  436. send: true,
  437. close: true,
  438. redirect: false,
  439. });
  440. // Go back to scan mode
  441. this.mode = 'scan';
  442. this.password = '';
  443. this.formLocked = false;
  444. // Initiate scan
  445. this.scan();
  446. }, () => {
  447. // do nothing
  448. });
  449. }
  450. private buildQrCode(payload: string) {
  451. // To calculate version and error correction, refer to this table:
  452. // http://www.thonky.com/qr-code-tutorial/character-capacities
  453. // The qr generator uses byte mode, therefore for 92 characters with
  454. // error correction level 'M' we need version 6.
  455. const len = payload.length;
  456. let version: number;
  457. if (len <= 134) {
  458. version = 6;
  459. } else if (len <= 154) {
  460. version = 7;
  461. } else if (len <= 192) {
  462. version = 8;
  463. } else if (len <= 230) {
  464. version = 9;
  465. } else if (len <= 271) {
  466. version = 10;
  467. } else if (len <= 321) {
  468. version = 11;
  469. } else if (len <= 367) {
  470. version = 12;
  471. } else if (len <= 425) {
  472. version = 13;
  473. } else if (len <= 458) {
  474. version = 14;
  475. } else if (len <= 520) {
  476. version = 15;
  477. } else if (len <= 586) {
  478. version = 16;
  479. } else {
  480. this.$log.error(this.logTag, 'QR Code payload too large: Is your SaltyRTC host string huge?');
  481. version = 40;
  482. }
  483. return {
  484. version: version,
  485. errorCorrectionLevel: 'L',
  486. size: 384,
  487. data: payload,
  488. };
  489. }
  490. /**
  491. * Actually start the webclient.
  492. *
  493. * It must be initialized before calling this method.
  494. */
  495. private start() {
  496. this.webClientService.start().then(
  497. // If connection buildup is done...
  498. () => {
  499. // Pass password to webclient service
  500. this.webClientService.setPassword(this.password);
  501. // Clear local password variable
  502. this.password = '';
  503. this.formLocked = false;
  504. // Redirect to home
  505. this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
  506. },
  507. // If an error occurs...
  508. (error) => {
  509. this.$log.error(this.logTag, 'Error state:', error);
  510. // TODO: should probably show an error message instead
  511. this.$timeout(() => this.$state.reload(), WelcomeController.REDIRECT_DELAY);
  512. },
  513. // State updates
  514. (progress: threema.ConnectionBuildupStateChange) => {
  515. // Do nothing
  516. },
  517. );
  518. }
  519. /**
  520. * Reload the page.
  521. */
  522. public reload() {
  523. this.$window.location.reload();
  524. }
  525. }
  526. angular.module('3ema.welcome', [])
  527. .config(['$stateProvider', ($stateProvider: UiStateProvider) => {
  528. $stateProvider
  529. .state('welcome', {
  530. url: '/welcome',
  531. templateUrl: 'partials/welcome.html',
  532. controller: 'WelcomeController',
  533. controllerAs: 'ctrl',
  534. params: {initParams: null},
  535. });
  536. }])
  537. .controller('WelcomeController', WelcomeController);