wren-linux / bin / wren-key
#!/bin/bash
# wren-key: written by Wren Linux extras/signing-key.sh
# Makes and uses Wren Linux's signing key, the key every Wren Linux computer checks
# updates against. The key is held by its own user, wren-signer, with no
# network, that your user can't read; this asks it to sign, after your sudo
# password. man wren-key has the details. Making, renewing and restoring the key
# go ahead only at a quiet moment: the network off, just after a restart, one
# window, nothing changed in your startup files.
# wren-key the first time: questions, the key, its backup
# wren-key status the key, and until when it signs
# wren-key git-setup [DIR] git in DIR signs every commit and tag with it
# wren-key pin [DIR] DIR's pins.sh and data/ name the key (then commit and push)
# wren-key sign-repo FOLDER DEB... a signed apt repository (tools/make-apt-repo.sh)
# wren-key renew BACKUP two more years for the signing subkey, from the backup
# wren-key restore BACKUP [FINGERPRINT] on a new computer: the key from its
# backup (the one pins.sh pins, unless you name one)
# wren-key public | fingerprint | clearsign | detach-sign
set -uo pipefail
umask 077
die() { printf 'wren-key: %s\n' "$*" >&2; exit 1; }
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
note() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
# (a test points these at a pretend sudo and a copy of the signer)
SUDO=${WREN_SUDO:-sudo}
SIGNER=${WREN_SIGNER:-/usr/local/libexec/wren-signer}
STATE=${XDG_DATA_HOME:-$HOME/.local/share}/wren/signing-key
BIN=$HOME/.local/bin
# Where Wren Linux is: WREN_DIR if you set it; else the root-owned copy the
# Wren Linux package installs, which no program running as you can change; else
# your own copy, ~/wren
if [ -n "${WREN_DIR:-}" ]; then
DIR=$WREN_DIR
elif [ -f "${WREN_SYSTEM_DIR:-/usr/share/wren}/lib.sh" ]; then
DIR=${WREN_SYSTEM_DIR:-/usr/share/wren}
else
DIR=$HOME/wren
fi
signer() { "$SUDO" -u wren-signer "$SIGNER" "$@"; }
# (a test points these at pretend ones)
IP=${WREN_KEY_IP:-ip}
UPTIME=${WREN_KEY_UPTIME:-/proc/uptime}
SWAYMSG=${WREN_KEY_SWAYMSG:-swaymsg}
WATCH=${WREN_WATCH_CMD:-/usr/local/bin/wren-watch}
WATCH_STATE=${WREN_WATCH_STATE:-/var/lib/wren-watch}
WATCH_RUN=${WREN_WATCH_RUN:-/run/wren-watch}
# How long after a restart the key may be made, renewed or restored
FRESH_MINUTES=30
# quiet_moment WHAT: making, renewing or restoring the key (WHAT) sends the backup's passphrase
# and the backup through programs running as you, so it goes ahead only at a quiet moment: the
# network off, just after a restart, no window open but this terminal, and nothing changed in
# your startup files. Every reason it isn't is said at once. A program already running as you
# could fake these answers: they keep you from doing it at a bad moment by mistake, they don't
# stop such a program. Anything it can't find out counts as no.
quiet_moment() {
local why=() up n me
me=$(id -un)
say "Checking this is a quiet moment to $1 (sudo asks for your password)"
# off the network: no way out at all, IPv4 or IPv6
if ! command -v "$IP" >/dev/null; then
why+=("can't tell whether the network is off (ip is missing: package iproute2)")
elif [ -n "$("$IP" -4 route show default 2>&1)$("$IP" -6 route show default 2>&1)" ]; then
why+=("the network is on. Turn it off: nmcli networking off (back on afterwards: nmcli networking on), or pull the cable")
fi
# just after a restart: nothing left over in memory from before
up=$(cut -d. -f1 "$UPTIME" 2>/dev/null)
if ! [[ $up =~ ^[0-9]+$ ]]; then
why+=("can't tell how long this computer has been on ($UPTIME)")
elif [ "$up" -ge $((FRESH_MINUTES * 60)) ]; then
why+=("this computer has been on for $((up / 60)) minutes. Restart it, log in, and run this first, within $FRESH_MINUTES minutes")
fi
# nothing open but this terminal (in sway; hidden windows count too)
if "$SWAYMSG" -t get_version >/dev/null 2>&1; then
n=$("$SWAYMSG" -t get_tree 2>/dev/null | python3 -I -c '
import json, sys
def views(node):
kids = node.get("nodes", []) + node.get("floating_nodes", [])
if not kids and node.get("type") in ("con", "floating_con") and node.get("pid"):
return 1
return sum(views(k) for k in kids)
print(views(json.load(sys.stdin)))' 2>/dev/null)
if ! [[ $n =~ ^[0-9]+$ ]]; then
why+=("can't tell which windows are open (swaymsg's answer didn't read)")
elif [ "$n" -ne 1 ]; then
why+=("$n windows are open. Close every one but this terminal (windows hidden away count too)")
fi
fi
# the startup watch: a record of yours, looked at again now, and nothing in its list
if [ ! -x "$WATCH" ]; then
why+=("the startup watch isn't installed, so a change to what starts at login can't be ruled out: wrenctl part startup-watch")
elif ! "$SUDO" test -f "$WATCH_STATE/$me/manifest"; then
why+=("the startup watch has no record of your files yet: sudo wren-watch accept, after looking at them")
elif ! "$SUDO" "$WATCH" check-all >/dev/null 2>&1; then
why+=("the startup watch couldn't look at your files just now")
elif [ -e "$WATCH_RUN/$me" ]; then
why+=("your startup files changed since you last accepted: see wren-watch. If it was you or Wren Linux, accept them (wren-watch accept); if not, don't use the key on this computer")
fi
[ ${#why[@]} -gt 0 ] || { note "Network off, just restarted, one window, startup files unchanged."; return 0; }
warn "Not now: the backup's passphrase and the backup would pass through programs running as you."
local w
for w in "${why[@]}"; do warn " - $w"; done
die "nothing was done. Once all of that is so, run it again."
}
# answer VAR "Question" [DEFAULT]: a line from the terminal (or, for a test, the
# file WREN_KEY_ANSWERS, a line each); hidden with HIDE=1
answer() {
local _v=$1 _q=$2 _d=${3:-} _a=''
if [ -n "${WREN_KEY_ANSWERS:-}" ]; then
[ -e /proc/self/fd/7 ] || exec 7<"$WREN_KEY_ANSWERS"
printf ' %s%s ' "$_q" "${_d:+ [$_d]}" >&2
IFS= read -r _a <&7 || _a=''
printf '\n' >&2
elif [ "${HIDE:-}" = 1 ]; then
IFS= read -rs -p " $_q " _a </dev/tty || _a=''
printf '\n' >&2
else
IFS= read -r -p " $_q${_d:+ [$_d]} " _a </dev/tty || _a=''
fi
printf -v "$_v" '%s' "${_a:-$_d}"
}
yes_no() { local r; answer r "$1 [y/N]"; [[ $r =~ ^[Yy] ]]; }
fingerprint() { signer fingerprint 2>/dev/null; }
# remember: what your own user may know about the key, kept without asking the
# signer each time: its fingerprint, until when it signs, and the public key
# (in a keyring of its own, for git to check signatures with)
remember() {
local f until
f=$(signer fingerprint) || die "the signer has no key."
until=$(signer expires) || die "the signer's key can't sign."
mkdir -p "$STATE/public"
chmod 700 "$STATE/public"
echo "$f" >"$STATE/fingerprint"
echo "$until" >"$STATE/expires"
signer public >"$STATE/public.asc"
gpg --homedir "$STATE/public" --batch --quiet --import "$STATE/public.asc" 2>/dev/null
gpgconf --homedir "$STATE/public" --kill all 2>/dev/null || true
}
# days_left: until the signing subkey stops signing (empty: never)
days_left() {
local until
until=$(cat "$STATE/expires" 2>/dev/null) || return 0
[ "$until" != 0 ] || return 0
echo $(((until - $(date +%s)) / 86400))
}
expiry_warning() {
local d
d=$(days_left)
[ -n "$d" ] || return 0
if [ "$d" -lt 90 ]; then
warn "The signing subkey stops signing in $d days. Renew it before then: wren-key renew BACKUP"
warn "(after it has stopped, every Wren Linux computer refuses updates until a new key reaches it by hand)"
fi
}
setup() {
if [ -s "$STATE/fingerprint" ] && fingerprint >/dev/null; then
status
return
fi
command -v gpg >/dev/null || die "gpg is missing."
[ -x "$SIGNER" ] || die "the signer isn't set up. Run: wrenctl install signing-key"
quiet_moment "make the key"
cat <<'EOF'
Wren Linux's signing key
---------------------
(Moving the key to a new computer? Don't make another: Ctrl+C, then
wren-key restore BACKUP, with the backup on your stick.)
Every Wren Linux computer will trust this key: update.sh brings in only
commits it signed, and apt only packages from a repository it signed.
Whoever has it can put anything onto every one of those computers, as root.
Whoever loses it can't publish Wren Linux any more (and computers keep the old
key until a new one reaches them by hand).
So it is made in two parts:
- the primary key, which vouches for the other. It goes into a backup,
encrypted with a passphrase you choose, and is then deleted from this
computer. You need it only to renew (every two years) or replace the
signing key.
- the signing key, kept here by its own user (wren-signer), with no
network, that your own user can't read. It signs when you type your
sudo password, and never hands itself out.
You need: a USB stick for the backup (better two), and paper.
EOF
local name email dest pass pass2 suggestion f
answer name "Name on the key:" "Wren Linux signing key"
answer email "Email on the key (public, in every signature):" "$(git config --global user.email 2>/dev/null || true)"
[ -n "$email" ] || die "an email address is needed."
say "Where the backup goes"
note "A folder on a USB stick, kept somewhere safe and away from this computer."
note "Not on this computer's disk: a backup there goes with the computer."
local sticks=()
mapfile -t sticks < <(find "/media/$(id -un)" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
[ ${#sticks[@]} -eq 0 ] || note "Sticks mounted now: ${sticks[*]} (usb mount mounts one)"
answer dest "Backup folder:" "${sticks[0]:-}"
[ -n "$dest" ] && [ -d "$dest" ] && [ -w "$dest" ] || die "\"$dest\" isn't a folder you can write to. Mount the stick (usb mount) and run wren-key again."
dest=$(cd "$dest" && pwd)
case $dest/ in
"$HOME"/* | /tmp/* | /var/tmp/*)
warn "$dest is on this computer, not on a stick."
yes_no "Put the backup there anyway (and copy it to a stick yourself later)?" || die "nothing was made." ;;
esac
[ ! -e "$dest/wren-signing-key/wren-signing-key-backup.asc" ] ||
die "$dest/wren-signing-key already holds a backup. Use another folder."
say "The backup's passphrase"
note "It protects the primary key in the backup. Write it on paper and keep that"
note "apart from the stick: someone with both has your key; someone with neither"
note "piece of it can't. Lose the passphrase and the backup can't be opened."
if command -v diceware >/dev/null; then
suggestion=$(diceware -n 7 -d ' ' 2>/dev/null || true)
[ -z "$suggestion" ] || note "A suggestion, made just now from random words: $suggestion"
fi
HIDE=1 answer pass "Passphrase (at least 20 characters):"
HIDE=1 answer pass2 "The same again:"
[ "$pass" = "$pass2" ] || die "the two didn't match; nothing was made."
[ ${#pass} -ge 20 ] || die "at least 20 characters, please (six or seven random words); nothing was made."
say "Making the key (sudo asks for your password)"
mkdir -p "$dest/wren-signing-key"
printf '%s\n' "$pass" | signer new "$name" "$email" >"$dest/wren-signing-key/new.tar" ||
{ rm -rf "$dest/wren-signing-key"; die "the key wasn't made (see above)."; }
unset pass pass2
tar -C "$dest/wren-signing-key" -xf "$dest/wren-signing-key/new.tar" &&
rm -f "$dest/wren-signing-key/new.tar" || die "the backup couldn't be written to $dest."
chmod 600 "$dest"/wren-signing-key/*
sync
remember
f=$(cat "$STATE/fingerprint")
cat <<EOF
The key: $f
(the primary key's fingerprint: this is what pins.sh pins)
In $dest/wren-signing-key:
wren-signing-key-backup.asc the whole key, encrypted with your passphrase
wren-signing-key-revocation.asc declares the key void, if it is ever stolen
wren-signing-key-public.asc the public key (anyone may have it)
Now, please:
1. Copy that folder to a second stick. Keep the two in different places.
2. Print the revocation certificate, or copy it by hand, and keep it apart
from the sticks: it works without the passphrase, so whoever has it can
void your key (but can't sign with it).
3. Write the passphrase on paper, and keep it apart from the sticks.
4. Check a stick can be read on another computer now and then; sticks
wear out.
EOF
if git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -w "$DIR" ]; then
if yes_no "Sign every commit and tag in $DIR with it from now on?"; then git_setup "$DIR"; fi
if yes_no "Pin it in $DIR (pins.sh and data/wren-signing-key.asc), for you to commit?"; then pin "$DIR"; fi
fi
}
status() {
local f d
f=$(cat "$STATE/fingerprint" 2>/dev/null) || die "there is no key yet: run wren-key"
d=$(days_left)
echo "Key: $f"
if [ -z "$d" ]; then echo "Signs: with no end date"; else echo "Signs: until $(date -d "@$(cat "$STATE/expires")" +%F) ($d days)"; fi
if git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if [ "$(git -C "$DIR" config user.signingkey)" = "$f" ] && [ "$(git -C "$DIR" config commit.gpgsign)" = true ]; then
echo "git in $DIR: signs every commit with it"
else
echo "git in $DIR: doesn't sign with it (wren-key git-setup)"
fi
fi
if grep -qx "WREN_SIGNING_FPR=$f" "$DIR/pins.sh" 2>/dev/null; then
echo "pins.sh in $DIR: pins it"
else
echo "pins.sh in $DIR: doesn't pin it yet (wren-key pin)"
fi
expiry_warning
}
git_setup() {
local d=${1:-$DIR} f
f=$(cat "$STATE/fingerprint" 2>/dev/null) || die "there is no key yet: run wren-key"
git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "$d isn't a git checkout."
git -C "$d" config user.signingkey "$f"
git -C "$d" config gpg.format openpgp
git -C "$d" config gpg.program "$BIN/wren-key-gpg"
git -C "$d" config commit.gpgsign true
git -C "$d" config tag.gpgSign true
note "git in $d signs every commit and tag with $f (sudo asks for your password now and then)."
note "Check one: git -C $d log --show-signature -1"
}
pin() {
local d=${1:-$DIR} f
f=$(cat "$STATE/fingerprint" 2>/dev/null) || die "there is no key yet: run wren-key"
[ -f "$d/pins.sh" ] && [ -w "$d/pins.sh" ] || die "$d has no pins.sh you can change (give your own copy: wren-key pin DIR)."
grep -q '^WREN_SIGNING_FPR=' "$d/pins.sh" || die "$d/pins.sh has no WREN_SIGNING_FPR line (an older Wren Linux)."
install -m 0644 "$STATE/public.asc" "$d/data/wren-signing-key.asc"
sed -i "s/^WREN_SIGNING_FPR=.*/WREN_SIGNING_FPR=$f/" "$d/pins.sh"
say "Pinned in $d"
note "pins.sh: WREN_SIGNING_FPR=$f"
note "data/wren-signing-key.asc: the public key"
note "Look at it (git -C $d diff), then commit it, signed, and push:"
note " git -C $d add pins.sh data/wren-signing-key.asc"
note " git -C $d commit -m 'Wren Linux is signed with its own key now'"
note "Both files, or computers that update to it refuse every update after it."
note "From then on every Wren Linux computer that updates past it takes only what this key signed."
}
renew() {
local backup=${1:-} pass dir
[ -n "$backup" ] && [ -f "$backup" ] || die "usage: wren-key renew BACKUP (the wren-signing-key-backup.asc on your stick)"
dir=$(cd "$(dirname -- "$backup")" && pwd)
[ -w "$dir" ] || die "$dir isn't writable: the renewed backup goes next to the old one."
quiet_moment "renew the key"
HIDE=1 answer pass "The backup's passphrase:"
say "Renewing the signing subkey (sudo asks for your password)"
{ printf '%s\n' "$pass"; cat "$backup"; } | signer renew >"$dir/renew.tar" || { rm -f "$dir/renew.tar"; die "it wasn't renewed (see above)."; }
unset pass
mv "$backup" "$backup.old"
tar -C "$dir" -xf "$dir/renew.tar" && rm -f "$dir/renew.tar" || die "the renewed backup couldn't be written to $dir."
chmod 600 "$dir"/wren-signing-key-*
sync
remember
note "Renewed: it signs until $(date -d "@$(cat "$STATE/expires")" +%F). The old backup is kept as $backup.old."
note "Copy the new backup to your second stick too. Then, before the old date, put the"
note "renewed public key in Wren Linux and publish it, signed: wren-key pin, commit, push."
}
# restore BACKUP [FINGERPRINT]: on a new computer (or after a reinstall), the
# key from its backup into the signer: the key pins.sh pins, unless you name one
restore() {
local backup=${1:-} f=${2:-} pass until
[ -n "$backup" ] && [ -f "$backup" ] || die "usage: wren-key restore BACKUP [FINGERPRINT] (BACKUP: the wren-signing-key-backup.asc on your stick; usb mount mounts it)"
if [ -z "$f" ]; then
f=$(sed -n 's/^WREN_SIGNING_FPR=\([0-9A-F]\{40\}\)$/\1/p' "$DIR/pins.sh" 2>/dev/null | head -n1)
[ -n "$f" ] || die "$DIR/pins.sh pins no signing key: name the key, wren-key restore BACKUP FINGERPRINT"
fi
[[ $f =~ ^[0-9A-F]{40}$ ]] || die "a fingerprint is 40 hex digits, in capitals."
command -v gpg >/dev/null || die "gpg is missing."
[ -x "$SIGNER" ] || die "the signer isn't set up. Run: wrenctl install signing-key"
say "Restoring the key $f (sudo asks for your password)"
if fingerprint >/dev/null; then die "this computer has a key already: wren-key status"; fi
quiet_moment "restore the key"
note "From $backup"
HIDE=1 answer pass "The backup's passphrase:"
{ printf '%s\n' "$pass"; cat "$backup"; } | signer restore "$f" >/dev/null || { unset pass; die "nothing was restored (see above)."; }
unset pass
if ! until=$(signer expires); then
die "restored, but its signing subkey no longer signs: renew it, wren-key renew $backup"
fi
remember
note "Restored: the signer has the signing subkey again (the primary key stays in the backup)."
if [ "$until" != 0 ]; then note "It signs until $(date -d "@$until" +%F)."; fi
expiry_warning
if git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -w "$DIR" ]; then
if yes_no "Sign every commit and tag in $DIR with it from now on?"; then git_setup "$DIR"; fi
else
note "To sign commits in your git copy: wren-key git-setup DIR"
fi
}
sign_repo() {
[ $# -ge 2 ] || die "usage: wren-key sign-repo FOLDER DEB..."
local out=$1
shift
expiry_warning
exec bash "$DIR/tools/make-apt-repo.sh" --sign-cmd "$BIN/wren-key" --out "$out" "$@"
}
case ${1:-} in
"" | setup) setup ;;
status) status ;;
git-setup) git_setup "${2:-}" ;;
pin) pin "${2:-}" ;;
renew) renew "${2:-}" ;;
restore) restore "${2:-}" "${3:-}" ;;
sign-repo) shift; sign_repo "$@" ;;
public | fingerprint | clearsign | detach-sign)
[ $# -eq 1 ] || die "usage: wren-key $1"
[ "$1" = public ] || [ "$1" = fingerprint ] || expiry_warning
signer "$1" ;;
-h | --help | help) sed -n '2,/^set /{ /^set /d; s/^# \{0,1\}//; p }' "$0" ;;
*) die "unknown command \"$1\" (wren-key --help)" ;;
esac