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')