2020-10-05 00:18:00 +00:00
|
|
|
#!/usr/local/lib/mailinabox/env/bin/python
|
|
|
|
# WDK (Web Key Directory) Manager: Facilitates discovery of keys by third-parties
|
2020-12-06 02:27:04 +00:00
|
|
|
# Current relevant documents: https://tools.ietf.org/id/draft-koch-openpgp-webkey-service-11.html
|
2020-10-05 00:18:00 +00:00
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
import pgp
|
|
|
|
import utils
|
|
|
|
import rtyaml
|
|
|
|
import mailconfig
|
|
|
|
import copy
|
|
|
|
import shutil
|
|
|
|
import os
|
|
|
|
import re
|
2020-10-17 21:12:40 +00:00
|
|
|
from cryptography.hazmat.primitives import hashes
|
2021-02-11 00:38:23 +00:00
|
|
|
from cryptography.hazmat.backends import default_backend
|
2020-10-05 00:18:00 +00:00
|
|
|
|
|
|
|
env = utils.load_environment()
|
|
|
|
|
2020-12-08 19:54:59 +00:00
|
|
|
wkdpath = f"{env['GNUPGHOME']}/.wkdlist.yml"
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-08 19:54:59 +00:00
|
|
|
class WKDError(Exception):
|
|
|
|
"""
|
|
|
|
Errors specifically related to WKD.
|
|
|
|
"""
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-08 19:54:59 +00:00
|
|
|
def __init__(self, msg):
|
|
|
|
self.message = msg
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return self.message
|
2020-10-05 00:18:00 +00:00
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-10-17 21:12:40 +00:00
|
|
|
def sha1(message):
|
2022-02-04 23:26:24 +00:00
|
|
|
h = hashes.Hash(hashes.SHA1(), default_backend())
|
|
|
|
h.update(message)
|
|
|
|
return h.finalize()
|
|
|
|
|
2020-10-17 21:12:40 +00:00
|
|
|
|
|
|
|
def zbase32(digest):
|
2022-02-04 23:26:24 +00:00
|
|
|
# Crudely check if all quintets are complete
|
|
|
|
if len(digest) % 5 != 0:
|
|
|
|
raise ValueError("Digest cannot have incomplete chunks of 40 bits!")
|
|
|
|
base = "ybndrfg8ejkmcpqxot1uwisza345h769"
|
|
|
|
encoded = ""
|
|
|
|
for i in range(0, len(digest), 5):
|
|
|
|
chunk = int.from_bytes(digest[i:i + 5], byteorder="big")
|
|
|
|
for j in range(35, -5, -5):
|
|
|
|
encoded += base[(chunk >> j) & 31]
|
|
|
|
return encoded
|
2020-10-17 21:12:40 +00:00
|
|
|
|
2020-12-06 01:37:20 +00:00
|
|
|
|
2021-03-29 16:14:44 +00:00
|
|
|
# Strips and exports a key so that only the UID's with the provided email remain.
|
2020-12-06 02:27:04 +00:00
|
|
|
# This is to comply with the following requirement, set forth in section 5 of the draft:
|
|
|
|
#
|
|
|
|
# The mail provider MUST make sure to publish a key in a way
|
|
|
|
# that only the mail address belonging to the requested user
|
|
|
|
# is part of the User ID packets included in the returned key.
|
|
|
|
# Other User ID packets and their associated binding signatures
|
|
|
|
# MUST be removed before publication.
|
2021-02-11 01:00:44 +00:00
|
|
|
#
|
2021-03-29 15:25:50 +00:00
|
|
|
# The arguments "buffer" and "context" are automatically added
|
|
|
|
# by the pgp.fork_context() decorator.
|
2020-12-08 19:54:59 +00:00
|
|
|
@pgp.fork_context
|
2021-03-29 16:14:44 +00:00
|
|
|
def strip_and_export(fpr, target_email, buffer=None, context=None):
|
2022-02-04 23:26:24 +00:00
|
|
|
context.armor = False # We need to disable armor output for this key
|
2020-12-06 02:27:04 +00:00
|
|
|
k = pgp.get_key(fpr, context)
|
|
|
|
if k is None:
|
|
|
|
return None
|
|
|
|
|
2021-03-29 16:14:44 +00:00
|
|
|
# Horrible hack: Because it's a reference (aka pointer), we can pass these around the functions
|
2022-02-04 23:26:24 +00:00
|
|
|
statusref = {"seq_read": False, "sequence": [], "seq_number": -1}
|
2021-03-29 16:14:44 +00:00
|
|
|
|
|
|
|
def parse_key_dump(dump):
|
|
|
|
UID_REGEX = r".*:.* <(.*)>:.*:([0-9]),.*"
|
|
|
|
at_least_one_not_deleted = False
|
|
|
|
lines = dump.decode().split("\n")
|
|
|
|
|
|
|
|
for line in lines:
|
|
|
|
if line[0:3] != "uid":
|
|
|
|
continue
|
|
|
|
# It's a uid, find the email and the "tag"
|
|
|
|
m = re.search(UID_REGEX, line)
|
2021-03-29 16:17:39 +00:00
|
|
|
|
2021-03-29 16:14:44 +00:00
|
|
|
if m.group(1) != target_email:
|
|
|
|
statusref["sequence"].append(f"uid {m.group(2)}")
|
|
|
|
else:
|
|
|
|
at_least_one_not_deleted = True
|
|
|
|
|
|
|
|
if not at_least_one_not_deleted:
|
|
|
|
raise WKDError("All UID's in this key would have been deleted!")
|
|
|
|
|
|
|
|
statusref["sequence"] += ["deluid", "save"]
|
|
|
|
statusref["seq_read"] = True
|
|
|
|
|
2020-12-06 02:27:04 +00:00
|
|
|
def interaction(request, prompt):
|
2021-03-29 15:25:50 +00:00
|
|
|
if request in ["GOT_IT", "KEY_CONSIDERED", "KEYEXPIRED", ""]:
|
2020-12-06 02:27:04 +00:00
|
|
|
return 0
|
|
|
|
elif request == "GET_BOOL":
|
|
|
|
# No way to confirm interactively, so we just say yes
|
2022-02-04 23:26:24 +00:00
|
|
|
return "y" # Yeah, I'd also rather just return True but that doesn't work
|
2020-12-06 02:27:04 +00:00
|
|
|
elif request == "GET_LINE" and prompt == "keyedit.prompt":
|
2021-03-29 16:14:44 +00:00
|
|
|
if not statusref["seq_read"]:
|
|
|
|
buffer.seek(0, os.SEEK_SET)
|
|
|
|
parse_key_dump(buffer.read())
|
|
|
|
|
|
|
|
statusref["seq_number"] += 1
|
|
|
|
seqnum = statusref["seq_number"]
|
|
|
|
return statusref["sequence"][seqnum]
|
2020-12-06 02:27:04 +00:00
|
|
|
else:
|
|
|
|
raise Exception("No idea of what to do!")
|
|
|
|
|
2021-03-29 16:14:44 +00:00
|
|
|
buffer.seek(0, os.SEEK_SET)
|
|
|
|
buffer.write(b'')
|
|
|
|
context.interact(k, interaction, sink=buffer)
|
2020-12-06 02:27:04 +00:00
|
|
|
return pgp.export_key(fpr, context)
|
2020-12-06 01:37:20 +00:00
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-08 22:18:59 +00:00
|
|
|
def email_compatible_with_key(email, fingerprint):
|
2020-12-08 19:54:59 +00:00
|
|
|
# 1. Does the user exist?
|
2021-03-07 23:39:31 +00:00
|
|
|
if not email in mailconfig.get_all_mail_addresses(env):
|
2020-12-08 22:18:59 +00:00
|
|
|
raise ValueError(f"User or alias {email} not found!")
|
2020-12-08 19:54:59 +00:00
|
|
|
|
|
|
|
if fingerprint is not None:
|
|
|
|
key = pgp.get_key(fingerprint)
|
|
|
|
# 2. Does the key exist?
|
|
|
|
if key is None:
|
2020-12-08 20:05:21 +00:00
|
|
|
raise ValueError(f"The key \"{fingerprint}\" does not exist!")
|
2020-12-08 19:54:59 +00:00
|
|
|
|
|
|
|
# 3. Does the key have a user id with the email of the user?
|
2020-12-08 22:18:59 +00:00
|
|
|
if email not in [u.email for u in key.uids]:
|
2022-02-04 23:26:24 +00:00
|
|
|
raise WKDError(
|
|
|
|
f"The key \"{fingerprint}\" has no such UID with the email \"{email}\"!"
|
|
|
|
)
|
2020-12-08 22:18:59 +00:00
|
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-26 22:34:19 +00:00
|
|
|
# Gets a table with all the keys that can be served for each user and/or alias
|
2022-02-04 23:26:24 +00:00
|
|
|
|
|
|
|
|
2020-12-26 22:34:19 +00:00
|
|
|
def get_user_fpr_maps():
|
|
|
|
uk_maps = {}
|
2021-03-07 23:39:31 +00:00
|
|
|
for email in mailconfig.get_all_mail_addresses(env):
|
2021-02-10 02:21:36 +00:00
|
|
|
uk_maps[email] = set()
|
2020-12-26 22:34:19 +00:00
|
|
|
for key in pgp.get_imported_keys() + [pgp.get_daemon_key()]:
|
|
|
|
for userid in key.uids:
|
|
|
|
try:
|
2021-02-10 02:21:36 +00:00
|
|
|
uk_maps[userid.email].add(key.fpr)
|
2021-02-10 02:06:00 +00:00
|
|
|
except:
|
2020-12-26 22:34:19 +00:00
|
|
|
# We don't host this email address, so ignore
|
|
|
|
pass
|
|
|
|
return uk_maps
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2021-02-07 02:42:16 +00:00
|
|
|
# Gets the current WKD configuration
|
2022-02-04 23:26:24 +00:00
|
|
|
|
|
|
|
|
2021-02-07 02:42:16 +00:00
|
|
|
def get_wkd_config():
|
2021-02-13 01:42:04 +00:00
|
|
|
# Test
|
|
|
|
try:
|
|
|
|
with open(wkdpath, "x"):
|
|
|
|
pass
|
|
|
|
except:
|
|
|
|
pass
|
|
|
|
|
2021-02-07 02:42:16 +00:00
|
|
|
with open(wkdpath, "r") as wkdfile:
|
|
|
|
try:
|
|
|
|
config = rtyaml.load(wkdfile)
|
|
|
|
if (type(config) != dict):
|
|
|
|
return {}
|
|
|
|
return config
|
|
|
|
except:
|
|
|
|
return {}
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2021-02-11 00:34:36 +00:00
|
|
|
# Sets the WKD configuration. Takes a dictionary {email: fingerprint}.
|
2020-12-08 22:18:59 +00:00
|
|
|
# email: An user or alias on this box. e.g. "administrator@example.com"
|
|
|
|
# fingerprint: The fingerprint of the key we want to bind it to. e.g "0123456789ABCDEF0123456789ABCDEF01234567"
|
2022-02-04 23:26:24 +00:00
|
|
|
|
|
|
|
|
2021-02-11 00:34:36 +00:00
|
|
|
def update_wkd_config(config_sample):
|
|
|
|
config = dict(config_sample)
|
|
|
|
for email, fingerprint in config_sample.items():
|
|
|
|
try:
|
|
|
|
if fingerprint is None or fingerprint == "":
|
|
|
|
config.pop(email)
|
|
|
|
else:
|
|
|
|
email_compatible_with_key(email, fingerprint)
|
|
|
|
except Exception as err:
|
|
|
|
raise err
|
2020-10-05 00:18:00 +00:00
|
|
|
|
2020-12-08 19:54:59 +00:00
|
|
|
# All conditions met, do the necessary modifications
|
2021-02-11 00:55:56 +00:00
|
|
|
with open(wkdpath, "w") as wkdfile:
|
2020-12-08 22:18:59 +00:00
|
|
|
wkdfile.write(rtyaml.dump(config))
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-08 22:18:59 +00:00
|
|
|
# Looks for incompatible email/key pairs on the WKD configuration file
|
|
|
|
# and returns the uid indexes for compatible email/key pairs
|
2022-02-04 23:26:24 +00:00
|
|
|
|
|
|
|
|
2020-12-08 22:18:59 +00:00
|
|
|
def parse_wkd_list():
|
|
|
|
removed = []
|
|
|
|
uidlist = []
|
|
|
|
with open(wkdpath, "a+") as wkdfile:
|
|
|
|
wkdfile.seek(0)
|
|
|
|
config = {}
|
|
|
|
try:
|
|
|
|
config = rtyaml.load(wkdfile)
|
|
|
|
if (type(config) != dict):
|
|
|
|
config = {}
|
|
|
|
except:
|
|
|
|
config = {}
|
2020-12-08 22:30:43 +00:00
|
|
|
|
|
|
|
writeable = copy.deepcopy(config)
|
2020-12-08 22:18:59 +00:00
|
|
|
for u, k in config.items():
|
|
|
|
try:
|
|
|
|
key = email_compatible_with_key(u, k)
|
|
|
|
# Key is compatible
|
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
# Swap with the full-length fingerprint (if somehow this was changed by hand)
|
|
|
|
writeable[u] = key.fpr
|
2021-03-29 15:32:47 +00:00
|
|
|
uidlist.append((u, key.fpr))
|
2020-12-08 22:18:59 +00:00
|
|
|
except:
|
2020-12-08 22:30:43 +00:00
|
|
|
writeable.pop(u)
|
2020-12-08 22:18:59 +00:00
|
|
|
removed.append((u, k))
|
|
|
|
# Shove the updated configuration back in the file
|
2020-12-08 19:54:59 +00:00
|
|
|
wkdfile.truncate(0)
|
2020-12-08 22:30:43 +00:00
|
|
|
wkdfile.write(rtyaml.dump(writeable))
|
2020-12-08 22:18:59 +00:00
|
|
|
return (removed, uidlist)
|
2020-12-20 23:49:36 +00:00
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2021-02-11 00:34:36 +00:00
|
|
|
WKD_LOCATION = "/var/lib/mailinabox/wkd/"
|
2020-12-20 23:49:36 +00:00
|
|
|
|
2022-02-04 23:26:24 +00:00
|
|
|
|
2020-12-20 23:49:36 +00:00
|
|
|
def build_wkd():
|
|
|
|
# Clean everything
|
2021-02-11 00:34:36 +00:00
|
|
|
try:
|
|
|
|
shutil.rmtree(WKD_LOCATION)
|
|
|
|
except FileNotFoundError:
|
|
|
|
pass
|
|
|
|
|
2021-02-13 01:42:04 +00:00
|
|
|
os.mkdir(WKD_LOCATION, mode=0o755)
|
2020-12-20 23:49:36 +00:00
|
|
|
|
|
|
|
# We serve WKD for all our emails and aliases (even if there are no keys)
|
|
|
|
for domain in mailconfig.get_mail_domains(env, users_only=False):
|
2021-02-13 01:42:04 +00:00
|
|
|
os.mkdir(f"{WKD_LOCATION}/{domain}/", mode=0o755)
|
2020-12-20 23:49:36 +00:00
|
|
|
|
2021-03-29 15:32:47 +00:00
|
|
|
for email, fpr in parse_wkd_list()[1]:
|
2020-12-26 22:34:19 +00:00
|
|
|
local, domain = email.split("@", 1)
|
2021-02-11 00:38:23 +00:00
|
|
|
localhash = zbase32(sha1(local.lower().encode()))
|
2020-12-26 22:34:19 +00:00
|
|
|
with open(f"{WKD_LOCATION}/{domain}/{localhash}", "wb") as k:
|
2021-03-29 15:32:47 +00:00
|
|
|
k.write(strip_and_export(fpr, email))
|