welcome.ts 21 KB

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