From bcf27cb3c15b049ab5c8ff57800928c1c7301111 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Philipp=20H=C3=B6rist?= <forenjunkie@chello.at>
Date: Tue, 1 May 2018 20:32:36 +0200
Subject: [PATCH] Support for PKIX over Secure HTTP (POSH)

Fixes #9083
---
 gajim/common/config.py                     |   1 +
 gajim/common/connection.py                 | 144 +++++++++++++--------
 gajim/common/connection_handlers_events.py |   5 +-
 gajim/common/const.py                      |  36 ++++++
 gajim/gui_interface.py                     |  19 ++-
 5 files changed, 142 insertions(+), 63 deletions(-)

diff --git a/gajim/common/config.py b/gajim/common/config.py
index cb81531ec..5e3fbbf51 100644
--- a/gajim/common/config.py
+++ b/gajim/common/config.py
@@ -409,6 +409,7 @@ class Config:
                     'recent_groupchats': [ opt_str, '' ],
                     'httpupload_verify': [ opt_bool, True, _('HTTP Upload: Enable HTTPS Verification')],
                     'filetransfer_preference' : [ opt_str, 'httpupload', _('Preferred file transfer mechanism for file drag&drop on chat window. Can be \'httpupload\' (default) or \'jingle\'')],
+                    'allow_posh': [ opt_bool, True, _('Allow cert verification with POSH')],
             }, {}),
             'statusmsg': ({
                     'message': [ opt_str, '' ],
diff --git a/gajim/common/connection.py b/gajim/common/connection.py
index c6d88a7ad..ef3d14339 100644
--- a/gajim/common/connection.py
+++ b/gajim/common/connection.py
@@ -43,8 +43,11 @@ import hmac
 import hashlib
 import json
 import logging
+import base64
 from functools import partial
 from string import Template
+from urllib.request import urlopen
+from urllib.error import URLError
 
 try:
     randomsource = random.SystemRandom()
@@ -75,42 +78,6 @@ from gajim.gtkgui_helpers import get_action
 
 log = logging.getLogger('gajim.c.connection')
 
-ssl_error = {
-    2: _("Unable to get issuer certificate"),
-    3: _("Unable to get certificate CRL"),
-    4: _("Unable to decrypt certificate's signature"),
-    5: _("Unable to decrypt CRL's signature"),
-    6: _("Unable to decode issuer public key"),
-    7: _("Certificate signature failure"),
-    8: _("CRL signature failure"),
-    9: _("Certificate is not yet valid"),
-    10: _("Certificate has expired"),
-    11: _("CRL is not yet valid"),
-    12: _("CRL has expired"),
-    13: _("Format error in certificate's notBefore field"),
-    14: _("Format error in certificate's notAfter field"),
-    15: _("Format error in CRL's lastUpdate field"),
-    16: _("Format error in CRL's nextUpdate field"),
-    17: _("Out of memory"),
-    18: _("Self signed certificate"),
-    19: _("Self signed certificate in certificate chain"),
-    20: _("Unable to get local issuer certificate"),
-    21: _("Unable to verify the first certificate"),
-    22: _("Certificate chain too long"),
-    23: _("Certificate revoked"),
-    24: _("Invalid CA certificate"),
-    25: _("Path length constraint exceeded"),
-    26: _("Unsupported certificate purpose"),
-    27: _("Certificate not trusted"),
-    28: _("Certificate rejected"),
-    29: _("Subject issuer mismatch"),
-    30: _("Authority and subject key identifier mismatch"),
-    31: _("Authority and issuer serial number mismatch"),
-    32: _("Key usage does not include certificate signing"),
-    50: _("Application verification failure")
-    #100 is for internal usage: host not correct
-}
-
 SERVICE_START_TLS = 'xmpp-client'
 SERVICE_DIRECT_TLS = 'xmpps-client'
 
@@ -693,6 +660,13 @@ class Connection(CommonConnection, ConnectionHandlers):
         self.secret_hmac = str(random.random())[2:].encode('utf-8')
         self.removing_account = False
 
+        # We only request POSH once
+        self._posh_requested = False
+        # Fingerprints received via POSH
+        self._posh_hashes = []
+        # The SSL Errors that we can override with POSH
+        self._posh_errors = [18, 19]
+
         self.sm = Smacks(self) # Stream Management
 
         app.ged.register_event_handler('privacy-list-received', ged.CORE,
@@ -1374,25 +1348,85 @@ class Connection(CommonConnection, ConnectionHandlers):
 
         cert = self.connection.Connection.ssl_certificate
         errnum = self._ssl_errors.pop()
-        hostname = app.config.get_per('accounts', self.name, 'hostname')
-        text = _('The authenticity of the %s '
-                 'certificate could be invalid') % hostname
-        if errnum in ssl_error:
-            text += _('\nSSL Error: <b>%s</b>') % ssl_error[errnum]
-        else:
-            text += _('\nUnknown SSL error: %d') % errnum
-        fingerprint_sha1 = cert.digest('sha1').decode('utf-8')
-        fingerprint_sha256 = cert.digest('sha256').decode('utf-8')
-        pem = OpenSSL.crypto.dump_certificate(
-            OpenSSL.crypto.FILETYPE_PEM, cert).decode('utf-8')
-        app.nec.push_incoming_event(
-            SSLErrorEvent(None, conn=self,
-                          error_text=text,
-                          error_num=errnum,
-                          cert=pem,
-                          fingerprint_sha1=fingerprint_sha1,
-                          fingerprint_sha256=fingerprint_sha256,
-                          certificate=cert))
+
+        # Check if we can verify the cert with POSH
+        if errnum in self._posh_errors:
+            # Request the POSH json file
+            self._get_posh_file(self._hostname)
+            self._posh_requested = True
+            cert_hash256 = self._calculate_cert_sha256(cert)
+            print(cert_hash256)
+            if cert_hash256 in self._posh_hashes:
+                # Ignore this error if this cert is
+                # verifyed with POSH
+                self.process_ssl_errors()
+                return
+
+        app.nec.push_incoming_event(SSLErrorEvent(None, conn=self,
+                                                  error_num=errnum,
+                                                  cert=cert))
+
+    @staticmethod
+    def _calculate_cert_sha256(cert):
+        der_encoded = OpenSSL.crypto.dump_certificate(
+            OpenSSL.crypto.FILETYPE_ASN1, cert)
+        hash_obj = hashlib.sha256(der_encoded)
+        hash256 = base64.b64encode(hash_obj.digest()).decode('utf8')
+        return hash256
+
+    def _get_posh_file(self, hostname=None, redirect=None):
+        if self._posh_requested:
+            # We already have requested POSH
+            return
+
+        if not app.config.get_per('accounts', self.name, 'allow_posh'):
+            return
+
+        if hostname is None and redirect is None:
+            raise ValueError('There must be either a hostname or a url')
+
+        url = redirect
+        if hostname is not None:
+            url = 'https://%s/.well-known/posh/xmpp-client.json' % hostname
+
+        cafile = None
+        if os.name == 'nt':
+            cafile = certifi.where()
+
+        log.info('Request POSH from %s', url)
+        try:
+            file = urlopen(
+                url, cafile=cafile, timeout=2)
+        except URLError as exc:
+            log.info('Error while requesting POSH: %s' % exc)
+            return
+
+        if file.getcode() != 200:
+            log.info('No POSH file found at %s', url)
+            return
+
+        try:
+            posh = json.loads(file.read())
+        except json.decoder.JSONDecodeError as json_error:
+            log.warning(json_error)
+            return
+
+        # Redirect
+        if 'url' in posh and redirect is None:
+            # We dont allow redirects in redirects
+            log.info('POSH redirect found')
+            self._get_posh_file(redirect=posh['url'])
+            return
+
+        if 'fingerprints' in posh:
+            fingerprints = posh['fingerprints']
+            for fingerprint in fingerprints:
+                if 'sha-256' not in fingerprint:
+                    continue
+                self._posh_hashes.append(fingerprint['sha-256'])
+
+        log.info('POSH sha-256 fingerprints found: %s',
+                 self._posh_hashes)
 
     def ssl_certificate_accepted(self):
         if not self.connection:
diff --git a/gajim/common/connection_handlers_events.py b/gajim/common/connection_handlers_events.py
index 9d29357d1..c5f06f7ba 100644
--- a/gajim/common/connection_handlers_events.py
+++ b/gajim/common/connection_handlers_events.py
@@ -42,7 +42,7 @@ from gajim.common import i18n
 from gajim.common import dataforms
 from gajim.common import configpaths
 from gajim.common.zeroconf.zeroconf import Constant
-from gajim.common.const import KindConstant
+from gajim.common.const import KindConstant, SSLError
 from gajim.common.pep import SUPPORTED_PERSONAL_USER_EVENTS
 from gajim.common.jingle_transport import JingleTransportSocks5
 from gajim.common.file_props import FilesProp
@@ -1985,8 +1985,7 @@ class NewAccountConnectedEvent(nec.NetworkIncomingEvent):
             self.errnum = 0 # we don't have an errnum
         self.ssl_msg = ''
         if self.errnum > 0:
-            from gajim.common.connection import ssl_error
-            self.ssl_msg = ssl_error.get(self.errnum,
+            self.ssl_msg = SSLError.get(self.errnum,
                 _('Unknown SSL error: %d') % self.errnum)
         self.ssl_cert = ''
         self.ssl_fingerprint_sha1 = ''
diff --git a/gajim/common/const.py b/gajim/common/const.py
index 80aba8da4..4125ea1c8 100644
--- a/gajim/common/const.py
+++ b/gajim/common/const.py
@@ -107,6 +107,42 @@ class JIDConstant(IntEnum):
     ROOM_TYPE = 1
 
 
+SSLError = {
+    2: _("Unable to get issuer certificate"),
+    3: _("Unable to get certificate CRL"),
+    4: _("Unable to decrypt certificate's signature"),
+    5: _("Unable to decrypt CRL's signature"),
+    6: _("Unable to decode issuer public key"),
+    7: _("Certificate signature failure"),
+    8: _("CRL signature failure"),
+    9: _("Certificate is not yet valid"),
+    10: _("Certificate has expired"),
+    11: _("CRL is not yet valid"),
+    12: _("CRL has expired"),
+    13: _("Format error in certificate's notBefore field"),
+    14: _("Format error in certificate's notAfter field"),
+    15: _("Format error in CRL's lastUpdate field"),
+    16: _("Format error in CRL's nextUpdate field"),
+    17: _("Out of memory"),
+    18: _("Self signed certificate"),
+    19: _("Self signed certificate in certificate chain"),
+    20: _("Unable to get local issuer certificate"),
+    21: _("Unable to verify the first certificate"),
+    22: _("Certificate chain too long"),
+    23: _("Certificate revoked"),
+    24: _("Invalid CA certificate"),
+    25: _("Path length constraint exceeded"),
+    26: _("Unsupported certificate purpose"),
+    27: _("Certificate not trusted"),
+    28: _("Certificate rejected"),
+    29: _("Subject issuer mismatch"),
+    30: _("Authority and subject key identifier mismatch"),
+    31: _("Authority and issuer serial number mismatch"),
+    32: _("Key usage does not include certificate signing"),
+    50: _("Application verification failure"),
+    }
+
+
 THANKS = u"""\
 Alexander Futász
 Alexander V. Butenko
diff --git a/gajim/gui_interface.py b/gajim/gui_interface.py
index 0929ba714..6afc52988 100644
--- a/gajim/gui_interface.py
+++ b/gajim/gui_interface.py
@@ -98,7 +98,7 @@ from gajim.common.connection import Connection
 from gajim.common.file_props import FilesProp
 from gajim.common import pep
 from gajim import emoticons
-from gajim.common.const import AvatarSize
+from gajim.common.const import AvatarSize, SSLError
 
 from gajim import roster_window
 from gajim import profile_window
@@ -1343,7 +1343,6 @@ class Interface:
             obj.exchange_items_list, obj.fjid)
 
     def handle_event_ssl_error(self, obj):
-        # ('SSL_ERROR', account, (text, errnum, cert, sha1_fingerprint, sha256_fingerprint))
         account = obj.conn.name
         server = app.config.get_per('accounts', account, 'hostname')
 
@@ -1379,22 +1378,32 @@ class Interface:
             app.nec.push_incoming_event(OurShowEvent(None, conn=obj.conn,
                 show='offline'))
 
+        text = _('The authenticity of the %s '
+                 'certificate could be invalid') % server
+
+        default_text = _('\nUnknown SSL error: %d') % obj.error_num
+        ssl_error_text = SSLError.get(obj.error_num, default_text)
+        text += _('\nSSL Error: <b>%s</b>') % ssl_error_text
+
+        fingerprint_sha1 = obj.cert.digest('sha1').decode('utf-8')
+        fingerprint_sha256 = obj.cert.digest('sha256').decode('utf-8')
+
         pritext = _('Error verifying SSL certificate')
         sectext = _('There was an error verifying the SSL certificate of your '
             'XMPP server: %(error)s\nDo you still want to connect to this '
-            'server?') % {'error': obj.error_text}
+            'server?') % {'error': text}
         if obj.error_num in (18, 27):
             checktext1 = _('Add this certificate to the list of trusted '
             'certificates.\nSHA-1 fingerprint of the certificate:\n%(sha1)s'
             '\nSHA-256 fingerprint of the certificate:\n%(sha256)s') % \
-            {'sha1': obj.fingerprint_sha1, 'sha256': obj.fingerprint_sha256}
+            {'sha1': fingerprint_sha1, 'sha256': fingerprint_sha256}
         else:
             checktext1 = ''
         checktext2 = _('Ignore this error for this certificate.')
         if 'ssl_error' in self.instances[account]['online_dialog']:
             self.instances[account]['online_dialog']['ssl_error'].destroy()
         self.instances[account]['online_dialog']['ssl_error'] = \
-            dialogs.SSLErrorDialog(obj.conn.name, obj.certificate, pritext,
+            dialogs.SSLErrorDialog(obj.conn.name, obj.cert, pritext,
             sectext, checktext1, checktext2, on_response_ok=on_ok,
             on_response_cancel=on_cancel)
         self.instances[account]['online_dialog']['ssl_error'].set_title(