← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~julian-edwards/launchpad/poppy-warn-unsigned-bug-374019 into lp:launchpad

 

Julian Edwards has proposed merging lp:~julian-edwards/launchpad/poppy-warn-unsigned-bug-374019 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #374019 bad keys result in no response on ppa uploads
  https://bugs.launchpad.net/bugs/374019

For more details, see:
https://code.launchpad.net/~julian-edwards/launchpad/poppy-warn-unsigned-bug-374019/+merge/51144

Add support to the new twisted-based poppy ftp server to reject invalidly gpg-signed uploaded .changes files directly in the FTP session.

This change depends on a patch to Twisted being applied, which is being tracked at http://twistedmatrix.com/trac/ticket/4909

It works by creating a new custom file-writer, which checks to see if the file is a .changes file, and does a quick lookup of the GPG key before ending the transfer session.

I've tested this approach using dput, and dput gets a lovely error message printed out like this:

Uploading to dogfood (via ftp to dogfood.launchpad.net):
  Uploading hello_2.5-1ubuntu1.dsc: done.
  Uploading hello_2.5-1ubuntu1.diff.gz: done.
  Uploading hello_2.5-1ubuntu1_source.changes: 1k/2k550 ('Changes file must be signed with a valid GPG signature: Verification failed 3 times: ["(7, 8, u\'Bad signature\')", "(7, 8, u\'Bad signature\')", "(7, 8, u\'Bad signature\')"] ',): Permission denied.
-- 
https://code.launchpad.net/~julian-edwards/launchpad/poppy-warn-unsigned-bug-374019/+merge/51144
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~julian-edwards/launchpad/poppy-warn-unsigned-bug-374019 into lp:launchpad.
=== modified file 'daemons/poppy-sftp.tac'
--- daemons/poppy-sftp.tac	2011-02-23 11:52:58 +0000
+++ daemons/poppy-sftp.tac	2011-02-25 10:43:08 +0000
@@ -19,6 +19,7 @@
 
 from canonical.config import config
 from canonical.launchpad.daemons import readyservice
+from canonical.launchpad.scripts import execute_zcml_for_scripts
 
 from lp.poppy import get_poppy_root
 from lp.poppy.twistedftp import (
@@ -105,5 +106,8 @@
     banner=config.poppy.banner)
 svc.setServiceParent(application)
 
+# We need Zope for looking up the GPG utilities.
+execute_zcml_for_scripts()
+
 # Service that announces when the daemon is ready
 readyservice.ReadyService().setServiceParent(application)

=== modified file 'lib/lp/poppy/tests/test_poppy.py'
--- lib/lp/poppy/tests/test_poppy.py	2011-02-24 17:23:57 +0000
+++ lib/lp/poppy/tests/test_poppy.py	2011-02-25 10:43:08 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 
+import ftplib
 import os
 import shutil
 import stat
@@ -37,6 +38,7 @@
     )
 from lp.poppy.hooks import Hooks
 from lp.testing import TestCaseWithFactory
+from lp.testing.keyserver import KeyServerTac
 
 
 class FTPServer(Fixture):
@@ -371,6 +373,36 @@
                 self.root_dir, upload_dirs[index], "test")).read()
             self.assertEqual(content, expected_contents[index])
 
+    def test_bad_gpg_on_changesfile(self):
+        """Check that we get a rejection error when uploading .changes files
+        with invalid GPG signatures.
+        """
+        # Start up the poppy server.
+        self.server.waitForStartUp()
+
+        transport = self.server.getTransport()
+        if transport.external_url().startswith("sftp"):
+            # We're not GPG-checking sftp uploads right now.
+            return
+
+        # Start up the test keyserver.
+        tac = KeyServerTac()
+        tac.setUp()
+        self.addCleanup(tac.tearDown)
+
+        fake_file = StringIO.StringIO("fake contents")
+
+        # We can't use bzrlib's transport here because it uploads a
+        # renamed file before renaming it on the server.
+        f = ftplib.FTP()
+        f.connect(host="localhost", port=config.poppy.ftp_port)
+        f.login()
+        self.assertRaises(
+            ftplib.error_perm,
+            f.storbinary,
+            'STOR '+'foo_source.changes',
+            fake_file)
+
 
 def test_suite():
     tests = unittest.TestLoader().loadTestsFromName(__name__)

