Merge "Serve public keys through webapp" into feature/zuulv3
diff --git a/tests/fixtures/config/single-tenant/main.yaml b/tests/fixtures/config/single-tenant/main.yaml
index a22ed5c..d9868fa 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 0000000..33a78c4
--- /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 acff09a..8791a25 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 @@
 
         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 0000000..4865edd
--- /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 e16f0b4..3d8f991 100644
--- a/zuul/webapp.py
+++ b/zuul/webapp.py
@@ -22,6 +22,7 @@
 from paste import httpserver
 import webob
 from webob import dec
+from cryptography.hazmat.primitives import serialization
 
 """Zuul main web app.
 
@@ -34,6 +35,7 @@
    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 @@
             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()