3 https://github.com/micahflee/torbrowser-launcher/
5 Copyright (c) 2013-2017 Micah Lee <micah@micahflee.com>
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
16 The above copyright notice and this permission notice shall be
17 included in all copies or substantial portions of the Software.
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.
29 from __future__ import print_function
42 from twisted.internet import reactor
43 from twisted.web.client import Agent, RedirectAgent, ResponseDone, ResponseFailed
44 from twisted.web.http_headers import Headers
45 from twisted.internet.protocol import Protocol
46 from twisted.internet.error import DNSLookupError, ConnectionRefusedError
54 import xml.etree.ElementTree as ET
63 class TryStableException(Exception):
67 class TryDefaultMirrorException(Exception):
71 class TryForcingEnglishException(Exception):
75 class DownloadErrorException(Exception):
80 def __init__(self, common, app, url_list):
84 self.url_list = url_list
85 self.force_redownload = False
87 # this is the current version of Tor Browser, which should get updated with every release
88 self.min_version = '6.0.2'
91 self.set_gui(None, '', [])
92 self.launch_gui = True
94 # if Tor Browser is not installed, detect latest version, download, and install
95 if not self.common.settings['installed'] or not self.check_min_version():
96 # if downloading over Tor, include txsocksx
97 if self.common.settings['download_over_tor']:
100 print(_('Downloading over Tor'))
102 md = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE, _("The python-txsocksx package is missing, downloads will not happen over tor"))
103 md.set_position(gtk.WIN_POS_CENTER)
106 self.common.settings['download_over_tor'] = False
107 self.common.save_settings()
109 # different message if downloading for the first time, or because your installed version is too low
110 download_message = ""
111 if not self.common.settings['installed']:
112 download_message = _("Downloading and installing Tor Browser for the first time.")
113 elif not self.check_min_version():
114 download_message = _("Your version of Tor Browser is out-of-date. Downloading and installing the newest version.")
116 # download and install
117 print(download_message)
118 self.set_gui('task', download_message,
119 ['download_version_check',
128 # Tor Browser is already installed, so run
130 self.launch_gui = False
133 # build the rest of the UI
136 def configure_window(self):
137 if not hasattr(self, 'window'):
138 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
139 self.window.set_title(_("Tor Browser"))
140 self.window.set_icon_from_file(self.common.paths['icon_file'])
141 self.window.set_position(gtk.WIN_POS_CENTER)
142 self.window.set_border_width(10)
143 self.window.connect("delete_event", self.delete_event)
144 self.window.connect("destroy", self.destroy)
146 # there are different GUIs that might appear, this sets which one we want
147 def set_gui(self, gui, message, tasks, autostart=True):
149 self.gui_message = message
150 self.gui_tasks = tasks
152 self.gui_autostart = autostart
154 # set all gtk variables to False
156 if hasattr(self, 'box') and hasattr(self.box, 'destroy'):
161 self.progressbar = False
162 self.button_box = False
163 self.start_button = False
164 self.exit_button = False
166 # build the application's UI
170 self.box = gtk.VBox(False, 20)
171 self.configure_window()
172 self.window.add(self.box)
174 if 'error' in self.gui:
176 self.label = gtk.Label(self.gui_message)
177 self.label.set_line_wrap(True)
178 self.box.pack_start(self.label, True, True, 0)
182 self.button_box = gtk.HButtonBox()
183 self.button_box.set_layout(gtk.BUTTONBOX_SPREAD)
184 self.box.pack_start(self.button_box, True, True, 0)
185 self.button_box.show()
187 if self.gui != 'error':
189 yes_image = gtk.Image()
190 yes_image.set_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON)
191 self.yes_button = gtk.Button("Yes")
192 self.yes_button.set_image(yes_image)
193 if self.gui == 'error_try_stable':
194 self.yes_button.connect("clicked", self.try_stable, None)
195 elif self.gui == 'error_try_default_mirror':
196 self.yes_button.connect("clicked", self.try_default_mirror, None)
197 elif self.gui == 'error_try_forcing_english':
198 self.yes_button.connect("clicked", self.try_forcing_english, None)
199 elif self.gui == 'error_try_tor':
200 self.yes_button.connect("clicked", self.try_tor, None)
201 self.button_box.add(self.yes_button)
202 self.yes_button.show()
205 exit_image = gtk.Image()
206 exit_image.set_from_stock(gtk.STOCK_CANCEL, gtk.ICON_SIZE_BUTTON)
207 self.exit_button = gtk.Button("Exit")
208 self.exit_button.set_image(exit_image)
209 self.exit_button.connect("clicked", self.destroy, None)
210 self.button_box.add(self.exit_button)
211 self.exit_button.show()
213 elif self.gui == 'task':
215 self.label = gtk.Label(self.gui_message)
216 self.label.set_line_wrap(True)
217 self.box.pack_start(self.label, True, True, 0)
221 self.progressbar = gtk.ProgressBar(adjustment=None)
222 self.progressbar.set_orientation(gtk.PROGRESS_LEFT_TO_RIGHT)
223 self.progressbar.set_pulse_step(0.01)
224 self.box.pack_start(self.progressbar, True, True, 0)
227 self.button_box = gtk.HButtonBox()
228 self.button_box.set_layout(gtk.BUTTONBOX_SPREAD)
229 self.box.pack_start(self.button_box, True, True, 0)
230 self.button_box.show()
233 start_image = gtk.Image()
234 start_image.set_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON)
235 self.start_button = gtk.Button(_("Start"))
236 self.start_button.set_image(start_image)
237 self.start_button.connect("clicked", self.start, None)
238 self.button_box.add(self.start_button)
239 if not self.gui_autostart:
240 self.start_button.show()
243 exit_image = gtk.Image()
244 exit_image.set_from_stock(gtk.STOCK_CANCEL, gtk.ICON_SIZE_BUTTON)
245 self.exit_button = gtk.Button(_("Cancel"))
246 self.exit_button.set_image(exit_image)
247 self.exit_button.connect("clicked", self.destroy, None)
248 self.button_box.add(self.exit_button)
249 self.exit_button.show()
254 if self.gui_autostart:
257 # start button clicked, begin tasks
258 def start(self, widget, data=None):
259 # disable the start button
260 if self.start_button:
261 self.start_button.set_sensitive(False)
263 # start running tasks
266 # run the next task in the task list
270 if self.gui_task_i >= len(self.gui_tasks):
274 task = self.gui_tasks[self.gui_task_i]
276 # get ready for the next task
279 if task == 'download_version_check':
280 print(_('Downloading'), self.common.paths['version_check_url'])
281 self.download('version check', self.common.paths['version_check_url'], self.common.paths['version_check_file'])
283 if task == 'set_version':
284 version = self.get_stable_version()
286 self.common.build_paths(self.get_stable_version())
287 print(_('Latest version: {}').format(version))
290 self.set_gui('error', _("Error detecting Tor Browser version."), [], False)
294 elif task == 'download_sig':
295 print(_('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror']))
296 self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
298 elif task == 'download_tarball':
299 print(_('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror']))
300 if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
303 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
305 elif task == 'verify':
306 print(_('Verifying Signature'))
309 elif task == 'extract':
310 print(_('Extracting'), self.common.paths['tarball_filename'])
314 print(_('Running'), self.common.paths['tbb']['start'])
317 elif task == 'start_over':
318 print(_('Starting download over again'))
321 def response_received(self, response):
322 class FileDownloader(Protocol):
323 def __init__(self, common, file, url, total, progress, done_cb):
327 self.progress = progress
328 self.all_done = done_cb
330 if response.code != 200:
331 if common.settings['mirror'] != common.default_mirror:
332 raise TryDefaultMirrorException(
333 (_("Download Error:") + " {0} {1}\n\n" + _("You are currently using a non-default mirror")
334 + ":\n{2}\n\n" + _("Would you like to switch back to the default?")).format(
335 response.code, response.phrase, common.settings['mirror']
338 elif common.language != 'en-US' and not common.settings['force_en-US']:
339 raise TryForcingEnglishException(
340 (_("Download Error:") + " {0} {1}\n\n"
341 + _("Would you like to try the English version of Tor Browser instead?")).format(
342 response.code, response.phrase
346 raise DownloadErrorException(
347 (_("Download Error:") + " {0} {1}").format(response.code, response.phrase)
350 def dataReceived(self, bytes):
351 self.file.write(bytes)
352 self.so_far += len(bytes)
353 percent = float(self.so_far) / float(self.total)
354 self.progress.set_fraction(percent)
355 amount = float(self.so_far)
357 for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
360 amount /= float(size)
363 self.progress.set_text(_('Downloaded')+(' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units)))
365 def connectionLost(self, reason):
366 self.all_done(reason)
368 if hasattr(self, 'current_download_url'):
369 url = self.current_download_url
374 self.common, self.file_download, url, response.length, self.progressbar, self.response_finished
376 response.deliverBody(dl)
378 def response_finished(self, msg):
379 if msg.check(ResponseDone):
380 self.file_download.close()
381 delattr(self, 'current_download_path')
382 delattr(self, 'current_download_url')
388 print("FINISHED", msg)
389 ## FIXME handle errors
391 def download_error(self, f):
392 print(_("Download Error:"), f.value, type(f.value))
394 if isinstance(f.value, TryStableException):
395 f.trap(TryStableException)
396 self.set_gui('error_try_stable', str(f.value), [], False)
398 elif isinstance(f.value, TryDefaultMirrorException):
399 f.trap(TryDefaultMirrorException)
400 self.set_gui('error_try_default_mirror', str(f.value), [], False)
402 elif isinstance(f.value, TryForcingEnglishException):
403 f.trap(TryForcingEnglishException)
404 self.set_gui('error_try_forcing_english', str(f.value), [], False)
406 elif isinstance(f.value, DownloadErrorException):
407 f.trap(DownloadErrorException)
408 self.set_gui('error', str(f.value), [], False)
410 elif isinstance(f.value, DNSLookupError):
411 f.trap(DNSLookupError)
412 if common.settings['mirror'] != common.default_mirror:
413 self.set_gui('error_try_default_mirror', (_("DNS Lookup Error") + "\n\n" +
414 _("You are currently using a non-default mirror")
416 + _("Would you like to switch back to the default?")
417 ).format(common.settings['mirror']), [], False)
419 self.set_gui('error', str(f.value), [], False)
421 elif isinstance(f.value, ResponseFailed):
422 for reason in f.value.reasons:
423 if isinstance(reason.value, OpenSSL.SSL.Error):
424 # TODO: add the ability to report attack by posting bug to trac.torproject.org
425 if not self.common.settings['download_over_tor']:
426 self.set_gui('error_try_tor',
427 _('The SSL certificate served by https://www.torproject.org is invalid! You may '
428 'be under attack.') + " " + _('Try the download again using Tor?'), [], False)
430 self.set_gui('error', _('The SSL certificate served by https://www.torproject.org is invalid! '
431 'You may be under attack.'), [], False)
433 elif isinstance(f.value, ConnectionRefusedError) and self.common.settings['download_over_tor']:
434 # If we're using Tor, we'll only get this error when we fail to
435 # connect to the SOCKS server. If the connection fails at the
436 # remote end, we'll get txsocksx.errors.ConnectionRefused.
437 addr = self.common.settings['tor_socks_address']
438 self.set_gui('error', _("Error connecting to Tor at {0}").format(addr), [], False)
441 self.set_gui('error', _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(f.value), [], False)
445 def download(self, name, url, path):
446 # keep track of current download
447 self.current_download_path = path
448 self.current_download_url = url
450 mirror_url = url.format(self.common.settings['mirror'])
452 # convert mirror_url from unicode to string, if needed (#205)
453 if isinstance(mirror_url, unicode):
454 mirror_url = unicodedata.normalize('NFKD', mirror_url).encode('ascii', 'ignore')
456 # initialize the progress bar
457 self.progressbar.set_fraction(0)
458 self.progressbar.set_text(_('Downloading') + ' {0}'.format(name))
459 self.progressbar.show()
462 if self.common.settings['download_over_tor']:
463 from twisted.internet.endpoints import clientFromString
464 from txsocksx.http import SOCKS5Agent
466 torendpoint = clientFromString(reactor, self.common.settings['tor_socks_address'])
468 # default mirror gets certificate pinning, only for requests that use the mirror
469 agent = SOCKS5Agent(reactor, proxyEndpoint=torendpoint)
471 agent = Agent(reactor)
473 # actually, agent needs to follow redirect
474 agent = RedirectAgent(agent)
477 d = agent.request('GET', mirror_url,
478 Headers({'User-Agent': ['torbrowser-launcher']}),
481 self.file_download = open(path, 'w')
482 d.addCallback(self.response_received).addErrback(self.download_error)
484 if not reactor.running:
487 def try_default_mirror(self, widget, data=None):
488 # change mirror to default and relaunch TBL
489 self.common.settings['mirror'] = self.common.default_mirror
490 self.common.save_settings()
491 subprocess.Popen([self.common.paths['tbl_bin']])
494 def try_forcing_english(self, widget, data=None):
495 # change force english to true and relaunch TBL
496 self.common.settings['force_en-US'] = True
497 self.common.save_settings()
498 subprocess.Popen([self.common.paths['tbl_bin']])
501 def try_tor(self, widget, data=None):
502 # set download_over_tor to true and relaunch TBL
503 self.common.settings['download_over_tor'] = True
504 self.common.save_settings()
505 subprocess.Popen([self.common.paths['tbl_bin']])
508 def get_stable_version(self):
509 tree = ET.parse(self.common.paths['version_check_file'])
510 for up in tree.getroot():
511 if up.tag == 'update' and up.attrib['appVersion']:
512 version = str(up.attrib['appVersion'])
514 # make sure the version does not contain directory traversal attempts
515 # e.g. "5.5.3", "6.0a", "6.0a-hardened" are valid but "../../../../.." is invalid
516 if not re.match(r'^[a-z0-9\.\-]+$', version):
523 self.progressbar.set_fraction(0)
524 self.progressbar.set_text(_('Verifying Signature'))
525 self.progressbar.show()
527 def gui_raise_sigerror(self, sigerror='MissingErr'):
531 sigerror = 'SIGNATURE VERIFICATION FAILED!\n\nError Code: {0}\n\nYou might be under attack, there might' \
532 ' be a network\nproblem, or you may be missing a recently added\nTor Browser verification key.' \
533 '\nClick Start to refresh the keyring and try again. If the message persists report the above' \
534 ' error code here:\nhttps://github.com/micahflee/torbrowser-launcher/issues'.format(sigerror)
536 self.set_gui('task', sigerror, ['start_over'], False)
541 with gpg.Context() as c:
542 c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
544 sig = gpg.Data(file=self.common.paths['sig_file'])
545 signed = gpg.Data(file=self.common.paths['tarball_file'])
548 c.verify(signature=sig, signed_data=signed)
549 except gpg.errors.BadSignatures as e:
550 result = str(e).split(": ")
551 if result[1] == 'Bad signature':
552 gui_raise_sigerror(self, str(e))
553 elif result[1] == 'No public key':
554 self.common.refresh_keyring(result[0])
555 gui_raise_sigerror(self, str(e))
559 FNULL = open(os.devnull, 'w')
560 p = subprocess.Popen(['/usr/bin/gpg', '--homedir', self.common.paths['gnupg_homedir'], '--verify',
561 self.common.paths['sig_file'], self.common.paths['tarball_file']], stdout=FNULL,
562 stderr=subprocess.STDOUT)
563 self.pulse_until_process_exits(p)
564 if p.returncode == 0:
567 self.common.refresh_keyring()
568 gui_raise_sigerror(self, 'GENERIC_VERIFY_FAIL')
569 if not reactor.running:
573 # initialize the progress bar
574 self.progressbar.set_fraction(0)
575 self.progressbar.set_text(_('Installing'))
576 self.progressbar.show()
581 if self.common.paths['tarball_file'][-2:] == 'xz':
582 # if tarball is .tar.xz
583 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
584 tf = tarfile.open(fileobj=xz)
585 tf.extractall(self.common.paths['tbb']['dir'])
588 # if tarball is .tar.gz
589 if tarfile.is_tarfile(self.common.paths['tarball_file']):
590 tf = tarfile.open(self.common.paths['tarball_file'])
591 tf.extractall(self.common.paths['tbb']['dir'])
597 self.set_gui('task', _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])), ['start_over'], False)
604 def check_min_version(self):
605 installed_version = None
606 for line in open(self.common.paths['tbb']['changelog']).readlines():
607 if line.startswith('Tor Browser '):
608 installed_version = line.split()[2]
611 if self.min_version <= installed_version:
616 def run(self, run_next_task=True):
617 # don't run if it isn't at least the minimum version
618 if not self.check_min_version():
619 message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
620 "sign of an attack!")
623 md = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE, _(message))
624 md.set_position(gtk.WIN_POS_CENTER)
630 # hide the TBL window (#151)
631 if hasattr(self, 'window'):
633 while gtk.events_pending():
634 gtk.main_iteration_do(True)
637 subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
642 # make the progress bar pulse until process p (a Popen object) finishes
643 def pulse_until_process_exits(self, p):
644 while p.poll() is None:
646 self.progressbar.pulse()
649 # start over and download TBB again
650 def start_over(self):
651 self.force_redownload = True # Overwrite any existing file
652 self.label.set_text(_("Downloading Tor Browser Bundle over again."))
653 self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
658 def refresh_gtk(self):
659 while gtk.events_pending():
660 gtk.main_iteration(False)
663 def delete_event(self, widget, event, data=None):
666 def destroy(self, widget, data=None):
667 if hasattr(self, 'file_download'):
668 self.file_download.close()
669 if hasattr(self, 'current_download_path'):
670 os.remove(self.current_download_path)
671 delattr(self, 'current_download_path')
672 delattr(self, 'current_download_url')