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:
parent
ff9b60a809
commit
5766ed1785
@ -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
|
||||
^^^^^^^
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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.
|
@ -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'
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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: <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)
|
||||
|
@ -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
|
||||
|
@ -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 '<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():
|
||||
ZuulClient().main()
|
||||
|
@ -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<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
|
||||
|
Loading…
Reference in New Issue
Block a user