Repository: freedomofpress/securedrop

Issue:
Securedrop-admin update should verify tag signature prior to checking out the tag
# Bug
## Description
SecureDrop admin update logic introduced in 0.6 checks out a tag before verifying that it was properly signed with the SecureDrop release signing key. Should an incorrect (non-production-ready) or malicious tag be selected, the error will appear to an administrator once. Should the admin ignore this error, the SecureDrop repository may no longer update to production-ready code.

As a SecureDrop administrator, I would like to check out and run only code that has been certified by the SecureDrop team as production ready.

## Expected Behavior
updater should only checkout code if the signature is valid. The order should be as follows:
1- fetch
2- tag -v
3- checkout

## Actual Behavior

The order is:
1- fetch
2- checkout
3- tag -v


## Comments

All upgrades to 0.7 should use the manual method of validating tags in the order described as above in the blog post, and can safely use the GUI updater in subsequent SecureDrop releases.



Discussion:

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -37,6 +37,7 @@
 import yaml
 
 sdlog = logging.getLogger(__name__)
+RELEASE_KEY = '22245C81E3BAEB4138B36061310F561200F4AD77'
 
 
 class FingerprintException(Exception):
@@ -612,7 +613,7 @@ def check_for_updates(args):
 
 def get_release_key_from_keyserver(args, keyserver=None, timeout=45):
     gpg_recv = ['timeout', str(timeout), 'gpg', '--recv-key']
-    release_key = ['22245C81E3BAEB4138B36061310F561200F4AD77']
+    release_key = [RELEASE_KEY]
 
     # We construct the gpg --recv-key command based on optional keyserver arg.
     if keyserver:
@@ -633,9 +634,6 @@ def update(args):
         # Exit if we're up to date
         return 0
 
-    git_checkout_cmd = ['git', 'checkout', latest_tag]
-    subprocess.check_call(git_checkout_cmd, cwd=args.root)
-
     sdlog.info("Verifying signature on latest update...")
 
     try:
@@ -648,14 +646,35 @@ def update(args):
                                        keyserver=secondary_keyserver)
 
     git_verify_tag_cmd = ['git', 'tag', '-v', latest_tag]
-    sig_result = subprocess.check_output(git_verify_tag_cmd,
-                                         stderr=subprocess.STDOUT,
-                                         cwd=args.root)
+    try:
+        sig_result = subprocess.check_output(git_verify_tag_cmd,
+                                             stderr=subprocess.STDOUT,
+                                             cwd=args.root)
+
+        good_sig_text = 'Good signature from "SecureDrop Release Signing Key"'
+        bad_sig_text = 'BAD signature'
+        # To ensure that an adversary cannot name a malicious key good_sig_text
+        # we check that bad_sig_text does not appear and that the release key
+        # appears on the second line of the output.
+        gpg_lines = sig_result.split('\n')
+        if RELEASE_KEY in gpg_lines[1] and \
+                sig_result.count(good_sig_text) == 1 and \
+                bad_sig_text not in sig_result:
+            sdlog.info("Signature verification successful.")
+        else:  # If anything else happens, fail and exit 1
+            sdlog.info("Signature verification failed.")
+            return 1
 
-    if 'Good signature' not in sig_result:
+    except subprocess.CalledProcessError:
+        # If there is no signature, or if the signature does not verify,
+        # then git tag -v exits subprocess.check_output will exit 1
+        # and subprocess.check_output will throw a CalledProcessError
         sdlog.info("Signature verification failed.")
-        return -1
-    sdlog.info("Signature verification successful.")
+        return 1
+
+    # Only if the proper signature verifies do we check out the latest
+    git_checkout_cmd = ['git', 'checkout', latest_tag]
+    subprocess.check_call(git_checkout_cmd, cwd=args.root)
 
     sdlog.info("Updated to SecureDrop {}.".format(latest_tag))
     return 0


Test patch:
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -114,7 +114,11 @@ def test_update_gpg_recv_primary_key_failure(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = 'Good signature from "SecureDrop Release Signing Key"'
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      'gpg: Good signature from "SecureDrop Release '
+                      'Signing Key" [unknown]\n')
 
         patchers = [
             mock.patch('securedrop_admin.check_for_updates',
@@ -156,7 +160,11 @@ def test_update_signature_verifies(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = 'Good signature from "SecureDrop Release Signing Key"'
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      'gpg: Good signature from "SecureDrop Release '
+                      'Signing Key" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -173,7 +181,11 @@ def test_update_signature_does_not_verify(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)
         args = argparse.Namespace(root=git_repo_path)
 
-        git_output = 'Bad signature from "SecureDrop Release Signing Key"'
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      'gpg: BAD signature from "SecureDrop Release '
+                      'Signing Key" [unknown]\n')
 
         with mock.patch('securedrop_admin.check_for_updates',
                         return_value=(True, "0.6.1")):
@@ -186,6 +198,88 @@ def test_update_signature_does_not_verify(self, tmpdir, caplog):
                     assert "Updated to SecureDrop" not in caplog.text
                     assert ret_code != 0
 
+    def test_update_malicious_key_named_fingerprint(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '1234567812345678123456781234567812345678\n'
+                      'gpg: Good signature from "22245C81E3BAEB4138'
+                      'B36061310F561200F4AD77" [unknown]\n')
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                return_value=git_output):
+                    ret_code = securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification failed." in caplog.text
+                    assert "Updated to SecureDrop" not in caplog.text
+                    assert ret_code != 0
+
+    def test_update_malicious_key_named_good_sig(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '1234567812345678123456781234567812345678\n'
+                      'gpg: Good signature from Good signature from '
+                      '"SecureDrop Release Signing Key" [unknown]\n')
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                return_value=git_output):
+                    ret_code = securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification failed." in caplog.text
+                    assert "Updated to SecureDrop" not in caplog.text
+                    assert ret_code != 0
+
+    def test_update_malicious_key_named_good_sig_fingerprint(self, tmpdir,
+                                                             caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '1234567812345678123456781234567812345678\n'
+                      'gpg: Good signature from 22245C81E3BAEB4138'
+                      'B36061310F561200F4AD77 Good signature from '
+                      '"SecureDrop Release Signing Key" [unknown]\n')
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                return_value=git_output):
+                    ret_code = securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification failed." in caplog.text
+                    assert "Updated to SecureDrop" not in caplog.text
+                    assert ret_code != 0
+
+    def test_no_signature_on_update(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        with mock.patch('securedrop_admin.check_for_updates',
+                        return_value=(True, "0.6.1")):
+            with mock.patch('subprocess.check_call'):
+                with mock.patch('subprocess.check_output',
+                                side_effect=subprocess.CalledProcessError(
+                                  1, 'git', 'error: no signature found')
+                                ):
+                    ret_code = securedrop_admin.update(args)
+                    assert "Applying SecureDrop updates..." in caplog.text
+                    assert "Signature verification failed." in caplog.text
+                    assert "Updated to SecureDrop" not in caplog.text
+                    assert ret_code != 0
+
 
 class TestSiteConfig(object):