]> git.lizzy.rs Git - torbrowser-launcher.git/blob - torbrowser_launcher/launcher.py
c058b70ddba18820186ed64db72971da0f8c24a6
[torbrowser-launcher.git] / torbrowser_launcher / launcher.py
1 """
2 Tor Browser Launcher
3 https://github.com/micahflee/torbrowser-launcher/
4
5 Copyright (c) 2013-2017 Micah Lee <micah@micahflee.com>
6
7 Permission is hereby granted, free of charge, to any person
8 obtaining a copy of this software and associated documentation
9 files (the "Software"), to deal in the Software without
10 restriction, including without limitation the rights to use,
11 copy, modify, merge, publish, distribute, sublicense, and/or sell
12 copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following
14 conditions:
15
16 The above copyright notice and this permission notice shall be
17 included in all copies or substantial portions of the Software.
18
19 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24 WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25 FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26 OTHER DEALINGS IN THE SOFTWARE.
27 """
28
29 import os
30 import subprocess
31 import time
32 import json
33 import tarfile
34 import hashlib
35 import lzma
36 import threading
37 import re
38 import unicodedata
39 import requests
40 import socks
41 import gpg
42 import xml.etree.ElementTree as ET
43
44 from PyQt5 import QtCore, QtWidgets, QtGui
45
46
47 class TryStableException(Exception):
48     pass
49
50
51 class TryDefaultMirrorException(Exception):
52     pass
53
54
55 class TryForcingEnglishException(Exception):
56     pass
57
58
59 class DownloadErrorException(Exception):
60     pass
61
62
63 class Launcher(QtWidgets.QMainWindow):
64     """
65     Launcher window.
66     """
67     def __init__(self, common, app, url_list):
68         super(Launcher, self).__init__()
69         self.common = common
70         self.app = app
71
72         self.url_list = url_list
73         self.force_redownload = False
74
75         # This is the current version of Tor Browser, which should get updated with every release
76         self.min_version = '7.5.2'
77
78         # Init launcher
79         self.set_state(None, '', [])
80         self.launch_gui = True
81
82         # If Tor Browser is not installed, detect latest version, download, and install
83         if not self.common.settings['installed'] or not self.check_min_version():
84             # Different message if downloading for the first time, or because your installed version is too low
85             download_message = ""
86             if not self.common.settings['installed']:
87                 download_message = _("Downloading and installing Tor Browser for the first time.")
88             elif not self.check_min_version():
89                 download_message = _("Your version of Tor Browser is out-of-date. Downloading and installing the newest version.")
90
91             # Download and install
92             print(download_message)
93             self.set_state('task', download_message,
94                          ['download_version_check',
95                           'set_version',
96                           'download_sig',
97                           'download_tarball',
98                           'verify',
99                           'extract',
100                           'run'])
101
102             if self.common.settings['download_over_tor']:
103                 print(_('Downloading over Tor'))
104
105         else:
106             # Tor Browser is already installed, so run
107             self.run(False)
108             self.launch_gui = False
109
110         if self.launch_gui:
111             # Build the rest of the UI
112
113             # Set up the window
114             self.setWindowTitle(_("Tor Browser"))
115             self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
116
117             # Label
118             self.label = QtWidgets.QLabel()
119
120             # Progress bar
121             self.progress_bar = QtWidgets.QProgressBar()
122             self.progress_bar.setTextVisible(True)
123             self.progress_bar.setMinimum(0)
124             self.progress_bar.setMaximum(0)
125             self.progress_bar.setValue(0)
126
127             # Buttons
128             self.yes_button = QtWidgets.QPushButton()
129             self.yes_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
130             self.yes_button.clicked.connect(self.yes_clicked)
131             self.start_button = QtWidgets.QPushButton()
132             self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
133             self.start_button.clicked.connect(self.start)
134             self.cancel_button = QtWidgets.QPushButton()
135             self.cancel_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
136             self.cancel_button.clicked.connect(self.close)
137             buttons_layout = QtWidgets.QHBoxLayout()
138             buttons_layout.addStretch()
139             buttons_layout.addWidget(self.yes_button)
140             buttons_layout.addWidget(self.start_button)
141             buttons_layout.addWidget(self.cancel_button)
142             buttons_layout.addStretch()
143
144             # Layout
145             layout = QtWidgets.QVBoxLayout()
146             layout.addWidget(self.label)
147             layout.addWidget(self.progress_bar)
148             layout.addLayout(buttons_layout)
149
150             central_widget = QtWidgets.QWidget()
151             central_widget.setLayout(layout)
152             self.setCentralWidget(central_widget)
153             self.show()
154
155             self.update()
156
157     # Set the current state of Tor Browser Launcher
158     def set_state(self, gui, message, tasks, autostart=True):
159         self.gui = gui
160         self.gui_message = message
161         self.gui_tasks = tasks
162         self.gui_task_i = 0
163         self.gui_autostart = autostart
164
165     # Show and hide parts of the UI based on the current state
166     def update(self):
167         # Hide widgets
168         self.progress_bar.hide()
169         self.yes_button.hide()
170         self.start_button.hide()
171
172         if 'error' in self.gui:
173             # Label
174             self.label.setText(self.gui_message)
175
176             # Yes button
177             if self.gui != 'error':
178                 self.yes_button.setText(_('Yes'))
179                 self.yes_button.show()
180
181             # Exit button
182             self.cancel_button.setText(_('Exit'))
183
184         elif self.gui == 'task':
185             # Label
186             self.label.setText(self.gui_message)
187
188             # Progress bar
189             self.progress_bar.show()
190
191             # Start button
192             if not self.gui_autostart:
193                 self.start_button.show()
194
195             # Cancel button
196             self.cancel_button.setText(_('Cancel'))
197
198         if self.gui_autostart:
199             self.start(None)
200
201     # Yes button clicked, based on the state decide what to do
202     def yes_clicked(self):
203         if self.gui == 'error_try_stable':
204             self.try_stable()
205         elif self.gui == 'error_try_default_mirror':
206             self.try_default_mirror()
207         elif self.gui == 'error_try_forcing_english':
208             self.try_forcing_english()
209         elif self.gui == 'error_try_tor':
210             self.try_tor()
211
212     # Start button clicked, begin tasks
213     def start(self, widget, data=None):
214         # Hide the start button
215         self.start_button.hide()
216
217         # Start running tasks
218         self.run_task()
219
220     # Run the next task in the task list
221     def run_task(self):
222         if self.gui_task_i >= len(self.gui_tasks):
223             self.close()
224             return
225
226         task = self.gui_tasks[self.gui_task_i]
227
228         # Get ready for the next task
229         self.gui_task_i += 1
230
231         if task == 'download_version_check':
232             print(_('Downloading'), self.common.paths['version_check_url'])
233             self.download('version check', self.common.paths['version_check_url'], self.common.paths['version_check_file'])
234
235         if task == 'set_version':
236             version = self.get_stable_version()
237             if version:
238                 self.common.build_paths(self.get_stable_version())
239                 print(_('Latest version: {}').format(version))
240                 self.run_task()
241             else:
242                 self.set_state('error', _("Error detecting Tor Browser version."), [], False)
243                 self.update()
244
245         elif task == 'download_sig':
246             print(_('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror']))
247             self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
248
249         elif task == 'download_tarball':
250             print(_('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror']))
251             if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
252                 self.run_task()
253             else:
254                 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
255
256         elif task == 'verify':
257             print(_('Verifying Signature'))
258             self.verify()
259
260         elif task == 'extract':
261             print(_('Extracting'), self.common.paths['tarball_filename'])
262             self.extract()
263
264         elif task == 'run':
265             print(_('Running'), self.common.paths['tbb']['start'])
266             self.run()
267
268         elif task == 'start_over':
269             print(_('Starting download over again'))
270             self.start_over()
271
272     def download(self, name, url, path):
273         # Download from the selected mirror
274         mirror_url = url.format(self.common.settings['mirror']).encode()
275
276         # Initialize the progress bar
277         self.progress_bar.setValue(0)
278         self.progress_bar.setMaximum(100)
279         if self.common.settings['download_over_tor']:
280             self.progress_bar.setFormat(_('Downloading') + ' {0} '.format(name) + _('(over Tor)') + ', %p%')
281         else:
282             self.progress_bar.setFormat(_('Downloading') + ' {0}, %p%'.format(name))
283
284         def progress_update(total_bytes, bytes_so_far):
285             percent = float(bytes_so_far) / float(total_bytes)
286             amount = float(bytes_so_far)
287             units = "bytes"
288             for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
289                 if amount > size:
290                     units = unit
291                     amount /= float(size)
292                     break
293
294             message = _('Downloaded') + (' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units))
295             if self.common.settings['download_over_tor']:
296                 message += ' ' + _('(over Tor)')
297
298             self.progress_bar.setMaximum(total_bytes)
299             self.progress_bar.setValue(bytes_so_far)
300             self.progress_bar.setFormat(message)
301
302         def download_complete():
303             # Download complete, next task
304             self.run_task()
305
306         def download_error(gui, message):
307             print(message)
308             self.set_state(gui, message, [], False)
309             self.update()
310
311         t = DownloadThread(self.common, mirror_url, path)
312         t.progress_update.connect(progress_update)
313         t.download_complete.connect(download_complete)
314         t.download_error.connect(download_error)
315         t.start()
316         time.sleep(0.2)
317
318     def try_default_mirror(self):
319         # change mirror to default and relaunch TBL
320         self.common.settings['mirror'] = self.common.default_mirror
321         self.common.save_settings()
322         subprocess.Popen([self.common.paths['tbl_bin']])
323         self.close()
324
325     def try_forcing_english(self):
326         # change force english to true and relaunch TBL
327         self.common.settings['force_en-US'] = True
328         self.common.save_settings()
329         subprocess.Popen([self.common.paths['tbl_bin']])
330         self.close()
331
332     def try_tor(self):
333         # set download_over_tor to true and relaunch TBL
334         self.common.settings['download_over_tor'] = True
335         self.common.save_settings()
336         subprocess.Popen([self.common.paths['tbl_bin']])
337         self.close()
338
339     def get_stable_version(self):
340         tree = ET.parse(self.common.paths['version_check_file'])
341         for up in tree.getroot():
342             if up.tag == 'update' and up.attrib['appVersion']:
343                 version = str(up.attrib['appVersion'])
344
345                 # make sure the version does not contain directory traversal attempts
346                 # e.g. "5.5.3", "6.0a", "6.0a-hardened" are valid but "../../../../.." is invalid
347                 if not re.match(r'^[a-z0-9\.\-]+$', version):
348                     return None
349
350                 return version
351         return None
352
353     def verify(self):
354         self.progress_bar.setValue(0)
355         self.progress_bar.setMaximum(0)
356         self.progress_bar.show()
357
358         self.label.setText(_('Verifying Signature'))
359
360         def success():
361             self.run_task()
362
363         def error(message):
364             sigerror = 'SIGNATURE VERIFICATION FAILED!\n\nError Code: {0}\n\nYou might be under attack, there might' \
365                        ' be a network\nproblem, or you may be missing a recently added\nTor Browser verification key.' \
366                        '\nClick Start to refresh the keyring and try again. If the message persists report the above' \
367                        ' error code here:\nhttps://github.com/micahflee/torbrowser-launcher/issues'.format(sigerror)
368
369             self.set_state('task', sigerror, ['start_over'], False)
370             self.update()
371
372         t = VerifyThread(self.common)
373         t.error.connect(error)
374         t.success.connect(success)
375         t.start()
376         time.sleep(0.2)
377
378     def extract(self):
379         self.progress_bar.setValue(0)
380         self.progress_bar.setMaximum(0)
381         self.progress_bar.show()
382
383         self.label.setText(_('Installing'))
384
385         def success():
386             self.run_task()
387
388         def error(message):
389             self.set_state('task', _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])), ['start_over'], False)
390             self.update()
391
392         t = ExtractThread(self.common)
393         t.error.connect(error)
394         t.success.connect(success)
395         t.start()
396         time.sleep(0.2)
397
398     def check_min_version(self):
399         installed_version = None
400         for line in open(self.common.paths['tbb']['changelog']).readlines():
401             if line.startswith('Tor Browser '):
402                 installed_version = line.split()[2]
403                 break
404
405         if self.min_version <= installed_version:
406             return True
407
408         return False
409
410     def run(self, run_next_task=True):
411         # Don't run if it isn't at least the minimum version
412         if not self.check_min_version():
413             message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
414                         "sign of an attack!")
415             print(message)
416
417             Alert(self.common, message)
418             return
419
420         # Hide the TBL window (#151)
421         self.hide()
422
423         # Run Tor Browser
424         subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
425
426         if run_next_task:
427             self.run_task()
428
429     # Start over and download TBB again
430     def start_over(self):
431         self.force_redownload = True  # Overwrite any existing file
432         self.label.setText(_("Downloading Tor Browser Bundle over again."))
433         self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
434         self.gui_task_i = 0
435         self.start(None)
436
437     def closeEvent(self, event):
438         # Clear the download cache
439         try:
440             os.remove(self.common.paths['version_check_file'])
441             os.remove(self.common.paths['sig_file'])
442             os.remove(self.common.paths['tarball_file'])
443         except:
444             pass
445
446         super(Launcher, self).closeEvent(event)
447
448
449 class Alert(QtWidgets.QMessageBox):
450     """
451     An alert box dialog.
452     """
453     def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
454         super(Alert, self).__init__(None)
455
456         self.setWindowTitle(_("Tor Browser Launcher"))
457         self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
458         self.setText(message)
459         self.setIcon(icon)
460         self.setStandardButtons(buttons)
461
462         if autostart:
463             self.exec_()
464
465
466 class DownloadThread(QtCore.QThread):
467     """
468     Download a file in a separate thread.
469     """
470     progress_update = QtCore.pyqtSignal(int, int)
471     download_complete = QtCore.pyqtSignal()
472     download_error = QtCore.pyqtSignal(str, str)
473
474     def __init__(self, common, url, path):
475         super(DownloadThread, self).__init__()
476         self.common = common
477         self.url = url
478         self.path = path
479
480         # Use tor socks5 proxy, if enabled
481         if self.common.settings['download_over_tor']:
482             socks5_address = 'socks5://{}'.format(self.common.settings['tor_socks_address'])
483             self.proxies = {
484                 'https': socks5_address,
485                 'http': socks5_address
486             }
487         else:
488             self.proxies = None
489
490     def run(self):
491         with open(self.path, "wb") as f:
492             try:
493                 # Start the request
494                 r = requests.get(self.url,
495                                  headers={'User-Agent': 'torbrowser-launcher'},
496                                  stream=True, proxies=self.proxies)
497
498                 # If status code isn't 200, something went wrong
499                 if r.status_code != 200:
500                     # Should we use the default mirror?
501                     if self.common.settings['mirror'] != self.common.default_mirror:
502                         message = (_("Download Error:") +
503                                    " {0}\n\n" + _("You are currently using a non-default mirror") +
504                                    ":\n{1}\n\n" + _("Would you like to switch back to the default?")).format(r.status_code, self.common.settings['mirror'])
505                         self.download_error.emit('error_try_default_mirror', message)
506
507                     # Should we switch to English?
508                     elif self.common.language != 'en-US' and not self.common.settings['force_en-US']:
509                         message = (_("Download Error:") +
510                                    " {0}\n\n" + _("Would you like to try the English version of Tor Browser instead?")).format(r.status_code)
511                         self.download_error.emit('error_try_forcing_english', message)
512
513                     else:
514                         message = (_("Download Error:") + " {0}").format(r.status_code)
515                         self.download_error.emit('error', message)
516
517                     r.close()
518                     return
519
520                 # Start streaming the download
521                 total_bytes = int(r.headers.get('content-length'))
522                 bytes_so_far = 0
523                 for data in r.iter_content(chunk_size=4096):
524                     bytes_so_far += len(data)
525                     f.write(data)
526                     self.progress_update.emit(total_bytes, bytes_so_far)
527
528             except requests.exceptions.SSLError:
529                 if not self.common.settings['download_over_tor']:
530                     message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.').format(self.url.decode()) + "\n\n" + _('Try the download again using Tor?')
531                     self.download_error.emit('error_try_tor', message)
532                 else:
533                     message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.'.format(self.url.decode()))
534                     self.download_error.emit('error', message)
535                 return
536
537             except requests.exceptions.ConnectionError:
538                 # Connection error
539                 if self.common.settings['download_over_tor']:
540                     message = _("Error starting download:\n\n{0}\n\nTrying to download over Tor. Are you sure Tor is configured correctly and running?").format(self.url.decode())
541                     self.download_error.emit('error', message)
542                 else:
543                     message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(self.url.decode())
544                     self.download_error.emit('error', message)
545
546                 return
547
548         self.download_complete.emit()
549
550
551 class VerifyThread(QtCore.QThread):
552     """
553     Verify the signature in a separate thread
554     """
555     success = QtCore.pyqtSignal()
556     error = QtCore.pyqtSignal(str)
557
558     def __init__(self, common):
559         super(VerifyThread, self).__init__()
560         self.common = common
561
562     def run(self):
563         with gpg.Context() as c:
564             c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
565
566             sig = gpg.Data(file=self.common.paths['sig_file'])
567             signed = gpg.Data(file=self.common.paths['tarball_file'])
568
569             try:
570                 c.verify(signature=sig, signed_data=signed)
571             except gpg.errors.BadSignatures as e:
572                 result = str(e).split(": ")
573                 if result[1] == 'No public key':
574                     self.common.refresh_keyring(result[0])
575                 self.error.emit(str(e))
576             else:
577                 self.success.emit()
578
579
580 class ExtractThread(QtCore.QThread):
581     """
582     Extract the tarball in a separate thread
583     """
584     success = QtCore.pyqtSignal()
585     error = QtCore.pyqtSignal()
586
587     def __init__(self, common):
588         super(ExtractThread, self).__init__()
589         self.common = common
590
591     def run(self):
592         extracted = False
593         try:
594             if self.common.paths['tarball_file'][-2:] == 'xz':
595                 # if tarball is .tar.xz
596                 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
597                 tf = tarfile.open(fileobj=xz)
598                 tf.extractall(self.common.paths['tbb']['dir'])
599                 extracted = True
600             else:
601                 # if tarball is .tar.gz
602                 if tarfile.is_tarfile(self.common.paths['tarball_file']):
603                     tf = tarfile.open(self.common.paths['tarball_file'])
604                     tf.extractall(self.common.paths['tbb']['dir'])
605                     extracted = True
606         except:
607             pass
608
609         if extracted:
610             self.success.emit()
611         else:
612             self.error.emit()