From 5766ed1785fb80b1d6bff9e754cc234aad4e2e20 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Wed, 9 Sep 2020 18:04:19 +0200 Subject: [PATCH] Add encrypt subcommand zuul-client can be used to encrypt a secret that can be then used in a project's jobs. Also improve the documentation section about using a configuration file. Change-Id: I10e56883f0b24ac429051af36f9bf58c7594c0ed --- doc/source/commands.rst | 9 ++ doc/source/configuration.rst | 10 +- doc/source/examples/.zuul.conf | 15 +-- .../encrypt_subcommand-3390ccc243039bc2.yaml | 5 + tests/unit/__init__.py | 4 +- tests/unit/test_api.py | 23 +++++ tests/unit/test_cmd.py | 57 +++++++++++ zuulclient/api/__init__.py | 8 ++ zuulclient/cmd/__init__.py | 96 ++++++++++++++++++- zuulclient/utils/__init__.py | 65 +++++++++++++ 10 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/encrypt_subcommand-3390ccc243039bc2.yaml diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 8f48ecc..d880b8f 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -67,6 +67,15 @@ Examples:: zuul-client dequeue --tenant openstack --pipeline check --project example_project --change 5,1 zuul-client dequeue --tenant openstack --pipeline periodic --project example_project --ref refs/heads/master +Encrypt +^^^^^^^ +.. program-output:: zuul-client encrypt --help + +Examples:: + + zuul-client encrypt --tenant openstack --project config --infile .pypirc --outfile encrypted.yaml --secret-name pypi_creds --field-name pypirc + cat .pypirc | zuul-client encrypt --tenant openstack --project config + Enqueue ^^^^^^^ diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 9effc51..be43a96 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -6,7 +6,15 @@ Configuration The web client will look by default for a ``.zuul.conf`` file for its configuration. The file should consist of a ``[webclient]`` section with at least the ``url`` attribute set. The optional ``verify_ssl`` can be set to False to -disable SSL verifications when connecting to Zuul (defaults to True). +disable SSL verifications when connecting to Zuul (defaults to True). An +authentication token can also be stored in the configuration file under the attribute +``auth_token`` to avoid passing the token in the clear on the command line. + +Here is an example of a ``.zuul.conf`` file that can be used with zuul-client: + +.. literalinclude:: /examples/.zuul.conf + :language: ini + It is also possible to run the web client without a configuration file, by using the ``--zuul-url`` option to specify the base URL of the Zuul web server. diff --git a/doc/source/examples/.zuul.conf b/doc/source/examples/.zuul.conf index cc95625..31b1994 100644 --- a/doc/source/examples/.zuul.conf +++ b/doc/source/examples/.zuul.conf @@ -1,7 +1,10 @@ -[opendev] -url=https://zuul.opendev.org -verify_ssl=True +# Example of a zuul-client configuration file. +# Several sections can be created for different Zuul instances or settings. +# The "example" section below can be used when calling zuul-client like so: +# +# zuul-client --use-conf example ... -[softwarefactory] -url=https://softwarefactory-project.io/zuul/ -verify_ssl=True +[example] +url=https://example.com/zuul/ +# verify_ssl=False +auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 diff --git a/releasenotes/notes/encrypt_subcommand-3390ccc243039bc2.yaml b/releasenotes/notes/encrypt_subcommand-3390ccc243039bc2.yaml new file mode 100644 index 0000000..39c7d3b --- /dev/null +++ b/releasenotes/notes/encrypt_subcommand-3390ccc243039bc2.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add the **encrypt** subcommand to zuul-client, allowing users to encrypt files + or text to be used with a job for a specific project. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 45f27d7..8bc75df 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -25,8 +25,10 @@ class BaseTestCase(testtools.TestCase): class FakeRequestResponse(object): - def __init__(self, status_code=None, json=None, exception_msg=None): + def __init__(self, status_code=None, json=None, text=None, + exception_msg=None): self._json = json + self.text = text self.status_code = status_code self.exception_msg = exception_msg or 'Error' diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index d37e1a8..35161f2 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -282,3 +282,26 @@ class TestApi(BaseTestCase): 'pipeline': 'check'} ) self.assertEqual(True, prom) + + def test_get_key(self): + """Test getting a project's public key""" + pubkey = """ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAqiwMzHBCMu8Nsz6LH5Rr +E0hUJvuHhEfGF2S+1Y7ux7MtrE7zFsKK3JYZLbJuuQ62w5UsDtjRCQ8A4RhDVItZ +lPzEIvrz3SVnOX61cAkc3FOZq3GG+vXZHzbyZUgQV6eh7cvvxKACaI10WLNTKvD2 +0Hb8comVtrFFG333x+9MxGQIKhoaBFGDcBnTsWlSVFxyWFxkvmlmFfglR2IV7c5O +YAKWItpRYDCfZMvptwsDm8fRnafW7ADvMsFhKgSkQX0YnXBwVDIjywYMiaz9zzo6 +zOfxhwe8fGWxUtaQObpnJ7uAiXrFBEefXdTR+5Zh5j0mR1MB0W0VupK7ezVOQ6LW +JNKtggslhDR/iPUbRaMMILWUJtLAin4I6ZOP05wNrau0zoYp5iW3hY4AV4+i+oYL +Rcl2SNzPYnZXMTvfsZV1f4J6fu1vLivRS6ynYWjYZWucK0C2NpD0NTMfP5jcUU3K +uM10zi/xzsPZ42xkVQFv0OfznwJVBDVMovQFOCBVKFP52wT44mmcMcTZQjyMBJLR +psLogzoSlPF9MfewbYwStYcA1HroexMPifQ7unvdzdb0S9y/RiN2WJgt8meXGrWU +JHyRBXb/ZW7Hy5CEMEkPY8+DcwvyNfN6cdTni8htcDZA/N1hzhaslKoUYcdCS8dH +GuS6/ewjS+arA1Iyeg/IxmECAwEAAQ== +-----END PUBLIC KEY-----""" + req = FakeRequestResponse(200, text=pubkey) + client = ZuulRESTClient(url='https://fake.zuul/') + client.session.get = MagicMock(return_value=req) + key = client.get_key('tenant1', 'project1') + self.assertEqual(pubkey, key) diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 766de06..de3a5c0 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -13,6 +13,8 @@ # under the License. +import os +import tempfile from tests.unit import BaseTestCase from tests.unit import FakeRequestResponse @@ -21,6 +23,19 @@ from unittest.mock import MagicMock, patch from zuulclient.cmd import ZuulClient +chunks = [ + 'V+Q+8Gq7u7YFq6mbmM+vM/4Z7xCx+qy3YHilYYSN6apJeqSjU2xyJVuNYI680kwBEFFXt' + 'QmEqDlVIOG3yYTHgGbDq9gemMj2lMTzoTyftaE8yfK2uGZqWGwplh8PcGR67IhdH2UdDh' + '8xD5ehKwX9j/ZBoSJ0LQCy4KBvpB6sccc8wywGvNaJZxte8StLHgBxUFFxmO96deNkhUS' + '7xcpT+aU86uXYspJXmVrGOpy1/5QahIdi171rReRUToTO850M7JYuqcNrDm5rNiCdtwwT' + 'BnEJbdXa6ZMvyD9UB4roXi8VIWp3laueh8qoE2INiZtxjOrVIJkhm2HASqZ13ROyycv1z' + '96Cr7UxH+LjrCm/yNfRMJpk00LZMwUOGUCueqH244e96UX5j6t+S/atkO+wVpG+9KDLhA' + 'BQ7pyiW/CDqK9Z1ZpQPlnFM5PX4Mu7LemYXjFH+7eSxp+N/T5V0MrVt41MPv0h6al9vAM' + 'sVJIQYeBNagYpjFSkEkMsJMXNAINJbfoT6vD4AS7pnCqiTaMgDC/6RQPwP9fklF+dJWq/' + 'Au3QSQb7YIrjKiz2A75xQLxWoz9T+Lz4qZkF00zh5nMPUrzJQRPaBwxH5I0wZG0bYi9AJ' + '1tlAuq+vIhlY3iYlzVtPTiIOtF/6V+qPHnq1k6Tiv8YzJms1WyOuw106Bzl9XM='] + + class TestCmd(BaseTestCase): def test_client_args_errors(self): @@ -303,3 +318,45 @@ class TestCmd(BaseTestCase): 'pipeline': 'gate'} ) self.assertEqual(0, exit_code) + + def test_encrypt(self): + """Test encrypting a secret via CLI""" + infile = tempfile.NamedTemporaryFile(delete=False) + infile.write(b'my little secret') + infile.close() + outfile = tempfile.NamedTemporaryFile(delete=False) + outfile.close() + ZC = ZuulClient() + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.get = MagicMock( + return_value=FakeRequestResponse(200, text='aaa')) + with patch('zuulclient.cmd.encrypt_with_openssl') as m_encrypt: + m_encrypt.return_value = chunks + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', '-v', + 'encrypt', '--tenant', 'tenant1', '--project', 'project1', + '--infile', infile.name, '--outfile', outfile.name]) + self.assertEqual(0, exit_code) + session.get.assert_called() + m_encrypt.assert_called() + secret = ''' +- secret: + name: + data: + : !encrypted/pkcs1-oaep + - V+Q+8Gq7u7YFq6mbmM+vM/4Z7xCx+qy3YHilYYSN6apJeqSjU2xyJVuNYI680kwBEFFXt + QmEqDlVIOG3yYTHgGbDq9gemMj2lMTzoTyftaE8yfK2uGZqWGwplh8PcGR67IhdH2UdDh + 8xD5ehKwX9j/ZBoSJ0LQCy4KBvpB6sccc8wywGvNaJZxte8StLHgBxUFFxmO96deNkhUS + 7xcpT+aU86uXYspJXmVrGOpy1/5QahIdi171rReRUToTO850M7JYuqcNrDm5rNiCdtwwT + BnEJbdXa6ZMvyD9UB4roXi8VIWp3laueh8qoE2INiZtxjOrVIJkhm2HASqZ13ROyycv1z + 96Cr7UxH+LjrCm/yNfRMJpk00LZMwUOGUCueqH244e96UX5j6t+S/atkO+wVpG+9KDLhA + BQ7pyiW/CDqK9Z1ZpQPlnFM5PX4Mu7LemYXjFH+7eSxp+N/T5V0MrVt41MPv0h6al9vAM + sVJIQYeBNagYpjFSkEkMsJMXNAINJbfoT6vD4AS7pnCqiTaMgDC/6RQPwP9fklF+dJWq/ + Au3QSQb7YIrjKiz2A75xQLxWoz9T+Lz4qZkF00zh5nMPUrzJQRPaBwxH5I0wZG0bYi9AJ + 1tlAuq+vIhlY3iYlzVtPTiIOtF/6V+qPHnq1k6Tiv8YzJms1WyOuw106Bzl9XM= +''' + with open(outfile.name) as f: + self.assertEqual(secret, f.read()) + os.unlink(infile.name) + os.unlink(outfile.name) diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index c8f1f8e..8fa3cf8 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -152,3 +152,11 @@ class ZuulRESTClient(object): req = self.session.post(url, json=args) self._check_request_status(req) return req.json() + + def get_key(self, tenant, project): + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/key/%s.pub' % (tenant, project)) + req = self.session.get(url) + self._check_request_status(req) + return req.text diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 21f225d..6a4cc05 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -18,11 +18,13 @@ import logging import os import prettytable import sys +import tempfile import textwrap import time from zuulclient.api import ZuulRESTClient from zuulclient.utils import get_default +from zuulclient.utils import encrypt_with_openssl class ZuulClient(): @@ -84,7 +86,7 @@ class ZuulClient(): self.add_enqueue_ref_subparser(subparsers) self.add_dequeue_subparser(subparsers) self.add_promote_subparser(subparsers) - + self.add_encrypt_subparser(subparsers) return subparsers def parseArguments(self, args=None): @@ -414,6 +416,98 @@ class ZuulClient(): client = ZuulRESTClient(server, verify, auth_token) return client + def add_encrypt_subparser(self, subparsers): + cmd_encrypt = subparsers.add_parser( + 'encrypt', help='Encrypt a secret to be used in a project\'s jobs') + cmd_encrypt.add_argument('--tenant', help='tenant name', + required=True) + cmd_encrypt.add_argument('--project', help='project name', + required=True) + cmd_encrypt.add_argument('--no-strip', action='store_true', + help='Do not strip whitespace from beginning ' + 'or end of input.', + default=False) + cmd_encrypt.add_argument('--secret-name', + default=None, + help='How the secret should be named. If not ' + 'supplied, a placeholder will be used.') + cmd_encrypt.add_argument('--field-name', + default=None, + help='How the name of the secret variable. ' + 'If not supplied, a placeholder will be ' + 'used.') + cmd_encrypt.add_argument('--infile', + default=None, + help='A filename whose contents will be ' + 'encrypted. If not supplied, the value ' + 'will be read from standard input.\n' + 'If entering the secret manually, press ' + 'Ctrl+d when finished to process the ' + 'secret.') + cmd_encrypt.add_argument('--outfile', + default=None, + help='A filename to which the encrypted ' + 'value will be written. If not ' + 'supplied, the value will be written ' + 'to standard output.') + cmd_encrypt.set_defaults(func=self.encrypt) + + def encrypt(self): + if self.args.infile: + try: + with open(self.args.infile) as f: + plaintext = f.read() + except FileNotFoundError: + raise Exception('File "%s" not found' % self.args.infile) + except PermissionError: + raise Exception( + 'Insufficient rights to open %s' % self.args.infile) + else: + plaintext = sys.stdin.read() + if not self.args.no_strip: + plaintext = plaintext.strip() + pubkey_file = tempfile.NamedTemporaryFile(delete=False) + self.log.debug('Creating temporary key file %s' % pubkey_file.name) + client = self.get_client() + try: + key = client.get_key(self.args.tenant, self.args.project) + pubkey_file.write(str.encode(key)) + pubkey_file.close() + + self.log.debug('Calling openssl') + ciphertext_chunks = encrypt_with_openssl(pubkey_file.name, + plaintext, + self.log) + output = textwrap.dedent( + ''' + - secret: + name: {} + data: + {}: !encrypted/pkcs1-oaep + '''.format(self.args.secret_name or '', + self.args.field_name or '')) + + twrap = textwrap.TextWrapper(width=79, + initial_indent=' ' * 8, + subsequent_indent=' ' * 10) + for chunk in ciphertext_chunks: + chunk = twrap.fill('- ' + chunk) + output += chunk + '\n' + + if self.args.outfile: + with open(self.args.outfile, "w") as f: + f.write(output) + else: + print(output) + return_code = True + except Exception as e: + self.log.exception(e) + return_code = False + finally: + self.log.debug('Deleting temporary key file %s' % pubkey_file.name) + os.unlink(pubkey_file.name) + return return_code + def main(): ZuulClient().main() diff --git a/zuulclient/utils/__init__.py b/zuulclient/utils/__init__.py index 5505b9b..2893475 100644 --- a/zuulclient/utils/__init__.py +++ b/zuulclient/utils/__init__.py @@ -12,7 +12,11 @@ # License for the specific language governing permissions and limitations # under the License. +import base64 +import math import os +import re +import subprocess def get_default(config, section, option, default=None, expand_user=False): @@ -30,3 +34,64 @@ def get_default(config, section, option, default=None, expand_user=False): if expand_user and value: return os.path.expanduser(value) return value + + +def encrypt_with_openssl(pubkey_path, plaintext, logger=None): + cmd = ['openssl', 'version'] + if logger: + logger.debug('calling "%s"' % ' '.join(cmd)) + try: + openssl_version = subprocess.check_output( + cmd).split()[1] + except FileNotFoundError: + raise Exception('"openssl" is not installed on the system') + + cmd = ['openssl', 'rsa', '-text', '-pubin', '-in', pubkey_path] + if logger: + logger.debug('calling "%s"' % ' '.join(cmd)) + p = subprocess.Popen(cmd, + stdout=subprocess.PIPE) + (stdout, stderr) = p.communicate() + if p.returncode != 0: + raise Exception('openssl failure (Return code %s)' % p.returncode) + + output = stdout.decode('utf-8') + if openssl_version.startswith(b'0.'): + key_length_re = r'^Modulus \((?P\d+) bit\):$' + else: + key_length_re = r'^(|RSA )Public-Key: \((?P\d+) bit\)$' + m = re.match(key_length_re, output, re.MULTILINE) + nbits = int(m.group('key_length')) + nbytes = int(nbits / 8) + max_bytes = nbytes - 42 # PKCS1-OAEP overhead + chunks = int(math.ceil(float(len(plaintext)) / max_bytes)) + + ciphertext_chunks = [] + + if logger: + logger.info( + 'Public key length: {} bits ({} bytes)'.format(nbits, nbytes)) + logger.info( + 'Max plaintext length per chunk: {} bytes'.format(max_bytes)) + logger.info( + 'Input plaintext length: {} bytes'.format(len(plaintext))) + logger.info('Number of chunks: {}'.format(chunks)) + + cmd = ['openssl', 'rsautl', '-encrypt', + '-oaep', '-pubin', '-inkey', + pubkey_path] + if logger: + logger.debug('calling "%s" with each data chunk:' % ' '.join(cmd)) + for count in range(chunks): + chunk = plaintext[int(count * max_bytes): + int((count + 1) * max_bytes)] + p = subprocess.Popen(cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + if logger: + logger.debug('\tchunk %s' % (count + 1)) + (stdout, stderr) = p.communicate(str.encode(chunk)) + if p.returncode != 0: + raise Exception('openssl failure (Return code %s)' % p.returncode) + ciphertext_chunks.append(base64.b64encode(stdout).decode('utf-8')) + return ciphertext_chunks