]> git.lizzy.rs Git - torbrowser-launcher.git/blob - torbrowser_launcher/launcher.py
Fix #462 DNS leak when “downloading over tor”
[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 sys
31 import subprocess
32 import time
33 import tarfile
34 import lzma
35 import re
36 import requests
37 import gpg
38 import shutil
39 import xml.etree.ElementTree as ET
40
41 from PyQt5 import QtCore, QtWidgets, QtGui
42
43
44 class TryStableException(Exception):
45     pass
46
47
48 class TryDefaultMirrorException(Exception):
49     pass
50
51
52 class TryForcingEnglishException(Exception):
53     pass
54
55
56 class DownloadErrorException(Exception):
57     pass
58
59
60 class Launcher(QtWidgets.QMainWindow):
61     """
62     Launcher window.
63     """
64     def __init__(self, common, app, url_list):
65         super(Launcher, self).__init__()
66         self.common = common
67         self.app = app
68
69         self.url_list = url_list
70         self.force_redownload = False
71
72         # This is the current version of Tor Browser, which should get updated with every release
73         self.min_version = '7.5.2'
74
75         # Init launcher
76         self.set_state(None, '', [])
77         self.launch_gui = True
78
79         # If Tor Browser is not installed, detect latest version, download, and install
80         if not self.common.settings['installed'] or not self.check_min_version():
81             # Different message if downloading for the first time, or because your installed version is too low
82             download_message = ""
83             if not self.common.settings['installed']:
84                 download_message = _("Downloading Tor Browser for the first time.")
85             elif not self.check_min_version():
86                 download_message = _("Your version of Tor Browser is out-of-date. "
87                                      "Downloading the newest version.")
88
89             # Download and install
90             print(download_message)
91             self.set_state('task', download_message,
92                            ['download_version_check',
93                             'set_version',
94                             'download_sig',
95                             'download_tarball',
96                             'verify',
97                             'extract',
98                             'run'])
99
100             if self.common.settings['download_over_tor']:
101                 print(_('Downloading over Tor'))
102
103         else:
104             # Tor Browser is already installed, so run
105             launch_message = "Launching Tor Browser."
106             print(launch_message)
107             self.set_state('task', launch_message, ['run'])
108
109         # Build the rest of the UI
110
111         # Set up the window
112         self.setWindowTitle(_("Tor Browser"))
113         self.setWindowIcon(QtGui.QIcon(self.common.paths['icon_file']))
114
115         # Label
116         self.label = QtWidgets.QLabel()
117
118         # Progress bar
119         self.progress_bar = QtWidgets.QProgressBar()
120         self.progress_bar.setTextVisible(True)
121         self.progress_bar.setMinimum(0)
122         self.progress_bar.setMaximum(0)
123         self.progress_bar.setValue(0)
124
125         # Buttons
126         self.yes_button = QtWidgets.QPushButton()
127         self.yes_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
128         self.yes_button.clicked.connect(self.yes_clicked)
129         self.start_button = QtWidgets.QPushButton(_('Start'))
130         self.start_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogApplyButton))
131         self.start_button.clicked.connect(self.start)
132         self.cancel_button = QtWidgets.QPushButton()
133         self.cancel_button.setIcon(self.style().standardIcon(QtWidgets.QStyle.SP_DialogCancelButton))
134         self.cancel_button.clicked.connect(self.close)
135         buttons_layout = QtWidgets.QHBoxLayout()
136         buttons_layout.addStretch()
137         buttons_layout.addWidget(self.yes_button)
138         buttons_layout.addWidget(self.start_button)
139         buttons_layout.addWidget(self.cancel_button)
140         buttons_layout.addStretch()
141
142         # Layout
143         layout = QtWidgets.QVBoxLayout()
144         layout.addWidget(self.label)
145         layout.addWidget(self.progress_bar)
146         layout.addLayout(buttons_layout)
147
148         central_widget = QtWidgets.QWidget()
149         central_widget.setLayout(layout)
150         self.setCentralWidget(central_widget)
151
152         self.update()
153
154     # Set the current state of Tor Browser Launcher
155     def set_state(self, gui, message, tasks, autostart=True):
156         self.gui = gui
157         self.gui_message = message
158         self.gui_tasks = tasks
159         self.gui_task_i = 0
160         self.gui_autostart = autostart
161
162     # Show and hide parts of the UI based on the current state
163     def update(self):
164         # Hide widgets
165         self.progress_bar.hide()
166         self.yes_button.hide()
167         self.start_button.hide()
168
169         if 'error' in self.gui:
170             # Label
171             self.label.setText(self.gui_message)
172
173             # Yes button
174             if self.gui != 'error':
175                 self.yes_button.setText(_('Yes'))
176                 self.yes_button.show()
177
178             # Exit button
179             self.cancel_button.setText(_('Exit'))
180
181         elif self.gui == 'task':
182             # Label
183             self.label.setText(self.gui_message)
184
185             # Progress bar
186             self.progress_bar.show()
187
188             # Start button
189             if not self.gui_autostart:
190                 self.start_button.show()
191
192             # Cancel button
193             self.cancel_button.setText(_('Cancel'))
194
195         # Resize the window
196         self.adjustSize()
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             # Make backup of tarball and sig
365             backup_tarball_filename = self.common.paths['tarball_file'] + '.verification_failed'
366             backup_sig_filename = self.common.paths['sig_file'] + '.verification_failed'
367             shutil.copyfile(self.common.paths['tarball_file'], backup_tarball_filename)
368             shutil.copyfile(self.common.paths['sig_file'], backup_sig_filename)
369
370             sigerror = 'SIGNATURE VERIFICATION FAILED!\n\n' \
371                        'Error Code: {0}\n\n' \
372                        'You might be under attack, there might be a network problem, or you may be missing a ' \
373                        'recently added Tor Browser verification key.\n\n' \
374                        'A copy of the Tor Browser files you downloaded have been saved here:\n' \
375                        '{1}\n{2}\n\n' \
376                        'Click Start to refresh the keyring and try again. If the message persists report the above ' \
377                        'error code here:\nhttps://github.com/micahflee/torbrowser-launcher/issues'
378             sigerror = sigerror.format(message, backup_tarball_filename, backup_sig_filename)
379
380             self.set_state('task', sigerror, ['start_over'], False)
381             self.update()
382
383         t = VerifyThread(self.common)
384         t.error.connect(error)
385         t.success.connect(success)
386         t.start()
387         time.sleep(0.2)
388
389     def extract(self):
390         self.progress_bar.setValue(0)
391         self.progress_bar.setMaximum(0)
392         self.progress_bar.show()
393
394         self.label.setText(_('Installing'))
395
396         def success():
397             self.run_task()
398
399         def error(message):
400             self.set_state(
401                 'task',
402                 _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])),
403                 ['start_over'], False
404             )
405             self.update()
406
407         t = ExtractThread(self.common)
408         t.error.connect(error)
409         t.success.connect(success)
410         t.start()
411         time.sleep(0.2)
412
413     def check_min_version(self):
414         installed_version = None
415         for line in open(self.common.paths['tbb']['changelog'],'rb').readlines():
416             if line.startswith(b'Tor Browser '):
417                 installed_version = line.split()[2].decode()
418                 break
419
420         if self.min_version <= installed_version:
421             return True
422
423         return False
424
425     def run(self):
426         # Don't run if it isn't at least the minimum version
427         if not self.check_min_version():
428             message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
429                         "sign of an attack!")
430             print(message)
431
432             Alert(self.common, message)
433             return
434
435         # Run Tor Browser
436         subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
437         sys.exit(0)
438
439     # Start over and download TBB again
440     def start_over(self):
441         self.force_redownload = True  # Overwrite any existing file
442         self.label.setText(_("Downloading Tor Browser over again."))
443         self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
444         self.gui_task_i = 0
445         self.start(None)
446
447     def closeEvent(self, event):
448         # Clear the download cache
449         try:
450             os.remove(self.common.paths['version_check_file'])
451             os.remove(self.common.paths['sig_file'])
452             os.remove(self.common.paths['tarball_file'])
453         except:
454             pass
455
456         super(Launcher, self).closeEvent(event)
457
458
459 class Alert(QtWidgets.QMessageBox):
460     """
461     An alert box dialog.
462     """
463     def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True):
464         super(Alert, self).__init__(None)
465
466         self.setWindowTitle(_("Tor Browser Launcher"))
467         self.setWindowIcon(QtGui.QIcon(common.paths['icon_file']))
468         self.setText(message)
469         self.setIcon(icon)
470         self.setStandardButtons(buttons)
471
472         if autostart:
473             self.exec_()
474
475
476 class DownloadThread(QtCore.QThread):
477     """
478     Download a file in a separate thread.
479     """
480     progress_update = QtCore.pyqtSignal(int, int)
481     download_complete = QtCore.pyqtSignal()
482     download_error = QtCore.pyqtSignal(str, str)
483
484     def __init__(self, common, url, path):
485         super(DownloadThread, self).__init__()
486         self.common = common
487         self.url = url
488         self.path = path
489
490         # Use tor socks5 proxy, if enabled
491         if self.common.settings['download_over_tor']:
492             socks5_address = 'socks5h://{}'.format(self.common.settings['tor_socks_address'])
493             self.proxies = {
494                 'https': socks5_address,
495                 'http': socks5_address
496             }
497         else:
498             self.proxies = None
499
500     def run(self):
501         with open(self.path, "wb") as f:
502             try:
503                 # Start the request
504                 r = requests.get(self.url,
505                                  headers={'User-Agent': 'torbrowser-launcher'},
506                                  stream=True, proxies=self.proxies)
507
508                 # If status code isn't 200, something went wrong
509                 if r.status_code != 200:
510                     # Should we use the default mirror?
511                     if self.common.settings['mirror'] != self.common.default_mirror:
512                         message = (_("Download Error:") +
513                                    " {0}\n\n" + _("You are currently using a non-default mirror") +
514                                    ":\n{1}\n\n" + _("Would you like to switch back to the default?")).format(
515                                        r.status_code, self.common.settings['mirror']
516                                    )
517                         self.download_error.emit('error_try_default_mirror', message)
518
519                     # Should we switch to English?
520                     elif self.common.language != 'en-US' and not self.common.settings['force_en-US']:
521                         message = (_("Download Error:") +
522                                    " {0}\n\n" +
523                                    _("Would you like to try the English version of Tor Browser instead?")).format(
524                                        r.status_code
525                                    )
526                         self.download_error.emit('error_try_forcing_english', message)
527
528                     else:
529                         message = (_("Download Error:") + " {0}").format(r.status_code)
530                         self.download_error.emit('error', message)
531
532                     r.close()
533                     return
534
535                 # Start streaming the download
536                 total_bytes = int(r.headers.get('content-length'))
537                 bytes_so_far = 0
538                 for data in r.iter_content(chunk_size=4096):
539                     bytes_so_far += len(data)
540                     f.write(data)
541                     self.progress_update.emit(total_bytes, bytes_so_far)
542
543             except requests.exceptions.SSLError:
544                 message = _('Invalid SSL certificate for:\n{0}\n\nYou may be under attack.').format(self.url.decode())
545                 if not self.common.settings['download_over_tor']:
546                     message += "\n\n" + _('Try the download again using Tor?')
547                     self.download_error.emit('error_try_tor', message)
548                 else:
549                     self.download_error.emit('error', message)
550                 return
551
552             except requests.exceptions.ConnectionError:
553                 # Connection error
554                 if self.common.settings['download_over_tor']:
555                     message = _("Error starting download:\n\n{0}\n\nTrying to download over Tor. "
556                                 "Are you sure Tor is configured correctly and running?").format(self.url.decode())
557                     self.download_error.emit('error', message)
558                 else:
559                     message = _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(
560                         self.url.decode()
561                     )
562                     self.download_error.emit('error', message)
563
564                 return
565
566         self.download_complete.emit()
567
568
569 class VerifyThread(QtCore.QThread):
570     """
571     Verify the signature in a separate thread
572     """
573     success = QtCore.pyqtSignal()
574     error = QtCore.pyqtSignal(str)
575
576     def __init__(self, common):
577         super(VerifyThread, self).__init__()
578         self.common = common
579
580     def run(self):
581         def verify(second_try=False):
582             with gpg.Context() as c:
583                 c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
584
585                 sig = gpg.Data(file=self.common.paths['sig_file'])
586                 signed = gpg.Data(file=self.common.paths['tarball_file'])
587
588                 try:
589                     c.verify(signature=sig, signed_data=signed)
590                 except gpg.errors.BadSignatures as e:
591                     if second_try:
592                         self.error.emit(str(e))
593                     else:
594                         raise Exception
595                 else:
596                     self.success.emit()
597
598         try:
599             # Try verifying
600             verify()
601         except:
602             # If it fails, refresh the keyring and try again
603             self.common.refresh_keyring()
604             verify(True)
605
606
607 class ExtractThread(QtCore.QThread):
608     """
609     Extract the tarball in a separate thread
610     """
611     success = QtCore.pyqtSignal()
612     error = QtCore.pyqtSignal()
613
614     def __init__(self, common):
615         super(ExtractThread, self).__init__()
616         self.common = common
617
618     def run(self):
619         extracted = False
620         try:
621             if self.common.paths['tarball_file'][-2:] == 'xz':
622                 # if tarball is .tar.xz
623                 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
624                 tf = tarfile.open(fileobj=xz)
625                 tf.extractall(self.common.paths['tbb']['dir'])
626                 extracted = True
627             else:
628                 # if tarball is .tar.gz
629                 if tarfile.is_tarfile(self.common.paths['tarball_file']):
630                     tf = tarfile.open(self.common.paths['tarball_file'])
631                     tf.extractall(self.common.paths['tbb']['dir'])
632                     extracted = True
633         except:
634             pass
635
636         if extracted:
637             self.success.emit()
638         else:
639             self.error.emit()