libervia-backend/libervia/backend/tools/common/gpg_provider/gpgme.py
Goffi 7325353d18 plugin XEP-0373, tools: Move GPG backend to tools.common:
GPG backend will be used for backup, and may be used in general to handle GPG.

rel 480
2026-09-15 11:48:11 +02:00

441 lines
16 KiB
Python

#!/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 <http://www.gnu.org/licenses/>.
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))