=== added file 'lib/lp/poppy/tests/test_twistedftp.py'
--- lib/lp/poppy/tests/test_twistedftp.py	1970-01-01 00:00:00 +0000
+++ lib/lp/poppy/tests/test_twistedftp.py	2011-02-25 10:43:08 +0000
@@ -0,0 +1,94 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for Twisted Poppy FTP."""
+
+__metaclass__ = type
+
+import os
+
+from testtools.deferredruntest import (
+    AsynchronousDeferredRunTest,
+    )
+from twisted.protocols import ftp
+from zope.component import getUtility
+
+from canonical.config import config
+from canonical.launchpad.ftests.keys_for_tests import gpgkeysdir
+from canonical.launchpad.interfaces.gpghandler import IGPGHandler
+from canonical.testing.layers import ZopelessDatabaseLayer
+
+from lp.poppy.twistedftp import PoppyFileWriter
+from lp.registry.interfaces.gpg import (
+    GPGKeyAlgorithm,
+    IGPGKeySet)
+from lp.testing import TestCaseWithFactory
+from lp.testing.keyserver import KeyServerTac
+
+
+class TestPoppyFileWriter(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=20)
+
+    def setUp(self):
+        TestCaseWithFactory.setUp(self)
+
+        # Start the test keyserver.  Starting up and tearing this down
+        # for each test is expensive, but we don't have a way of having
+        # cross-test persistent fixtures yet.  See bug 724349.
+        self.tac = KeyServerTac()
+        self.tac.setUp()
+        self.addCleanup(self.tac.tearDown)
+
+        # Load a key.
+        gpg_handler = getUtility(IGPGHandler)
+        key_path = os.path.join(gpgkeysdir, 'ftpmaster@xxxxxxxxxxxxxxxxx')
+        key_data = open(key_path).read()
+        key = gpg_handler.importPublicKey(key_data)
+        assert key is not None
+
+        # Make a new user and add the above key to it.
+        user = self.factory.makePerson()
+        key_set = getUtility(IGPGKeySet)
+        user_key = key_set.new(
+            ownerID=user.id, keyid=key.keyid, fingerprint=key.fingerprint,
+            algorithm=GPGKeyAlgorithm.items[key.algorithm],
+            keysize=key.keysize, can_encrypt=key.can_encrypt,
+            active=True)
+
+        # Locate the directory with test files.
+        self.test_files_dir = os.path.join(
+            config.root, "lib/lp/soyuz/scripts/tests/upload_test_files/")
+
+    def test_changes_file_with_valid_GPG(self):
+        valid_changes_file = os.path.join(
+            self.test_files_dir, "etherwake_1.08-1_source.changes")
+
+        def callback(result):
+            self.assertIs(None, result)
+
+        with open(valid_changes_file) as opened_file:
+            file_writer = PoppyFileWriter(opened_file)
+            d = file_writer.close()
+            d.addBoth(callback)
+            return d
+
+    def test_changes_file_with_invalid_GPG(self):
+        invalid_changes_file = os.path.join(
+            self.test_files_dir, "broken_source.changes")
+
+        def error_callback(failure):
+            self.assertTrue(failure.check, ftp.PermissionDeniedError)
+            self.assertIn(
+                "Changes file must be signed with a valid GPG signature",
+                failure.getErrorMessage())
+
+        def success_callback(result):
+            self.fail("Success when there should have been failure.")
+
+        with open(invalid_changes_file) as opened_file:
+            file_writer = PoppyFileWriter(opened_file)
+            d = file_writer.close()
+            d.addCallbacks(success_callback, error_callback)
+            return d

=== modified file 'lib/lp/poppy/twistedftp.py'
--- lib/lp/poppy/twistedftp.py	2011-02-23 14:24:43 +0000
+++ lib/lp/poppy/twistedftp.py	2011-02-25 10:43:08 +0000
@@ -21,11 +21,18 @@
 from twisted.python import filepath
 
 from zope.interface import implements
+from zope.component import getUtility
+
+from canonical.launchpad.interfaces.gpghandler import (
+    GPGVerificationError,
+    IGPGHandler,
+    )
 
 from canonical.config import config
 from lp.poppy import get_poppy_root
 from lp.poppy.filesystem import UploadFileSystem
 from lp.poppy.hooks import Hooks
+from lp.registry.interfaces.gpg import IGPGKeySet
 
 
 class PoppyAccessCheck:
@@ -72,7 +79,14 @@
         """
         filename = os.sep.join(file_segments)
         self._create_missing_directories(filename)
-        return super(PoppyAnonymousShell, self).openForWriting(file_segments)
+        path = self._path(file_segments)
+        try:
+            fObj = path.open("w")
+        except (IOError, OSError), e:
+            return ftp.errnoToFailure(e.errno, path)
+        except:
+            return defer.fail()
+        return defer.succeed(PoppyFileWriter(fObj))
 
     def makeDirectory(self, path):
         """Make a directory using the secure `UploadFileSystem`."""
@@ -128,6 +142,43 @@
             "Only IFTPShell interface is supported by this realm")
 
 
+class PoppyFileWriter(ftp._FileWriter):
+    """An `IWriteFile` that checks for signed changes files."""
+
+    def close(self):
+        """Called after the file has been completely downloaded."""
+        if self.fObj.name.endswith(".changes"):
+            error = self.validateGPG(self.fObj.name)
+            if error is not None:
+                # PermissionDeniedError is one of the few ftp exceptions
+                # that lets us pass an error string back to the client.
+                return defer.fail(ftp.PermissionDeniedError(error))
+        return defer.succeed(None)
+
+    def validateGPG(self, signed_file):
+        """Check the GPG signature in the file referenced by signed_file.
+
+        Return an error string if there's a problem, or None.
+        """
+        try:
+            sig = getUtility(IGPGHandler).getVerifiedSignatureResilient(
+                file(signed_file, "rb").read())
+        except GPGVerificationError, error:
+            return ("Changes file must be signed with a valid GPG "
+                    "signature: %s" % error)
+
+        key = getUtility(IGPGKeySet).getByFingerprint(sig.fingerprint)
+        if key is None:
+            return (
+                "Signing key %s not registered in launchpad."
+                % sig.fingerprint)
+
+        if key.active == False:
+            return "Changes file is signed with a deactivated key"
+
+        return None
+
+
 class FTPServiceFactory(service.Service):
     """A factory that makes an `FTPService`"""