vswitch: Add support for IPsec certificate authentication.
authorJustin Pettit <jpettit@nicira.com>
Thu, 16 Dec 2010 07:44:41 +0000 (23:44 -0800)
committerJustin Pettit <jpettit@nicira.com>
Wed, 29 Dec 2010 00:26:47 +0000 (16:26 -0800)
Previously, it was possible to fake configuring the use of certificate
authentication for IPsec, but it really just used a static pre-shared key
behind the scenes.  This commit publicly mentions certificate
authentication and finally does the real work behind the scenes.

debian/ovs-monitor-ipsec
lib/netdev-vport.c
vswitchd/vswitch.xml

index 27c15e8..00fcd3c 100755 (executable)
 
 # xxx To-do:
 #  - Doesn't actually check that Interface is connected to bridge
-#  - Doesn't support cert authentication
+#  - If a certificate is badly formed, Racoon will refuse to start.  We
+#    should do a better job of verifying certificates are valid before
+#    adding an interface to racoon.conf.
 
 
 import getopt
+import glob
 import logging, logging.handlers
 import os
-import stat
 import subprocess
 import sys
 
@@ -53,75 +55,199 @@ setkey = "/usr/sbin/setkey"
 class Racoon:
     # Default locations for files
     conf_file = "/etc/racoon/racoon.conf"
-    cert_file = "/etc/racoon/certs"
+    cert_dir = "/etc/racoon/certs"
     psk_file = "/etc/racoon/psk.txt"
 
-    # Default racoon configuration file we use for IKE
-    conf_template = """# Configuration file generated by Open vSwitch
+    # Racoon configuration header we use for IKE
+    conf_header = """# Configuration file generated by Open vSwitch
 #
 # Do not modify by hand!
 
-path pre_shared_key "/etc/racoon/psk.txt";
-path certificate "/etc/racoon/certs";
+path pre_shared_key "%s";
+path certificate "%s";
 
