Isolate encryption-related methods

Create an interface to the cryptography library so that internally
Zuul uses simple facade methods.  Unit test that interface, and
that it is compatible with OpenSSL.

Change-Id: I57da1081c8d43b0b44af5967d075908459c91687
diff --git a/bindep.txt b/bindep.txt
index b34b158..6895444 100644
--- a/bindep.txt
+++ b/bindep.txt
@@ -4,6 +4,7 @@
 mysql-client [test]
 mysql-server [test]
 libjpeg-dev [test]
+openssl [test]
 zookeeperd [platform:dpkg]
 build-essential [platform:dpkg]
 gcc [platform:rpm]
diff --git a/tests/encrypt_secret.py b/tests/encrypt_secret.py
index ab45018..ab2c1df 100644
--- a/tests/encrypt_secret.py
+++ b/tests/encrypt_secret.py
@@ -15,10 +15,7 @@
 import sys
 import os
 
-from cryptography.hazmat.backends import default_backend
-from cryptography.hazmat.primitives.asymmetric import padding
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives import hashes
+from zuul.lib import encryption
 
 FIXTURE_DIR = os.path.join(os.path.dirname(__file__),
                            'fixtures')
@@ -27,24 +24,10 @@
 def main():
     private_key_file = os.path.join(FIXTURE_DIR, 'private.pem')
     with open(private_key_file, "rb") as f:
-        private_key = serialization.load_pem_private_key(
-            f.read(),
-            password=None,
-            backend=default_backend()
-        )
+        private_key, public_key = \
+            encryption.deserialize_rsa_keypair(f.read())
 
-    # Extract public key from private
-    public_key = private_key.public_key()
-
-    # https://cryptography.io/en/stable/hazmat/primitives/asymmetric/rsa/#encryption
-    ciphertext = public_key.encrypt(
-        sys.argv[1],
-        padding.OAEP(
-            mgf=padding.MGF1(algorithm=hashes.SHA1()),
-            algorithm=hashes.SHA1(),
-            label=None
-        )
-    )
+    ciphertext = encryption.encrypt_pkcs1(sys.argv[1], public_key)
     print(ciphertext.encode('base64'))
 
 if __name__ == '__main__':
diff --git a/tests/unit/test_encryption.py b/tests/unit/test_encryption.py
new file mode 100644
index 0000000..28ed76d
--- /dev/null
+++ b/tests/unit/test_encryption.py
@@ -0,0 +1,69 @@
+# Copyright 2017 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import os
+import subprocess
+import tempfile
+
+from zuul.lib import encryption
+
+from tests.base import BaseTestCase
+
+
+class TestEncryption(BaseTestCase):
+
+    def setUp(self):
+        super(TestEncryption, self).setUp()
+        self.private, self.public = encryption.generate_rsa_keypair()
+
+    def test_serialization(self):
+        "Verify key serialization"
+        pem_private = encryption.serialize_rsa_private_key(self.private)
+        private2, public2 = encryption.deserialize_rsa_keypair(pem_private)
+
+        # cryptography public / private key objects don't implement
+        # equality testing, so we make sure they have the same numbers.
+        self.assertEqual(self.private.private_numbers(),
+                         private2.private_numbers())
+        self.assertEqual(self.public.public_numbers(),
+                         public2.public_numbers())
+
+    def test_pkcs1(self):
+        "Verify encryption and decryption"
+        orig_plaintext = "some text to encrypt"
+        ciphertext = encryption.encrypt_pkcs1(orig_plaintext, self.public)
+        plaintext = encryption.decrypt_pkcs1(ciphertext, self.private)
+        self.assertEqual(orig_plaintext, plaintext)
+
+    def test_openssl_pkcs1(self):
+        "Verify that we can decrypt something encrypted with OpenSSL"
+        orig_plaintext = "some text to encrypt"
+        pem_public = encryption.serialize_rsa_public_key(self.public)
+        public_file = tempfile.NamedTemporaryFile(delete=False)
+        try:
+            public_file.write(pem_public)
+            public_file.close()
+
+            p = subprocess.Popen(['openssl', 'rsautl', '-encrypt',
+                                  '-oaep', '-pubin', '-inkey',
+                                  public_file.name],
+                                 stdin=subprocess.PIPE,
+                                 stdout=subprocess.PIPE)
+            (stdout, stderr) = p.communicate(orig_plaintext)
+            ciphertext = stdout
+        finally:
+            os.unlink(public_file.name)
+
+        plaintext = encryption.decrypt_pkcs1(ciphertext, self.private)
+        self.assertEqual(orig_plaintext, plaintext)
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index d2da426..377193f 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -19,11 +19,10 @@
 import fixtures
 import testtools
 import yaml
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.backends import default_backend
 
 from zuul import model
 from zuul import configloader
