I got tired of Evolution email client giving me those horrid error messages when ever I try to email someone who’s key isn’t in my current list of keys.
The design of this is appallingly bad. It discourages the use of GPG rather than encouraging the importing of keys and it makes no mention of helping you acquire keys if possible. It also allows for no additional or optional footer to explain to the recipient that their message couldn’t be encrypted because they don’t use GPG.
While I couldn’t do much about the later, without hacking on the evolution codebase directly. I did do a bit of hacking on the former with a gpg middlware. Yes, when I say hack, I mean HACK. A dangerous and potentially devastating way of wrapping the gpg binary with my own python script that could intercept the evolution call and do work to search, display and add keys to encourage the use of encryption overall.
The design was simple. When we are asked to encrypt for a person who we don’t have the keys for, we do a search. The results are shown in a GUI to the user and they can select a key to use. This then is added to the key ring and used to encrypt the email.
This setup allows for experimentation with user prompting and workflow. It’s not something I would recommend be installed on user’s computers. But for designers and developers, this sort of match-stick making is a valuable platform to build, try, test and rebuild quickly.
I use zenity for the user interface. This is a Gtk command line tool that lets you launch a window from the command line and the interface is good enough to support photos in lists and returning which item was selected. Very cool.
Bellow you will find the script I created for this hack, this is saved to /usr/bin/gpg and gpg is moved to gpg.orig:
#!/usr/bin/python # # Wrap the gpg command to provide evolution with a bit of extra functionality # This is certainly a hack and you should feel very bad about using it. # # Public Domain, Authored by Martin Owens2016 # import os import sys import atexit from collections import defaultdict from subprocess import Popen, PIPE, call from tempfile import mkdtemp, mktemp from datetime import date from shutil import rmtree to_date = lambda d: date(*[int(p) for p in d.split('-')]) class GPG(object): keyserver = 'hkp://pgp.mit.edu' remote_commands = ['--search-keys', '--recv-keys'] def __init__(self, cmd='/usr/bin/gpg', local=False): self.command = cmd self.photos = [] self.local = local self.homedir = mkdtemp() if local else None atexit.register(self.at_exit) def at_exit(self): """Remove any temporary files and cleanup""" # Clean up any used local home directory (only if it's local) if self.local and self.homedir and os.path.isdir(self.homedir): rmtree(self.homedir) # Clean up any downloaded photo-ids for photo in self.photos: if os.path.isfile(photo): os.unlink(photo) try: os.rmdir(os.path.dirname(photo)) except OSError: pass def __call__(self, *args): """Call gpg command for result""" # Add key server if required if any([cmd in args for cmd in self.remote_commands]): args = ('--keyserver', self.keyserver) + args if self.homedir: args = ('--homedir', self.homedir) + args command = Popen([self.command, '--batch'] + list(args), stdout=PIPE) (out, err) = command.communicate() self.status = command.returncode return out def list_keys(self, *keys, **options): """Returns a list of keys (with photos if needed)""" with_photos = options.get('photos', False) args = () if with_photos: args += ('--list-options', 'show-photos', '--photo-viewer', 'echo PHOTO:%I') out = self(*(args + ('--list-keys',) + keys)) # Processing the output with this parser units = [] current = defaultdict(list) for line in out.split('\n'): if not line.strip(): # We should always output entries if they have a uid and key if current and 'uid' in current and 'key' in current: # But ignore revoked keys if revoked option is True if not (current.get('revoked', False) and options.get('revoked', False)): units.append(dict(current)) current = defaultdict(list) elif line.startswith('PHOTO:'): current['photo'] = line.split(':', 1)[-1] self.photos.append(current['photo']) elif ' of size ' in line: continue elif ' ' in line: (kind, line) = line.split(' ', 1) if kind == 'pub': current['expires'] = False current['revoked'] = False if '[' in line: (line, mod) = line.strip().split('[', 1) (mod, _) = mod.split(']', 1) if ': ' in mod: (mod, edited) = mod.split(': ', 1) current[mod] = to_date(edited) (key, created) = line.split(' ', 1) current['created'] = to_date(created) (current['bits'], current['key']) = key.split('/', 1) elif kind in ('uid', 'sub'): current[kind].append(line.strip()) else: current[kind] = line.strip() return units @property def default_photo(self): if not hasattr(self, '_photo'): self._photo = mktemp('.svg') with open(self._photo, 'w') as fhl: fhl.write("""""") self.photos.append(self._photo) return self._photo def recieve_keys(self, *keys, **options): """Present the opotunity to add the key to the user: Returns - True if the key was already or is now imported. - False if keys were available but the user canceled. - None if no keys were found within the search. """ keys = self.search_keys(*keys) if not keys: return None # User doesn't have GPG # Always use a temporary gpg home to review keys gpg = GPG(cmd=self.command, local=True) if not self.local else self # B. Import each of the keys gpg('--recv-keys', *zip(*keys)[0]) # C. List keys (with photo options) choices = [] for key in gpg.list_keys(photos=True): choices.append(key.get('photo', self.default_photo)) choices.append('\n'.join(key['uid'])) choices.append(key['key']) choices.append(str(key['expires'])) if len(choices) / 4 == 1: title = "Can I use this GPG key to encrypt for this user?" else: title = "Please select the GPG key to use for encryption" # Show using gtk zenity (easier than gtk3 directly) p = Popen(['zenity', '--width', '900', '--height', '700', '--title', title, '--list', '--imagelist', '--print-column', '3', '--column', 'Photo ID', '--column', 'ID', '--column', 'Key', '--column', 'Expires', ] + choices, stdout=PIPE, stderr=PIPE) # Returncode is generated after communicate! key = p.communicate()[0].strip() # Select the default first key if one choice. # (person pressed ok without looking) if not key and len(choices) == 4: key = choices[2] if p.returncode != 0: # Cancel was pressed return False # E. Import the selected key self('--recv-keys', key) return self.status == 0 def is_key_available(self, search): """Return False if the email is not found in the local key list""" self('--list-keys', search) if self.status == 2: # Keys not found return False # We return true, even if gpg returned some other kind of error # Because this prevents us running more commands to a broken gpg return True def search_keys(self, *keys): """Returns a list of (key_id, info) tuples from a search""" out = self('--search-keys', *keys) found = [] prev = [] for line in out.split("\n"): if line.startswith('gpg:'): continue if 'created:' in line: key_id = line.split('key ')[-1].split(',')[0] if '(revoked)' not in line: found.append((key_id, prev)) prev = [] else: prev.append(line) return found if __name__ == '__main__': cmd = sys.argv[0] + '.orig' if not os.path.isfile(cmd): sys.stderr.write("Can't find pass-through command '%s'\n" % args[0]) sys.exit(-13) args = [cmd] + sys.argv[1:] # Check to see if call is from an application if 'GIO_LAUNCHED_DESKTOP_FILE' in os.environ: # We use our moved gpg command file gpg = GPG(cmd=cmd) # Check if we've got a missing key during an encryption, we get the # very next argument after a -r or -R argument (which should be # the email address) for recipient in [args[i+1] for (i, v) in enumerate(args) if v in ('-r', '-R')]: # Only check email addresses if '@' in recipient: if not gpg.is_key_available(recipient): if gpg.recieve_keys(recipient) is None: pass # We can add a footer to the message here explaining GPG # We can't do this, evolution will wrap it all up in a # message structure. #msg = sys.stdin.read() #if msg: # msg += GPG_TRIED_FOOTER #sys.stdout.write(msg) #sys.exit(0) # We call and do not PIPE anything (pass-through) try: sys.exit(call(args)) except KeyboardInterrupt: sys.exit(-14)