-remote anonymous {
+"""
+
+    # Racoon configuration footer we use for IKE
+    conf_footer = """sainfo anonymous {
+        pfs_group 2;
+        lifetime time 1 hour;
+        encryption_algorithm aes;
+        authentication_algorithm hmac_sha1, hmac_md5;
+        compression_algorithm deflate;
+}
+
+"""
+
+    # Certificate entry template.
+    cert_entry = """remote %s {
         exchange_mode main;
         nat_traversal on;
+        certificate_type x509 "%s" "%s";
+        my_identifier asn1dn;
+        peers_identifier asn1dn;
+        peers_certfile x509 "%s";
+        verify_identifier on;
         proposal {
                 encryption_algorithm aes;
                 hash_algorithm sha1;
-                authentication_method pre_shared_key;
+                authentication_method rsasig;
                 dh_group 2;
         }
 }
 
-sainfo anonymous {
-        pfs_group 2;
-        lifetime time 1 hour;
-        encryption_algorithm aes;
-        authentication_algorithm hmac_sha1, hmac_md5;
-        compression_algorithm deflate;
+"""
+
+    # Pre-shared key template.
+    psk_entry = """remote %s {
+        exchange_mode main;
+        nat_traversal on;
+        proposal {
+                encryption_algorithm aes;
+                hash_algorithm sha1;
+                authentication_method pre_shared_key;
+                dh_group 2;
+        }
 }
+
 """
 
     def __init__(self):
         self.psk_hosts = {}
         self.cert_hosts = {}
 
-        # Replace racoon's conf file with our template
-        f = open(Racoon.conf_file, "w")
-        f.write(Racoon.conf_template)
-        f.close()
+        # Clean out stale peer certs from previous runs
+        for ovs_cert in glob.glob("%s/ovs-*.pem" % self.cert_dir):
+            try:
+                os.remove(ovs_cert)
+            except OSError:
+                s_log.warning("couldn't remove %s" % ovs_cert)
 
-        # Clear out any pre-shared keys
-        self.commit_psk()
-
-        self.reload()
+        # Replace racoon's conf file with our template
+        self.commit()
 
     def reload(self):
         exitcode = subprocess.call(["/etc/init.d/racoon", "reload"])
         if exitcode != 0:
-            s_log.warning("couldn't reload racoon")
+            # Racoon is finicky about it's configuration file and will
+            # refuse to start if it sees something it doesn't like
+            # (e.g., a certificate file doesn't exist).  Try restarting
+            # the process before giving up.
+            s_log.warning("attempting to restart racoon")
+            exitcode = subprocess.call(["/etc/init.d/racoon", "restart"])
+            if exitcode != 0:
+                s_log.warning("couldn't reload racoon")
+
+    def commit(self):
+        # Rewrite the Racoon configuration file
+        conf_file = open(self.conf_file, 'w')
+        conf_file.write(Racoon.conf_header % (self.psk_file, self.cert_dir))
+
+        for host, vals in self.cert_hosts.iteritems():
+            conf_file.write(Racoon.cert_entry % (host, vals["certificate"],
+                    vals["private_key"], vals["peer_cert_file"]))
+
+        for host in self.psk_hosts:
+            conf_file.write(Racoon.psk_entry % host)
+
+        conf_file.write(Racoon.conf_footer)
+        conf_file.close()
+
+        # Rewrite the pre-shared keys file; it must only be readable by root.
+        orig_umask = os.umask(0077)
+        psk_file = open(Racoon.psk_file, 'w')
+        os.umask(orig_umask)
+
+        psk_file.write("# Generated by Open vSwitch...do not modify by hand!")
+        psk_file.write("\n\n")
+        for host, vals in self.psk_hosts.iteritems():
+            psk_file.write("%s   %s\n" % (host, vals["psk"]))
+        psk_file.close()
 
-    def commit_psk(self):
-        f = open(Racoon.psk_file, 'w')
-        # The file must only be accessible by root
-        os.chmod(Racoon.psk_file, stat.S_IRUSR | stat.S_IWUSR)
+        self.reload()
 
-        f.write("# Generated by Open vSwitch...do not modify by hand!\n\n")
-        for host, psk in self.psk_hosts.iteritems():
-            f.write("%s   %s\n" % (host, psk))
-        f.close()
+    def _add_psk(self, host, psk):
+        if host in self.cert_hosts:
+            raise error.Error("host %s already defined for cert" % host)
 
-    def add_psk(self, host, psk):
         self.psk_hosts[host] = psk
-        self.commit_psk()
-
-    def del_psk(self, host):
+        self.commit()
+
+    def _verify_certs(self, vals):
+        # Racoon will refuse to start if the certificate files don't
+        # exist, so verify that they're there.
+        if not os.path.isfile(vals["certificate"]):
+            raise error.Error("'certificate' file does not exist: %s"
+                    % vals["certificate"])
+        elif not os.path.isfile(vals["private_key"]):
+            raise error.Error("'private_key' file does not exist: %s"
+                    % vals["private_key"])
+
+        # Racoon won't start if a given certificate or private key isn't
+        # valid.  This is a weak test, but will detect the most flagrant
+        # errors.
+        if vals["peer_cert"].find("-----BEGIN CERTIFICATE-----") == -1:
+            raise error.Error("'peer_cert' is not in valid PEM format")
+
+        cert = open(vals["certificate"]).read()
+        if cert.find("-----BEGIN CERTIFICATE-----") == -1:
+            raise error.Error("'certificate' is not in valid PEM format")
+
+        cert = open(vals["private_key"]).read()
+        if cert.find("-----BEGIN RSA PRIVATE KEY-----") == -1:
+            raise error.Error("'private_key' is not in valid PEM format")
+            
+
+    def _add_cert(self, host, vals):
         if host in self.psk_hosts:
+            raise error.Error("host %s already defined for psk" % host)
+
+        if "certificate" not in vals:
+            raise error.Error("'certificate' not defined for %s" % host)
+        elif "private_key" not in vals:
+            # Assume the private key is stored in the same PEM file as 
+            # the certificate.  We make a copy of "vals" so that we don't
+            # modify the original "vals", which would cause the script
+            # to constantly think that the configuration has changed
+            # in the database.
+            vals = vals.copy()
+            vals["private_key"] = vals["certificate"]
+
+        self._verify_certs(vals)
+
+        # The peer's certificate comes to us in PEM format as a string.
+        # Write that string to a file for Racoon to use.
+        peer_cert_file = "%s/ovs-%s.pem" % (self.cert_dir, host)
+        f = open(peer_cert_file, "w")
+        f.write(vals["peer_cert"])
+        f.close()
+
+        vals["peer_cert_file"] = peer_cert_file
+
+        self.cert_hosts[host] = vals
+        self.commit()
+
+    def _del_cert(self, host):
+        peer_cert_file = self.cert_hosts[host]["peer_cert_file"]
+        del self.cert_hosts[host]
+        self.commit()
+        try:
+            os.remove(peer_cert_file)
+        except OSError:
+            pass
+
+    def add_entry(self, host, vals):
+        if vals["peer_cert"]:
+            self._add_cert(host, vals)
+        elif vals["psk"]:
+            self._add_psk(host, vals)
+
+    def del_entry(self, host):
+        if host in self.cert_hosts:
+            self._del_cert(host)
+        elif host in self.psk_hosts:
             del self.psk_hosts[host]
-            self.commit_psk()
+            self.commit()
 
 
 # Class to configure IPsec on a system using racoon for IKE and setkey
@@ -132,6 +258,7 @@ class IPsec:
         self.sad_flush()
         self.spd_flush()
         self.racoon = Racoon()
+        self.entries = []
 
     def call_setkey(self, cmds):
         try:
@@ -190,38 +317,35 @@ class IPsec:
         self.call_setkey("spdflush;")
 
     def spd_add(self, local_ip, remote_ip):
-        cmds = ("spdadd %s %s gre -P out ipsec esp/transport//default;" %
+        cmds = ("spdadd %s %s gre -P out ipsec esp/transport//default;\n" %
                     (local_ip, remote_ip))
-        cmds += "\n"
         cmds += ("spdadd %s %s gre -P in ipsec esp/transport//default;" %
                     (remote_ip, local_ip))
         self.call_setkey(cmds)
 
     def spd_del(self, local_ip, remote_ip):
-        cmds = "spddelete %s %s gre -P out;" % (local_ip, remote_ip)
-        cmds += "\n"
+        cmds = "spddelete %s %s gre -P out;\n" % (local_ip, remote_ip)
         cmds += "spddelete %s %s gre -P in;" % (remote_ip, local_ip)
         self.call_setkey(cmds)
 
-    def ipsec_cert_del(self, local_ip, remote_ip):
-        # Need to support cert...right now only PSK supported
-        self.racoon.del_psk(remote_ip)
-        self.spd_del(local_ip, remote_ip)
-        self.sad_del(local_ip, remote_ip)
+    def add_entry(self, local_ip, remote_ip, vals):
+        if remote_ip in self.entries:
+            raise error.Error("host %s already configured for ipsec"
+                              % remote_ip)
 
-    def ipsec_cert_update(self, local_ip, remote_ip, cert):
-        # Need to support cert...right now only PSK supported
-        self.racoon.add_psk(remote_ip, "abc12345")
+        self.racoon.add_entry(remote_ip, vals)
         self.spd_add(local_ip, remote_ip)
 
-    def ipsec_psk_del(self, local_ip, remote_ip):
-        self.racoon.del_psk(remote_ip)
-        self.spd_del(local_ip, remote_ip)
-        self.sad_del(local_ip, remote_ip)
+        self.entries.append(remote_ip)
 
-    def ipsec_psk_update(self, local_ip, remote_ip, psk):
-        self.racoon.add_psk(remote_ip, psk)
-        self.spd_add(local_ip, remote_ip)
+
+    def del_entry(self, local_ip, remote_ip):
+        if remote_ip in self.entries:
+            self.racoon.del_entry(remote_ip)
+            self.spd_del(local_ip, remote_ip)
+            self.sad_del(local_ip, remote_ip)
+
+            self.entries.remove(remote_ip)
 
 
 def keep_table_columns(schema, table_name, column_types):
@@ -266,6 +390,26 @@ def usage():
     print "  -h, --help               display this help message"
     sys.exit(0)
  
+def update_ipsec(ipsec, interfaces, new_interfaces):
+    for name, vals in interfaces.iteritems():
+        if name not in new_interfaces:
+            ipsec.del_entry(vals["local_ip"], vals["remote_ip"])
+
+    for name, vals in new_interfaces.iteritems():
+        orig_vals = interfaces.get(name)
+        if orig_vals:
+            # Configuration for this host already exists.  Check if it's
+            # changed.
+            if vals == orig_vals:
+                continue
+            else:
+                ipsec.del_entry(vals["local_ip"], vals["remote_ip"])
+
+        try:
+            ipsec.add_entry(vals["local_ip"], vals["remote_ip"], vals)
+        except error.Error, msg:
+            s_log.warning("skipping ipsec config for %s: %s" % (name, msg))
+
 def main(argv):
     try:
         options, args = getopt.gnu_getopt(
@@ -306,44 +450,30 @@ def main(argv):
  
         new_interfaces = {}
         for rec in idl.data["Interface"].itervalues():
-            name = rec.name.as_scalar()
-            ipsec_cert = rec.options.get("ipsec_cert")
-            ipsec_psk = rec.options.get("ipsec_psk")
-            is_ipsec = ipsec_cert or ipsec_psk
-
             if rec.type.as_scalar() == "ipsec_gre":
-                if ipsec_cert or ipsec_psk:
-                    new_interfaces[name] = {
-                        "remote_ip": rec.options.get("remote_ip"),
-                        "local_ip": rec.options.get("local_ip", "0.0.0.0/0"),
-                        "ipsec_cert": ipsec_cert,
-                        "ipsec_psk": ipsec_psk }
-                else:
-                    s_log.warning(
-                        "no ipsec_cert or ipsec_psk defined for %s" % name)
-        if interfaces != new_interfaces:
-            for name, vals in interfaces.items():
-                if name not in new_interfaces.keys():
-                    ipsec.ipsec_cert_del(vals["local_ip"], vals["remote_ip"])
-            for name, vals in new_interfaces.items():
-                orig_vals = interfaces.get(name):
-                if orig_vals:
-                    # Configuration for this host already exists.  If
-                    # it has changed, this is an error.
-                    if vals != orig_vals:
-                        s_log.warning(
-                            "configuration changed for %s, need to delete "
-                            "interface first" % name)
-                    continue
+                name = rec.name.as_scalar()
+                peer_cert = rec.options.get("peer_cert")
+                psk = rec.options.get("psk")
 
-                if vals["ipsec_cert"]:
-                    ipsec.ipsec_cert_update(vals["local_ip"],
-                            vals["remote_ip"], vals["ipsec_cert"])
-                else:
-                    ipsec.ipsec_psk_update(vals["local_ip"], 
-                            vals["remote_ip"], vals["ipsec_psk"])
+                if peer_cert and psk:
+                    s_log.warning("both 'peer_cert' and 'psk' defined for %s" 
+                            % name)
+                    continue
+                elif not peer_cert and not psk: 
+                    s_log.warning("no 'peer_cert' or 'psk' defined for %s" 
+                            % name)
+                    continue
 
+                new_interfaces[name] = {
+                    "remote_ip": rec.options.get("remote_ip"),
+                    "local_ip": rec.options.get("local_ip", "0.0.0.0/0"),
+                    "certificate": rec.options.get("certificate"),
+                    "private_key": rec.options.get("private_key"),
+                    "peer_cert": peer_cert,
+                    "psk": psk }
+        if interfaces != new_interfaces:
+            update_ipsec(ipsec, interfaces, new_interfaces)
             interfaces = new_interfaces
  
 if __name__ == '__main__':
index 681bc69..9ae21d1 100644 (file)
@@ -514,19 +514,37 @@ parse_tunnel_config(const struct netdev_dev *dev, const struct shash *args,
             if (!strcmp(node->data, "false")) {
                 config.flags &= ~TNL_F_HDR_CACHE;
             }
-        } else if ((!strcmp(node->name, "ipsec_cert")
-                   || !strcmp(node->name, "ipsec_psk")) && is_ipsec) {
+        } else if (!strcmp(node->name, "peer_cert") && is_ipsec) {
+            if (shash_find(args, "certificate")) {
+                ipsec_mech_set = true;
+            } else {
+                VLOG_WARN("%s: 'peer_cert' requires 'certificate' argument",
+                          name);
+                return EINVAL;
+            }
+        } else if (!strcmp(node->name, "psk") && is_ipsec) {
             ipsec_mech_set = true;
+        } else if (is_ipsec 
+                && (!strcmp(node->name, "certificate")
+                    || !strcmp(node->name, "private_key"))) {
+            /* Ignore options not used by the netdev. */
         } else {
             VLOG_WARN("%s: unknown %s argument '%s'",
                       name, type, node->name);
         }
     }
 
-    if (is_ipsec && !ipsec_mech_set) {
-        VLOG_WARN("%s: IPsec requires an 'ipsec_cert' or ipsec_psk' argument",
-                  name);
-        return EINVAL;
+    if (is_ipsec) {
+        if (shash_find(args, "peer_cert") && shash_find(args, "psk")) {
+            VLOG_WARN("%s: cannot define both 'peer_cert' and 'psk'", name);
+            return EINVAL;
+        }
+
+        if (!ipsec_mech_set) {
+            VLOG_WARN("%s: IPsec requires an 'peer_cert' or psk' argument",
+                      name);
+            return EINVAL;
+        }
     }
 
     if (!config.daddr) {
index 4cc29da..4aa4649 100644 (file)
             </dl>
           </dd>
           <dt><code>ipsec_gre</code></dt>
-          <dd>An Ethernet over RFC 2890 Generic Routing Encapsulation over
-             IPv4 IPsec tunnel.  Each tunnel (including those of type
-             <code>gre</code>) must be uniquely identified by the
-             combination of <code>remote_ip</code> and
-             <code>local_ip</code>.  Note that if two ports are defined
-             that are the same except one has an optional identifier and
-             the other does not, the more specific one is matched first.
-             The following options may be specified in the 
-             <ref column="options"/> column:
+          <dd>An Ethernet over RFC 2890 Generic Routing Encapsulation
+            over IPv4 IPsec tunnel.  Each tunnel (including those of type
+            <code>gre</code>) must be uniquely identified by the
+            combination of <code>remote_ip</code> and
+            <code>local_ip</code>.  Note that if two ports are defined
+            that are the same except one has an optional identifier and
+            the other does not, the more specific one is matched first.
+            An authentication method of <code>peer_cert</code> or
+            <code>psk</code> must be defined.  The following options may
+            be specified in the <ref column="options"/> column:
             <dl>
               <dt><code>remote_ip</code></dt>
               <dd>Required.  The tunnel endpoint.</dd>
                 match.  Default is to match all addresses.</dd>
             </dl>
             <dl>
-              <dt><code>ipsec_psk</code></dt>
-              <dd>Required.  Specifies a pre-shared key for authentication 
-                that must be identical on both sides of the tunnel.</dd>
+              <dt><code>peer_cert</code></dt>
+              <dd>Required for certificate authentication.  A string
+                containing the peer's certificate in PEM format.
+                Additionally the host's certificate must be specified
+                with the <code>certificate</code> option.</dd>
+            </dl>
+            <dl>
+              <dt><code>certificate</code></dt>
+              <dd>Required for certificate authentication.  The name of a
+                PEM file containing a certificate that will be presented
+                to the peer during authentication.</dd>
+            </dl>
+            <dl>
+              <dt><code>private_key</code></dt>
+              <dd>Optional for certificate authentication.  The name of
+                a PEM file containing the private key associated with
+                <code>certificate</code>.  If <code>certificate</code>
+                contains the private key, this option may be omitted.</dd>
+            </dl>
+            <dl>
+              <dt><code>psk</code></dt>
+              <dd>Required for pre-shared key authentication.  Specifies a
+                pre-shared key for authentication that must be identical on
+                both sides of the tunnel.</dd>
             </dl>
             <dl>
               <dt><code>in_key</code></dt>