+from zuul.lib import encryption
 
 from tests.base import BaseTestCase, FIXTURE_DIR
 
@@ -35,11 +34,8 @@
         self.project = model.Project('project', None)
         private_key_file = os.path.join(FIXTURE_DIR, 'private.pem')
         with open(private_key_file, "rb") as f:
-            self.project.private_key = serialization.load_pem_private_key(
-                f.read(),
-                password=None,
-                backend=default_backend()
-            )
+            self.project.private_key, self.project.public_key = \
+                encryption.deserialize_rsa_keypair(f.read())
         self.context = model.SourceContext(self.project, 'master',
                                            'test', True)
         self.start_mark = yaml.Mark('name', 0, 0, 0, '', 0)
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 97816ea..efd0b13 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -17,11 +17,10 @@
 import os
 import textwrap
 
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.backends import default_backend
 import testtools
 
 import zuul.configloader
+from zuul.lib import encryption
 from tests.base import AnsibleZuulTestCase, ZuulTestCase, FIXTURE_DIR
 
 
@@ -328,11 +327,8 @@
         private_key_file = os.path.join(key_root, 'gerrit/org/project.pem')
         # Make sure that a proper key was created on startup
         with open(private_key_file, "rb") as f:
-            private_key = serialization.load_pem_private_key(
-                f.read(),
-                password=None,
-                backend=default_backend()
-            )
+            private_key, public_key = \
+                encryption.deserialize_rsa_keypair(f.read())
 
         with open(os.path.join(FIXTURE_DIR, 'private.pem')) as i:
             fixture_private_key = i.read()
diff --git a/zuul/configloader.py b/zuul/configloader.py
index cbd7d40..73408c3 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -21,15 +21,11 @@
 
 import voluptuous as vs
 
-from cryptography.hazmat.backends import default_backend
-from cryptography.hazmat.primitives.asymmetric import rsa
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import padding
-from cryptography.hazmat.primitives import hashes
 from zuul import model
 import zuul.manager.dependent
 import zuul.manager.independent
 from zuul import change_matcher
+from zuul.lib import encryption
 
 
 # Several forms accept either a single item or a list, this makes
@@ -147,16 +143,7 @@
         return cls(node.value)
 
     def decrypt(self, private_key):
-        # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#decryption
-        plaintext = private_key.decrypt(
-            self.ciphertext,
-            padding.OAEP(
-                mgf=padding.MGF1(algorithm=hashes.SHA1()),
-                algorithm=hashes.SHA1(),
-                label=None
-            )
-        )
-        return plaintext
+        return encryption.decrypt_pkcs1(self.ciphertext, private_key)
 
 
 class NodeSetParser(object):
@@ -793,26 +780,15 @@
         TenantParser.log.info(
             "Generating RSA keypair for project %s" % (project.name,)
         )
+        private_key, public_key = encryption.generate_rsa_keypair()
+        pem_private_key = encryption.serialize_rsa_private_key(private_key)
 
-        # Generate private RSA key
-        private_key = rsa.generate_private_key(
-            public_exponent=65537,
-            key_size=4096,
-            backend=default_backend()
-        )
-        # Serialize private key
-        pem_private_key = private_key.private_bytes(
-            encoding=serialization.Encoding.PEM,
-            format=serialization.PrivateFormat.TraditionalOpenSSL,
-            encryption_algorithm=serialization.NoEncryption()
-        )
-
+        # Dump keys to filesystem.  We only save the private key
+        # because the public key can be constructed from it.
         TenantParser.log.info(
             "Saving RSA keypair for project %s to %s" % (
                 project.name, project.private_key_file)
         )
-
-        # Dump keys to filesystem
         with open(project.private_key_file, 'wb') as f:
             f.write(pem_private_key)
 
@@ -824,16 +800,10 @@
                 'Private key file {0} not found'.format(
                     project.private_key_file))
 
-        # Load private key
+        # Load keypair
         with open(project.private_key_file, "rb") as f:
-            project.private_key = serialization.load_pem_private_key(
-                f.read(),
-                password=None,
-                backend=default_backend()
-            )
-
-        # Extract public key from private
-        project.public_key = project.private_key.public_key()
+            (project.private_key, project.public_key) = \
+                encryption.deserialize_rsa_keypair(f.read())
 
     @staticmethod
     def _loadTenantConfigRepos(project_key_dir, connections, conf_tenant):
