From bf1a4f21924a5dff5fbafc061702c009e1078dd8 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Fri, 17 Mar 2017 10:59:37 -0700 Subject: [PATCH] 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 --- bindep.txt | 1 + tests/encrypt_secret.py | 25 +----- tests/unit/test_encryption.py | 69 +++++++++++++++++ tests/unit/test_model.py | 10 +-- tests/unit/test_v3.py | 10 +-- zuul/configloader.py | 48 +++--------- zuul/lib/encryption.py | 138 ++++++++++++++++++++++++++++++++++ zuul/webapp.py | 10 +-- 8 files changed, 231 insertions(+), 80 deletions(-) create mode 100644 tests/unit/test_encryption.py create mode 100644 zuul/lib/encryption.py diff --git a/bindep.txt b/bindep.txt index b34b158864..6895444827 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 ab45018d51..ab2c1df6cd 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 @@ FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 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 0000000000..28ed76d249 --- /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 d2da426f1d..377193fd4b 100644 --- a/tests/unit/test_model.py +++ b/tests/unit/test_model.py @@ -19,11 +19,10 @@ import random 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 @@ class TestJob(BaseTestCase): 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 97816ea0f4..efd0b13447 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 @@ class TestProjectKeys(ZuulTestCase): 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 cbd7d40de7..73408c3581 100644 --- a/zuul/configloader.py +++ b/zuul/configloader.py @@ -21,15 +21,11 @@ import textwrap 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 @@ class EncryptedPKCS1(yaml.YAMLObject): 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 @@ class TenantParser(object): 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 @@ class TenantParser(object): '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 0000000000..76f07f9f26 --- /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 3d8f991a34..4f040fad9c 100644 --- a/zuul/webapp.py +++ b/zuul/webapp.py @@ -22,7 +22,8 @@ import time 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 @@ class WebApp(threading.Thread): 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')