diff --git a/management/daemon.py b/management/daemon.py index cac8a13..6e4ac1c 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -580,6 +580,63 @@ def smtp_relay_set(): except Exception as e: return (str(e), 500) +# PGP + +@app.route('/system/pgp/', methods=["GET"]) +@authorized_personnel_only +def get_keys(): + from pgp import get_daemon_key, get_imported_keys, key_representation + return { + "daemon": key_representation(get_daemon_key()), + "imported": list(map(key_representation, get_imported_keys())) + } + +@app.route('/system/pgp/', methods=["GET"]) +@authorized_personnel_only +def get_key(fpr): + from pgp import get_key, key_representation + k = get_key(fpr) + if k is None: + abort(404) + return key_representation(k) + +@app.route('/system/pgp/', methods=["DELETE"]) +@authorized_personnel_only +def delete_key(fpr): + from pgp import delete_key + try: + if delete_key(fpr) is None: + abort(404) + return "OK" + except ValueError as e: + return (str(e), 400) + +@app.route('/system/pgp//export', methods=["GET"]) +@authorized_personnel_only +def export_key(fpr): + from pgp import export_key + exp = export_key(fpr) + if exp is None: + abort(404) + return exp + +@app.route('/system/pgp/import', methods=["POST"]) +@authorized_personnel_only +def import_key(): + from pgp import import_key + k = request.form.get('key') + try: + result = import_key(k) + return { + "keys_read": result.considered, + "keys_added": result.imported, + "keys_unchanged": result.unchanged, + "uids_added": result.new_user_ids, + "sigs_added": result.new_signatures, + "revs_added": result.new_revocations + } + except ValueError as e: + return (str(e), 400) # MUNIN diff --git a/management/daily_tasks.sh b/management/daily_tasks.sh index dee8b60..f569e89 100755 --- a/management/daily_tasks.sh +++ b/management/daily_tasks.sh @@ -21,5 +21,8 @@ management/backup.py 2>&1 | management/email_administrator.py "Backup Status" # Provision any new certificates for new domains or domains with expiring certificates. management/ssl_certificates.py -q 2>&1 | management/email_administrator.py "TLS Certificate Provisioning Result" +# Renew the daemon's PGP key if about to expire +management/pgp.py 2>&1 | management/email_administrator.py "PGP Key Renewal Result" + # Run status checks and email the administrator if anything changed. management/status_checks.py --show-changes 2>&1 | management/email_administrator.py "Status Checks Change Notice" diff --git a/management/email_administrator.py b/management/email_administrator.py index 8ed6e2a..fbb3a0a 100755 --- a/management/email_administrator.py +++ b/management/email_administrator.py @@ -7,9 +7,12 @@ import sys import html import smtplib +from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from pgp import create_signature + # In Python 3.6: #from email.message import Message @@ -45,6 +48,7 @@ content_html = "
{}
".format(html.escape(cont msg.attach(MIMEText(content, 'plain')) msg.attach(MIMEText(content_html, 'html')) +msg.attach(MIMEApplication(create_signature(content.encode()), Name="signed.asc")) # In Python 3.6: #msg.set_content(content) diff --git a/management/pgp.py b/management/pgp.py new file mode 100755 index 0000000..89b81ff --- /dev/null +++ b/management/pgp.py @@ -0,0 +1,125 @@ +#!/usr/local/lib/mailinabox/env/bin/python +# Tools to manipulate PGP keys + +import gpg, utils, datetime + +env = utils.load_environment() + +# Import daemon's keyring - usually in /home/user-data/.gnupg/ +gpghome = env['GNUPGHOME'] +daemon_key_fpr = env['PGPKEY'] +context = gpg.Context(armor=True, home_dir=gpghome) + +# Global auxiliary lookup tables +crpyt_algos = { + 0: "Unknown", + gpg.constants.PK_RSA: "RSA", + gpg.constants.PK_RSA_E: "RSA-E", + gpg.constants.PK_RSA_S: "RSA-S", + gpg.constants.PK_ELG_E: "ELG-E", + gpg.constants.PK_DSA: "DSA", + gpg.constants.PK_ECC: "ECC", + gpg.constants.PK_ELG: "ELG", + gpg.constants.PK_ECDSA: "ECDSA", + gpg.constants.PK_ECDH: "ECDH", + gpg.constants.PK_EDDSA: "EDDSA" +} + +# Auxiliary function to process the key in order to be read more conveniently +def key_representation(key): + if key is None: + return None + key_rep = { + "master_fpr": key.fpr, + "revoked": key.revoked != 0, + "ids": [], + "subkeys": [] + } + + now = datetime.datetime.utcnow() + key_rep["ids"] = [ id.uid for id in key.uids ] + key_rep["subkeys"] = [{ + "master": skey.fpr == key.fpr, + "sign": skey.can_sign == 1, + "cert": skey.can_certify == 1, + "encr": skey.can_encrypt == 1, + "auth": skey.can_authenticate == 1, + "fpr": skey.fpr, + "expires": skey.expires if skey.expires != 0 else None, + "expires_date": datetime.datetime.utcfromtimestamp(skey.expires).strftime("%x") if skey.expires != 0 else None, + "expires_days": (datetime.datetime.utcfromtimestamp(skey.expires) - now).days if skey.expires != 0 else None, + "expired": skey.expired == 1, + "algorithm": crpyt_algos[skey.pubkey_algo] if skey.pubkey_algo in crpyt_algos.keys() else crpyt_algos[0], + "bits": skey.length + } for skey in key.subkeys ] + + return key_rep + +# Tests an import as for whether we have any sort of private key material in our import +def contains_private_keys(imports): + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + with gpg.Context(home_dir=tmpdir, armor=True) as tmp: + result = tmp.key_import(imports) + return result.secret_read != 0 + +def get_key(fingerprint): + try: + return context.get_key(fingerprint, secret=False) + except KeyError: + return None + +def get_daemon_key(): + if daemon_key_fpr is None or daemon_key_fpr == "": + return None + return context.get_key(daemon_key_fpr, secret=True) + +def get_imported_keys(): + # All the keys in the keyring, except for the daemon's key + return list( + filter( + lambda k: k.fpr != daemon_key_fpr, + context.keylist(secret=False) + ) + ) + +def import_key(key): + data = str.encode(key) + if contains_private_keys(data): + raise ValueError("Import cannot contain private keys!") + return context.key_import(data) + +def export_key(fingerprint): + if get_key(fingerprint) is None: + return None + return context.key_export(pattern=fingerprint) # Key does exist, export it! + +def delete_key(fingerprint): + key = get_key(fingerprint) + if fingerprint == daemon_key_fpr: + raise ValueError("You cannot delete the daemon's key!") + elif key is None: + return None + context.op_delete_ext(key, gpg.constants.DELETE_ALLOW_SECRET | gpg.constants.DELETE_FORCE) + return True + +# Key usage + +# Uses the daemon key to sign the provided message. If 'detached' is True, only the signature will be returned +def create_signature(data, detached=False): + signed_data, _ = context.sign(data, mode=gpg.constants.SIG_MODE_DETACH if detached else gpg.constants.SIG_MODE_CLEAR) + return signed_data + +if __name__ == "__main__": + import sys, utils + # Check if we should renew the key + + daemon_key = get_daemon_key() + + exp = daemon_key.subkeys[0].expires + now = datetime.datetime.utcnow() + days_left = (datetime.datetime.utcfromtimestamp(exp) - now).days + if days_left > 14: + sys.exit(0) + else: + utils.shell("check_output", ["management/pgp_renew.sh"]) diff --git a/management/pgp_renew.sh b/management/pgp_renew.sh new file mode 100755 index 0000000..3957684 --- /dev/null +++ b/management/pgp_renew.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Renews the daemon's PGP key, if needed. + +source /etc/mailinabox.conf # load global vars +export GNUPGHOME # Dump into the environment so that gpg uses it as homedir + +gpg --batch --command-fd=0 --edit-key "${PGPKEY-}" << EOF; +key 0 +expire +180d +save +EOF \ No newline at end of file diff --git a/management/status_checks.py b/management/status_checks.py index 6e0cdb4..e4632d7 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -17,6 +17,7 @@ from dns_update import get_dns_zones, build_tlsa_record, get_custom_dns_config, from web_update import get_web_domains, get_domains_with_a_records from ssl_certificates import get_ssl_certificates, get_domain_ssl_files, check_certificate from mailconfig import get_mail_domains, get_mail_aliases +from pgp import get_daemon_key, get_imported_keys from utils import shell, sort_domains, load_env_vars_from_file, load_settings @@ -59,6 +60,7 @@ def run_checks(rounded_values, env, output, pool): shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True) run_system_checks(rounded_values, env, output) + run_pgp_checks(env, output) # perform other checks asynchronously @@ -266,6 +268,75 @@ def check_free_memory(rounded_values, env, output): if rounded_values: memory_msg = "System free memory is below 10%." output.print_error(memory_msg) +def run_pgp_checks(env, output): + now = datetime.datetime.utcnow() + output.add_heading("PGP Keyring") + + # Check daemon key + k = None + sk = None + try: + k = get_daemon_key() + sk = k.subkeys[0] + except KeyError: + pass + + if k is None: + output.print_error("The daemon's key does not exist!") + elif sk.expired == 1: + output.print_error(f"The daemon's key ({k.fpr}) expired.") + elif k.revoked == 1: + output.print_error(f"The daemon's key ({k.fpr}) has been revoked.") + else: + exp = datetime.datetime.utcfromtimestamp(sk.expires) # Our daemon key only has one subkey + if (exp - now).days < 10 and sk.expires != 0: + output.print_warning(f"The daemon's key ({k.fpr}) will expire soon, in {(exp - now).days} days on {exp.strftime('%x')}.") + else: + output.print_ok(f"The daemon's key ({k.fpr}) is good. It expires in {(exp - now).days} days on {exp.strftime('%x')}.") + + # Check imported keys + keys = get_imported_keys() + if len(keys) == 0: + output.print_warning("There are no imported keys here.") + else: + about_to_expire = [] + expired = [] + revoked = [] + for key in keys: + if key.revoked == 1: + revoked.append(key) + continue + else: + for skey in key.subkeys: + exp = datetime.datetime.utcfromtimestamp(skey.expires) + if skey.expired == 1: + expired.append((key, skey)) + elif (exp - now).days < 10 and skey.expires != 0: + about_to_expire.append((key, skey)) + + all_good = True + def printpair(keytuple): + key, skey = keytuple + output.print_line(f"Key {key.fpr}, subkey {skey.keyid}") + + if len(about_to_expire) != 0: + all_good = False + output.print_warning(f"There {'is 1 subkey' if len(about_to_expire) == 1 else f'are {len(about_to_expire)} subkeys'} about to expire.") + list(map(printpair, about_to_expire)) + + if len(expired) != 0: + all_good = False + output.print_error(f"There {'is 1 expired subkey' if len(expired) == 1 else f'are {len(expired)} expired subkeys'}.") + list(map(printpair, expired)) + + if len(revoked) != 0: + all_good = False + output.print_error(f"There {'is 1 revoked key' if len(revoked) == 1 else f'are {len(revoked)} revoked keys'}.") + list(map(lambda k: output.print_line(k.fpr), revoked)) + + if all_good: + output.print_ok("All imported keys are good.") + def run_network_checks(env, output): # Also see setup/network-checks.sh. diff --git a/management/templates/index.html b/management/templates/index.html index 8dcc0ac..61e61bc 100644 --- a/management/templates/index.html +++ b/management/templates/index.html @@ -105,6 +105,8 @@ DNS + @@ -153,6 +155,10 @@ {% include "custom-dns.html" %} +
+ {% include "pgp-keyring.html" %} +
+
{% include "login.html" %}
@@ -199,7 +205,7 @@