From 4d9e7d2ea44af7e612de55cf6a4f4f590bbf6c9e Mon Sep 17 00:00:00 2001 From: Dax Harris Date: Fri, 29 Mar 2024 10:34:07 -0400 Subject: [PATCH] Add key revocation functions --- gpyg/operators/keys.py | 102 +++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- scratchpad.py | 9 ++-- tests/test_op_keys.py | 9 ++++ 4 files changed, 115 insertions(+), 7 deletions(-) diff --git a/gpyg/operators/keys.py b/gpyg/operators/keys.py index 00174ea..ffac880 100644 --- a/gpyg/operators/keys.py +++ b/gpyg/operators/keys.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from datetime import date, datetime, timedelta from enum import StrEnum +from tempfile import NamedTemporaryFile from typing import Any, Literal from pydantic import Field, PrivateAttr, computed_field @@ -615,6 +616,107 @@ def edit(self, user: str | None = None) -> Generator["KeyEditor", Any, Any]: editor = KeyEditor(self, user if user else self.fingerprint, interactive) yield editor + def generate_revocation( + self, + passphrase: str | None = None, + reason: KeyRevocationReason = KeyRevocationReason.KEY_COMPROMISED, + description: str = "", + ) -> str: + """Generate a revocation certificate for the specified key. + + Args: + passphrase (str | None, optional): The key's passphrase. Defaults to None. + reason (KeyRevocationReason, optional): A revocation reason, if desired. Defaults to KeyRevocationReason.KEY_COMPROMISED. + description (str, optional): A revocation description, if required. Defaults to "". + + Raises: + ValueError: If the specified password is invalid + ExecutionError: If another error occurs + + Returns: + str: The ASCII-armored representation of the revocation certificate. + """ + with StatusInteractive( + self.session, + f"gpg --status-fd 1 --pinentry-mode loopback --command-fd 0 --no-tty --gen-revoke {self.fingerprint}", + ) as inter: + result = [] + reading_result = False + + for line in inter.readlines(): + if line: + if "make_keysig_packet failed" in line.content: + raise ValueError("Bad password.") + if "-BEGIN PGP PUBLIC KEY BLOCK-" in line.content: + reading_result = True + result.append(line.content) + + elif "-END PGP PUBLIC KEY BLOCK-" in line.content: + reading_result = False + result.append(line.content) + break + + elif reading_result: + result.append(line.content) + elif line.is_status: + if ( + line.code == StatusCodes.GET_BOOL + and line.arguments[0] == "gen_revoke.okay" + ): + inter.writelines("y") + + if ( + line.code == StatusCodes.GET_LINE + and line.arguments[0] == "ask_revocation_reason.code" + ): + inter.writelines(str(reason)) + + if ( + line.code == StatusCodes.GET_LINE + and line.arguments[0] == "ask_revocation_reason.text" + ): + inter.writelines(description) + + if ( + line.code == StatusCodes.GET_BOOL + and line.arguments[0] == "ask_revocation_reason.okay" + ): + inter.writelines("y") + + if ( + line.code == StatusCodes.GET_HIDDEN + and line.arguments[0] == "passphrase.enter" + ): + inter.writelines(passphrase if passphrase else "") + elif inter.code: + inter.seek() + raise ExecutionError("Failed to execute:\n" + inter.read().decode()) + + return "\n".join(result) + + def revoke( + self, + passphrase: str | None = None, + reason: KeyRevocationReason = KeyRevocationReason.KEY_COMPROMISED, + description: str = "", + ): + """Convenience function to generate a revocation certificate and automatically apply it. + + Args: + passphrase (str | None, optional): Key passphrase. Defaults to None. + reason (KeyRevocationReason, optional): Revocation reason, if required. Defaults to KeyRevocationReason.KEY_COMPROMISED. + description (str, optional): Revocation description, if required. Defaults to "". + """ + cert = self.generate_revocation( + passphrase=passphrase, reason=reason, description=description + ) + with NamedTemporaryFile() as revocfile: + revocfile.write(cert.encode()) + revocfile.seek(0) + self.operator.import_key(revocfile.name) + + self.reload() + class KeyEditor: diff --git a/pyproject.toml b/pyproject.toml index 80cf135..81b533c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gpyg" -version = "0.1.2" +version = "0.2.0" dependencies = [ "typing-extensions", "pydantic" diff --git a/scratchpad.py b/scratchpad.py index f8d7f1f..e348b21 100644 --- a/scratchpad.py +++ b/scratchpad.py @@ -10,12 +10,9 @@ os.makedirs("./tmp", exist_ok=True) with TemporaryDirectory(dir="tmp", delete=False) as tmpdir: gpg = GPG(homedir=tmpdir, kill_existing_agent=True) - with gpg.smart_card() as card: - print(card.active) - if card.active: - card.reset() - card.set_usage_info("decrypt", True, "12345678") - # print(card.active.model_dump_json(indent=4)) + key = gpg.keys.generate_key("Test Key", passphrase="beans") + print(key.revoke(passphrase="beans")) + print(gpg.keys.list_keys()) """gpg = GPG(homedir=tmpdir, kill_existing_agent=True) key = gpg.keys.generate_key("Bongus", passphrase="test") diff --git a/tests/test_op_keys.py b/tests/test_op_keys.py index 82a8b55..037fb88 100644 --- a/tests/test_op_keys.py +++ b/tests/test_op_keys.py @@ -134,3 +134,12 @@ def test_import_key(environment): assert len(environment.keys.list_keys()) == 3 environment.keys.import_key(os.path.join(environment.homedir, "export.asc")) assert len(environment.keys.list_keys()) == 4 + + +def test_key_revocation(environment): + keys = environment.keys.list_keys() + assert len(keys) == 4 + assert keys[0].validity == FieldValidity.ULTIMATELY_VALID + keys[0].revoke(passphrase="test-psk-0") + keys = environment.keys.list_keys() + assert keys[0].validity == FieldValidity.REVOKED