]> git.lizzy.rs Git - torbrowser-launcher.git/blobdiff - torbrowser_launcher/launcher.py
Add pre-import check that signing keyfile exists; remove asserts
[torbrowser-launcher.git] / torbrowser_launcher / launcher.py
index 1d0575bcd4130c229caa59c4b90fd1c39f22394d..ec87cf87b0d0d07e216d71915a07e1cec550f368 100644 (file)
@@ -2,7 +2,7 @@
 Tor Browser Launcher
 https://github.com/micahflee/torbrowser-launcher/
 
-Copyright (c) 2013-2014 Micah Lee <micah@micahflee.com>
+Copyright (c) 2013-2017 Micah Lee <micah@micahflee.com>
 
 Permission is hereby granted, free of charge, to any person
 obtaining a copy of this software and associated documentation
@@ -26,16 +26,12 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 OTHER DEALINGS IN THE SOFTWARE.
 """
 
-import os, subprocess, time, json, tarfile, hashlib, lzma, threading, re
+import os, subprocess, time, json, tarfile, hashlib, lzma, threading, re, unicodedata
 from twisted.internet import reactor
 from twisted.web.client import Agent, RedirectAgent, ResponseDone, ResponseFailed
 from twisted.web.http_headers import Headers
-from twisted.web.iweb import IPolicyForHTTPS
 from twisted.internet.protocol import Protocol
-from twisted.internet.ssl import CertificateOptions
-from twisted.internet._sslverify import ClientTLSOptions
-from twisted.internet.error import DNSLookupError
-from zope.interface import implementer
+from twisted.internet.error import DNSLookupError, ConnectionRefusedError
 
 import xml.etree.ElementTree as ET
 
@@ -51,89 +47,69 @@ class TryStableException(Exception):
 class TryDefaultMirrorException(Exception):
     pass
 
-class DownloadErrorException(Exception):
+class TryForcingEnglishException(Exception):
     pass
 
-class TorProjectCertificateOptions(CertificateOptions):
-    def __init__(self, torproject_pem):
-        CertificateOptions.__init__(self)
-        self.torproject_ca = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, open(torproject_pem, 'r').read())
-
-    def getContext(self, host, port):
-        ctx = CertificateOptions.getContext(self)
-        ctx.set_verify_depth(0)
-        ctx.set_verify(OpenSSL.SSL.VERIFY_PEER | OpenSSL.SSL.VERIFY_FAIL_IF_NO_PEER_CERT, self.verifyHostname)
-        return ctx
-
-    def verifyHostname(self, connection, cert, errno, depth, preverifyOK):
-        return cert.digest('sha256') == self.torproject_ca.digest('sha256')
-
-@implementer(IPolicyForHTTPS)
-class TorProjectPolicyForHTTPS:
-    def __init__(self, torproject_pem):
-        self.torproject_pem = torproject_pem
-
-    def creatorForNetloc(self, hostname, port):
-        certificateOptions = TorProjectCertificateOptions(self.torproject_pem)
-        return ClientTLSOptions(hostname.decode('utf-8'),
-                                certificateOptions.getContext(hostname, port))
+class DownloadErrorException(Exception):
+    pass
 
 class Launcher:
     def __init__(self, common, url_list):
         self.common = common
         self.url_list = url_list
+        self.force_redownload = False
+
+        # this is the current version of Tor Browser, which should get updated with every release
+        self.min_version = '6.0.2'
 
         # init launcher
         self.set_gui(None, '', [])
         self.launch_gui = True
-        self.common.build_paths(self.common.settings['latest_version'])
-
-        if self.common.settings['update_over_tor']:
-            try:
-                import txsocksx
-                print _('Updating over Tor')
-            except ImportError:
-                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"))
-                md.set_position(gtk.WIN_POS_CENTER)
-                md.run()
-                md.destroy()
-                self.common.settings['update_over_tor'] = False
-                self.common.save_settings()
-
-        # is firefox already running?
-        if self.common.settings['installed_version']:
-            firefox_pid = self.common.get_pid('./Browser/firefox')
-            if firefox_pid:
-                print _('Firefox is open, bringing to focus')
-                # bring firefox to front
-                self.common.bring_window_to_front(firefox_pid)
-                return
-
-        # check for updates?
-        check_for_updates = False
-        if self.common.settings['check_for_updates']:
-            check_for_updates = True
-
-        if not check_for_updates:
-            # how long was it since the last update check?
-            # 86400 seconds = 24 hours
-            current_timestamp = int(time.time())
-            if current_timestamp - self.common.settings['last_update_check_timestamp'] >= 86400:
-                check_for_updates = True
-
-        if check_for_updates:
-            # check for update
-            print 'Checking for update'
-            self.set_gui('task', _("Checking for Tor Browser update."),
-                         ['download_update_check',
-                          'attempt_update'])
+
+        # if Tor Browser is not installed, detect latest version, download, and install
+        if not self.common.settings['installed'] or not self.check_min_version():
+            # if downloading over Tor, include txsocksx
+            if self.common.settings['download_over_tor']:
+                try:
+                    import txsocksx
+                    print _('Downloading over Tor')
+                except ImportError:
+                    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"))
+                    md.set_position(gtk.WIN_POS_CENTER)
+                    md.run()
+                    md.destroy()
+                    self.common.settings['download_over_tor'] = False
+                    self.common.save_settings()
+
+            # different message if downloading for the first time, or because your installed version is too low
+            download_message = ""
+            if not self.common.settings['installed']:
+                download_message = _("Downloading and installing Tor Browser for the first time.")
+            elif not self.check_min_version():
+                download_message = _("Your version of Tor Browser is out-of-date. Downloading and installing the newest version.")
+
+            # download and install
+            print download_message
+            self.set_gui('task', download_message,
+                         ['download_version_check',
+                          'set_version',
+                          'download_sig',
+                          'download_tarball',
+                          'verify',
+                          'extract',
+                          'run'])
+
         else:
-            # no need to check for update
-            print _('Checked for update within 24 hours, skipping')
-            self.start_launcher()
+            # Tor Browser is already installed, so run
+            self.run(False)
+            self.launch_gui = False
 
         if self.launch_gui:
-            # set up the window
+            # build the rest of the UI
+            self.build_ui()
+
+    def configure_window(self):
+        if not hasattr(self, 'window'):
             self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
             self.window.set_title(_("Tor Browser"))
             self.window.set_icon_from_file(self.common.paths['icon_file'])
@@ -142,55 +118,6 @@ class Launcher:
             self.window.connect("delete_event", self.delete_event)
             self.window.connect("destroy", self.destroy)
 
-            # build the rest of the UI
-            self.build_ui()
-
-    # download or run TBB
-    def start_launcher(self):
-        # is TBB already installed?
-        latest_version = self.common.settings['latest_version']
-        installed_version = self.common.settings['installed_version']
-
-        # verify installed version for newer versions of TBB (#58)
-        if installed_version >= '3.0':
-            versions_filename = self.common.paths['tbb']['versions']
-            if os.path.exists(versions_filename):
-                for line in open(versions_filename):
-                    if 'TORBROWSER_VERSION' in line:
-                        installed_version = line.lstrip('TORBROWSER_VERSION=').strip()
-
-        start = self.common.paths['tbb']['start']
-        if os.path.isfile(start) and os.access(start, os.X_OK):
-            if installed_version == latest_version:
-                print _('Latest version of TBB is installed, launching')
-                # current version of tbb is installed, launch it
-                self.run(False)
-                self.launch_gui = False
-            elif installed_version < latest_version:
-                print _('TBB is out of date, attempting to upgrade to {0}'.format(latest_version))
-                # there is a tbb upgrade available
-                self.set_gui('task', _("Your Tor Browser is out of date. Upgrading from {0} to {1}.".format(installed_version, latest_version)),
-                             ['download_sha256',
-                              'download_sha256_sig',
-                              'download_tarball',
-                              'verify',
-                              'extract',
-                              'run'])
-            else:
-                # for some reason the installed tbb is newer than the current version?
-                self.set_gui('error', _("Something is wrong. The version of Tor Browser Bundle you have installed is newer than the current version?"), [])
-
-        # not installed
-        else:
-            print _('TBB is not installed, attempting to install {0}'.format(latest_version))
-            self.set_gui('task', _("Downloading and installing Tor Browser for the first time."),
-                         ['download_sha256',
-                          'download_sha256_sig',
-                          'download_tarball',
-                          'verify',
-                          'extract',
-                          'run'])
-
     # there are different GUIs that might appear, this sets which one we want
     def set_gui(self, gui, message, tasks, autostart=True):
         self.gui = gui
@@ -216,6 +143,7 @@ class Launcher:
         self.clear_ui()
 
         self.box = gtk.VBox(False, 20)
+        self.configure_window()
         self.window.add(self.box)
 
         if 'error' in self.gui:
@@ -241,6 +169,8 @@ class Launcher:
                     self.yes_button.connect("clicked", self.try_stable, None)
                 elif self.gui == 'error_try_default_mirror':
                     self.yes_button.connect("clicked", self.try_default_mirror, None)
+                elif self.gui == 'error_try_forcing_english':
+                    self.yes_button.connect("clicked", self.try_forcing_english, None)
                 elif self.gui == 'error_try_tor':
                     self.yes_button.connect("clicked", self.try_tor, None)
                 self.button_box.add(self.yes_button)
@@ -287,7 +217,7 @@ class Launcher:
             # exit button
             exit_image = gtk.Image()
             exit_image.set_from_stock(gtk.STOCK_CANCEL, gtk.ICON_SIZE_BUTTON)
-            self.exit_button = gtk.Button(_("Exit"))
+            self.exit_button = gtk.Button(_("Cancel"))
             self.exit_button.set_image(exit_image)
             self.exit_button.connect("clicked", self.destroy, None)
             self.button_box.add(self.exit_button)
@@ -321,28 +251,34 @@ class Launcher:
         # get ready for the next task
         self.gui_task_i += 1
 
-        if task == 'download_update_check':
-            print _('Downloading'), self.common.paths['update_check_url']
-            self.download('update check', self.common.paths['update_check_url'], self.common.paths['update_check_file'])
-
-        if task == 'attempt_update':
-            print _('Checking to see if update is needed')
-            self.attempt_update()
+        if task == 'download_version_check':
+            print _('Downloading'), self.common.paths['version_check_url']
+            self.download('version check', self.common.paths['version_check_url'], self.common.paths['version_check_file'])
 
-        elif task == 'download_sha256':
-            print _('Downloading'), self.common.paths['sha256_url'].format(self.common.settings['mirror'])
-            self.download('signature', self.common.paths['sha256_url'], self.common.paths['sha256_file'])
+        if task == 'set_version':
+            version = self.get_stable_version()
+            if version:
+                self.common.build_paths(self.get_stable_version())
+                print _('Latest version: {}').format(version)
+                self.run_task()
+            else:
+                self.set_gui('error', _("Error detecting Tor Browser version."), [], False)
+                self.clear_ui()
+                self.build_ui()
 
-        elif task == 'download_sha256_sig':
-            print _('Downloading'), self.common.paths['sha256_sig_url'].format(self.common.settings['mirror'])
-            self.download('signature', self.common.paths['sha256_sig_url'], self.common.paths['sha256_sig_file'])
+        elif task == 'download_sig':
+            print _('Downloading'), self.common.paths['sig_url'].format(self.common.settings['mirror'])
+            self.download('signature', self.common.paths['sig_url'], self.common.paths['sig_file'])
 
         elif task == 'download_tarball':
             print _('Downloading'), self.common.paths['tarball_url'].format(self.common.settings['mirror'])
-            self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
+            if not self.force_redownload and os.path.exists(self.common.paths['tarball_file']):
+                self.run_task()
+            else:
+                self.download('tarball', self.common.paths['tarball_url'], self.common.paths['tarball_file'])
 
         elif task == 'verify':
-            print _('Verifying signature')
+            print _('Verifying Signature')
             self.verify()
 
         elif task == 'extract':
@@ -368,9 +304,11 @@ class Launcher:
 
                 if response.code != 200:
                     if common.settings['mirror'] != common.default_mirror:
-                        raise TryDefaultMirrorException(_("Download Error: {0} {1}\n\nYou are currently using a non-default mirror:\n{2}\n\nWould you like to switch back to the default?").format(response.code, response.phrase, common.settings['mirror']))
+                        raise TryDefaultMirrorException((_("Download Error:") +  " {0} {1}\n\n" + _("You are currently using a non-default mirror") + ":\n{2}\n\n" + _("Would you like to switch back to the default?")).format(response.code, response.phrase, common.settings['mirror']))
+                    elif common.language != 'en-US' and not common.settings['force_en-US']:
+                        raise TryForcingEnglishException((_("Download Error:") + " {0} {1}\n\n" + _("Would you like to try the English version of Tor Browser instead?")).format(response.code, response.phrase))
                     else:
-                        raise DownloadErrorException(_("Download Error: {0} {1}").format(response.code, response.phrase))
+                        raise DownloadErrorException((_("Download Error:") + " {0} {1}").format(response.code, response.phrase))
 
             def dataReceived(self, bytes):
                 self.file.write(bytes)
@@ -412,7 +350,7 @@ class Launcher:
             ## FIXME handle errors
 
     def download_error(self, f):
-        print _("Download error:"), f.value, type(f.value)
+        print _("Download Error:"), f.value, type(f.value)
 
         if isinstance(f.value, TryStableException):
             f.trap(TryStableException)
@@ -422,6 +360,10 @@ class Launcher:
             f.trap(TryDefaultMirrorException)
             self.set_gui('error_try_default_mirror', str(f.value), [], False)
 
+        elif isinstance(f.value, TryForcingEnglishException):
+            f.trap(TryForcingEnglishException)
+            self.set_gui('error_try_forcing_english', str(f.value), [], False)
+
         elif isinstance(f.value, DownloadErrorException):
             f.trap(DownloadErrorException)
             self.set_gui('error', str(f.value), [], False)
@@ -429,7 +371,7 @@ class Launcher:
         elif isinstance(f.value, DNSLookupError):
             f.trap(DNSLookupError)
             if common.settings['mirror'] != common.default_mirror:
-                self.set_gui('error_try_default_mirror', _("DNS Lookup Error\n\nYou are currently using a non-default mirror:\n{0}\n\nWould you like to switch back to the default?").format(common.settings['mirror']), [], False)
+                self.set_gui('error_try_default_mirror', (_("DNS Lookup Error") + "\n\n" + _("You are currently using a non-default mirror") + ":\n{0}\n\n" + _("Would you like to switch back to the default?")).format(common.settings['mirror']), [], False)
             else:
                 self.set_gui('error', str(f.value), [], False)
 
@@ -437,11 +379,18 @@ class Launcher:
             for reason in f.value.reasons:
                 if isinstance(reason.value, OpenSSL.SSL.Error):
                     # TODO: add the ability to report attack by posting bug to trac.torproject.org
-                    if not self.common.settings['update_over_tor']:
-                        self.set_gui('error_try_tor', _('The SSL certificate served by https://www.torproject.org is invalid! You may be under attack. Try the download again using Tor?'), [], False)
+                    if not self.common.settings['download_over_tor']:
+                        self.set_gui('error_try_tor', _('The SSL certificate served by https://www.torproject.org is invalid! You may be under attack.') + " " + _('Try the download again using Tor?'), [], False)
                     else:
                         self.set_gui('error', _('The SSL certificate served by https://www.torproject.org is invalid! You may be under attack.'), [], False)
 
+        elif isinstance(f.value, ConnectionRefusedError) and self.common.settings['download_over_tor']:
+            # If we're using Tor, we'll only get this error when we fail to
+            # connect to the SOCKS server.  If the connection fails at the
+            # remote end, we'll get txsocksx.errors.ConnectionRefused.
+            addr = self.common.settings['tor_socks_address']
+            self.set_gui('error', _("Error connecting to Tor at {0}").format(addr), [], False)
+
         else:
             self.set_gui('error', _("Error starting download:\n\n{0}\n\nAre you connected to the internet?").format(f.value), [], False)
 
@@ -452,29 +401,28 @@ class Launcher:
         self.current_download_path = path
         self.current_download_url = url
 
-        # initialize the progress bar
         mirror_url = url.format(self.common.settings['mirror'])
+
+        # convert mirror_url from unicode to string, if needed (#205)
+        if isinstance(mirror_url, unicode):
+            mirror_url = unicodedata.normalize('NFKD', mirror_url).encode('ascii','ignore')
+
+        # initialize the progress bar
         self.progressbar.set_fraction(0)
-        self.progressbar.set_text(_('Downloading {0}').format(name))
+        self.progressbar.set_text(_('Downloading') + ' {0}'.format(name))
         self.progressbar.show()
         self.refresh_gtk()
 
-        if self.common.settings['update_over_tor']:
-            from twisted.internet.endpoints import TCP4ClientEndpoint
+        if self.common.settings['download_over_tor']:
+            from twisted.internet.endpoints import clientFromString
             from txsocksx.http import SOCKS5Agent
 
-            torEndpoint = TCP4ClientEndpoint(reactor, '127.0.0.1', 9050)
+            torEndpoint = clientFromString(reactor, self.common.settings['tor_socks_address'])
 
             # default mirror gets certificate pinning, only for requests that use the mirror
-            if self.common.settings['mirror'] == self.common.default_mirror and '{0}' in url:
-                agent = SOCKS5Agent(reactor, TorProjectPolicyForHTTPS(self.common.paths['torproject_pem']), proxyEndpoint=torEndpoint)
-            else:
-                agent = SOCKS5Agent(reactor, proxyEndpoint=torEndpoint)
+            agent = SOCKS5Agent(reactor, proxyEndpoint=torEndpoint)
         else:
-            if self.common.settings['mirror'] == self.common.default_mirror and '{0}' in url:
-                agent = Agent(reactor, TorProjectPolicyForHTTPS(self.common.paths['torproject_pem']))
-            else:
-                agent = Agent(reactor)
+            agent = Agent(reactor)
 
         # actually, agent needs to follow redirect
         agent = RedirectAgent(agent)
@@ -497,45 +445,33 @@ class Launcher:
         subprocess.Popen([self.common.paths['tbl_bin']])
         self.destroy(False)
 
+    def try_forcing_english(self, widget, data=None):
+        # change force english to true and relaunch TBL
+        self.common.settings['force_en-US'] = True
+        self.common.save_settings()
+        subprocess.Popen([self.common.paths['tbl_bin']])
+        self.destroy(False)
+
     def try_tor(self, widget, data=None):
-        # set update_over_tor to true and relaunch TBL
-        self.common.settings['update_over_tor'] = True
+        # set download_over_tor to true and relaunch TBL
+        self.common.settings['download_over_tor'] = True
         self.common.save_settings()
         subprocess.Popen([self.common.paths['tbl_bin']])
         self.destroy(False)
 
     def get_stable_version(self):
-        tree = ET.parse(self.common.paths['update_check_file'])
+        tree = ET.parse(self.common.paths['version_check_file'])
         for up in tree.getroot():
             if up.tag == 'update' and up.attrib['appVersion']:
-                return up.attrib['appVersion']
-        return None
-
-    def attempt_update(self):
-        # load the update check file
-        try:
-            latest = self.get_stable_version()
-            if latest:
-                latest = str(latest)
+                version = str(up.attrib['appVersion'])
 
-                self.common.settings['latest_version'] = latest
-                self.common.settings['last_update_check_timestamp'] = int(time.time())
-                self.common.settings['check_for_updates'] = False
-                self.common.save_settings()
-                self.common.build_paths(self.common.settings['latest_version'])
-                self.start_launcher()
+                # make sure the version does not contain directory traversal attempts
+                # e.g. "5.5.3", "6.0a", "6.0a-hardned" are valid but "../../../../.." is invalid
+                if not re.match(r'^[a-z0-9\.\-]+$', version):
+                    return None
 
-            else:
-                # failed to find the latest version
-                self.set_gui('error', _("Error checking for updates."), [], False)
-
-        except:
-            # not a valid JSON object
-            self.set_gui('error', _("Error checking for updates."), [], False)
-
-        # now start over
-        self.clear_ui()
-        self.build_ui()
+                return version
+        return None
 
     def verify(self):
         # initialize the progress bar
@@ -543,17 +479,13 @@ class Launcher:
         self.progressbar.set_text(_('Verifying Signature'))
         self.progressbar.show()
 
+        # verify the PGP signature
         verified = False
-        # check the sha256 file's sig, and also take the sha256 of the tarball and compare
         FNULL = open(os.devnull, 'w')
-        p = subprocess.Popen(['/usr/bin/gpg', '--homedir', self.common.paths['gnupg_homedir'], '--verify', self.common.paths['sha256_sig_file']], stdout=FNULL, stderr=subprocess.STDOUT)
+        p = subprocess.Popen(['/usr/bin/gpg', '--homedir', self.common.paths['gnupg_homedir'], '--verify', self.common.paths['sig_file'], self.common.paths['tarball_file']], stdout=FNULL, stderr=subprocess.STDOUT)
         self.pulse_until_process_exits(p)
         if p.returncode == 0:
-            # compare with sha256 of the tarball
-            tarball_sha256 = hashlib.sha256(open(self.common.paths['tarball_file'], 'r').read()).hexdigest()
-            for line in open(self.common.paths['sha256_file'], 'r').readlines():
-                if tarball_sha256.lower() in line.lower() and self.common.paths['tarball_filename'] in line:
-                    verified = True
+            verified = True
 
         if verified:
             self.run_task()
@@ -596,13 +528,33 @@ class Launcher:
             self.build_ui()
             return
 
-        # installation is finished, so save installed_version
-        self.common.settings['installed_version'] = self.common.settings['latest_version']
-        self.common.save_settings()
-
         self.run_task()
 
+    def check_min_version(self):
+        installed_version = None
+        for line in open(self.common.paths['tbb']['versions']).readlines():
+            if line.startswith('TORBROWSER_VERSION='):
+                installed_version = line.split('=')[1].strip()
+                break
+
+        if self.min_version <= installed_version:
+            return True
+
+        return False
+
     def run(self, run_next_task=True):
+        # don't run if it isn't at least the minimum version
+        if not self.check_min_version():
+            message =  _("The version of Tor Browser you have installed is earlier than it should be, which could be a sign of an attack!")
+            print message
+
+            md = gtk.MessageDialog(None, gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, gtk.BUTTONS_CLOSE, _(message))
+            md.set_position(gtk.WIN_POS_CENTER)
+            md.run()
+            md.destroy()
+
+            return
+
         # play modem sound?
         if self.common.settings['modem_sound']:
             def play_modem_sound():
@@ -642,6 +594,7 @@ class Launcher:
 
     # start over and download TBB again
     def start_over(self):
+        self.force_redownload = True # Overwrite any existing file
         self.label.set_text(_("Downloading Tor Browser Bundle over again."))
         self.gui_tasks = ['download_tarball', 'verify', 'extract', 'run']
         self.gui_task_i = 0