#!/usr/bin/env python3 # Libervia: an XMPP client # Copyright (C) 2025-2026 Jérôme Poisosn (goffi@goffi.org) # Copyright (C) 2022-2026 Tim Henkes (me@syndace.dev) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Any import gpg from .exceptions import ( DecryptionFailed, GPGProviderError, InvalidPacket, UnknownKey, VerificationFailed, ) from .models import GPGProviderBase, GPGPublicKeyBase, GPGSecretKeyBase class GPGPublicKey(GPGPublicKeyBase): """ GPG public key implementation based on GnuPG Made Easy (GPGME). """ def __init__(self, key_obj: Any) -> None: """ @param key_obj: The GPGME key object. """ self.__key_obj = key_obj @property def fingerprint(self) -> str: return self.__key_obj.fpr @property def key_obj(self) -> Any: return self.__key_obj class GPGSecretKey(GPGSecretKeyBase): """ GPG secret key implementation based on GnuPG Made Easy (GPGME). """ def __init__(self, public_key: GPGPublicKey) -> None: """ @param public_key: The public key corresponding to this secret key. """ self.__public_key = public_key @property def public_key(self) -> GPGPublicKey: return self.__public_key class GPGProvider(GPGProviderBase): """ GPG provider implementation based on GnuPG Made Easy (GPGME). """ ALGORITHM = "ed25519/cert,sign+cv25519/encr" def __init__(self, home_dir: str | None = None) -> None: """ @param home_dir: Optional GPG home directory path to use for all operations. """ self.__home_dir = home_dir def export_public_key(self, public_key: GPGPublicKeyBase) -> bytes: assert isinstance(public_key, GPGPublicKey) pattern = public_key.fingerprint with gpg.Context(home_dir=self.__home_dir) as c: try: result = c.key_export_minimal(pattern) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e if result is None: raise UnknownKey(f"Public key {pattern} not found.") return result def import_public_key(self, packet: bytes) -> GPGPublicKeyBase: # TODO # - Reject packets older than version 4 # - Check whether it's actually a public key (through packet inspection?) with gpg.Context(home_dir=self.__home_dir) as c: try: result = c.key_import(packet) except gpg.errors.GPGMEError as e: # From looking at the code, `key_import` never raises. The documentation # says it does though, so this is included for future-proofness. raise GPGProviderError("Internal GPGME error") from e if not hasattr(result, "considered"): raise InvalidPacket( f"Data not considered for public key import: {result}" ) if len(result.imports) != 1: raise InvalidPacket( "Public key packet does not contain exactly one public key (not" " counting subkeys)." ) try: key_obj = c.get_key(result.imports[0].fpr, secret=False) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.KeyError as e: raise GPGProviderError("Newly imported public key not found") from e return GPGPublicKey(key_obj) def backup_secret_key(self, secret_key: GPGSecretKeyBase) -> bytes: assert isinstance(secret_key, GPGSecretKey) # TODO # - Handle password protection/pinentry # - Make sure the key is exported unencrypted pattern = secret_key.public_key.fingerprint with gpg.Context(home_dir=self.__home_dir) as c: try: result = c.key_export_secret(pattern) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e if result is None: raise UnknownKey(f"Secret key {pattern} not found.") return result def restore_secret_keys(self, data: bytes) -> set[GPGSecretKeyBase]: # TODO # - Reject packets older than version 4 # - Check whether it's actually secret keys (through packet inspection?) with gpg.Context(home_dir=self.__home_dir) as c: try: result = c.key_import(data) except gpg.errors.GPGMEError as e: # From looking at the code, `key_import` never raises. The documentation # says it does though, so this is included for future-proofness. raise GPGProviderError("Internal GPGME error") from e if not hasattr(result, "considered"): raise InvalidPacket( f"Data not considered for secret key import: {result}" ) if len(result.imports) == 0: raise InvalidPacket("Secret key packet does not contain a secret key.") secret_keys = set() for import_status in result.imports: try: key_obj = c.get_key(import_status.fpr, secret=True) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.KeyError as e: raise GPGProviderError("Newly imported secret key not found") from e secret_keys.add(GPGSecretKey(GPGPublicKey(key_obj))) return secret_keys def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes: with gpg.Context(home_dir=self.__home_dir) as c: try: ciphertext, __, __ = c.encrypt(plaintext, passphrase=password, sign=False) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e return ciphertext def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes: with gpg.Context(home_dir=self.__home_dir) as c: try: plaintext, __, __ = c.decrypt( ciphertext, passphrase=password, verify=False ) except gpg.errors.GPGMEError as e: # TODO: Find out what kind of error is raised if the password is wrong and # re-raise it as DecryptionFailed instead. raise GPGProviderError("Internal GPGME error") from e except gpg.UnsupportedAlgorithm as e: raise DecryptionFailed("Unsupported algorithm") from e return plaintext def sign(self, data: bytes, secret_keys: set[GPGSecretKeyBase]) -> bytes: signers = [] for secret_key in secret_keys: assert isinstance(secret_key, GPGSecretKey) signers.append(secret_key.public_key.key_obj) with gpg.Context(home_dir=self.__home_dir, signers=signers) as c: try: signed_data, __ = c.sign(data) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.InvalidSigners as e: raise GPGProviderError( "At least one of the secret keys is invalid for signing" ) from e return signed_data def sign_detached(self, data: bytes, secret_keys: set[GPGSecretKeyBase]) -> bytes: signers = [] for secret_key in secret_keys: assert isinstance(secret_key, GPGSecretKey) signers.append(secret_key.public_key.key_obj) with gpg.Context(home_dir=self.__home_dir, signers=signers) as c: try: signature, __ = c.sign(data, mode=gpg.constants.sig.mode.DETACH) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.InvalidSigners as e: raise GPGProviderError( "At least one of the secret keys is invalid for signing" ) from e return signature def verify(self, signed_data: bytes, public_keys: set[GPGPublicKeyBase]) -> bytes: with gpg.Context(home_dir=self.__home_dir) as c: try: data, result = c.verify(signed_data) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.BadSignatures as e: raise VerificationFailed("Bad signatures on signed data") from e valid_signature_found = False for public_key in public_keys: assert isinstance(public_key, GPGPublicKey) for subkey in public_key.key_obj.subkeys: for sig in result.signatures: if subkey.can_sign and subkey.fpr == sig.fpr: valid_signature_found = True if not valid_signature_found: raise VerificationFailed( "Data not signed by one of the expected public keys" ) return data def verify_detached( self, data: bytes, signature: bytes, public_keys: set[GPGPublicKeyBase] ) -> None: with gpg.Context(home_dir=self.__home_dir) as c: try: __, result = c.verify(data, signature=signature) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.BadSignatures as e: raise VerificationFailed("Bad signatures on signed data") from e valid_signature_found = False for public_key in public_keys: assert isinstance(public_key, GPGPublicKey) for subkey in public_key.key_obj.subkeys: for sig in result.signatures: if subkey.can_sign and subkey.fpr == sig.fpr: valid_signature_found = True if not valid_signature_found: raise VerificationFailed( "Data not signed by one of the expected public keys" ) def encrypt( self, plaintext: bytes, public_keys: set[GPGPublicKeyBase], signing_keys: set[GPGSecretKeyBase] | None = None, ) -> bytes: recipients = [] for public_key in public_keys: assert isinstance(public_key, GPGPublicKey) recipients.append(public_key.key_obj) signers = [] if signing_keys is not None: for secret_key in signing_keys: assert isinstance(secret_key, GPGSecretKey) signers.append(secret_key.public_key.key_obj) sign = signing_keys is not None kwargs = {"home_dir": self.__home_dir, "signers": signers} with gpg.Context(**kwargs) as c: try: ciphertext, __, __ = c.encrypt( plaintext, recipients=recipients, sign=sign, always_trust=True, add_encrypt_to=True, ) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.InvalidRecipients as e: raise GPGProviderError( "At least one of the public keys is invalid for encryption" ) from e except gpg.errors.InvalidSigners as e: raise GPGProviderError( "At least one of the signing keys is invalid for signing" ) from e return ciphertext def decrypt( self, ciphertext: bytes, secret_keys: set[GPGSecretKeyBase], public_keys: set[GPGPublicKeyBase] | None = None, ) -> bytes: verify = public_keys is not None with gpg.Context(home_dir=self.__home_dir) as c: try: plaintext, result, verify_result = c.decrypt(ciphertext, verify=verify) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.UnsupportedAlgorithm as e: raise DecryptionFailed("Unsupported algorithm") from e # TODO: Check whether the data was decrypted using one of the expected secret # keys if public_keys is not None: valid_signature_found = False for public_key in public_keys: assert isinstance(public_key, GPGPublicKey) for subkey in public_key.key_obj.subkeys: for sig in verify_result.signatures: if subkey.can_sign and subkey.fpr == sig.fpr: valid_signature_found = True if not valid_signature_found: raise VerificationFailed( "Data not signed by one of the expected public keys" ) return plaintext def list_public_keys(self, user_id: str) -> set[GPGPublicKeyBase]: with gpg.Context(home_dir=self.__home_dir) as c: try: return { GPGPublicKey(key) for key in c.keylist(pattern=user_id, secret=False) } except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e def list_secret_keys(self, user_id: str) -> set[GPGSecretKeyBase]: with gpg.Context(home_dir=self.__home_dir) as c: try: return { GPGSecretKey(GPGPublicKey(key)) for key in c.keylist(pattern=user_id, secret=True) } except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e def can_sign(self, public_key: GPGPublicKeyBase) -> bool: assert isinstance(public_key, GPGPublicKey) return any(subkey.can_sign for subkey in public_key.key_obj.subkeys) def can_encrypt(self, public_key: GPGPublicKeyBase) -> bool: assert isinstance(public_key, GPGPublicKey) return any(subkey.can_encrypt for subkey in public_key.key_obj.subkeys) def create_key(self, user_id: str) -> GPGSecretKeyBase: with gpg.Context(home_dir=self.__home_dir) as c: try: # A single ECC (primary) key with signing and encryption capabilities is # not supported, a subkey is required for encryption. Create a primary key # only capable of certifying subkeys primary_create_result = c.create_key( user_id, algorithm=self.ALGORITHM, expires=False, certify=True, force=True, ) primary_key_obj = c.get_key(primary_create_result.fpr, secret=True) c.create_subkey( primary_key_obj, algorithm=self.ALGORITHM, expires=False, sign=True ) c.create_subkey( primary_key_obj, algorithm=self.ALGORITHM, expires=False, encrypt=True, ) except gpg.errors.GPGMEError as e: raise GPGProviderError("Internal GPGME error") from e except gpg.errors.KeyNotFound as e: raise GPGProviderError("Newly created key not found") from e return GPGSecretKey(GPGPublicKey(primary_key_obj))