← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:gpg-inject-generated into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:gpg-inject-generated into launchpad:master.

Commit message:
Allow injecting new GPG keys into signing service

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/389992

This is guarded by the feature rule 'gpg.signing_service.injection.enabled'.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:gpg-inject-generated into launchpad:master.
diff --git a/lib/lp/services/gpg/doc/gpghandler.txt b/lib/lp/services/gpg/doc/gpghandler.txt
index 38fd104..d57bd75 100644
--- a/lib/lp/services/gpg/doc/gpghandler.txt
+++ b/lib/lp/services/gpg/doc/gpghandler.txt
@@ -173,93 +173,6 @@ Secret keys can be exported in ASCII-armored format.
     <BLANKLINE>
 
 
-== Generating new keys ==
-
-IGPGHandler support GPG key generation.
-
-We are not testing real key generation because it dependes on machine
-entropy that we may not have in PQM or EC2 (to not mention that it
-take minutes even when we have). So, the sample key in disk was
-generated by running the code below.
-
-We intentionally add some non-ascii charaters in order to check if key
-generation and presentation cope with them.
-
-    ### new_key = gpghandler.generateKey(
-    ###     u"Launchpad PPA for Celso \xe1\xe9\xed\xf3\xfa Providelo")
-    ### filepath = os.path.join(gpgkeysdir, 'ppa-sample@xxxxxxxxxxxxxxxxx')
-    ### export_file = open(filepath, 'w')
-    ### export_file.write(new_key.export())
-    ### export_file.close()
-
-Let's carry on with importing the sampledata key.
-
-    >>> filepath = os.path.join(gpgkeysdir, 'ppa-sample@xxxxxxxxxxxxxxxxx')
-    >>> seckey = open(filepath).read()
-    >>> new_key = gpghandler.importSecretKey(seckey)
-
-The secret key is returned. Currently, generateKey() only generates
-password-less sign-only keys, i.e. they can sign content but cannot
-encrypt.
-
-    >>> print new_key.secret
-    True
-
-    >>> print new_key.algorithm.title
-    R
-
-    >>> print new_key.keysize
-    1024
-
-    >>> print new_key.can_sign
-    True
-
-    >>> print new_key.can_encrypt
-    False
-
-    >>> print new_key.can_certify
-    True
-
-    >>> print new_key.can_authenticate
-    False
-
-The generated key contains a single UID and only its 'name' term is
-set.
-
-    >>> [uid] = new_key.uids
-
-    >>> print uid.name
-    Launchpad PPA for Celso áéíóú Providelo
-
-    >>> uid.comment
-    u''
-
-    >>> uid.email
-    u''
-
-The public key is also available.
-
-    >>> pub_key = gpghandler.retrieveKey(new_key.fingerprint)
-
-    >>> print pub_key.secret
-    False
-
-    >>> print pub_key.algorithm.title
-    R
-
-    >>> print pub_key.keysize
-    1024
-
-    >>> print pub_key.uids[0].name
-    Launchpad PPA for Celso áéíóú Providelo
-
-    >>> print pub_key.can_encrypt
-    False
-
-    >>> print pub_key.can_sign
-    True
-
-
 == Keyserver uploads ==
 
 IGPGHandler also allow callsites to upload the public part of a local
@@ -271,6 +184,12 @@ We will set up and use the test-keyserver.
     >>> tac = KeyServerTac()
     >>> tac.setUp()
 
+Import a test key.
+
+    >>> filepath = os.path.join(gpgkeysdir, 'ppa-sample@xxxxxxxxxxxxxxxxx')
+    >>> seckey = open(filepath).read()
+    >>> new_key = gpghandler.importSecretKey(seckey)
+
 Upload the just-generated key to the keyserver so that we can reset
 the local keyring.
 
