Reimplementation (#1)

* refactor into separate modules

* update README
This commit is contained in:
ulinja
2022-08-01 12:23:41 +02:00
committed by GitHub
parent 5665759667
commit b69fd8cfa9
25 changed files with 1274 additions and 1323 deletions

0
gpg/__init__.py Normal file
View File

37
gpg/exceptions.py Normal file
View File

@@ -0,0 +1,37 @@
"""Exceptions which may be raised during execution of gpg wrapper functions."""
class GpgProgrammingException(Exception):
"""A general exception indicating a programming error."""
pass
class GpgRuntimeError(RuntimeError):
"""A general exception indicating a runtime error."""
pass
class UnexpectedOutputException(GpgProgrammingException):
"""Raised when a gpg subprocess produces unexpected output."""
pass
class MissingLocalKeyError(GpgRuntimeError):
"""Raised when an expected key is missing from the local keystore."""
pass
class InvalidSignatureError(GpgRuntimeError):
"""Raised when an invalid gpg signature is encountered."""
pass
class VerificationFailedError(GpgRuntimeError):
"""Raised when a gpg verfication encounters a bad signature."""
pass

112
gpg/keystore.py Normal file
View File

@@ -0,0 +1,112 @@
"""Utilities for searching and importing GPG keys locally."""
from subprocess import run, STDOUT, PIPE
from re import compile
_DEBIAN_KEY_SERVER_HOSTNAME = "keyring.debian.org"
_DEBIAN_CD_SIGNING_KEY_ID = "DA87E80D6294BE9B"
def import_debian_signing_key():
"""Imports the public debian CD signing key using gpg.
The key is imported from keyring.debian.org into the invoking user's
GPG public key store using a shell command.
"""
# execute a gpg key import as a shell command, redirecting stderr to stdout
process_result = run(
[
"gpg", "--keyserver", _DEBIAN_KEY_SERVER_HOSTNAME,
"--recv-key", _DEBIAN_CD_SIGNING_KEY_ID
],
stdout=PIPE,
stderr=STDOUT,
text=True,
)
# check shell return code
if process_result.returncode != 0:
if process_result.stdout:
raise RuntimeError(
f"Failed to import key using gpg:\n{process_result.stdout}"
)
else:
raise RuntimeError("Failed to import key using gpg.")
# check shell output:
# the first line of stdout should look like this
expected_first_line = str(
f"gpg: key {_DEBIAN_CD_SIGNING_KEY_ID}: public key "
f"\"Debian CD signing key <debian-cd@lists.debian.org>\""
f" imported"
)
if not process_result.stdout.split("\n")[0] == expected_first_line:
raise RuntimeError(
f"Unexpected output while importing PGP public key:\n"
f"{process_result.stdout}"
)
def debian_signing_key_is_imported():
"""Checks whether the debian PGP signing key exists in the local key store.
The invoking user's GPG key store is checked using a shell command.
Returns
-------
True : bool
If the public PGP debian cd signing key exists in the invoking user's
GPG key store.
False : bool
If the public PGP debian cd signing key does not exist in the invoking
user's GPG key store.
"""
# execute a local gpg key lookup as a shell command, redirecting stderr to
# stdout
# NOTE: this command returns 0 even if the key is not present
process_result = run(
["gpg", "--locate-keys", _DEBIAN_CD_SIGNING_KEY_ID],
stdout=PIPE,
stderr=STDOUT,
text=True,
)
# check shell return code
if process_result.returncode != 0:
raise RuntimeError("Failed to search local keys using gpg.")
# no shell output means that the key does not exist locally
if not process_result.stdout:
return False
# verify existing key shell output using regex:
# it should contain six lines in the following format
expected_output_lines_regexes = [
compile(r"^pub .*$"),
compile(r"^ *[0-9A-F]{40}$"),
compile(r"^uid .*$"),
compile(r"^sub .*$"),
compile(r"^$"),
compile(r"^$"),
]
actual_output_lines = process_result.stdout.split("\n")
if not len(actual_output_lines) == len(expected_output_lines_regexes):
raise RuntimeError(
f"Unexpected line count in shell output while performing local"
f"GPG key lookup:\n"
f"{process_result.stdout}"
)
for i in range(4):
if not expected_output_lines_regexes[i].match(actual_output_lines[i]):
raise RuntimeError(
f"Unexpected shell output format while performing local"
f"GPG key lookup:\n"
f"{process_result.stdout}"
)
return True

128
gpg/verify.py Normal file
View File

@@ -0,0 +1,128 @@
"""Utilities for verifying files using gpg."""
from re import compile
from pathlib import Path
from subprocess import run, PIPE, STDOUT
import gpg.exceptions as ex
def assert_detached_signature_is_valid(
path_to_input_file,
path_to_signature_file
):
"""Verifies the input file using the specified detached gpg signature.
The invoking user's local gpg key store is used for verification, the
public key used to create the signature must be present in the invoking
user's local gpg key store.
Parameters
----------
path_to_input_file : str or pathlike object
Path to the file which should be verified.
path_to_signature_file : str or pathlike object
Path to the file containing a detached gpg signature of the input file.
Raises
------
FileNotFoundError
If either of the input files do not exist.
gpg.exceptions.MissingLocalKeyError
If a key referenced by the signature file could not be found in the
invoking user's local key store.
gpg.exceptions.VerificationFailedError
If the gpg verification detects a bad signature.
"""
if '~' in str(path_to_input_file):
path_to_input_file = Path(path_to_input_file).expanduser()
path_to_input_file = Path(path_to_input_file).resolve()
if '~' in str(path_to_signature_file):
path_to_signature_file = Path(path_to_signature_file).expanduser()
path_to_signature_file = Path(path_to_signature_file).resolve()
if not path_to_input_file.is_file():
raise FileNotFoundError(
f"No such file: '{path_to_input_file}'."
)
if not path_to_signature_file.is_file():
raise FileNotFoundError(
f"No such file: '{path_to_signature_file}'."
)
# execute a gpg verification as a shell command, redirecting stderr to
# stdout
process_result = run(
[
"gpg", "--verify", path_to_signature_file, path_to_input_file,
],
stdout=PIPE,
stderr=STDOUT,
text=True,
)
output_lines = process_result.stdout.split("\n")
if len(output_lines) < 3:
raise ex.UnexpectedOutputException(
f"Unexpected output during gpg verification:\n"
f"{process_result.stdout}"
)
if process_result.returncode == 2:
# a missing local key causes return code 2
# and the following output on the third line:
missing_key_regex = compile(
r"^gpg: Can't check signature: No public key$"
)
# an invalid detached signature file causes return code 2
# and the following output on the first line:
invalid_signature_regex = compile(
r"^gpg: no valid OpenPGP data found.$"
)
if missing_key_regex.match(output_lines[2]):
raise ex.MissingLocalKeyError(
"Failed to verify gpg signature: no matching local key."
)
elif invalid_signature_regex.match(output_lines[0]):
raise ex.InvalidSignatureError(
"Invalid signature file."
)
else:
raise ex.UnexpectedOutputException(
f"Unexpected output during gpg verification:\n"
f"{process_result.stdout}"
)
elif process_result.returncode == 1:
# failed verification causes return code 1
# and the following output on line 3:
verification_failed_regex = compile(
r"^gpg: BAD signature from .*$"
)
if not verification_failed_regex.match(output_lines[2]):
raise ex.UnexpectedOutputException(
f"Unexpected output during gpg verification:\n"
f"{process_result.stdout}"
)
else:
raise ex.VerificationFailedError(
"gpg signature verification failed: BAD SIGNATURE!"
)
elif process_result.returncode == 0:
# successful verification causes return code 0
# and the following output on line 3:
verification_successful_regex = compile(
r"^gpg: Good signature from .*$"
)
if not verification_successful_regex.match(output_lines[2]):
raise ex.UnexpectedOutputException(
f"Unexpected output during gpg verification:\n"
f"{process_result.stdout}"
)
else:
raise ex.UnexpectedOutputException(
f"Unexpected return code during gpg verification:\n"
f"{process_result.returncode}"
f"{process_result.stdout}"
)