Wren Linux

wren-linux / bin / wren-signer

#!/usr/bin/python3 -I
# wren-signer: written by Wren Linux extras/signing-key.sh
# Installed as /usr/local/libexec/wren-signer, run as the user
# wren-signer, which holds Wren Linux's signing subkey: that user has no
# password, no login and no network (a firewall rule drops all it sends), and
# your own user can't read its files. Your user may run this, and nothing
# else, as wren-signer, after typing your sudo password. It does a few
# fixed things and never hands out a secret key:
#   new NAME EMAIL   make the key (once): a primary key that only certifies and
#                    a signing subkey; the passphrase for the backup on stdin.
#                    Writes, as a tar on stdout, the backup (the whole key,
#                    encrypted with that passphrase), a revocation certificate
#                    and the public key. Keeps only the subkey: the primary key
#                    lives on in the backup alone.
#   sign             what git asks for: a detached, armored signature of stdin
#   clearsign        stdin signed in place (an apt repository's InRelease)
#   detach-sign      a detached, armored signature of stdin (Release.gpg)
#   public           the public key
#   fingerprint      the primary key's fingerprint
#   expires          until when the signing subkey can sign (seconds since 1970; 0: never)
#   renew            the signing subkey good for two more years: the backup's
#                    passphrase on the first line of stdin, the backup after it;
#                    writes the renewed backup and public key as a tar on stdout
#   restore FPR      on a new computer: the key FPR from its backup, the
#                    passphrase on the first line of stdin, the backup after it.
#                    Only when there is no key yet; keeps only the subkey, as new
#                    does, and writes the public key on stdout

# Python, standard library only, started with -I (no PYTHON* settings, no user site, nothing
# imported from the folder it is in). Every gpg here is run with its arguments as a list, never
# through a shell; the key's folders are fixed below, never taken from the caller.
import os
import pwd
import re
import shutil
import signal
import subprocess
import sys
import tempfile

os.umask(0o077)
os.environ["PATH"] = "/usr/bin:/bin"
# (a test points these at a temp folder, and at its own user, in a copy of this file)
HOME_DIR = "/var/lib/wren-signer"
SHM = "/dev/shm"
SIGNER_USER = "wren-signer"
KEYRING = os.path.join(HOME_DIR, "gnupg")
USAGE = "usage: wren-signer new NAME EMAIL | sign | clearsign | detach-sign | public | fingerprint | expires | renew | restore FPR"
G = ["gpg", "--homedir", KEYRING, "--batch", "--no-tty", "--quiet", "--no-auto-key-retrieve", "--no-auto-key-locate"]


class Die(Exception):
    pass


def die(msg):
    raise Die(msg)


def run(cmd, **kw):
    """CMD, its output thrown away unless asked for; True when it succeeded"""
    kw.setdefault("stdout", subprocess.DEVNULL)
    kw.setdefault("stderr", subprocess.DEVNULL)
    return subprocess.run(cmd, **kw).returncode == 0


def out(cmd, **kw):
    """CMD's output, as text (empty when it failed)"""
    r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, **kw)
    return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""


def colons(text):
    return [line.split(":") for line in text.splitlines()]


def fpr():
    try:
        with open(os.path.join(KEYRING, "fingerprint")) as f:
            return f.read().strip()
    except OSError:
        die("there is no key yet: wren-key makes one.")


def args(n, given):
    """the arguments a command may have: exactly this many"""
    if len(given) != n:
        die("unexpected arguments.")


def primary_secret(home, f):
    """+ when the primary key's secret part is in that keyring, # when only a stub of it is
    (gpg's field 15)"""
    for rec in colons(out(["gpg", "--homedir", home, "--batch", "--with-colons", "--list-secret-keys", f])):
        if rec[0] == "sec":
            return rec[14] if len(rec) > 14 else ""
    return ""


def kill_agent(home):
    run(["gpgconf", "--homedir", home, "--kill", "all"])


