From bbeb381c0f93a4bfa0794342fde2355e007097ac Mon Sep 17 00:00:00 2001 From: Johannes Schauer Marin Rodrigues Date: Wed, 17 Jul 2024 11:42:34 +0200 Subject: [PATCH] add --keyring and --fingerprint options These options allow one to pass a custom keyring with --keyring as well as panning the fingerprint using --fingerprint. This is for example useful in situations where a vendor wants to give users access to updated system images and for that purpose, ships a custom keyring with their installations. Using the --keyring option, the user can use GPG to verify that the updated system image they downloaded indeed comes from the expected source (even more so when using the --fingerprint option together with it) without having to add the key into their own personal GPG keyring. Furthermore, this is useful in scripts which call bmaptool programmatically. For example, an updater script could come shipped with an embedded GPG keyring and then call bmaptool with this keyring (and optionally with a known --fingerprint) to facilitate GPG-verified automatic updates. --- docs/man1/bmaptool.1 | 12 +++++++ src/bmaptool/CLI.py | 67 ++++++++++++++++++++++++++++++------- tests/test_CLI.py | 79 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/docs/man1/bmaptool.1 b/docs/man1/bmaptool.1 index 5093b48..05faf6c 100644 --- a/docs/man1/bmaptool.1 +++ b/docs/man1/bmaptool.1 @@ -194,6 +194,18 @@ integrity and publisher. If this option is not specified, \fIbmaptool\fR tries to automatically discover the signature file. .RE +.PP +\-\-fingerprint FINGERPRINT +.RS 2 +The GPG fingerprint which you expect to have signed the bmap file. +.RE + +.PP +\-\-keyring KEYRING +.RS 2 +Path to the GPG keyring that will be used when verifying GPG signatures. +.RE + .PP \-\-nobmap .RS 2 diff --git a/src/bmaptool/CLI.py b/src/bmaptool/CLI.py index a15050a..ae53b77 100644 --- a/src/bmaptool/CLI.py +++ b/src/bmaptool/CLI.py @@ -139,7 +139,12 @@ class Signature(NamedTuple): uid: str -def verify_bmap_signature_gpgme(bmap_obj, detached_sig): +def verify_bmap_signature_gpgme(bmap_obj, detached_sig, keyring): + if keyring: + error_out( + "Python gpgme binding is not able to verify " + "signatures against a custom keyring." + ) try: import gpg except ImportError: @@ -187,8 +192,17 @@ def fpr2uid(fpr): ] -def verify_bmap_signature_gpgbin(bmap_obj, detached_sig, gpgargv): +def verify_bmap_signature_gpgbin(bmap_obj, detached_sig, gpgargv, keyring): with tempfile.TemporaryDirectory(suffix=".bmaptool.gnupg") as td: + if keyring: + if gpgargv[0] == "gpg": + gpgargv.extend( + [ + f"--homedir={td}", + "--no-default-keyring", + ] + ) + gpgargv.append(f"--keyring={keyring}") if detached_sig: with open(f"{td}/sig", "wb") as f: shutil.copyfileobj(detached_sig, f) @@ -237,13 +251,13 @@ def verify_bmap_signature_gpgbin(bmap_obj, detached_sig, gpgargv): ] -def verify_bmap_signature_gpgv(bmap_obj, detached_sig): +def verify_bmap_signature_gpgv(bmap_obj, detached_sig, keyring): return verify_bmap_signature_gpgbin( - bmap_obj, detached_sig, ["gpgv", "--output=-", "--status-fd=2"] + bmap_obj, detached_sig, ["gpgv", "--output=-", "--status-fd=2"], keyring ) -def verify_bmap_signature_gpg(bmap_obj, detached_sig): +def verify_bmap_signature_gpg(bmap_obj, detached_sig, keyring): return verify_bmap_signature_gpgbin( bmap_obj, detached_sig, @@ -257,6 +271,7 @@ def verify_bmap_signature_gpg(bmap_obj, detached_sig): "-", "--status-fd=2", ], + keyring, ) @@ -317,6 +332,10 @@ def _add_ext(p, ext): detached_sig = TransRead.TransRead(_add_ext(bmap_path, ".sig")) except TransRead.Error: # No detached signatures found + if args.fingerprint: + error_out("no signature found but --fingerprint given") + if args.keyring: + error_out("no signature found but --keyring given") return None log.info("discovered signature file for bmap '%s'" % detached_sig.name) @@ -327,12 +346,16 @@ def _add_ext(p, ext): "gpgv": verify_bmap_signature_gpgv, } have_method = set() - try: - import gpg - have_method.add("gpgme") - except ImportError: - pass + if not args.keyring: + # The python gpgme binding is not able to verify against a custom + # keyring. Only try this method if we have no keyring. + try: + import gpg + + have_method.add("gpgme") + except ImportError: + pass if shutil.which("gpg") is not None: have_method.add("gpg") if shutil.which("gpgv") is not None: @@ -342,10 +365,10 @@ def _add_ext(p, ext): error_out("Cannot verify GPG signature without GPG") for method in ["gpgme", "gpgv", "gpg"]: - log.info(f"Trying to verify signature using {method}") if method not in have_method: continue - plaintext, sigs = methods[method](bmap_obj, detached_sig) + log.info(f"Trying to verify signature using {method}") + plaintext, sigs = methods[method](bmap_obj, detached_sig, args.keyring) break bmap_obj.seek(0) @@ -359,6 +382,12 @@ def _add_ext(p, ext): "contain any valid signatures" ) else: + if args.fingerprint and args.fingerprint not in [sig.fpr for sig in sigs]: + error_out( + f"requested fingerprint {args.fingerprint} " + "did not sign the bmap file. Only have these sigs: " + + ("".join([f"\n * {sig.fpr}" for sig in sigs])) + ) for sig in sigs: if sig.valid: log.info( @@ -575,6 +604,12 @@ def copy_command(args): if args.bmap_sig and args.no_sig_verify: error_out("--bmap-sig and --no-sig-verify cannot be used together") + if args.no_sig_verify and args.keyring: + error_out("--no-sig-verify and --keyring cannot be used together") + + if args.no_sig_verify and args.fingerprint: + error_out("--no-sig-verify and --fingerprint cannot be used together") + image_obj, dest_obj, bmap_obj, bmap_path, image_size, dest_is_blkdev = open_files( args ) @@ -808,6 +843,14 @@ def parse_arguments(): text = "do not verify bmap file GPG signature" parser_copy.add_argument("--no-sig-verify", action="store_true", help=text) + # The --keyring option + text = "the GPG keyring to verify the GPG signature on the bmap file" + parser_copy.add_argument("--keyring", help=text) + + # The --fingerprint option + text = "the GPG fingerprint that is expected to have signed the bmap file" + parser_copy.add_argument("--fingerprint", help=text) + # The --no-verify option text = "do not verify the data checksum while writing" parser_copy.add_argument("--no-verify", action="store_true", help=text) diff --git a/tests/test_CLI.py b/tests/test_CLI.py index 9f09e86..86e9cf0 100644 --- a/tests/test_CLI.py +++ b/tests/test_CLI.py @@ -61,6 +61,56 @@ def test_valid_signature(self): b"successfully verified bmap file signature", completed_process.stderr ) + def test_valid_signature_fingerprint(self): + assert testkeys["correct"].fpr is not None + completed_process = subprocess.run( + [ + "bmaptool", + "copy", + "--bmap", + "tests/test-data/signatures/test.image.bmap.v2.0correct.asc", + "--fingerprint", + testkeys["correct"].fpr, + "tests/test-data/test.image.gz", + self.tmpfile, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(completed_process.returncode, 0) + self.assertEqual(completed_process.stdout, b"") + self.assertIn( + b"successfully verified bmap file signature", completed_process.stderr + ) + + def test_valid_signature_fingerprint_keyring(self): + assert testkeys["correct"].fpr is not None + completed_process = subprocess.run( + [ + "bmaptool", + "copy", + "--bmap", + "tests/test-data/signatures/test.image.bmap.v2.0correct.asc", + "--fingerprint", + testkeys["correct"].fpr, + "--keyring", + testkeys["correct"].gnupghome + ".keyring", + "tests/test-data/test.image.gz", + self.tmpfile, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + # should work without GNUPGHOME set because we supply --keyring + env={k: v for k, v in os.environ.items() if k != "GNUPGHOME"}, + ) + self.assertEqual(completed_process.returncode, 0) + self.assertEqual(completed_process.stdout, b"") + self.assertIn( + b"successfully verified bmap file signature", completed_process.stderr + ) + def test_unknown_signer(self): completed_process = subprocess.run( [ @@ -141,6 +191,29 @@ def test_clearsign(self): b"successfully verified bmap file signature", completed_process.stderr ) + def test_fingerprint_without_signature(self): + assert testkeys["correct"].fpr is not None + completed_process = subprocess.run( + [ + "bmaptool", + "copy", + "--bmap", + "tests/test-data/test.image.bmap.v2.0", + "--fingerprint", + testkeys["correct"].fpr, + "tests/test-data/test.image.gz", + self.tmpfile, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(completed_process.returncode, 1) + self.assertEqual(completed_process.stdout, b"") + self.assertIn( + b"no signature found but --fingerprint given", completed_process.stderr + ) + def setUp(self): try: import gpg @@ -160,8 +233,6 @@ def setUp(self): certify=True, ) key.fpr = dmkey.fpr - with open(f"{key.gnupghome}.keyring", "wb") as f: - f.write(context.key_export_minimal()) for bmapv in ["2.0", "1.4"]: testp = "tests/test-data" imbn = "test.image.bmap.v" @@ -184,6 +255,10 @@ def setUp(self): bmapcontent, mode=gpg.constants.sig.mode.DETACH ) detsigf.write(signed_data) + # the file supplied to gpgv via --keyring must not be armored + context.armor = False + with open(f"{key.gnupghome}.keyring", "wb") as f: + f.write(context.key_export_minimal()) self.tmpfile = tempfile.mkstemp(prefix="testfile_", dir=".")[1] os.environ["GNUPGHOME"] = testkeys["correct"].gnupghome