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)