diff --git a/tests/fixtures/config/single-tenant/main.yaml b/tests/fixtures/config/single-tenant/main.yaml index a22ed5c60c..d9868fad0f 100644 --- a/tests/fixtures/config/single-tenant/main.yaml +++ b/tests/fixtures/config/single-tenant/main.yaml @@ -4,3 +4,5 @@ gerrit: config-repos: - common-config + project-repos: + - org/project diff --git a/tests/fixtures/public.pem b/tests/fixtures/public.pem new file mode 100644 index 0000000000..33a78c457d --- /dev/null +++ b/tests/fixtures/public.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsGqZLUUwV/EZJKddMS20 +6mH7qYmqYhWLo/TUlpDt2JuEaBqCYV8mF9LsjpoqM/Pp0U/r5aQLDUXbRLDn+K+N +qbvTJajYxHJicP1CAWg1eKUNZjUaya5HP4Ow1hS7AeiF4TSRdiwtHT/gJO2NSsav +yc30/meKt0WBgbYlrBB81HEQjYWnajf/4so5E8DdrC9tAqmmzde1qcTz7ULouIz5 +3hjp/U3yVMFbpawv194jzHvddmAX3aEUByx2t6lP7dhOAEIEmzmh15hRbacxQI5a +YWv+ZR0z9PqdwwD+DBbb1AwiX5MJjtIoVCmkEZvcUFiDicyteNMCa5ulpj2SF0oH +4MlialOP6MiJnmxklDYO07AM/qomcU55pCD8ctu1yD/UydecLk0Uj/9XxqmPQJFE +cstdXJZQfr5ZNnChOEg6oQ9UImWjav8HQsA6mFW1oAKbDMrgEewooWriqGW5pYtR +7JBfph6Mt5HGaeH4uqYpb1fveHG1ODa7HBnlNo3qMendBb2wzHGCgtUgWnGfp24T +sUOUlndCXYhsYbOZbCTW5GwElK0Gri06KPpybY43AIaxcxqilVh5Eapmq7axBm4Z +zbTOfv15L0FIemEGgpnklevQbZNLIrcE0cS/13qJUvFaYX4yjrtEnzZ3ntjXrpFd +gLPBKn7Aqf6lWz6BPi07axECAwEAAQ== +-----END PUBLIC KEY----- diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py index acff09a9b2..8791a257eb 100644 --- a/tests/unit/test_webapp.py +++ b/tests/unit/test_webapp.py @@ -15,11 +15,12 @@ # License for the specific language governing permissions and limitations # under the License. +import os import json from six.moves import urllib -from tests.base import ZuulTestCase +from tests.base import ZuulTestCase, FIXTURE_DIR class TestWebapp(ZuulTestCase): @@ -85,3 +86,13 @@ class TestWebapp(ZuulTestCase): self.assertEqual(1, len(data), data) self.assertEqual("org/project1", data[0]['project'], data) + + def test_webapp_keys(self): + with open(os.path.join(FIXTURE_DIR, 'public.pem')) as f: + public_pem = f.read() + + req = urllib.request.Request( + "http://localhost:%s/tenant-one/keys/gerrit/org/project.pub" % + self.port) + f = urllib.request.urlopen(req) + self.assertEqual(f.read(), public_pem) diff --git a/tools/encrypt_secret.py b/tools/encrypt_secret.py new file mode 100644 index 0000000000..4865edd97b --- /dev/null +++ b/tools/encrypt_secret.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +# 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 argparse +import os +import subprocess +import sys +import tempfile +from six.moves import urllib + +DESCRIPTION = """Encrypt a secret for Zuul. + +This program fetches a project-specific public key from a Zuul server and +uses that to encrypt a secret. The only pre-requisite is an installed +OpenSSL binary. +""" + + +def main(): + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument('url', + help="The base URL of the zuul server and tenant. " + "E.g., https://zuul.example.com/tenant-name") + # TODO(jeblair,mordred): When projects have canonical names, use that here. + # TODO(jeblair): Throw a fit if SSL is not used. + parser.add_argument('source', + help="The Zuul source of the project.") + parser.add_argument('project', + help="The name of the project.") + parser.add_argument('--infile', + default=None, + help="A filename whose contents will be encrypted. " + "If not supplied, the value will be read from " + "standard input.") + parser.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.") + args = parser.parse_args() + + req = urllib.request.Request("%s/keys/%s/%s.pub" % ( + args.url, args.source, args.project)) + pubkey = urllib.request.urlopen(req) + + if args.infile: + with open(args.infile) as f: + plaintext = f.read() + else: + plaintext = sys.stdin.read() + + pubkey_file = tempfile.NamedTemporaryFile(delete=False) + try: + pubkey_file.write(pubkey.read()) + pubkey_file.close() + + p = subprocess.Popen(['openssl', 'rsautl', '-encrypt', + '-oaep', '-pubin', '-inkey', + pubkey_file.name], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + (stdout, stderr) = p.communicate(plaintext) + if p.returncode != 0: + raise Exception("Return code %s from openssl" % p.returncode) + ciphertext = stdout.encode('base64') + finally: + os.unlink(pubkey_file.name) + + if args.outfile: + with open(args.outfile, "w") as f: + f.write(ciphertext) + else: + print(ciphertext) + + +if __name__ == '__main__': + main() diff --git a/zuul/webapp.py b/zuul/webapp.py index e16f0b41cd..3d8f991a34 100644 --- a/zuul/webapp.py +++ b/zuul/webapp.py @@ -22,6 +22,7 @@ import time from paste import httpserver import webob from webob import dec +from cryptography.hazmat.primitives import serialization """Zuul main web app. @@ -34,6 +35,7 @@ The supported urls are: queue / pipeline structure of the system - /status.json (backwards compatibility): same as /status - /status/change/X,Y: return status just for gerrit change X,Y + - /keys/SOURCE/PROJECT.pub: return the public key for PROJECT When returning status for a single gerrit change you will get an array of changes, they will not include the queue structure. @@ -96,9 +98,34 @@ class WebApp(threading.Thread): return m.group(1) return None + def _handle_keys(self, request, path): + m = re.match('/keys/(.*?)/(.*?).pub', path) + if not m: + raise webob.exc.HTTPNotFound() + source_name = m.group(1) + project_name = m.group(2) + source = self.scheduler.connections.getSource(source_name) + if not source: + raise webob.exc.HTTPNotFound() + project = source.getProject(project_name) + 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 + ) + + response = webob.Response(body=pem_public_key, + content_type='text/plain') + return response.conditional_response_app + def app(self, request): tenant_name = request.path.split('/')[1] path = request.path.replace('/' + tenant_name, '') + if path.startswith('/keys'): + return self._handle_keys(request, path) path = self._normalize_path(path) if path is None: raise webob.exc.HTTPNotFound()