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
This commit is contained in:
Matthieu Huin 2020-09-09 18:04:19 +02:00
parent ff9b60a809
commit 5766ed1785
10 changed files with 283 additions and 9 deletions

View File

@ -67,6 +67,15 @@ Examples::
zuul-client dequeue --tenant openstack --pipeline check --project example_project --change 5,1 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 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 Enqueue
^^^^^^^ ^^^^^^^

View File

@ -6,7 +6,15 @@ Configuration
The web client will look by default for a ``.zuul.conf`` file for its 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 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 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 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. ``--zuul-url`` option to specify the base URL of the Zuul web server.

View File

@ -1,7 +1,10 @@
[opendev] # Example of a zuul-client configuration file.
url=https://zuul.opendev.org # Several sections can be created for different Zuul instances or settings.
verify_ssl=True # The "example" section below can be used when calling zuul-client like so:
#
# zuul-client --use-conf example ...
[softwarefactory] [example]
url=https://softwarefactory-project.io/zuul/ url=https://example.com/zuul/
verify_ssl=True # verify_ssl=False
auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

View File

@ -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.

View File

@ -25,8 +25,10 @@ class BaseTestCase(testtools.TestCase):
class FakeRequestResponse(object): 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._json = json
self.text = text
self.status_code = status_code self.status_code = status_code
self.exception_msg = exception_msg or 'Error' self.exception_msg = exception_msg or 'Error'

View File

@ -282,3 +282,26 @@ class TestApi(BaseTestCase):
'pipeline': 'check'} 'pipeline': 'check'}
) )
self.assertEqual(True, prom) 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)

View File

@ -13,6 +13,8 @@
# under the License. # under the License.
import os
import tempfile
from tests.unit import BaseTestCase from tests.unit import BaseTestCase
from tests.unit import FakeRequestResponse from tests.unit import FakeRequestResponse
@ -21,6 +23,19 @@ from unittest.mock import MagicMock, patch
from zuulclient.cmd import ZuulClient 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): class TestCmd(BaseTestCase):
def test_client_args_errors(self): def test_client_args_errors(self):
@ -303,3 +318,45 @@ class TestCmd(BaseTestCase):
'pipeline': 'gate'} 'pipeline': 'gate'}
) )
self.assertEqual(0, exit_code) 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: <name>
data:
<fieldname>: !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)

View File

@ -152,3 +152,11 @@ class ZuulRESTClient(object):
req = self.session.post(url, json=args) req = self.session.post(url, json=args)
self._check_request_status(req) self._check_request_status(req)
return req.json() 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

View File

@ -18,11 +18,13 @@ import logging
import os import os
import prettytable import prettytable
import sys import sys
import tempfile
import textwrap import textwrap
import time import time
from zuulclient.api import ZuulRESTClient from zuulclient.api import ZuulRESTClient
from zuulclient.utils import get_default from zuulclient.utils import get_default
from zuulclient.utils import encrypt_with_openssl
class ZuulClient(): class ZuulClient():
@ -84,7 +86,7 @@ class ZuulClient():
self.add_enqueue_ref_subparser(subparsers) self.add_enqueue_ref_subparser(subparsers)
self.add_dequeue_subparser(subparsers) self.add_dequeue_subparser(subparsers)
self.add_promote_subparser(subparsers) self.add_promote_subparser(subparsers)
self.add_encrypt_subparser(subparsers)
return subparsers return subparsers
def parseArguments(self, args=None): def parseArguments(self, args=None):
@ -414,6 +416,98 @@ class ZuulClient():
client = ZuulRESTClient(server, verify, auth_token) client = ZuulRESTClient(server, verify, auth_token)
return client 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 '<name>',
self.args.field_name or '<fieldname>'))
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(): def main():
ZuulClient().main() ZuulClient().main()

View File

@ -12,7 +12,11 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import base64
import math
import os import os
import re
import subprocess
def get_default(config, section, option, default=None, expand_user=False): 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: if expand_user and value:
return os.path.expanduser(value) return os.path.expanduser(value)
return 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<key_length>\d+) bit\):$'
else:
key_length_re = r'^(|RSA )Public-Key: \((?P<key_length>\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