irkerd: Initial SSL/TLS implementation
authorW. Trevor King <wking@tremily.us>
Fri, 7 Mar 2014 04:21:23 +0000 (20:21 -0800)
committerEric S. Raymond <esr@thyrsus.com>
Tue, 11 Mar 2014 04:55:44 +0000 (00:55 -0400)
This is pretty basic, just using as much of Python's ssl module as the
host Python implementation supports.  I also added error-level logging
of IRCServerConnectionError instances, to get helpful messages like:

  Invalid SSL/TLS certificate:
  hostname 'localhost' doesn't match 'irc.example.net'

and:

  Couldn't connect to socket: _ssl.c:334: No root certificates
  specified for verification of other-side certificates.

Important milestones in the standard library's ssl module:

* Python 2.5 [1,2]: No ssl module at all
* Python 2.6 [1,2]: ssl module added
* Python 3.2 [3,4]: ssl.SSLContext class added, with
  SSLContext.set_default_verify_paths [4].  ssl.match_hostname is also
  added [5], which can be used with the existing getpeercert [6] to
  ensure the server certificate belongs to the target host.

So for full verification, we need Python 3.2.  We can scrape by with
2.6 and later, by manually supplying a ca_certs path and ignoring
hostname mismatches.  That's more succeptible to man-in-the-middle
attacks, but still better than sending server, nick, and channel
passwords in plaintext.

[1]: http://docs.python.org/2/library/ssl.html
[2]: http://docs.python.org/2/whatsnew/2.6.html#improved-ssl-support
[3]: http://docs.python.org/3/whatsnew/3.2.html#ssl
[4]: http://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_default_verify_paths
[5]: http://docs.python.org/3/library/ssl.html#ssl.match_hostname
[6]: http://docs.python.org/2/library/ssl.html#ssl.SSLSocket.getpeercert

NEWS
irkerd
irkerd.xml

diff --git a/NEWS b/NEWS
index ceacfb137036035a7b15d1505d8c4b4eb44e4e74..18c6859d155112f8666b4d9ccb2b6541c44494ca 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -1,5 +1,8 @@
                        irker history 
 
+2.7 @ unreleased
+  Add support for ircs:// and SSL/TLS connections to IRC servers.
+
 2.6 @ 2014-02-04
    Fix for an infinite loop on failing to connect to IRC
 
diff --git a/irkerd b/irkerd
index 7f12c58b78318aa1dd0660d6184aa772e9cf133a..adcde8bad0ca5f52a1e87c52a2d00e18a74d5f22 100755 (executable)
--- a/irkerd
+++ b/irkerd
@@ -58,6 +58,7 @@ try:  # Python 3
     import socketserver
 except ImportError:  # Python 2
     import SocketServer as socketserver
+import ssl
 import sys
 import threading
 import time
@@ -230,8 +231,40 @@ class IRCServerConnection():
         self.master = master
         self.socket = None
 
+    def _wrap_socket(self, socket, target, cafile=None,
+                     protocol=ssl.PROTOCOL_TLSv1):
+        try:  # Python 3.2 and greater
+            ssl_context = ssl.SSLContext(protocol)
+        except AttributeError:  # Python < 3.2
+            self.socket = ssl.wrap_socket(
+                self.socket, cert_reqs=ssl.CERT_REQUIRED,
+                ssl_version=protocol, ca_certs=cafile)
+        else:
+            ssl_context.verify_mode = ssl.CERT_REQUIRED
+            if cafile:
+                ssl_context.load_verify_locations(cafile=cafile)
+            else:
+                ssl_context.set_default_verify_paths()
+            kwargs = {}
+            if ssl.HAS_SNI:
+                kwargs['server_hostname'] = target.servername
+            self.socket = ssl_context.wrap_socket(self.socket, **kwargs)
+        return self.socket
+
+    def _check_hostname(self, target):
+        if hasattr(ssl, 'match_hostname'):  # Python >= 3.2
+            cert = self.socket.getpeercert()
+            try:
+                ssl.match_hostname(cert, target.servername)
+            except ssl.CertificateError as e:
+                raise IRCServerConnectionError(
+                    'Invalid SSL/TLS certificate: %s' % e)
+        else:  # Python < 3.2
+            LOG.warning(
+                'cannot check SSL/TLS hostname with Python %s' % sys.version)
+
     def connect(self, target, nickname,
-                password=None, username=None, ircname=None):
+                password=None, username=None, ircname=None, **kwargs):
         LOG.debug("connect(server=%r, port=%r, nickname=%r, ...)" % (
             target.servername, target.port, nickname))
         if self.socket is not None:
@@ -244,11 +277,16 @@ class IRCServerConnection():
         self.nickname = nickname
         try:
             self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            if target.ssl:
+                self.socket = self._wrap_socket(
+                    socket=self.socket, target=target, **kwargs)
             self.socket.bind(('', 0))
             self.socket.connect((target.servername, target.port))
         except socket.error as err:
             raise IRCServerConnectionError("Couldn't connect to socket: %s" % err)
 
+        if target.ssl:
+            self._check_hostname(target=target)
         if password:
             self.ship("PASS " + password)
         self.nick(self.nickname)
@@ -537,7 +575,8 @@ class Connection:
                                 self.target, time.asctime()))
                             self.last_xmit = time.time()
                             self.last_ping = time.time()
-                        except IRCServerConnectionError:
+                        except IRCServerConnectionError as e:
+                            LOG.error(e)
                             self.status = "expired"
                             break
                 elif self.status == "handshaking":
@@ -631,9 +670,14 @@ class Target():
     def __init__(self, url):
         self.url = url
         parsed = urllib_parse.urlparse(url)
+        self.ssl = parsed.scheme == 'ircs'
+        if self.ssl:
+            default_ircport = 6697
+        else:
+            default_ircport = 6667
         irchost, _, ircport = parsed.netloc.partition(':')
         if not ircport:
-            ircport = 6667
+            ircport = default_ircport
         self.servername = irchost
         # IRC channel names are case-insensitive.  If we don't smash
         # case here we may run into problems later. There was a bug
@@ -894,6 +938,9 @@ class IrkerUDPHandler(socketserver.BaseRequestHandler):
 if __name__ == '__main__':
     parser = argparse.ArgumentParser(
         description=__doc__.strip().splitlines()[0])
+    parser.add_argument(
+        '-c', '--ca-file', metavar='PATH',
+        help='file of trusted certificates for SSL/TLS')
     parser.add_argument(
         '-d', '--log-level', metavar='LEVEL', choices=LOG_LEVELS,
         help='file of trusted certificates for SSL/TLS')
@@ -925,6 +972,7 @@ if __name__ == '__main__':
         nick_template=args.nick,
         nick_needs_number=re.search('%.*d', args.nick),
         password=args.password,
+        cafile=args.ca_file,
         )
     LOG.info("irkerd version %s" % version)
     if args.immediate:
index c2c37fb53d61a5021a74debfbb8b16e2ed0b773b..729c4f4178f4c272435b28bab657e348a519ba75 100644 (file)
@@ -18,6 +18,7 @@
 
 <cmdsynopsis>
   <command>irkerd</command>
+     <arg>-c <replaceable>ca-file</replaceable></arg>
      <arg>-d <replaceable>debuglevel</replaceable></arg>
      <arg>-l <replaceable>logfile</replaceable></arg>
      <arg>-n <replaceable>nick</replaceable></arg>
@@ -52,7 +53,7 @@ Examples:
 {"to":"irc://chat.freenode.net/git-ciabot", "privmsg":"Hello, world!"}
 {"to":["irc://chat.freenode.net/#git-ciabot","irc://chat.freenode.net/#gpsd"],"privmsg":"Multichannel test"}
 {"to":"irc://chat.hypothetical.net:6668/git-ciabot", "privmsg":"Hello, world!"}
-{"to":"irc://chat.hypothetical.net:6668/git-private?key=topsecret", "privmsg":"Keyed channel test"}
+{"to":"ircs://chat.hypothetical.net/git-private?key=topsecret", "privmsg":"Keyed channel test"}
 </programlisting></para>
 
 <para>If the channel part of the URL does not have one of the prefix
@@ -63,8 +64,24 @@ shipping - <emphasis>unless</emphasis>the channel part has the suffix
 
 <para>The host part of the URL may have a port-number suffix separated by a
 colon, as shown in the third example; otherwise
-<application>irkerd</application> sends messages to the the default 6667 IRC
-port of each server.</para>
+<application>irkerd</application> sends plaintext messages to the default
+6667 IRC port of each server, and SSL/TLS messages to 6697.</para>
+
+<para>When the <quote>to</quote> URL uses the <quote>ircs</quote>
+scheme (as shown in the fourth and fifth examples), the connection to
+the server is made via SSL/TLS (vs. a plaintext connection with the
+<quote>irc</quote> scheme).  To connect via SSL/TLS with Python 2.x,
+you need to explicitly declare the certificate authority file used to
+verify server certificates.  For example, <quote>-c
+/etc/ssl/certs/ca-certificates.crt</quote>.  In Python 3.2 and later,
+you can still set this option to declare a custom CA file, but
+<application>irkerd</application>; if you don't set it
+<application>irkerd</application> will use OpenSSL's default file
+(using Python's
+<quote>ssl.SSLContext.set_default_verify_paths</quote>).  In Python
+3.2 and later, <quote>ssl.match_hostname</quote> is used to ensure the
+server certificate belongs to the intended host, as well as being
+signed by a trusted CA.</para>
 
 <para>To join password-protected (mode +k) channels, the channel part of the
 URL may be followed with a query-string indicating the channel key, of the