welcome.ts 22 KB

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