: what an API endpoint answers. The query string is not
+# part of the path, and the fixture server decodes %2F, so a project path is
+# two directories here.
+json() { mkdir -p "${SERVE}/$1"; printf '%s\n' "$2" > "${SERVE}/$1/index.html"; }
+
+# freedesktop: ordered by DATE, so the newest version is not first, and a
+# release candidate is numbered 1.31.901 with no "rc" anywhere in it.
+json "gitlab.freedesktop.org/api/v4/projects/libinput/libinput/releases" \
+ '[{"name":"libinput 1.30.4","tag_name":"1.30.4"},{"name":"libinput 1.32.901","tag_name":"1.32.901"},{"name":"libinput 1.32.0","tag_name":"1.32.0"},{"name":"libinput 1.31.3","tag_name":"1.31.3"}]'
+json "gitlab.freedesktop.org/api/v4/projects/wayland/wayland/releases" \
+ '[{"name":"1.26.91","tag_name":"1.26.91"},{"name":"1.26.0","tag_name":"1.26.0"},{"name":"1.25.0","tag_name":"1.25.0"}]'
+
+# wlroots: tags, and only the pinned series counts. The commit author's
+# "author_name" must not be read as a tag name.
+json "gitlab.freedesktop.org/api/v4/projects/wlroots/wlroots/repository/tags" \
+ '[{"name":"0.20.2","commit":{"author_name":"9.9.9"}},{"name":"0.19.3","commit":{"author_name":"x"}},{"name":"0.19.2","commit":{"author_name":"x"}},{"name":"0.19.0-rc1","commit":{"author_name":"x"}}]'
+
+# Forgejo tags with a leading v and pre-release spellings that have letters.
+json "codeberg.org/api/v1/repos/dwl/dwl/tags" \
+ '[{"name":"v0.9-dev","id":"a"},{"name":"v0.8","id":"b"},{"name":"v0.8-rc1","id":"c"},{"name":"v0.7","id":"d"}]'
+
+# less: the directory has a newer tarball, and it is a beta. The front page
+# says which version is for general use.
+mkdir -p "${SERVE}/www.greenwoodsoftware.com/less"
+printf '%s\n' 'less-718 has been released for beta testing.
' \
+ 'less-710 has been released for general use.
' \
+ 'less-718.tar.gz less-710.tar.gz' \
+ > "${SERVE}/www.greenwoodsoftware.com/less/index.html"
+
+# lynx: development snapshots beside the release.
+page "invisible-mirror.net/archives/lynx/tarballs" \
+ "lynx2.9.3.tar.gz" "lynx2.9.3dev.4.tar.gz" "lynx2.9.4dev.2.tar.gz"
+
+# openssh: the portable suffix is part of the version.
+page "ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable" \
+ "openssh-10.4p1.tar.gz" "openssh-10.5p1.tar.gz" "openssh-10.5p1.tar.gz.asc"
+
+# A tags feed for a project that publishes no releases.
+mkdir -p "${SERVE}/github.com/a13xp0p0v/kernel-hardening-checker"
+printf '%s\n' 'Tags from kernel-hardening-checker' \
+ 'v0.6.17.1v0.6.10' \
+ > "${SERVE}/github.com/a13xp0p0v/kernel-hardening-checker/tags.atom"
+
# --- fixture server ---------------------------------------------------------
python3 - "$SERVE" "${W}/port" >/dev/null 2>&1 <<'PY' &
@@ -135,6 +180,7 @@ build_root() {
cat > "${FAKE}/build/config/versions.env" <<'EOF'
V_PYTHON=3.12.5
V_OPENSSL=3.3.1
+V_WLROOTS=0.19.3
EOF
cat > "${FAKE}/tools/fetch-sources.sh" <<'STUB'
#!/usr/bin/env bash
@@ -150,6 +196,15 @@ perl 5.40.0 https://www.cpan.org/src/5.0/perl-5.40.0.tar.xz
zlib 1.3.1 https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz
preview 1.0 https://github.com/acme/preview/releases/download/v1.0/preview-1.0.tar.gz
linux 6.18.50 https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.50.tar.xz
+libinput 1.30.4 https://gitlab.freedesktop.org/libinput/libinput/-/archive/1.30.4/libinput-1.30.4.tar.gz
+wayland 1.26.0 https://gitlab.freedesktop.org/wayland/wayland/-/releases/1.26.0/downloads/wayland-1.26.0.tar.xz
+wlroots 0.19.3 https://gitlab.freedesktop.org/wlroots/wlroots/-/releases/0.19.3/downloads/wlroots-0.19.3.tar.gz
+dwl 0.8 https://codeberg.org/dwl/dwl/releases/download/v0.8/dwl-v0.8.tar.gz
+less 661 https://www.greenwoodsoftware.com/less/less-661.tar.gz
+lynx 2.9.3 https://invisible-mirror.net/archives/lynx/tarballs/lynx2.9.3.tar.gz
+openssh 10.5p1 https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-10.5p1.tar.gz
+kernel-hardening-checker 0.6.17.1 https://github.com/a13xp0p0v/kernel-hardening-checker/archive/refs/tags/v0.6.17.1.tar.gz
+glibc-fhs-patch 2.40 https://www.linuxfromscratch.org/patches/lfs/12.2/glibc-2.40-fhs-1.patch
ROWS
STUB
chmod 755 "${FAKE}/tools/fetch-sources.sh"
@@ -202,6 +257,18 @@ expect_row preview "" UNKNOWN # a prerelease is not a release
expect_row mystery "" UNKNOWN # nothing parsed is not "current"
expect_row linux "" deferred # support status, not version
+# Hosts read through an API or a page. Each of these was UNKNOWN once, and
+# each has a way to be confidently wrong.
+expect_row libinput 1.32.0 BEHIND # 1.32.901 is a release candidate; the list is by date
+expect_row wayland 1.26.0 current # 1.26.91 is a release candidate
+expect_row wlroots 0.19.3 current # the pinned series; 0.20.2 is not a drop-in; author_name is not a tag
+expect_row dwl 0.8 current # v0.9-dev and v0.8-rc1 are not releases
+expect_row less 710 BEHIND # 718 is a beta, whatever the directory offers
+expect_row lynx 2.9.3 current # 2.9.4dev.2 is a snapshot
+expect_row openssh 10.5p1 current # the portable suffix is part of the version
+expect_row kernel-hardening-checker 0.6.17.1 current # tags only, no releases
+expect_row glibc-fhs-patch "" deferred # follows the glibc pin
+
if [[ "$(field linux 5)" == *check-kernel-eol* ]]; then
green "the kernel row names the tool that does answer the question"
else
diff --git a/tools/test-firstboot.sh b/tools/test-firstboot.sh
new file mode 100755
index 0000000..49a0b19
--- /dev/null
+++ b/tools/test-firstboot.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# First-boot setup knows when it is done from the account database, so a setup
+# cut short anywhere is finished by the next boot. The three predicates are
+# taken from build/service-scripts/firstboot.sh itself and pointed at staged
+# passwd and shadow files. No root.
+set -uo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SRC="$ROOT/build/service-scripts/firstboot.sh"
+PASS=0; FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+
+T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
+sed -n '/^regular_user() /p; /^has_password() /p; /^complete() /p' "$SRC" \
+ | sed "s|/etc/passwd|$T/passwd|; s|/etc/shadow|$T/shadow|" > "$T/fns.sh"
+[[ "$(grep -c '' "$T/fns.sh")" -eq 3 ]] || { echo "could not extract the three predicates from $SRC"; exit 1; }
+# shellcheck source=/dev/null
+. "$T/fns.sh"
+
+state() { # state "PASSWD LINES" "SHADOW LINES"
+ printf '%s\n' "root:x:0:0::/root:/bin/bash" "nobody:x:65534:65534::/:/bin/false" $1 > "$T/passwd"
+ printf '%s\n' $2 > "$T/shadow"
+}
+HASH='$6$salt$abcdefghijklmnopqrstuvwxyz'
+is() { if complete; then ok "$1"; else bad "$1"; fi; }
+not() { if complete; then bad "$1"; else ok "$1"; fi; }
+
+state "" "root:!:1::::::"
+not "a fresh install is not done"
+state "ana:x:1000:1000::/home/ana:/bin/bash" "root:!:1:::::: ana:!:1::::::"
+not "a user made a moment before the power went (no password yet) is not done"
+[[ "$(regular_user)" == ana ]] && ok "and the next boot finds that user instead of asking for a name" || bad "regular_user: '$(regular_user)'"
+state "ana:x:1000:1000::/home/ana:/bin/bash" "root:!:1:::::: ana:${HASH}:1::::::"
+not "a user who can log in, with root still locked, is not done: nobody could administer it"
+state "ana:x:1000:1000::/home/ana:/bin/bash" "root:${HASH}:1:::::: ana:!${HASH}:1::::::"
+not "a locked hash is not a password"
+state "ana:x:1000:1000::/home/ana:/bin/bash" "root:${HASH}:1:::::: ana:${HASH}:1::::::"
+is "a user and root who can both authenticate: done"
+state "svc:x:999:999::/:/bin/false" "root:${HASH}:1:::::: svc:${HASH}:1::::::"
+not "a system account is not the desktop user"
+
+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
+[[ "$FAIL" -eq 0 ]]
diff --git a/tools/test-git-hooks.sh b/tools/test-git-hooks.sh
index 60efe76..09ccc6d 100755
--- a/tools/test-git-hooks.sh
+++ b/tools/test-git-hooks.sh
@@ -183,7 +183,9 @@ fi
echo
echo "=== and the real repository, which is where it was actually wrong ==="
-real_mode="$(git -C "$ROOT" ls-files -s -- tools/git-hooks/pre-commit | awk '{print $1}')"
+# safe.directory: acceptance runs this as root over a checkout that is not
+# root's, where git otherwise refuses to read the repository at all.
+real_mode="$(git -c safe.directory='*' -C "$ROOT" ls-files -s -- tools/git-hooks/pre-commit | awk '{print $1}')"
if [[ "$real_mode" == "100755" ]]; then
green "tools/git-hooks/pre-commit is 100755 in this repository's index"
else
diff --git a/tools/test-launch-secrets.py b/tools/test-launch-secrets.py
old mode 100644
new mode 100755
diff --git a/tools/test-release-manifest.sh b/tools/test-release-manifest.sh
index 6393423..8cbf98c 100755
--- a/tools/test-release-manifest.sh
+++ b/tools/test-release-manifest.sh
@@ -472,6 +472,91 @@ else
fi
rm -f "${REL}/usr/share/.kryptik-update" "${REL}/.kryptik-update"
+# --- the update channel's statement of what is current -----------------------
+# `pointer` writes and signs it; the machine's side is kryptik-update's
+# check-pointer. The rows that matter are the ones where the two meet: what
+# this tool emits is accepted by the updater's own function, under an anchor
+# shaped like the image's (each key honoured in one namespace only).
+build_release
+make_signed 1.0.3 development
+ssh-keygen -q -t ed25519 -N '' -C latest -f "${W}/keys/latest" "$ANCHOR"
+PTR="${W}/latest"
+NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "$PTR" \
+ --issued 2027-03-02T14:05:00+00:00 > "$OUT" 2>&1; RC=$?
+want="$(printf 'KRYPTIK-LATEST-1\nrole: development\nversion: 1.0.3\nissued: 2027-03-02T14:05:00+00:00\nmanifest-sha256: %s\nbase: 1.0.3/\n' "$(sha256sum "$MAN" | cut -c1-64)")"
+if [[ "$RC" -eq 0 && "$(cat "$PTR")" == "$want" && -s "${PTR}.sig" ]]; then
+ green "pointer: names the manifest's version and role, its hash, the base and the date, and nothing else"
+else
+ red "pointer: wrote something else (exit ${RC})"; show; cat "$PTR" 2>/dev/null
+fi
+if ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-latest -n kryptik-latest -s "${PTR}.sig" < "$PTR" >/dev/null 2>&1 \
+ && ! ssh-keygen -Y verify -f "$ANCHOR" -I kryptik-latest -n kryptik-release -s "${PTR}.sig" < "$PTR" >/dev/null 2>&1; then
+ green "pointer: signed in its own namespace, and not a signature a manifest could borrow"
+else
+ red "pointer: the signature is not in kryptik-latest alone"
+fi
+
+# The updater's own check, lifted out of the tool as its suite does.
+UPD="${ROOT}/tools/update/kryptik-update"
+{
+ echo 'LATEST_NAMESPACE=kryptik-latest'; echo 'LATEST_MAGIC=KRYPTIK-LATEST-1'
+ echo "SIGNERS=${ANCHOR}"
+ echo 'say() { printf "%s\n" "$*"; }'
+ echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }'
+ sed -n '/^verify_signed() {/,/^}/p' "$UPD"
+ sed -n '/^cmd_check_pointer() {/,/^}/p' "$UPD"
+ printf 'SNAP=%q\n' "${W}/snap"; echo 'mkdir -p "$SNAP"'
+ echo 'cmd_check_pointer "$1" "$2" && echo ACCEPTED'
+} > "${W}/check-pointer.sh"
+if bash "${W}/check-pointer.sh" "$PTR" "${PTR}.sig" 2>&1 | grep -qx ACCEPTED; then
+ green "pointer: what this tool writes is what kryptik-update's check-pointer accepts"
+else
+ red "pointer: kryptik-update refuses what this tool wrote: $(bash "${W}/check-pointer.sh" "$PTR" "${PTR}.sig" 2>&1 | tail -1)"
+fi
+# Signed by the release key instead: a statement the anchor does not honour.
+NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/rel" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-by-rel" > /dev/null 2>&1
+# Into a variable first: the refusal exits 1, and under pipefail that would
+# fail the pipeline whatever grep found.
+said="$(bash "${W}/check-pointer.sh" "${W}/latest-by-rel" "${W}/latest-by-rel.sig" 2>&1)"
+if [[ "$said" == *"REFUSED:"*"does NOT verify"* && "$said" != *ACCEPTED* ]]; then
+ green "pointer: one signed with the release key is refused by the updater, because the anchor honours that key for releases only"
+else
+ red "pointer: the updater accepted a statement signed by the release key"
+fi
+
+# Re-issued later for the same release: only the date moves.
+NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-2" \
+ --issued 2027-04-01T00:00:00+00:00 > /dev/null 2>&1
+if [[ "$(diff <(cat "$PTR") <(cat "${W}/latest-2") | grep -c '^[<>]')" -eq 2 ]] && grep -qx 'issued: 2027-04-01T00:00:00+00:00' "${W}/latest-2"; then
+ green "pointer: re-issued for an unchanged release, only the date differs"
+else
+ red "pointer: a re-issue changed more than the date"
+fi
+
+# A signature file that is there and is not the manifest's signature.
+cp "${MAN}.sig" "${W}/man.sig.good"; ssh-keygen -q -t ed25519 -N "" -f "${W}/keys/stranger" > /dev/null
+rm -f "${MAN}.sig"; ssh-keygen -Y sign -f "${W}/keys/stranger" -n kryptik-release "$MAN" < /dev/null > /dev/null 2>&1
+NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-stranger" > "$OUT" 2>&1; RC=$?
+if [[ "$RC" -ne 0 && ! -e "${W}/latest-stranger" ]] && grep -q 'does not verify' "$OUT"; then
+ green "pointer: no statement is written about a manifest signed by a key the image does not carry"
+else
+ red "pointer: wrote a statement for a manifest a stranger signed (exit ${RC})"; show
+fi
+cp "${W}/man.sig.good" "${MAN}.sig"
+
+rm -f "${MAN}.sig"
+NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --signers "$ANCHOR" --manifest "$MAN" --base 1.0.3/ --out "${W}/latest-unsigned" > "$OUT" 2>&1; RC=$?
+if [[ "$RC" -ne 0 && ! -e "${W}/latest-unsigned" ]] && grep -q 'is not signed yet' "$OUT"; then
+ green "pointer: no statement is written about a manifest nobody has signed"
+else
+ red "pointer: wrote a statement for an unsigned manifest (exit ${RC})"; show
+fi
+
echo
if [[ "$FAIL" -gt 0 ]]; then
echo "${FAIL} of $((PASS + FAIL)) checks failed."
diff --git a/tools/test-update-fetch.sh b/tools/test-update-fetch.sh
new file mode 100755
index 0000000..fc6361f
--- /dev/null
+++ b/tools/test-update-fetch.sh
@@ -0,0 +1,191 @@
+#!/usr/bin/env bash
+# The net zone's half of the update channel, tools/net/update-fetch.py, against
+# a real HTTP server on loopback and a stand-in for zone 0's broker on a unix
+# socket (docs/design/update-channel.md).
+#
+# The fetcher decides nothing, so what is checked is that it is a faithful
+# pipe: the bytes that arrive are the bytes that were served, in the order
+# and from the offsets zone 0 asked for, in pieces zone 0 will take, and that
+# it stops when zone 0 says no. The stand-in keeps zone 0's side of the
+# conversation honest enough for that: it answers a poll from what it holds,
+# and refuses a piece that is not at the offset it holds.
+#
+# Needs bash and python3; no root, no network beyond loopback. Exit 0 when
+# every row passes, 77 when python3 is missing.
+set -uo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+FETCH="$ROOT/tools/net/update-fetch.py"
+command -v python3 >/dev/null 2>&1 || { echo "python3 not found; cannot run"; exit 77; }
+
+PASS=0; FAIL=0
+ok() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
+bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
+
+T="$(mktemp -d)"
+PIDS=()
+cleanup() { for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done; rm -rf "$T"; }
+trap cleanup EXIT
+
+# --- a release to serve --------------------------------------------------------
+REL="$T/www/chan/1.0.3"
+mkdir -p "$REL" "$T/stage"
+head -c 2621445 /dev/urandom > "$REL/kryptik-root.img" # 2.5 MiB and five bytes: three pieces
+head -c 70000 /dev/urandom > "$REL/kryptik-a.efi"
+printf '{ "fixture": true }\n' > "$REL/root.json"
+printf 'KRYPTIK-MANIFEST-1\nfixture\n' > "$REL/manifest"
+printf 'a signature, as far as this suite cares\n' > "$REL/manifest.sig"
+printf 'KRYPTIK-LATEST-1\nversion: 1.0.3\n' > "$T/www/chan/latest"
+printf 'and its signature\n' > "$T/www/chan/latest.sig"
+# What the stand-in "verified manifest" lists: name and size.
+for f in kryptik-root.img kryptik-a.efi root.json; do printf '%s %s\n' "$f" "$(stat -c %s "$REL/$f")"; done > "$T/listed"
+
+# --- the release host: tools/image/release-host.py, the one the update suite
+# serves a real release from. Range honoured unless $T/norange exists.
+HOST="$ROOT/tools/image/release-host.py"
+
+# --- zone 0's broker, as far as the fetcher can tell ------------------------------
+cat > "$T/broker.py" <<'EOF'
+import os, socket, sys
+sock, work, base = sys.argv[1:4]
+stage = os.path.join(work, "stage")
+def held(n):
+ p = os.path.join(stage, n)
+ return os.path.getsize(p) if os.path.exists(p) else 0
+def poll():
+ if not os.path.exists(os.path.join(work, "wanted")):
+ return "idle"
+ need = [(n, 0) for n in ("manifest", "manifest.sig") if held(n) == 0]
+ if not need:
+ for line in open(os.path.join(work, "listed")):
+ n, size = line.split()
+ if held(n) < int(size):
+ need.append((n, held(n)))
+ return "fetch 1.0.3 %s need %s" % (base, " ".join("%s %d" % x for x in need)) if need else "idle"
+srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+srv.bind(sock); srv.listen(8)
+while True:
+ c, _ = srv.accept()
+ data = b""
+ while True:
+ chunk = c.recv(1 << 16)
+ if not chunk:
+ break
+ data += chunk
+ header, _, payload = data.partition(b"\n")
+ words = header.decode().split()
+ open(os.path.join(work, "requests.log"), "a").write("%s payload=%d\n" % (header.decode(), len(payload)))
+ if words[0] == "update-latest":
+ plen, slen = int(words[1]), int(words[2])
+ open(os.path.join(work, "got-latest"), "wb").write(payload[:plen])
+ open(os.path.join(work, "got-latest.sig"), "wb").write(payload[plen:plen + slen])
+ reply = "ok available 1.0.3" if len(payload) == plen + slen else "error: payload short"
+ elif words[0] == "update-poll":
+ reply = poll()
+ elif words[0] == "update-put":
+ name, offset, length = words[1], int(words[2]), int(words[3])
+ refused = os.path.join(work, "refuse")
+ if os.path.exists(refused) and open(refused).read().strip() == name:
+ reply = "error: %s: zone 0 says no" % name
+ elif offset != held(name) or length != len(payload):
+ reply = "error: %s: %d bytes are held; the next byte wanted is %d, not %d" % (name, held(name), held(name), offset)
+ else:
+ open(os.path.join(stage, name), "ab").write(payload)
+ reply = "ok %s %d" % (name, held(name))
+ else:
+ reply = "error: unknown verb"
+ c.sendall((reply + "\n").encode()); c.close()
+EOF
+
+python3 "$HOST" "$T/www" "$T/port" "$T/http.log" "$T/norange" & PIDS+=($!)
+for _ in $(seq 50); do [[ -s "$T/port" ]] && break; sleep 0.1; done
+PORT="$(cat "$T/port" 2>/dev/null)"
+[[ -n "$PORT" ]] || { echo "the HTTP server did not start"; exit 1; }
+BASE="http://127.0.0.1:$PORT/chan"
+python3 "$T/broker.py" "$T/broker.sock" "$T" "$BASE/1.0.3/" & PIDS+=($!)
+for _ in $(seq 50); do [[ -S "$T/broker.sock" ]] && break; sleep 0.1; done
+[[ -S "$T/broker.sock" ]] || { echo "the broker stand-in did not start"; exit 1; }
+printf '# where releases are\nchannel = %s\n' "$BASE" > "$T/update.conf"
+
+run() { python3 "$FETCH" "$@" --conf "$T/update.conf" --broker "$T/broker.sock" --ca /nonexistent; }
+identical() { cmp -s "$REL/$1" "$T/stage/$1"; }
+
+# --- the statement of what is current ---------------------------------------------
+out="$(run latest 2>&1)"; rc=$?
+if [[ "$rc" = 0 && "$out" == "ok available 1.0.3" ]] && cmp -s "$T/www/chan/latest" "$T/got-latest" && cmp -s "$T/www/chan/latest.sig" "$T/got-latest.sig"; then
+ ok "latest: the statement and its signature reach zone 0 byte for byte, and its answer is passed on"
+else
+ bad "latest: rc=$rc out=$out"
+fi
+
+cp "$T/www/chan/latest" "$T/latest.keep"
+head -c 9000 /dev/zero > "$T/www/chan/latest"
+: > "$T/requests.log"
+out="$(run latest 2>&1)"; rc=$?
+if [[ "$rc" = 1 && "$out" == *"larger than 8192"* && ! -s "$T/requests.log" ]]; then
+ ok "latest: a statement larger than zone 0 will take is not sent at all"
+else
+ bad "latest, oversized: rc=$rc out=$out"
+fi
+cp "$T/latest.keep" "$T/www/chan/latest"
+
+# --- nothing asked for --------------------------------------------------------------
+out="$(run poll 2>&1)"; rc=$?
+[[ "$rc" = 0 && "$out" == "idle" && -z "$(ls -A "$T/stage")" ]] \
+ && ok "poll: told idle, it fetches nothing" || bad "poll, idle: rc=$rc out=$out"
+
+# --- a release, whole -----------------------------------------------------------------
+: > "$T/wanted"; : > "$T/requests.log"
+out="$(run poll 2>&1)"; rc=$?
+if [[ "$rc" = 0 && "$out" == "idle" ]] && identical manifest && identical manifest.sig && identical kryptik-root.img && identical kryptik-a.efi && identical root.json; then
+ ok "poll: every file of the release arrives byte for byte, and the run ends when zone 0 says idle"
+else
+ bad "poll, whole release: rc=$rc out=$out"
+fi
+first_two="$(grep '^update-put' "$T/requests.log" | head -2 | awk '{print $2}' | tr '\n' ' ')"
+[[ "$first_two" == "manifest manifest.sig " ]] \
+ && ok "poll: the manifest and its signature cross before anything else, because that is what zone 0 asked for" \
+ || bad "poll: the first two pieces were: $first_two"
+biggest="$(grep '^update-put' "$T/requests.log" | awk '{print $4}' | sort -n | tail -1)"
+pieces="$(grep -c '^update-put kryptik-root.img' "$T/requests.log")"
+[[ "$biggest" = 1048576 && "$pieces" = 3 ]] \
+ && ok "poll: no piece is larger than 1 MiB (the root image crossed in $pieces)" \
+ || bad "poll: biggest piece $biggest, root image pieces $pieces"
+
+# --- a download cut short resumes from the byte zone 0 names -------------------------
+truncate -s 1048581 "$T/stage/kryptik-root.img"; : > "$T/http.log"
+out="$(run poll 2>&1)"; rc=$?
+if [[ "$rc" = 0 ]] && identical kryptik-root.img && grep -q '^/chan/1.0.3/kryptik-root.img bytes=1048581-$' "$T/http.log"; then
+ ok "poll: a file cut at byte 1048581 is asked for from that byte, and ends up identical"
+else
+ bad "poll, resume: rc=$rc out=$out http: $(cat "$T/http.log" | tr '\n' ' ')"
+fi
+truncate -s 1048581 "$T/stage/kryptik-root.img"; : > "$T/norange"
+out="$(run poll 2>&1)"; rc=$?
+rm -f "$T/norange"
+[[ "$rc" = 0 ]] && identical kryptik-root.img \
+ && ok "poll: a server that ignores Range sends the whole file; the bytes before the offset are dropped, not sent to zone 0" \
+ || bad "poll, resume without Range: rc=$rc out=$out"
+
+# --- zone 0 has the last word ----------------------------------------------------------
+rm -f "$T/stage/kryptik-root.img"; echo kryptik-root.img > "$T/refuse"; : > "$T/requests.log"
+out="$(run poll 2>&1)"; rc=$?
+tries="$(grep -c '^update-put kryptik-root.img' "$T/requests.log")"
+if [[ "$rc" = 1 && "$out" == *"zone 0 says no"* && "$tries" = 1 && ! -e "$T/stage/kryptik-root.img" ]]; then
+ ok "poll: a piece zone 0 refuses ends the run; it is not sent again"
+else
+ bad "poll, refusal: rc=$rc tries=$tries out=$out"
+fi
+rm -f "$T/refuse"
+
+# --- what it cannot do without -----------------------------------------------------------
+printf '# nothing here\n' > "$T/empty.conf"
+out="$(python3 "$FETCH" latest --conf "$T/empty.conf" --broker "$T/broker.sock" 2>&1)"; rc=$?
+[[ "$rc" = 1 && "$out" == *"names no channel"* ]] \
+ && ok "without a channel address from zone 0 it asks nobody" || bad "no channel: rc=$rc out=$out"
+printf 'channel = http://127.0.0.1:1/chan\n' > "$T/dead.conf"
+out="$(python3 "$FETCH" latest --conf "$T/dead.conf" --broker "$T/broker.sock" 2>&1)"; rc=$?
+[[ "$rc" = 1 && "$out" == update-fetch:* ]] \
+ && ok "a host that does not answer is one line and exit 1, not a traceback" || bad "dead host: rc=$rc out=$out"
+
+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
+[[ "$FAIL" -eq 0 ]]
diff --git a/tools/test-update-manifest-snapshot.sh b/tools/test-update-manifest-snapshot.sh
index 5777396..482db25 100755
--- a/tools/test-update-manifest-snapshot.sh
+++ b/tools/test-update-manifest-snapshot.sh
@@ -63,7 +63,15 @@ mkpayload signed 2
mkpayload replacement 3
ssh-keygen -q -t ed25519 -N '' -f key >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; }
-printf 'review %s\n' "$(cat key.pub)" > signers
+ssh-keygen -q -t ed25519 -N '' -f latestkey >/dev/null 2>&1 || { echo "cannot make a key"; exit 77; }
+# The trust anchor as stage 04 installs it: the release key honoured for
+# manifests and nothing else, a second key honoured for statements of what
+# is current and nothing else. An anchor without the namespaces would let
+# this suite pass things the installed system refuses.
+{
+ printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 key.pub)"
+ printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 latestkey.pub)"
+} > signers
ssh-keygen -Y sign -f key -n kryptik-release signed/manifest >/dev/null 2>&1 || { echo "cannot sign"; exit 77; }
printf 'development\n' > role
@@ -72,16 +80,20 @@ printf 'development\n' > role
{
echo 'NAMESPACE=kryptik-release'
echo 'MAGIC=KRYPTIK-MANIFEST-1'
+ echo 'LATEST_NAMESPACE=kryptik-latest'
+ echo 'LATEST_MAGIC=KRYPTIK-LATEST-1'
echo "SIGNERS=$T/signers"
echo "ROLE_FILE=$T/role"
echo 'say() { printf "%s\n" "$*"; }'
echo 'die() { printf "REFUSED: %s\n" "$*"; exit 1; }'
echo 'hdr() { awk -F": " -v k="$2" '"'"'$1==k {print $2; exit}'"'"' "$1"; }'
echo 'running_version() { echo 1; }'
- sed -n '/^verify_payload() {/,/^cmd_apply() {/p' "$TOOL" | sed '$d'
+ sed -n '/^pin() {/,/^cmd_apply() {/p' "$TOOL" | sed '$d'
} > verify.sh
grep -q '^verify_payload() {' verify.sh || { echo "could not extract verify_payload from $TOOL"; exit 1; }
+grep -q '^pin() {' verify.sh || { echo "could not extract pin from $TOOL"; exit 1; }
+
# run_case NAME WHAT-THE-WRITER-REPLACES: a fresh copy of the signed payload,
# the tool's verify_payload over it, and a writer that lands the moment the
# real ssh-keygen has accepted the signature. Prints the tool's output plus
@@ -100,6 +112,14 @@ ssh-keygen() {
all) cp $T/replacement/* $T/payload/ ;;
esac
fi
+ # Before anything is judged: what a payload directory may simply contain.
+ if [ "\${2:-}" = verify ]; then
+ case "$what" in
+ link) mv $T/payload/kryptik-root.img $T/payload-root.img; ln -s $T/payload-root.img $T/payload/kryptik-root.img ;;
+ dotfile) echo "ride along" > $T/payload/.hidden ;;
+ lookalike) echo "ride along" > $T/payload/kryptik-rootXimg ;;
+ esac
+ fi
return "\$rc"
}
SNAP=$T/snap-$name
@@ -141,11 +161,158 @@ else
bad "unexpected outcome for a replaced payload: $(tail -2 <<<"$out" | tr '\n' ' ')"
fi
-if command ssh-keygen -Y verify -f signers -I review -n kryptik-release -s "$T/payload/manifest.sig" < "$T/replacement/manifest" >/dev/null 2>&1; then
+# Cases 4 to 6: what the listing and the opening must refuse.
+out="$(run_case link link)"
+if [[ "$out" == *"REFUSED:"*"not a regular file"* ]]; then ok "a root image that is a link is refused, not followed"; else bad "a linked root image: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi
+out="$(run_case dotfile dotfile)"
+if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: .hidden"* ]]; then ok "an unlisted file whose name begins with a dot is seen and refused"; else bad "a dotfile stowaway: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi
+out="$(run_case lookalike lookalike)"
+if [[ "$out" == *"REFUSED:"*"unlisted file in the payload: kryptik-rootXimg"* ]]; then ok "a name that only matches a listed one as a pattern is refused"; else bad "a look-alike name: $(tail -2 <<<"$out" | tr '\n' ' ')"; fi
+
+if command ssh-keygen -Y verify -f signers -I kryptik-release -n kryptik-release -s "$T/payload/manifest.sig" < "$T/replacement/manifest" >/dev/null 2>&1; then
bad "control: the replacement manifest has a valid signature, which it must not"
else
ok "control: the real verifier rejects the replacement manifest"
fi
+# --- the update channel's two checks -----------------------------------------
+# check-manifest and check-pointer (docs/design/update-channel.md) are what
+# zone 0 runs on a manifest and on a statement of what is current before it
+# believes either. Same functions, same real ssh-keygen, the same trust anchor
+# as above; each case in its own bash because die exits.
+check() { # check FUNCTION ARGS... -> the tool's output, REFUSED: on a refusal
+ { echo "source $T/verify.sh"; printf 'SNAP=%q\n' "$(mktemp -d "$T/snap.XXXXXX")"; printf '%q ' "$@"; echo; } > "$T/check.sh"
+ bash "$T/check.sh" 2>&1
+}
+staged() { # staged NAME -> a directory holding only the signed manifest and its signature
+ rm -rf "${T:?}/$1"; mkdir -p "$T/$1"; cp "$T/signed/manifest" "$T/signed/manifest.sig" "$T/$1/"
+}
+
+staged stage
+out="$(check cmd_check_manifest "$T/stage")"
+want_sha="$(sha256sum "$T/signed/manifest" | cut -c1-64)"
+if [[ "$out" == *"version: 2"* && "$out" == *"sha256: $want_sha"* && "$(grep -c '^file [0-9]* ' <<<"$out")" = 4 ]] \
+ && grep -qx "file $(stat -c %s "$T/signed/kryptik-root.img") kryptik-root.img" <<<"$out"; then
+ ok "check-manifest: a signed manifest with no payload beside it verifies, and prints its version, its hash and the four files with their sizes"
+else
+ bad "check-manifest on a signed manifest: $(tail -3 <<<"$out" | tr '\n' ' ')"
+fi
+
+# Signed by the right key in the pointer's namespace: a pointer's signature
+# must never pass for a manifest's.
+staged crossed; rm -f "$T/crossed/manifest.sig"
+ssh-keygen -Y sign -f key -n kryptik-latest "$T/crossed/manifest" >/dev/null 2>&1
+out="$(check cmd_check_manifest "$T/crossed")"
+[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *"version:"* ]] \
+ && ok "check-manifest: a manifest signed in the pointer's namespace is refused" \
+ || bad "check-manifest accepted a signature from the pointer's namespace: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+ssh-keygen -q -t ed25519 -N '' -f otherkey >/dev/null 2>&1
+staged stranger; rm -f "$T/stranger/manifest.sig"
+ssh-keygen -Y sign -f otherkey -n kryptik-release "$T/stranger/manifest" >/dev/null 2>&1
+out="$(check cmd_check_manifest "$T/stranger")"
+[[ "$out" == *"REFUSED:"*"not enrolled"* ]] \
+ && ok "check-manifest: a manifest signed by a key that is not enrolled is refused" \
+ || bad "check-manifest accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+# The rules `apply` has, because they are the same function: the role, and no
+# downgrade (nothing that arrives over the network is a recovery).
+resigned() { # resigned NAME SED-EXPRESSION -> the signed manifest, edited, signed again
+ rm -rf "${T:?}/$1"; mkdir -p "$T/$1"
+ sed "$2" "$T/signed/manifest" > "$T/$1/manifest"
+ ssh-keygen -Y sign -f key -n kryptik-release "$T/$1/manifest" >/dev/null 2>&1
+}
+resigned prod 's/^role: development/role: production/'
+out="$(check cmd_check_manifest "$T/prod")"
+[[ "$out" == *"REFUSED:"*"this image requires 'development'"* ]] \
+ && ok "check-manifest: a validly signed manifest for another role is refused" \
+ || bad "check-manifest accepted another role: $(tail -2 <<<"$out" | tr '\n' ' ')"
+resigned older 's/^version: 2/version: 0.9/'
+out="$(check cmd_check_manifest "$T/older")"
+[[ "$out" == *"REFUSED:"*"older than the running"* ]] \
+ && ok "check-manifest: a validly signed older release is refused; the channel has no --recovery" \
+ || bad "check-manifest accepted a downgrade: $(tail -2 <<<"$out" | tr '\n' ' ')"
+resigned climbs 's| root.json$| ../root.json|'
+out="$(check cmd_check_manifest "$T/climbs")"
+[[ "$out" == *"REFUSED:"*"directory component"* && "$out" != *"file "* ]] \
+ && ok "check-manifest: a listed name with a directory component is refused before any name is printed" \
+ || bad "check-manifest printed a path that climbs: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+# The pointer.
+mkdir -p "$T/ptr"
+printf 'KRYPTIK-LATEST-1\nrole: development\nversion: 2\nissued: 2027-03-02T14:05:00+00:00\nmanifest-sha256: %s\nbase: 2/\n' "$want_sha" > "$T/ptr/latest"
+ssh-keygen -Y sign -f latestkey -n kryptik-latest "$T/ptr/latest" >/dev/null 2>&1
+out="$(check cmd_check_pointer "$T/ptr/latest" "$T/ptr/latest.sig")"; rc=$?
+[[ "$rc" = 0 && "$out" == *"signature verifies"* ]] \
+ && ok "check-pointer: a pointer signed by an enrolled key in its own namespace verifies" \
+ || bad "check-pointer refused a good pointer: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+cp "$T/ptr/latest" "$T/ptr/replayed-ns"
+ssh-keygen -Y sign -f latestkey -n kryptik-release "$T/ptr/replayed-ns" >/dev/null 2>&1
+out="$(check cmd_check_pointer "$T/ptr/replayed-ns" "$T/ptr/replayed-ns.sig")"
+[[ "$out" == *"REFUSED:"*"does NOT verify"* ]] \
+ && ok "check-pointer: a pointer signed in the manifest's namespace is refused" \
+ || bad "check-pointer accepted a signature from the manifest's namespace: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+cp "$T/ptr/latest" "$T/ptr/stranger"
+ssh-keygen -Y sign -f otherkey -n kryptik-latest "$T/ptr/stranger" >/dev/null 2>&1
+out="$(check cmd_check_pointer "$T/ptr/stranger" "$T/ptr/stranger.sig")"
+[[ "$out" == *"REFUSED:"*"not enrolled"* ]] \
+ && ok "check-pointer: a pointer signed by a key that is not enrolled is refused" \
+ || bad "check-pointer accepted a stranger's key: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+# The anchor's own rule, both ways round. The release key signing a pointer
+# in the pointer's namespace is a well-formed signature by an enrolled key,
+# and is refused because that key is not enrolled for that namespace; so is
+# the statement key signing a manifest. This is what lets the statement key
+# live where a timer can reach it.
+cp "$T/ptr/latest" "$T/ptr/by-release-key"
+ssh-keygen -Y sign -f key -n kryptik-latest "$T/ptr/by-release-key" >/dev/null 2>&1
+out="$(check cmd_check_pointer "$T/ptr/by-release-key" "$T/ptr/by-release-key.sig")"
+[[ "$out" == *"REFUSED:"*"does NOT verify"*"not for kryptik-latest"* ]] \
+ && ok "check-pointer: the release key is not honoured for a pointer, whatever namespace it signs in" \
+ || bad "check-pointer accepted a pointer signed by the release key: $(tail -2 <<<"$out" | tr '\n' ' ')"
+staged by-latest-key; rm -f "$T/by-latest-key/manifest.sig"
+ssh-keygen -Y sign -f latestkey -n kryptik-release "$T/by-latest-key/manifest" >/dev/null 2>&1
+out="$(check cmd_check_manifest "$T/by-latest-key")"
+[[ "$out" == *"REFUSED:"*"does NOT verify"* && "$out" != *"version:"* ]] \
+ && ok "check-manifest: the statement key cannot sign a release, whatever namespace it signs in" \
+ || bad "check-manifest accepted a manifest signed by the statement key: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+sed 's/^version: 2/version: 1/' "$T/ptr/latest" > "$T/ptr/edited"
+out="$(check cmd_check_pointer "$T/ptr/edited" "$T/ptr/latest.sig")"
+[[ "$out" == *"REFUSED:"*"does NOT verify"* ]] \
+ && ok "check-pointer: a pointer edited after it was signed is refused" \
+ || bad "check-pointer accepted an edited pointer: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+# A manifest is not a pointer even when someone signs it as one.
+cp "$T/signed/manifest" "$T/ptr/manifest-as-pointer"
+ssh-keygen -Y sign -f latestkey -n kryptik-latest "$T/ptr/manifest-as-pointer" >/dev/null 2>&1
+out="$(check cmd_check_pointer "$T/ptr/manifest-as-pointer" "$T/ptr/manifest-as-pointer.sig")"
+[[ "$out" == *"REFUSED:"*"not a KRYPTIK-LATEST-1"* ]] \
+ && ok "check-pointer: a manifest presented as a pointer is refused by its first line" \
+ || bad "check-pointer accepted a manifest: $(tail -2 <<<"$out" | tr '\n' ' ')"
+
+# The tool itself, not the functions lifted out of it: the two checks need
+# neither root nor this installation's disks, and say what they do need.
+if [[ "$(id -u)" != 0 ]]; then
+ out="$(sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" 2>&1)"
+ [[ "$out" != *"must run as root"* && "$out" == *"no trust anchor at /usr/share/kryptik/trust/release-signers"* ]] \
+ && ok "check-pointer runs without root and stops at the image's trust anchor, which this host does not have" \
+ || bad "the tool's own check-pointer, unprivileged: $(tail -2 <<<"$out" | tr '\n' ' ')"
+ # The directory the tool copies into is removed by its exit trap, so it
+ # must never be one the caller's environment named.
+ mkdir -p "$T/precious"; echo keep > "$T/precious/marker"
+ SNAP="$T/precious" sh "$TOOL" check-pointer "$T/ptr/latest" "$T/ptr/latest.sig" >/dev/null 2>&1
+ [[ -f "$T/precious/marker" && -z "$(find "$T/precious" -name 'latest*')" ]] \
+ && ok "a SNAP in the environment is not where the tool copies, and is not what its exit trap removes" \
+ || bad "the tool used, or removed, the directory the environment named as SNAP"
+ out="$(sh "$TOOL" apply "$T/signed" 2>&1)"
+ [[ "$out" == *"must run as root"* ]] \
+ && ok "control: apply still refuses to run without root" \
+ || bad "apply without root: $(tail -2 <<<"$out" | tr '\n' ' ')"
+fi
+
+
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover
index 9f72d37..2230253 100755
--- a/tools/update/kryptik-recover
+++ b/tools/update/kryptik-recover
@@ -7,6 +7,8 @@
# kryptik-recover --disk DEV --commit-slot a|b make that slot the boot file
# kryptik-recover --disk DEV --restore-slot a|b rewrite that slot from this medium's root image
# kryptik-recover --disk DEV --status
+# kryptik-recover --disk DEV --backup-state-header FILE
+# kryptik-recover --disk DEV --restore-state-header FILE
#
# --commit-slot: the other slot is intact (an update went wrong after the
# commit, or the committed kernel file was damaged): copy that slot's kernel
@@ -19,20 +21,26 @@
# an older version than what was there is what "recover from the medium"
# means, and it is said.
#
+# The state partition is LUKS2 (docs/design/state-encryption.md) and its
+# header is the state: without it, or without the passphrase, the partition
+# is lost, and there is no escrow. A backup belongs off this disk.
+#
# Nothing here depends on the damaged system: every byte written comes from
# the medium, which the firmware verified.
set -eu
PROG=kryptik-recover
say() { printf '%s: %s\n' "$PROG" "$*"; }
die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; }
-DISK=""; COMMIT=""; RESTORE=""; STATUS=0
+DISK=""; COMMIT=""; RESTORE=""; STATUS=0; HDR_OUT=""; HDR_IN=""
while [ $# -gt 0 ]; do
case "$1" in
--disk) DISK="${2:-}"; shift 2 ;;
--commit-slot) COMMIT="${2:-}"; shift 2 ;;
--restore-slot) RESTORE="${2:-}"; shift 2 ;;
--status) STATUS=1; shift ;;
- -h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ --backup-state-header) HDR_OUT="${2:-}"; shift 2 ;;
+ --restore-state-header) HDR_IN="${2:-}"; shift 2 ;;
+ -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
@@ -72,6 +80,17 @@ if [ "$STATUS" = 1 ]; then
exit 0
fi
+if [ -n "$HDR_OUT" ]; then # cryptsetup refuses to overwrite an existing file
+ cryptsetup luksHeaderBackup "$ST" --header-backup-file "$HDR_OUT" || die "could not back up the header of ${ST}"
+ say "the header of ${ST} is in ${HDR_OUT}"
+ exit 0
+fi
+if [ -n "$HDR_IN" ]; then
+ cryptsetup -q luksHeaderRestore "$ST" --header-backup-file "$HDR_IN" || die "could not restore the header of ${ST} from ${HDR_IN}"
+ say "the header of ${ST} is restored from ${HDR_IN}"
+ exit 0
+fi
+
commit_slot() { # commit_slot SLOT (ESP mounted rw at /run/kryptik-recover)
s="$1"; k="/run/kryptik-recover/EFI/kryptik/kryptik-$s.efi"
[ -f "$k" ] || die "no kernel for slot $s on the ESP"
@@ -90,9 +109,11 @@ if [ -n "$RESTORE" ]; then
mkdir -p /run/kryptik-recover-media
case "$media" in
usb)
- src="$(blkid -t PARTLABEL=kryptik-media -o device 2>/dev/null | head -1)"; off=0
- mdisk="$(printf '%s' "$src" | sed 's/p\{0,1\}[0-9]*$//')"
- mesp="$(blkid -t PARTLABEL=kryptik-esp -o device 2>/dev/null | grep "^${mdisk}" | head -1)"
+ # This medium's own partitions (devices.sh), never the first
+ # disk that carries the label.
+ . /usr/libexec/kryptik/devices.sh
+ src="$(kryptik_part kryptik-media)" || die "no single kryptik-media partition on this medium"; off=0
+ mesp="$(kryptik_part kryptik-esp)" || die "no single kryptik-esp partition on this medium"
mount -o ro "$mesp" /run/kryptik-recover-media || die "cannot mount the medium's ESP"
rj=/run/kryptik-recover-media/kryptik/root.json; kdir=/run/kryptik-recover-media/EFI/kryptik ;;
iso)
@@ -107,6 +128,7 @@ if [ -n "$RESTORE" ]; then
say "restoring slot $RESTORE from this medium (${ver}, ${bytes} bytes); the state partition is untouched"
if [ "$off" -gt 0 ]; then dd if="$src" of="$sd" bs=4M iflag=skip_bytes,count_bytes skip="$off" count="$bytes" conv=fsync status=none
else dd if="$src" of="$sd" bs=4M iflag=count_bytes count="$bytes" conv=fsync status=none; fi
+ blockdev --flushbufs "$sd" # so the read-back is of the disk, not of the page cache
got="$(dd if="$sd" bs=4M iflag=count_bytes count="$bytes" status=none | sha256sum | cut -c1-64)"
[ "$got" = "$sha" ] || die "slot $RESTORE reads back as $got, expected $sha"
say "slot $RESTORE verifies"
@@ -129,4 +151,4 @@ if [ -n "$COMMIT" ]; then
umount /run/kryptik-recover
exit 0
fi
-die "one of --status, --commit-slot or --restore-slot is required"
+die "one of --status, --commit-slot, --restore-slot, --backup-state-header or --restore-state-header is required"
diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update
index 383af3b..bcb1b16 100755
--- a/tools/update/kryptik-update
+++ b/tools/update/kryptik-update
@@ -8,6 +8,8 @@
# kryptik-update apply DIR [--retry] [--recovery]
# kryptik-update rollback
# kryptik-update status
+# kryptik-update check-manifest DIR for the update channel: the signature,
+# kryptik-update check-pointer FILE SIG role and version steps below, no writes
#
# DIR holds: manifest, manifest.sig, kryptik-root.img, kryptik-a.efi,
# kryptik-b.efi, root.json - exactly those and nothing else.
@@ -35,6 +37,8 @@ die() { printf '%s: FAILED: %s\n' "$PROG" "$*" >&2; exit 1; }
NAMESPACE=kryptik-release
MAGIC=KRYPTIK-MANIFEST-1
+LATEST_NAMESPACE=kryptik-latest
+LATEST_MAGIC=KRYPTIK-LATEST-1
SIGNERS=/usr/share/kryptik/trust/release-signers
ROLE_FILE=/usr/share/kryptik/trust/required-role
DEGRADED=/run/kryptik/state-degraded
@@ -42,17 +46,35 @@ B=/var/lib/kryptik/boot
ESP_MNT=/run/kryptik/update-esp
LOCK=/run/kryptik/update.lock
LOG=/var/log/kryptik/update.log
+# The directory the manifest is copied into is this script's to make, never
+# the caller's to name: the exit trap removes it, whatever it is. (It is a
+# variable at all so that the suite, which runs the functions below in a
+# shell of its own, can say where to look.)
+SNAP=""
-[ "$(id -u)" = 0 ] || die "must run as root"
-mkdir -p "$B" /run/kryptik /var/log/kryptik
-for tool in ssh-keygen sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev; do
+# The two checks the update channel calls read what they are given and write
+# nothing outside a directory of their own: no root, no devices, and only the
+# tools a signature and a hash need.
+case "${1:-}" in
+ check-manifest|check-pointer)
+ tools="ssh-keygen sha256sum cp awk sed sort head mktemp" ;;
+ *)
+ tools="ssh-keygen sha256sum blkid dd cp mv sync flock mount umount cmp kryptik-efiboot awk sed sort head wc stat grep blockdev"
+ [ "$(id -u)" = 0 ] || die "must run as root"
+ mkdir -p "$B" /run/kryptik /var/log/kryptik ;;
+esac
+for tool in $tools; do
command -v "$tool" >/dev/null 2>&1 || die "missing tool: $tool"
done
# Partitions are this installation's - the ones on the disk the root came
# from - and nothing else's; an ambiguity is refused, never resolved by
# taking the first (devices.sh).
-# shellcheck source=/dev/null
-. /usr/libexec/kryptik/devices.sh
+case "${1:-}" in
+ check-manifest|check-pointer) ;;
+ *)
+ # shellcheck source=/dev/null
+ . /usr/libexec/kryptik/devices.sh ;;
+esac
running_slot() { sed -n 's/^slot=//p' /run/kryptik/boot-identity 2>/dev/null; }
other_slot() { case "$1" in a) echo b ;; b) echo a ;; *) echo "" ;; esac; }
@@ -91,8 +113,93 @@ cmd_status() {
kryptik-efiboot list 2>/dev/null | sed 's/^/ efi: /' || echo " efi: (efivarfs unavailable)"
}
+# The four files a release is made of are opened once, and from then on they
+# are read through those descriptors: hashing a name and opening the name
+# again later is two files if the directory changed in between, and a payload
+# directory can change (the net zone staged it; a medium can be swapped).
+# What is hashed is then what is written. A link is refused, and so is a name
+# that stopped being the opened file while it was being opened.
+pin() { # pin FD NAME
+ f="$dir/$2"
+ { [ -f "$f" ] && [ ! -L "$f" ]; } || die "payload lacks $2, or it is not a regular file"
+ eval "exec $1< \"\$f\"" || die "cannot open $2"
+ [ "$(stat -c %d:%i "$f")" = "$(stat -L -c %d:%i "/proc/self/fd/$1")" ] || die "$2 changed while it was being opened"
+}
+payload_path() { # NAME -> where to read it
+ case "$1" in
+ kryptik-root.img) echo /proc/self/fd/3 ;; kryptik-a.efi) echo /proc/self/fd/4 ;;
+ kryptik-b.efi) echo /proc/self/fd/5 ;; root.json) echo /proc/self/fd/6 ;;
+ *) echo "$dir/$1" ;;
+ esac
+}
+
# --- verification, all of it before any write ------------------------------
verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH
+ verify_manifest "$1" "$2"
+ pin 3 kryptik-root.img; pin 4 kryptik-a.efi; pin 5 kryptik-b.efi; pin 6 root.json
+
+ # 3. Nothing unlisted, which costs a directory listing, before every
+ # listed file's hash, which costs a pass over gigabytes. Names are
+ # compared whole, as text, and a name that begins with a dot is a name.
+ names="$(sed -n '/^--$/,$p' "$m" | sed '1d' | awk '{print $3}')"
+ for f in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do
+ [ -e "$f" ] || [ -L "$f" ] || continue
+ n="$(basename "$f")"
+ case "$n" in manifest|manifest.sig) continue ;; esac
+ # A payload that is the root of an ext4 medium carries the
+ # filesystem's own lost+found. Empty, it is nothing and is passed
+ # over; anything inside it is a stowaway like any other and refused.
+ if [ "$n" = "lost+found" ] && [ -d "$f" ] && [ ! -L "$f" ]; then
+ [ -z "$(ls -A "$f")" ] || die "unlisted file in the payload: lost+found is not empty"
+ continue
+ fi
+ printf '%s\n' "$names" | grep -Fxq -- "$n" || die "unlisted file in the payload: $n"
+ done
+
+ listed=0
+ while read -r want_hash want_size rel; do
+ [ -n "$rel" ] || continue
+ case "$rel" in */*|..*) die "manifest lists a path with a directory component: $rel" ;; esac
+ f="$(payload_path "$rel")"
+ [ -f "$f" ] || die "listed file missing: $rel"
+ got_size="$(stat -L -c %s "$f")"
+ [ "$got_size" = "$want_size" ] || die "$rel: size $got_size, manifest says $want_size (truncated or altered)"
+ got_hash="$(sha256sum "$f" | cut -c1-64)"
+ [ "$got_hash" = "$want_hash" ] || die "$rel: sha256 does not match the manifest"
+ listed=$((listed + 1))
+ done < sets dir m sig VERSION count
dir="$1"; recovery="$2"
[ -f "$dir/manifest" ] || die "no manifest in $dir"
[ -f "$dir/manifest.sig" ] || die "no manifest.sig in $dir: an unsigned manifest verifies nothing"
@@ -108,15 +215,10 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH
cp -- "$dir/manifest" "$SNAP/manifest" || die "cannot copy the manifest"
cp -- "$dir/manifest.sig" "$SNAP/manifest.sig" || die "cannot copy the manifest signature"
m="$SNAP/manifest"; sig="$SNAP/manifest.sig"
- [ -f "$SIGNERS" ] || die "no trust anchor at $SIGNERS in this image"
[ "$(head -1 "$m")" = "$MAGIC" ] || die "manifest is not a $MAGIC"
# 1. Signature, by an enrolled key, over the manifest bytes.
- principal="$(ssh-keygen -Y find-principals -s "$sig" -f "$SIGNERS" 2>/dev/null | head -1 || true)"
- [ -n "$principal" ] || die "the manifest's signing key is not enrolled in $SIGNERS"
- ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$NAMESPACE" -s "$sig" < "$m" >/dev/null 2>&1 \
- || die "the manifest signature does NOT verify (principal $principal)"
- say "signature verifies (signed by $principal)"
+ verify_signed "$m" "$sig" "$NAMESPACE" manifest
# 2. Headers inside the signed bytes.
role="$(hdr "$m" role)"; VERSION="$(hdr "$m" version)"; count="$(hdr "$m" files)"
@@ -137,60 +239,65 @@ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH
an older signed release can reintroduce a fixed defect. --recovery accepts it deliberately."
fi
fi
+}
- # 3. Every listed file, hash and size; and nothing unlisted.
- listed=0
- while read -r want_hash want_size rel; do
+# A signature by an enrolled key, in one namespace, over one file's bytes. A
+# manifest is signed in kryptik-release and a statement of what is current in
+# kryptik-latest, so neither signature can be presented as the other - and
+# the trust anchor says so too: each line of it names the namespaces its key
+# is honoured in (`namespaces="..."`), so a key enrolled for one kind of
+# statement cannot sign the other whatever namespace it claims. A key may be
+# enrolled under more than one principal; each is tried.
+verify_signed() { # verify_signed FILE SIG NAMESPACE WHAT
+ [ -f "$SIGNERS" ] || die "no trust anchor at $SIGNERS in this image"
+ principals="$(ssh-keygen -Y find-principals -s "$2" -f "$SIGNERS" 2>/dev/null || true)"
+ [ -n "$principals" ] || die "the $4's signing key is not enrolled in $SIGNERS"
+ for principal in $principals; do
+ if ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$3" -s "$2" < "$1" >/dev/null 2>&1; then
+ say "signature verifies (signed by $principal)"
+ return 0
+ fi
+ done
+ die "the $4 signature does NOT verify (its key is enrolled as: $(echo $principals), and not for $3)"
+}
+
+# For the update channel (docs/design/update-channel.md). DIR holds a manifest
+# and its signature and, so far, nothing else: what is printed is what zone 0
+# will then accept from the net zone, and nothing is accepted before this has
+# exited 0. The hash is of the copy that was verified; the channel compares it
+# with the one the signed pointer announced. A downgrade is refused as in
+# `apply`: nothing that arrives over the network is a recovery.
+cmd_check_manifest() {
+ [ $# = 1 ] && [ -d "$1" ] || die "check-manifest needs the directory holding manifest and manifest.sig"
+ verify_manifest "$1" 0
+ n=0
+ while read -r _hash size rel; do
[ -n "$rel" ] || continue
case "$rel" in */*|..*) die "manifest lists a path with a directory component: $rel" ;; esac
- f="$dir/$rel"
- [ -f "$f" ] || die "listed file missing: $rel"
- got_size="$(stat -c %s "$f")"
- [ "$got_size" = "$want_size" ] || die "$rel: size $got_size, manifest says $want_size (truncated or altered)"
- got_hash="$(sha256sum "$f" | cut -c1-64)"
- [ "$got_hash" = "$want_hash" ] || die "$rel: sha256 does not match the manifest"
- listed=$((listed + 1))
+ case "$size" in ''|*[!0-9]*) die "$rel: '$size' is not a size" ;; esac
+ n=$((n + 1))
done < "$ESP_MNT/kryptik/version-$target.new"
mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target"
sync
@@ -293,5 +405,7 @@ case "${1:-}" in
apply) shift; cmd_apply "$@" ;;
rollback) cmd_rollback ;;
status) cmd_status ;;
- *) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
+ check-manifest) shift; cmd_check_manifest "$@" ;;
+ check-pointer) shift; cmd_check_pointer "$@" ;;
+ *) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac