# -*- coding: utf-8 -*- # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2024, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import hashlib from datetime import datetime from datetime import timedelta from datetime import timezone from ..apprise_attachment import AppriseAttachment from ..asset import AppriseAsset from ..logger import logger from ..exception import ApprisePluginException try: import pgpy # Pretty Good Privacy (PGP) Support enabled PGP_SUPPORT = True except ImportError: # Pretty Good Privacy (PGP) Support disabled PGP_SUPPORT = False class ApprisePGPException(ApprisePluginException): """ Thrown when there is an error with the Pretty Good Privacy Controller """ def __init__(self, message, error_code=602): super().__init__(message, error_code=error_code) class ApprisePGPController: """ Pretty Good Privacy Controller Tool for the Apprise Library """ # There is no reason a PGP Public Key should exceed 8K in size # If it is more than this, then it is not accepted max_pgp_public_key_size = 8000 def __init__(self, path, pub_keyfile=None, email=None, asset=None, **kwargs): """ Path should be the directory keys can be written and read from such as .store.path Optionally additionally specify a keyfile to explicitly open """ # PGP hash self.__key_lookup = {} # Directory we can work with self.path = path # Our email self.email = email # Prepare our Asset Object self.asset = \ asset if isinstance(asset, AppriseAsset) else AppriseAsset() if pub_keyfile: # Create ourselves an Attachment to work with; this grants us the # ability to pull this key from a remote site or anything else # supported by the Attachment object self._pub_keyfile = AppriseAttachment(asset=self.asset) # Add our definition to our pgp_key reference self._pub_keyfile.add(pub_keyfile) # Enforce maximum file size self._pub_keyfile[0].max_file_size = self.max_pgp_public_key_size else: self._pub_keyfile = None def keygen(self, email=None, name=None, force=False): """ Generates a set of keys based on email configured. """ try: # Create a new RSA key pair with 2048-bit strength key = pgpy.PGPKey.new( pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048) except NameError: # PGPy not installed logger.debug('PGPy not installed; keygen disabled') return False if self._pub_keyfile is not None or not self.path: logger.trace( 'PGP keygen disabled, reason=%s', 'keyfile-defined' if self._pub_keyfile is not None else 'no-write-path') return False if not name: name = self.asset.app_id if not email: email = self.email # Prepare our UID uid = pgpy.PGPUID.new(name, email=email) # Filenames file_prefix = email.split('@')[0].lower() pub_path = os.path.join(self.path, f'{file_prefix}-pub.asc') prv_path = os.path.join(self.path, f'{file_prefix}-prv.asc') if os.path.isfile(pub_path) and not force: logger.debug( 'PGP generation skipped; Public Key already exists: %s', pub_path) return True # Persistent Storage Key lookup_key = hashlib.sha1( os.path.abspath(pub_path).encode('utf-8')).hexdigest() if lookup_key in self.__key_lookup: # Ensure our key no longer exists del self.__key_lookup[lookup_key] # Add the user ID to the key key.add_uid(uid, usage={ pgpy.constants.KeyFlags.Sign, pgpy.constants.KeyFlags.EncryptCommunications}, hashes=[pgpy.constants.HashAlgorithm.SHA256], ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256], compression=[pgpy.constants.CompressionAlgorithm.ZLIB]) try: # Write our keys to disk with open(pub_path, 'w') as f: f.write(str(key.pubkey)) except OSError as e: logger.warning('Error writing PGP file %s', pub_path) logger.debug(f'I/O Exception: {e}') # Cleanup try: os.unlink(pub_path) logger.trace('Removed %s', pub_path) except OSError: pass try: with open(prv_path, 'w') as f: f.write(str(key)) except OSError as e: logger.warning('Error writing PGP file %s', prv_path) logger.debug(f'I/O Exception: {e}') try: os.unlink(pub_path) logger.trace('Removed %s', pub_path) except OSError: pass try: os.unlink(prv_path) logger.trace('Removed %s', prv_path) except OSError: pass return False logger.info( 'Wrote PGP Keys for %s/%s', os.path.dirname(pub_path), os.path.basename(pub_path)) return True def public_keyfile(self, *emails): """ Returns the first match of a useable public key based emails provided """ if not PGP_SUPPORT: msg = 'PGP Support unavailable; install PGPy library' logger.warning(msg) raise ApprisePGPException(msg) if self._pub_keyfile is not None: # If our code reaches here, then we fetch our public key pgp_key = self._pub_keyfile[0] if not pgp_key: # We could not access the attachment logger.error( 'Could not access PGP Public Key {}.'.format( pgp_key.url(privacy=True))) return False return pgp_key.path elif not self.path: # No path return None fnames = [ 'pgp-public.asc', 'pgp-pub.asc', 'public.asc', 'pub.asc', ] if self.email: # Include our email in the list emails = [self.email] + [*emails] for email in emails: _entry = email.split('@')[0].lower() fnames.insert(0, f'{_entry}-pub.asc') # Lowercase email (Highest Priority) _entry = email.lower() fnames.insert(0, f'{_entry}-pub.asc') return next( (os.path.join(self.path, fname) for fname in fnames if os.path.isfile(os.path.join(self.path, fname))), None) def public_key(self, *emails, autogen=None): """ Opens a spcified pgp public file and returns the key from it which is used to encrypt the message """ path = self.public_keyfile(*emails) if not path: if (autogen if autogen is not None else self.asset.pgp_autogen) \ and self.keygen(*emails): path = self.public_keyfile(*emails) if path: # We should get a hit now return self.public_key(*emails) logger.warning('No PGP Public Key could be loaded') return None # Persistent Storage Key key = hashlib.sha1( os.path.abspath(path).encode('utf-8')).hexdigest() if key in self.__key_lookup: # Take an early exit return self.__key_lookup[key]['public_key'] try: with open(path, 'r') as key_file: public_key, _ = pgpy.PGPKey.from_blob(key_file.read()) except NameError: # PGPy not installed logger.debug( 'PGPy not installed; skipping PGP support: %s', path) return None except FileNotFoundError: # Generate keys logger.debug('PGP Public Key file not found: %s', path) return None except OSError as e: logger.warning('Error accessing PGP Public Key file %s', path) logger.debug(f'I/O Exception: {e}') return None self.__key_lookup[key] = { 'public_key': public_key, 'expires': datetime.now(timezone.utc) + timedelta(seconds=86400) } return public_key # Encrypt message using the recipient's public key def encrypt(self, message, *emails): """ If provided a path to a pgp-key, content is encrypted """ # Acquire our key public_key = self.public_key(*emails) if not public_key: # Encryption not possible return False try: message_object = pgpy.PGPMessage.new(message) encrypted_message = public_key.encrypt(message_object) return str(encrypted_message) except pgpy.errors.PGPError: # Encryption not Possible logger.debug( 'PGP Public Key Corruption; encryption not possible') except NameError: # PGPy not installed logger.debug('PGPy not installed; Skipping PGP encryption') return None def prune(self): """ Prunes old entries from the public_key index """ self.__key_lookup = { key: value for key, value in self.__key_lookup.items() if value['expires'] > datetime.now(timezone.utc)} @property def pub_keyfile(self): """ Returns the Public Keyfile Path if set otherwise it returns None This property returns False if a keyfile was provided, but was invalid """ return None if self._pub_keyfile is None else ( False if not self._pub_keyfile[0] else self._pub_keyfile[0].path)