diff --git a/lib/lp/services/gpg/handler.py b/lib/lp/services/gpg/handler.py
index e3ac2df..965282a 100644
--- a/lib/lp/services/gpg/handler.py
+++ b/lib/lp/services/gpg/handler.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -12,6 +12,7 @@ __all__ = [
 
 import atexit
 from contextlib import contextmanager
+from datetime import datetime
 import os
 import shutil
 import socket
@@ -22,18 +23,22 @@ import tempfile
 
 import gpgme
 from lazr.restful.utils import get_current_browser_request
+import pytz
 import requests
 import six
 from six.moves import http_client
 from six.moves.urllib.parse import urlencode
+from zope.component import getUtility
 from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.validators.email import valid_email
 from lp.services.config import config
+from lp.services.features import getFeatureFlag
 from lp.services.gpg.interfaces import (
     get_gpg_path,
     get_gpgme_context,
+    GPG_INJECT,
     GPGKeyAlgorithm,
     GPGKeyDoesNotExistOnServer,
     GPGKeyExpired,
@@ -51,6 +56,8 @@ from lp.services.gpg.interfaces import (
     SecretGPGKeyImportDetected,
     valid_fingerprint,
     )
+from lp.services.signing.enums import SigningKeyType
+from lp.services.signing.interfaces.signingkey import ISigningKeySet
 from lp.services.timeline.requesttimeline import get_request_timeline
 from lp.services.timeout import (
     TimeoutError,
@@ -318,6 +325,15 @@ class GPGHandler:
         assert key.exists_in_local_keyring
         return key
 
+    def _injectKeyPair(self, key):
+        """Inject a key pair into the signing service."""
+        secret_key = key.export()
+        public_key = self.retrieveKey(key.fingerprint).export()
+        now = datetime.now().replace(tzinfo=pytz.UTC)
+        getUtility(ISigningKeySet).inject(
+            SigningKeyType.OPENPGP, secret_key, public_key, key.uids[0].name,
+            now)
+
     def generateKey(self, name):
         """See `IGPGHandler`."""
         context = get_gpgme_context()
@@ -351,6 +367,17 @@ class GPGHandler:
         assert key.exists_in_local_keyring, (
             'The key does not seem to exist in the local keyring.')
 
+        if getFeatureFlag(GPG_INJECT):
+            try:
+                self._injectKeyPair(key)
+            except Exception:
+                with gpgme_timeline("delete", key.fingerprint):
+                    # For clarity this should be allow_secret=True, but
+                    # pygpgme doesn't allow it to be passed as a keyword
+                    # argument.
+                    context.delete(key.key, True)
+                raise
+
         return key
 
     def encryptContent(self, content, key):
diff --git a/lib/lp/services/gpg/interfaces.py b/lib/lp/services/gpg/interfaces.py
index 462c279..78b44c8 100644
--- a/lib/lp/services/gpg/interfaces.py
+++ b/lib/lp/services/gpg/interfaces.py
@@ -1,9 +1,10 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
     'get_gpg_path',
     'get_gpgme_context',
+    'GPG_INJECT',
     'GPGKeyAlgorithm',
     'GPGKeyDoesNotExistOnServer',
     'GPGKeyExpired',
@@ -38,6 +39,9 @@ from zope.interface import (
     )
 
 
+GPG_INJECT = 'gpg.signing_service.injection.enabled'
+
+
 def valid_fingerprint(fingerprint):
     """Is the fingerprint of valid form."""
     # Fingerprints of v3 keys are md5, fingerprints of v4 keys are sha1;
diff --git a/lib/lp/services/gpg/tests/test_gpghandler.py b/lib/lp/services/gpg/tests/test_gpghandler.py
index 7007553..162f39a 100644
--- a/lib/lp/services/gpg/tests/test_gpghandler.py
+++ b/lib/lp/services/gpg/tests/test_gpghandler.py
@@ -1,24 +1,44 @@
-# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2020 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 import base64
+from datetime import datetime
 import os
 import shutil
 import subprocess
 
+from fixtures import (
+    Fixture,
+    MockPatch,
+    )
 import gpgme
+import pytz
 import responses
+import six
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesListwise,
+    MatchesStructure,
+    )
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.services.compat import mock
+from lp.services.features.testing import FeatureFixture
+from lp.services.gpg.handler import signing_only_param
 from lp.services.gpg.interfaces import (
     get_gpg_path,
+    get_gpgme_context,
+    GPG_INJECT,
     GPGKeyDoesNotExistOnServer,
     GPGKeyMismatchOnServer,
     GPGKeyTemporarilyNotFoundError,
     IGPGHandler,
     )
 from lp.services.log.logger import BufferLogger
+from lp.services.signing.enums import SigningKeyType
+from lp.services.signing.tests.helpers import SigningServiceClientFixture
 from lp.services.timeout import TimeoutError
 from lp.testing import (
     ANONYMOUS,
@@ -27,6 +47,7 @@ from lp.testing import (
     TestCase,
     )
 from lp.testing.gpgkeys import (
+    gpgkeysdir,
     import_secret_test_key,
     iter_test_key_emails,
     test_keyrings,
@@ -37,6 +58,40 @@ from lp.testing.keyserver import KeyServerTac
 from lp.testing.layers import LaunchpadFunctionalLayer
 
 
+class FakeGenerateKey(Fixture):
+
+    def __init__(self, keyfile):
+        filepath = os.path.join(gpgkeysdir, keyfile)
+        with open(filepath) as f:
+            self.secret_key = f.read()
+
+    def _setUp(self):
+        class GenKeyResult:
+            def __init__(_self, fpr):
+                _self.primary = True
+                _self.sub = False
+                _self.fpr = fpr
+
+        def mock_genkey(params):
+            # Import the key so that it's in the local keyring.
+            imported_key = getUtility(IGPGHandler).importSecretKey(
+                self.secret_key)
+
+            # Fail if the key generation parameters aren't what we expect.
+            expected_params = six.ensure_str(signing_only_param % {
+                "name": imported_key.uids[0].name,
+                })
+            if params != expected_params:
+                raise ValueError(
+                    "Got params %r, expected %r" % (params, expected_params))
+            return GenKeyResult(imported_key.fingerprint)
+
+        real_context = get_gpgme_context()
+        mock_context = mock.Mock(wraps=real_context)
+        mock_context.genkey = mock.Mock(side_effect=mock_genkey)
+        self.useFixture(MockPatch("gpgme.Context", return_value=mock_context))
+
+
 class TestGPGHandler(TestCase):
     """Unit tests for the GPG handler."""
     layer = LaunchpadFunctionalLayer
@@ -307,6 +362,113 @@ class TestGPGHandler(TestCase):
             "op=index&search=0x%s" % fingerprint,
             self.gpg_handler.getURLForKeyInServer(fingerprint, public=True))
 
+    def assertGeneratesKey(self):
+        # We don't test real key generation because it depends on machine
+        # entropy that we may not have in buildbot, and in general may be
+        # slow.  The sample key on disk was generated by running the code
+        # below.
+        #
+        # We intentionally add some non-ascii characters in order to check
+        # if key generation and presentation cope with them.
+        #
+        # new_key = gpghandler.generateKey(
+        #     u"Launchpad PPA for Celso \xe1\xe9\xed\xf3\xfa Providelo")
+        # filepath = os.path.join(gpgkeysdir, "ppa-sample@xxxxxxxxxxxxxxxxx")
+        # with open(filepath, "w") as export_file:
+        #     export_file.write(new_key.export())
+        self.useFixture(FakeGenerateKey("ppa-sample@xxxxxxxxxxxxxxxxx"))
+        new_key = self.gpg_handler.generateKey(
+            u"Launchpad PPA for Celso \xe1\xe9\xed\xf3\xfa Providelo")
+        # generateKey currently only generates passwordless sign-only keys,
+        # i.e. they can sign content but cannot encrypt.  The generated key
+        # contains a single UID and only its "name" term is set.
+        self.assertThat(new_key, MatchesStructure(
+            secret=Is(True),
+            algorithm=MatchesStructure.byEquality(title="R"),
+            keysize=Equals(1024),
+            can_sign=Is(True),
+            can_encrypt=Is(False),
+            can_certify=Is(True),
+            can_authenticate=Is(False),
+            uids=MatchesListwise([
+                MatchesStructure.byEquality(
+                    name=(
+                        u"Launchpad PPA for Celso "
+                        u"\xe1\xe9\xed\xf3\xfa Providelo"),
+                    comment=u"",
+                    email=u""),
+                ])))
+        # The public key is also available.
+        pub_key = self.gpg_handler.retrieveKey(new_key.fingerprint)
+        self.assertThat(pub_key, MatchesStructure(
+            secret=Is(False),
+            algorithm=MatchesStructure.byEquality(title="R"),
+            keysize=Equals(1024),
+            can_sign=Is(True),
+            can_encrypt=Is(False),
+            can_certify=Is(True),
+            can_authenticate=Is(False),
+            uids=MatchesListwise([
+                MatchesStructure.byEquality(
+                    name=(
+                        u"Launchpad PPA for Celso "
+                        u"\xe1\xe9\xed\xf3\xfa Providelo"),
+                    comment=u"",
+                    email=u""),
+                ])))
+        return new_key
+
+    def test_generateKey(self):
+        # Generating a key works as expected.
+        signing_service_client = self.useFixture(
+            SigningServiceClientFixture(self.factory))
+        self.assertGeneratesKey()
+        # The gpg.signing_service.injection.enabled feature flag is
+        # disabled, so the key is not injected into the signing service.
+        self.assertEqual(0, signing_service_client.inject.call_count)
+
+    def test_generateKey_injects_key(self):
+        # If the gpg.signing_service.injection.enabled feature flag is
+        # enabled, a generated key is injected into the signing service.
+        signing_service_client = self.useFixture(
+            SigningServiceClientFixture(self.factory))
+        self.useFixture(FeatureFixture({GPG_INJECT: "on"}))
+        now = datetime.now()
+        mock_datetime = self.useFixture(MockPatch(
+            "lp.services.gpg.handler.datetime")).mock
+        mock_datetime.now = lambda: now
+        new_key = self.assertGeneratesKey()
+        new_public_key = self.gpg_handler.retrieveKey(new_key.fingerprint)
+        self.assertEqual(1, signing_service_client.inject.call_count)
+        self.assertThat(
+            signing_service_client.inject.call_args[0],
+            MatchesListwise([
+                Equals(SigningKeyType.OPENPGP),
+                Equals(new_key.export()),
+                Equals(new_public_key.export()),
+                Equals(
+                    u"Launchpad PPA for Celso "
+                    u"\xe1\xe9\xed\xf3\xfa Providelo"),
+                Equals(now.replace(tzinfo=pytz.UTC)),
+                ]))
+
+    def test_generateKey_handles_key_injection_failure(self):
+        # If the gpg.signing_service.injection.enabled feature flag is
+        # enabled but key injection fails, the GPG handler deletes the
+        # generated key and re-raises the exception raised by the failed
+        # injection.
+        signing_service_client = self.useFixture(
+            SigningServiceClientFixture(self.factory))
+        signing_service_client.inject.side_effect = ValueError("boom")
+        self.useFixture(FeatureFixture({GPG_INJECT: "on"}))
+        self.useFixture(FakeGenerateKey("ppa-sample@xxxxxxxxxxxxxxxxx"))
+        self.assertRaisesWithContent(
+            ValueError, "boom",
+            self.gpg_handler.generateKey,
+            u"Launchpad PPA for Celso \xe1\xe9\xed\xf3\xfa Providelo")
+        self.assertEqual(1, signing_service_client.inject.call_count)
+        self.assertEqual([], list(self.gpg_handler.localKeys()))
+
     def test_signContent_uses_sha512_digests(self):
         secret_keys = [
             ("ppa-sample@xxxxxxxxxxxxxxxxx", ""),       # 1024R
diff --git a/lib/lp/services/signing/configure.zcml b/lib/lp/services/signing/configure.zcml
index 4b72d7d..b97f96d 100644
--- a/lib/lp/services/signing/configure.zcml
+++ b/lib/lp/services/signing/configure.zcml
@@ -10,6 +10,12 @@
         <allow
             interface="lp.services.signing.interfaces.signingkey.ISigningKey"/>
     </class>
+    <securedutility
+        component="lp.services.signing.model.signingkey.SigningKey"
+        provides="lp.services.signing.interfaces.signingkey.ISigningKeySet">
+        <allow
+            interface="lp.services.signing.interfaces.signingkey.ISigningKeySet"/>
+    </securedutility>
 
     <securedutility
         class="lp.services.signing.model.signingkey.ArchiveSigningKeySet"