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.
40 from twisted.internet import reactor
41 from twisted.web.client import Agent, RedirectAgent, ResponseDone, ResponseFailed
42 from twisted.web.http_headers import Headers
43 from twisted.internet.protocol import Protocol
44 from twisted.internet.error import DNSLookupError, ConnectionRefusedError
52 import xml.etree.ElementTree as ET
61 class TryStableException(Exception):
65 class TryDefaultMirrorException(Exception):
69 class TryForcingEnglishException(Exception):
73 class DownloadErrorException(Exception):
78 def __init__(self, common, url_list):
80 self.url_list = url_list
81 self.force_redownload = False
83 # this is the current version of Tor Browser, which should get updated with every release
84 self.min_version = '6.0.2'
87 self.set_gui(None, '', [])
88 self.launch_gui = True
90 # if Tor Browser is not installed, detect latest version, download, and install
91 if not self.common.settings['installed'] or not self.check_min_version():
92 # if downloading over Tor, include txsocksx
93 if self.common.settings['download_over_tor']:
96 print _('Downloading over Tor')
98 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"))
99 md.set_position(gtk.WIN_POS_CENTER)
102 self.common.settings['download_over_tor'] = False
103 self.common.save_settings()
105 # different message if downloading for the first time, or because your installed version is too low
106 download_message = ""
107 if not self.common.settings['installed']:
108 download_message = _("Downloading and installing Tor Browser for the first time.")
109 elif not self.check_min_version():
110 download_message = _("Your version of Tor Browser is out-of-date. Downloading and installing the newest version.")
112 # download and install
113 print download_message
114 self.set_gui('task', download_message,
115 ['download_version_check',
124 # Tor Browser is already installed, so run
126 self.launch_gui = False
129 # build the rest of the UI
132 def configure_window(self):
133 if not hasattr(self, 'window'):
134 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
135 self.window.set_title(_("Tor Browser"))
136 self.window.set_icon_from_file(self.common.paths['icon_file'])
137 self.window.set_position(gtk.WIN_POS_CENTER)
138 self.window.set_border_width(10)
139 self.window.connect("delete_event", self.delete_event)
140 self.window.connect("destroy", self.destroy)
142 # there are different GUIs that might appear, this sets which one we want
143 def set_gui(self, gui, message, tasks, autostart=True):
145 self.gui_message = message
146 self.gui_tasks = tasks
148 self.gui_autostart = autostart
150 # set all gtk variables to False
152 if hasattr(self, 'box') and hasattr(self.box, 'destroy'):
157 self.progressbar = False
158 self.button_box = False
159 self.start_button = False
160 self.exit_button = False
162 # build the application's UI
166 self.box = gtk.VBox(False, 20)
167 self.configure_window()
168 self.window.add(self.box)
170 if 'error' in self.gui:
172 self.label = gtk.Label(self.gui_message)
173 self.label.set_line_wrap(True)
174 self.box.pack_start(self.label, True, True, 0)
178 self.button_box = gtk.HButtonBox()
179 self.button_box.set_layout(gtk.BUTTONBOX_SPREAD)
180 self.box.pack_start(self.button_box, True, True, 0)
181 self.button_box.show()
183 if self.gui != 'error':
185 yes_image = gtk.Image()
186 yes_image.set_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON)
187 self.yes_button = gtk.Button("Yes")
188 self.yes_button.set_image(yes_image)
189 if self.gui == 'error_try_stable':
190 self.yes_button.connect("clicked", self.try_stable, None)
191 elif self.gui == 'error_try_default_mirror':
192 self.yes_button.connect("clicked", self.try_default_mirror, None)
193 elif self.gui == 'error_try_forcing_english':
194 self.yes_button.connect("clicked", self.try_forcing_english, None)
195 elif self.gui == 'error_try_tor':
196 self.yes_button.connect("clicked", self.try_tor, None)
197 self.button_box.add(self.yes_button)
198 self.yes_button.show()
201 exit_image = gtk.Image()
202 exit_image.set_from_stock(gtk.STOCK_CANCEL, gtk.ICON_SIZE_BUTTON)
203 self.exit_button = gtk.Button("Exit")
204 self.exit_button.set_image(exit_image)
205 self.exit_button.connect("clicked", self.destroy, None)
206 self.button_box.add(self.exit_button)
207 self.exit_button.show()
209 elif self.gui == 'task':
211 self.label = gtk.Label(self.gui_message)
212 self.label.set_line_wrap(True)
213 self.box.pack_start(self.label, True, True, 0)
217 self.progressbar = gtk.ProgressBar(adjustment=None)
218 self.progressbar.set_orientation(gtk.PROGRESS_LEFT_TO_RIGHT)
219 self.progressbar.set_pulse_step(0.01)
220 self.box.pack_start(self.progressbar, True, True, 0)
223 self.button_box = gtk.HButtonBox()
224 self.button_box.set_layout(gtk.BUTTONBOX_SPREAD)
225 self.box.pack_start(self.button_box, True, True, 0)
226 self.button_box.show()
229 start_image = gtk.Image()
230 start_image.set_from_stock(gtk.STOCK_APPLY, gtk.ICON_SIZE_BUTTON)
231 self.start_button = gtk.Button(_("Start"))
232 self.start_button.set_image(start_image)
233 self.start_button.connect("clicked", self.start, None)
234 self.button_box.add(self.start_button)
235 if not self.gui_autostart:
236 self.start_button.show()
239 exit_image = gtk.Image()
240 exit_image.set_from_stock(gtk.STOCK_CANCEL, gtk.ICON_SIZE_BUTTON)
241 self.exit_button = gtk.Button(_("Cancel"))
242 self.exit_button.set_image(exit_image)
243 self.exit_button.connect("clicked", self.destroy, None)
244 self.button_box.add(self.exit_button)
245 self.exit_button.show()
250 if self.gui_autostart:
253 # start button clicked, begin tasks
254 def start(self, widget, data=None):
255 # disable the start button
256 if self.start_button:
257 self.start_button.set_sensitive(False)
259 # start running tasks
262 # run the next task in the task list
266 if self.gui_task_i >= len(self.gui_tasks):
270 task = self.gui_tasks[self.gui_task_i]
272 # get ready for the next task
275 if task == 'download_version_check':
276 print _('Downloading'), self.common.paths['version_check_url']
277 self.download('version check', self.common.paths['version_check_url'],
278 self.common.paths['version_check_file'])
280 if task == 'set_version':
281 version = self.get_stable_version()
283 self.common.build_paths(self.get_stable_version())
284 print _('Latest version: {}').format(version)
287 self.set_gui('error', _("Error detecting Tor Browser version."), [], False)
291 elif task == 'download_sig':
292 print _('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror'])
293 self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
295 elif task == 'download_tarball':
296 print _('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror'])
297 if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
300 self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
302 elif task == 'verify':
303 print _('Verifying Signature')
306 elif task == 'extract':
307 print _('Extracting'), self.common.paths['tarball_filename']
311 print _('Running'), self.common.paths['tbb']['start']
314 elif task == 'start_over':
315 print _('Starting download over again')
318 def response_received(self, response):
319 class FileDownloader(Protocol):
320 def __init__(self, common, file, url, total, progress, done_cb):
324 self.progress = progress
325 self.all_done = done_cb
327 if response.code != 200:
328 if common.settings['mirror'] != common.default_mirror:
329 raise TryDefaultMirrorException(
330 (_("Download Error:") + " {0} {1}\n\n" + _("You are currently using a non-default mirror")
331 + ":\n{2}\n\n" + _("Would you like to switch back to the default?")).format(
332 response.code, response.phrase, common.settings['mirror']
335 elif common.language != 'en-US' and not common.settings['force_en-US']:
336 raise TryForcingEnglishException(
337 (_("Download Error:") + " {0} {1}\n\n"
338 + _("Would you like to try the English version of Tor Browser instead?")).format(
339 response.code, response.phrase
343 raise DownloadErrorException(
344 (_("Download Error:") + " {0} {1}").format(response.code, response.phrase)
347 def dataReceived(self, bytes):
348 self.file.write(bytes)
349 self.so_far += len(bytes)
350 percent = float(self.so_far) / float(self.total)
351 self.progress.set_fraction(percent)
352 amount = float(self.so_far)
354 for (size, unit) in [(1024 * 1024, "MiB"), (1024, "KiB")]:
357 amount /= float(size)
360 self.progress.set_text(_('Downloaded')+(' %2.1f%% (%2.1f %s)' % ((percent * 100.0), amount, units)))
362 def connectionLost(self, reason):
363 self.all_done(reason)
365 if hasattr(self, 'current_download_url'):
366 url = self.current_download_url
371 self.common, self.file_download, url, response.length, self.progressbar, self.response_finished
373 response.deliverBody(dl)
375 def response_finished(self, msg):
376 if msg.check(ResponseDone):
377 self.file_download.close()
378 delattr(self, 'current_download_path')
379 delattr(self, 'current_download_url')
385 print "FINISHED", msg
386 # FIXME handle errors
388 def download_error(self, f):
389 print _("Download Error:"), f.value, type(f.value)
391 if isinstance(f.value, TryStableException):
392 f.trap(TryStableException)
393 self.set_gui('error_try_stable', str(f.value), [], False)
395 elif isinstance(f.value, TryDefaultMirrorException):
396 f.trap(TryDefaultMirrorException)
397 self.set_gui('error_try_default_mirror', str(f.value), [], False)
399 elif isinstance(f.value, TryForcingEnglishException):
400 f.trap(TryForcingEnglishException)
401 self.set_gui('error_try_forcing_english', str(f.value), [], False)
403 elif isinstance(f.value, DownloadErrorException):
404 f.trap(DownloadErrorException)
405 self.set_gui('error', str(f.value), [], False)
407 elif isinstance(f.value, DNSLookupError):
408 f.trap(DNSLookupError)
409 if common.settings['mirror'] != common.default_mirror:
410 self.set_gui('error_try_default_mirror', (_("DNS Lookup Error") + "\n\n" +
411 _("You are currently using a non-default mirror")
413 + _("Would you like to switch back to the default?")
414 ).format(common.settings['mirror']), [], False)
416 self.set_gui('error', str(f.value), [], False)
418 elif isinstance(f.value, ResponseFailed):
419 for reason in f.value.reasons:
420 if isinstance(reason.value, OpenSSL.SSL.Error):
421 # TODO: add the ability to report attack by posting bug to trac.torproject.org
422 if not self.common.settings['download_over_tor']:
423 self.set_gui('error_try_tor',
424 _('The SSL certificate served by https://www.torproject.org is invalid! You may '
425 'be under attack.') + " " + _('Try the download again using Tor?'), [], False)
427 self.set_gui('error', _('The SSL certificate served by https://www.torproject.org is invalid! '
428 'You may be under attack.'), [], False)
430 elif isinstance(f.value, ConnectionRefusedError) and self.common.settings['download_over_tor']:
431 # If we're using Tor, we'll only get this error when we fail to
432 # connect to the SOCKS server. If the connection fails at the
433 # remote end, we'll get txsocksx.errors.ConnectionRefused.
434 addr = self.common.settings['tor_socks_address']
435 self.set_gui('error', _("Error connecting to Tor at {0}").format(addr), [], False)
438 self.set_gui('error', _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(f.value), [], False)
442 def download(self, name, url, path):
443 # keep track of current download
444 self.current_download_path = path
445 self.current_download_url = url
447 mirror_url = url.format(self.common.settings['mirror'])
449 # convert mirror_url from unicode to string, if needed (#205)
450 if isinstance(mirror_url, unicode):
451 mirror_url = unicodedata.normalize('NFKD', mirror_url).encode('ascii', 'ignore')
453 # initialize the progress bar
454 self.progressbar.set_fraction(0)
455 self.progressbar.set_text(_('Downloading') + ' {0}'.format(name))
456 self.progressbar.show()
459 if self.common.settings['download_over_tor']:
460 from twisted.internet.endpoints import clientFromString
461 from txsocksx.http import SOCKS5Agent
463 torendpoint = clientFromString(reactor, self.common.settings['tor_socks_address'])
465 # default mirror gets certificate pinning, only for requests that use the mirror
466 agent = SOCKS5Agent(reactor, proxyEndpoint=torendpoint)
468 agent = Agent(reactor)
470 # actually, agent needs to follow redirect
471 agent = RedirectAgent(agent)
474 d = agent.request('GET', mirror_url,
475 Headers({'User-Agent': ['torbrowser-launcher']}),
478 self.file_download = open(path, 'w')
479 d.addCallback(self.response_received).addErrback(self.download_error)
481 if not reactor.running:
484 def try_default_mirror(self, widget, data=None):
485 # change mirror to default and relaunch TBL
486 self.common.settings['mirror'] = self.common.default_mirror
487 self.common.save_settings()
488 subprocess.Popen([self.common.paths['tbl_bin']])
491 def try_forcing_english(self, widget, data=None):
492 # change force english to true and relaunch TBL
493 self.common.settings['force_en-US'] = True
494 self.common.save_settings()
495 subprocess.Popen([self.common.paths['tbl_bin']])
498 def try_tor(self, widget, data=None):
499 # set download_over_tor to true and relaunch TBL
500 self.common.settings['download_over_tor'] = True
501 self.common.save_settings()
502 subprocess.Popen([self.common.paths['tbl_bin']])
505 def get_stable_version(self):
506 tree = ET.parse(self.common.paths['version_check_file'])
507 for up in tree.getroot():
508 if up.tag == 'update' and up.attrib['appVersion']:
509 version = str(up.attrib['appVersion'])
511 # make sure the version does not contain directory traversal attempts
512 # e.g. "5.5.3", "6.0a", "6.0a-hardened" are valid but "../../../../.." is invalid
513 if not re.match(r'^[a-z0-9\.\-]+$', version):
520 self.progressbar.set_fraction(0)
521 self.progressbar.set_text(_('Verifying Signature'))
522 self.progressbar.show()
524 def gui_raise_sigerror(self, sigerror='MissingErr'):
528 sigerror = 'SIGNATURE VERIFICATION FAILED!\n\nError Code: {0}\n\nYou might be under attack, there might' \
529 ' be a network\nproblem, or you may be missing a recently added\nTor Browser verification key.' \
530 '\n\nFor support, report the above error code.\nClick Start to try again.'.format(sigerror)
531 self.set_gui('task', sigerror, ['start_over'], False)
536 with gpg.Context() as c:
537 c.set_engine_info(gpg.constants.protocol.OpenPGP, home_dir=self.common.paths['gnupg_homedir'])
539 sig = gpg.Data(file=self.common.paths['sig_file'])
540 signed = gpg.Data(file=self.common.paths['tarball_file'])
543 c.verify(signature=sig, signed_data=signed)
544 except gpg.errors.BadSignatures as e:
545 result = str(e).split(": ")
546 if result[1] == 'Bad signature':
547 gui_raise_sigerror(self, str(e))
548 elif result[1] == 'No public key':
549 gui_raise_sigerror(self, str(e))
553 FNULL = open(os.devnull, 'w')
554 p = subprocess.Popen(['/usr/bin/gpg', '--homedir', self.common.paths['gnupg_homedir'], '--verify',
555 self.common.paths['sig_file'], self.common.paths['tarball_file']], stdout=FNULL,
556 stderr=subprocess.STDOUT)
557 self.pulse_until_process_exits(p)
558 if p.returncode == 0:
561 gui_raise_sigerror(self, 'VERIFY_FAIL_NO_GPGME')
562 if not reactor.running:
566 # initialize the progress bar
567 self.progressbar.set_fraction(0)
568 self.progressbar.set_text(_('Installing'))
569 self.progressbar.show()
574 if self.common.paths['tarball_file'][-2:] == 'xz':
575 # if tarball is .tar.xz
576 xz = lzma.LZMAFile(self.common.paths['tarball_file'])
577 tf = tarfile.open(fileobj=xz)
578 tf.extractall(self.common.paths['tbb']['dir'])
581 # if tarball is .tar.gz
582 if tarfile.is_tarfile(self.common.paths['tarball_file']):
583 tf = tarfile.open(self.common.paths['tarball_file'])
584 tf.extractall(self.common.paths['tbb']['dir'])
590 self.set_gui('task', _("Tor Browser Launcher doesn't understand the file format of {0}".format(self.common.paths['tarball_file'])), ['start_over'], False)
597 def check_min_version(self):
598 installed_version = None
599 for line in open(self.common.paths['tbb']['versions']).readlines():
600 if line.startswith('TORBROWSER_VERSION='):
601 installed_version = line.split('=')[1].strip()
604 if self.min_version <= installed_version:
609 def run(self, run_next_task=True):
610 # don't run if it isn't at least the minimum version
611 if not self.check_min_version():
612 message = _("The version of Tor Browser you have installed is earlier than it should be, which could be a "
613 "sign of an attack!")
616 md = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE, _(message))
617 md.set_position(gtk.WIN_POS_CENTER)
624 if self.common.settings['modem_sound']:
625 def play_modem_sound():
629 sound = pygame.mixer.Sound(self.common.paths['modem_sound'])
633 md = gtk.MessageDialog(
634 None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE,
635 _("The python-pygame package is missing, the modem sound is unavailable.")
637 md.set_position(gtk.WIN_POS_CENTER)
641 t = threading.Thread(target=play_modem_sound)
644 # hide the TBL window (#151)
645 if hasattr(self, 'window'):
647 while gtk.events_pending():
648 gtk.main_iteration_do(True)
651 subprocess.call([self.common.paths['tbb']['start']], cwd=self.common.paths['tbb']['dir_tbb'])
656 # make the progress bar pulse until process p (a Popen object) finishes
657 def pulse_until_process_exits(self, p):
658 while p.poll() is None:
660 self.progressbar.pulse()
663 # start over and download TBB again
664 def start_over(self):
665 self.force_redownload = True # Overwrite any existing file
666 self.label.set_text(_("Downloading Tor Browser Bundle over again."))
667 self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
672 def refresh_gtk(self):
673 while gtk.events_pending():
674 gtk.main_iteration(False)
677 def delete_event(self, widget, event, data=None):
680 def destroy(self, widget, data=None):
681 if hasattr(self, 'file_download'):
682 self.file_download.close()
683 if hasattr(self, 'current_download_path'):
684 os.remove(self.current_download_path)
685 delattr(self, 'current_download_path')
686 delattr(self, 'current_download_url')