diff --git a/zuul/lib/encryption.py b/zuul/lib/encryption.py
new file mode 100644
index 0000000..76f07f9
--- /dev/null
+++ b/zuul/lib/encryption.py
@@ -0,0 +1,138 @@
+# Copyright 2017 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.primitives import hashes
+
+
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#generation
+def generate_rsa_keypair():
+    """Generate an RSA keypair.
+
+    :returns: A tuple (private_key, public_key)
+
+    """
+    private_key = rsa.generate_private_key(
+        public_exponent=65537,
+        key_size=4096,
+        backend=default_backend()
+    )
+    public_key = private_key.public_key()
+    return (private_key, public_key)
+
+
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#key-serialization
+def serialize_rsa_private_key(private_key):
+    """Serialize an RSA private key
+
+    This returns a PEM-encoded serialized form of an RSA private key
+    suitable for storing on disk.  It is not password-protected.
+
+    :arg private_key: A private key object as returned by
+        :func:generate_rsa_keypair()
+
+    :returns: A PEM-encoded string representation of the private key.
+
+    """
+    return private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.TraditionalOpenSSL,
+        encryption_algorithm=serialization.NoEncryption()
+    )
+
+
+def serialize_rsa_public_key(public_key):
+    """Serialize an RSA public key
+
+    This returns a PEM-encoded serialized form of an RSA public key
+    suitable for distribution.
+
+    :arg public_key: A pubilc key object as returned by
+        :func:generate_rsa_keypair()
+
+    :returns: A PEM-encoded string representation of the public key.
+
+    """
+    return public_key.public_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PublicFormat.SubjectPublicKeyInfo
+    )
+
+
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#key-loading
+def deserialize_rsa_keypair(data):
+    """Deserialize an RSA private key
+
+    This deserializes an RSA private key and returns the keypair
+    (private and public) for use in decryption.
+
+    :arg data: A PEM-encoded serialized private key
+
+    :returns: A tuple (private_key, public_key)
+
+    """
+    private_key = serialization.load_pem_private_key(
+        data,
+        password=None,
+        backend=default_backend()
+    )
+    public_key = private_key.public_key()
+    return (private_key, public_key)
+
+
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#decryption
+def decrypt_pkcs1(ciphertext, private_key):
+    """Decrypt PKCS1 (RSAES-OAEP) encoded ciphertext
+
+    :arg ciphertext: A string previously encrypted with PKCS1
+        (RSAES-OAEP).
+    :arg private_key: A private key object as returned by
+        :func:generate_rsa_keypair()
+
+    :returns: The decrypted form of the ciphertext as a string.
+
+    """
+    return private_key.decrypt(
+        ciphertext,
+        padding.OAEP(
+            mgf=padding.MGF1(algorithm=hashes.SHA1()),
+            algorithm=hashes.SHA1(),
+            label=None
+        )
+    )
+
+
+# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#encryption
+def encrypt_pkcs1(plaintext, public_key):
+    """Encrypt data with PKCS1 (RSAES-OAEP)
+
+    :arg plaintext: A string to encrypt with PKCS1 (RSAES-OAEP).
+
+    :arg public_key: A public key object as returned by
+        :func:generate_rsa_keypair()
+
+    :returns: The encrypted form of the plaintext.
+
+    """
+    return public_key.encrypt(
+        plaintext,
+        padding.OAEP(
+            mgf=padding.MGF1(algorithm=hashes.SHA1()),
+            algorithm=hashes.SHA1(),
+            label=None
+        )
+    )
diff --git a/zuul/webapp.py b/zuul/webapp.py
index 3d8f991..4f040fa 100644
--- a/zuul/webapp.py
+++ b/zuul/webapp.py
@@ -22,7 +22,8 @@
 from paste import httpserver
 import webob
 from webob import dec
-from cryptography.hazmat.primitives import serialization
+
+from zuul.lib import encryption
 
 """Zuul main web app.
 
@@ -111,11 +112,8 @@
         if not project:
             raise webob.exc.HTTPNotFound()
 
-        # Serialize public key
-        pem_public_key = project.public_key.public_bytes(
-            encoding=serialization.Encoding.PEM,
-            format=serialization.PublicFormat.SubjectPublicKeyInfo
-        )
+        pem_public_key = encryption.serialize_rsa_public_key(
+            project.public_key)
 
         response = webob.Response(body=pem_public_key,
                                   content_type='text/plain')