class Scratch:
    """a temp keyring in memory, for the moments the primary key is out; gone when this ends,
    however it ends"""

    def __enter__(self):
        self.d = tempfile.mkdtemp(prefix="wren-signer.", dir=SHM)
        for sub in ("g", "out", "h"):
            os.mkdir(os.path.join(self.d, sub), 0o700)
        self.g, self.out, self.h = (os.path.join(self.d, x) for x in ("g", "out", "h"))
        self.pass_file = os.path.join(self.d, "pass")
        # T: gpg in the scratch keyring, which has no passphrase of its own
        self.T = ["gpg", "--homedir", self.g, "--batch", "--no-tty", "--quiet", "--pinentry-mode", "loopback", "--passphrase", ""]
        return self

    def __exit__(self, *exc):
        kill_agent(self.g)
        kill_agent(self.h)
        shutil.rmtree(self.d, ignore_errors=True)
        return False

    def write_pass(self, passphrase):
        with open(os.open(self.pass_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as f:
            f.write(passphrase + b"\n")

    def pgpg(self, *a, **kw):
        """gpg with the backup's passphrase (its file), in a home of its own"""
        return ["gpg", "--homedir", self.h, "--batch", "--no-tty", "--quiet", "--pinentry-mode", "loopback",
                "--passphrase-file", self.pass_file] + list(a)


def read_passphrase():
    """the first line of stdin (as bash's read -r: a line, its newline taken off, nothing else)"""
    line = sys.stdin.buffer.readline()
    if not line.endswith(b"\n"):
        die("no passphrase was given.")
    return line[:-1]


def encrypt_backup(s, src, dest):
    """SRC encrypted to the passphrase (AES-256, gpg's slowest key derivation)"""
    return run(s.pgpg("--symmetric", "--cipher-algo", "AES256", "--s2k-mode", "3", "--s2k-digest-algo", "SHA512",
                      "--s2k-count", "65011712", "--armor", "-o", dest, src))


def keep_subkey(home, f):
    """the subkeys of the key in HOME into the keyring, the public key next to them; the
    primary's secret part isn't copied"""
    tmp = tempfile.mkdtemp(prefix=".new.", dir=HOME_DIR)
    os.chmod(tmp, 0o700)
    sub, public = os.path.join(tmp, "sub.gpg"), os.path.join(tmp, "public.asc")
    with open(sub, "wb") as o:
        subprocess.run(["gpg", "--homedir", home, "--batch", "--quiet", "--export-secret-subkeys", f], stdout=o, check=False)
    with open(public, "wb") as o:
        subprocess.run(["gpg", "--homedir", home, "--batch", "--quiet", "--armor", "--export", f], stdout=o, check=False)
    run(["gpg", "--homedir", tmp, "--batch", "--quiet", "--import", sub])
    run(["gpg", "--homedir", tmp, "--batch", "--quiet", "--import", public])
    # only a stub of the primary: it can't certify, so it can't make new keys
    if primary_secret(tmp, f) != "#":
        kill_agent(tmp)
        shutil.rmtree(tmp, ignore_errors=True)
        die("the primary key's secret part didn't stay out of the keyring.")
    with open(os.path.join(tmp, "fingerprint"), "w") as o:
        o.write(f + "\n")
    os.unlink(sub)
    kill_agent(tmp)
    kill_agent(KEYRING)
    old = KEYRING + ".old"
    shutil.rmtree(old, ignore_errors=True)
    if os.path.isdir(KEYRING):
        os.rename(KEYRING, old)
    os.rename(tmp, KEYRING)
    shutil.rmtree(old, ignore_errors=True)


def tar_out(s, *names):
    sys.stdout.flush()
    if not run(["tar", "-C", s.out, "-cf", "-"] + list(names), stdout=sys.stdout.buffer, stderr=None):
        die("the tar of the backup couldn't be written.")


def has_key():
    try:
        return os.path.getsize(os.path.join(KEYRING, "fingerprint")) > 0
    except OSError:
        return False


def cmd_new(a):
    args(2, a)
    name, email = a
    if not re.fullmatch(r"[^\W_][\w .'-]{0,63}", name):
        die("a name of letters, digits, spaces and . ' _ -, please.")
    if not re.fullmatch(r"[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}", email):
        die("that isn't an email address.")
    if has_key():
        die("there is a key already (%s). Renew it (wren-key renew), or remove the extra first." % fpr())
    with Scratch() as s:
        passphrase = read_passphrase()
        if len(passphrase.decode("utf-8", "surrogateescape")) < 20:
            die("the passphrase must be at least 20 characters (six random words are about 40).")
        s.write_pass(passphrase)
        del passphrase
        run(s.T + ["--quick-generate-key", "%s <%s>" % (name, email), "ed25519", "cert", "never"])
        f = ""
        for rec in colons(out(s.T + ["--with-colons", "--list-keys"])):
            if rec[0] == "fpr":
                f = rec[9]
                break
        if not re.fullmatch(r"[0-9A-F]{40}", f):
            die("gpg didn't make the key.")
        run(s.T + ["--quick-add-key", f, "ed25519", "sign", "2y"])
        # the whole key, encrypted to the passphrase; checked before anything is kept
        full = os.path.join(s.d, "full.gpg")
        with open(full, "wb") as o:
            subprocess.run(s.T + ["--export-secret-keys", f], stdout=o, check=False)
        backup = os.path.join(s.out, "wren-signing-key-backup.asc")
        if not encrypt_backup(s, full, backup):
            die("the backup couldn't be made; nothing was kept.")
        back = subprocess.run(s.pgpg("--decrypt", backup), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout
        with open(full, "rb") as i:
            if back != i.read():
                die("the backup didn't decrypt to the key again; nothing was kept.")
        os.unlink(full)
        os.unlink(s.pass_file)
        shutil.copyfile(os.path.join(s.g, "openpgp-revocs.d", f + ".rev"), os.path.join(s.out, "wren-signing-key-revocation.asc"))
        with open(os.path.join(s.out, "wren-signing-key-public.asc"), "wb") as o:
            subprocess.run(s.T + ["--armor", "--export", f], stdout=o, check=False)
        keep_subkey(s.g, f)
        tar_out(s, "wren-signing-key-backup.asc", "wren-signing-key-revocation.asc", "wren-signing-key-public.asc")


def open_backup(s):
    """the passphrase, then the backup, from stdin; the backup opened into the scratch keyring"""
    s.write_pass(read_passphrase())
    backup, full = os.path.join(s.d, "backup.asc"), os.path.join(s.d, "full.gpg")
    with open(backup, "wb") as o:
        shutil.copyfileobj(sys.stdin.buffer, o)
    if not run(s.pgpg("--decrypt", "-o", full, backup)):
        die("the backup doesn't open with that passphrase.")
    return full


def cmd_renew(a):
    args(0, a)
    f = fpr()
    with Scratch() as s:
        full = open_backup(s)
        run(s.T + ["--import", full])
        ps = primary_secret(s.g, f)
        if ps == "":
            die("that backup isn't the key %s." % f)
        if ps != "+":
            die("that backup has no primary key in it, so it can't renew the subkey.")
        subs, recs = [], colons(out(s.T + ["--with-colons", "--list-keys", f]))
        for i, rec in enumerate(recs):
            if rec[0] == "sub" and len(rec) > 11 and "s" in rec[11] and i + 1 < len(recs) and recs[i + 1][0] == "fpr":
                subs.append(recs[i + 1][9])
        if not subs:
            die("the key has no signing subkey.")
        if not run(s.T + ["--quick-set-expire", f, "2y"] + subs):
            die("gpg didn't give the signing subkey its new date; nothing was renewed.")
        with open(full, "wb") as o:
            subprocess.run(s.T + ["--export-secret-keys", f], stdout=o, check=False)
        if not encrypt_backup(s, full, os.path.join(s.out, "wren-signing-key-backup.asc")):
            die("the renewed backup couldn't be made; nothing was renewed.")
        os.unlink(full)
        os.unlink(s.pass_file)
        with open(os.path.join(s.out, "wren-signing-key-public.asc"), "wb") as o:
            subprocess.run(s.T + ["--armor", "--export", f], stdout=o, check=False)
        keep_subkey(s.g, f)
        tar_out(s, "wren-signing-key-backup.asc", "wren-signing-key-public.asc")


def cmd_restore(a):
    args(1, a)
    f = a[0]
    if not re.fullmatch(r"[0-9A-F]{40}", f):
        die("a fingerprint is 40 hex digits, in capitals.")
    if has_key():
        die("there is a key already (%s)." % fpr())
    with Scratch() as s:
        full = open_backup(s)
        os.unlink(s.pass_file)
        run(s.T + ["--import", full])
        os.unlink(full)
        # that key and nothing else: a backup Wren Linux made holds one key
        if sum(1 for rec in colons(out(s.T + ["--with-colons", "--list-secret-keys"])) if rec[0] == "sec") != 1:
            die("that backup doesn't hold exactly one key.")
        ps = primary_secret(s.g, f)
        if ps == "":
            die("that backup isn't the key %s." % f)
        if ps != "+":
            die("that backup has no primary key in it: it isn't a Wren Linux backup.")
        if not any(rec[0] == "ssb" and len(rec) > 11 and "s" in rec[11]
                   for rec in colons(out(s.T + ["--with-colons", "--list-secret-keys", f]))):
            die("the key has no signing subkey.")
        keep_subkey(s.g, f)
        sys.stdout.flush()
        with open(os.path.join(KEYRING, "public.asc"), "rb") as i:
            sys.stdout.buffer.write(i.read())


def cmd_expires(a):
    args(0, a)
    found, never, most = False, False, 0
    for rec in colons(out(G + ["--with-colons", "--list-secret-keys", fpr()])):
        if rec[0] == "ssb" and len(rec) > 11 and not re.search("[erd]", rec[1]) and "s" in rec[11]:
            found = True
            if rec[6] == "":
                never = True
            elif int(rec[6]) > most:
                most = int(rec[6])
    if not found:
        sys.exit(1)
    print(0 if never else most)


def sign_with(*how):
    """gpg itself, in this process's place: stdin to sign, stdout the signature"""
    f = fpr()
    sys.stdout.flush()
    os.execvp("gpg", G + list(how) + ["--local-user", f])


def main(argv):
    if pwd.getpwuid(os.getuid()).pw_name != SIGNER_USER:
        die("this runs as the user wren-signer; use wren-key.")
    if not argv:
        die(USAGE)
    cmd, a = argv[0], argv[1:]
    if cmd == "new":
        cmd_new(a)
    elif cmd == "sign":
        args(0, a)
        sign_with("--status-fd=2", "--armor", "--detach-sign")
    elif cmd == "clearsign":
        args(0, a)
        sign_with("--clearsign")
    elif cmd == "detach-sign":
        args(0, a)
        sign_with("--armor", "--detach-sign")
    elif cmd == "public":
        args(0, a)
        try:
            with open(os.path.join(KEYRING, "public.asc"), "rb") as i:
                sys.stdout.buffer.write(i.read())
        except OSError:
            sys.exit(1)
    elif cmd == "fingerprint":
        args(0, a)
        print(fpr())
    elif cmd == "expires":
        cmd_expires(a)
    elif cmd == "renew":
        cmd_renew(a)
    elif cmd == "restore":
        cmd_restore(a)
    else:
        die('unknown command "%s".' % cmd)


if __name__ == "__main__":
    # a signal ends it as an error does: the scratch keyring is removed on the way out
    for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
        signal.signal(sig, lambda *_: sys.exit(130))
    try:
        main(sys.argv[1:])
    except Die as e:
        sys.stderr.write("wren-signer: %s\n" % e)
        sys.exit(1)
    except Exception as e:
        # (anything unforeseen ends it as a die does: one line, and the scratch keyring gone)
        sys.stderr.write("wren-signer: it stopped: %s\n" % e)
        sys.exit(1)