From d602fb0131deff9d868db9ddf2aa5faa3bd975a0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 13:38:49 -0700 Subject: [PATCH 01/25] A pin that is behind its upstream is held to a written review, and the review stops covering it when upstream releases again check-source-currency.sh reports that a newer version exists and, rightly, judges nothing: newer is not a security verdict. Nothing else judged either, so 51 of 113 pins are behind today and no file says which of those gaps holds a security fix. This is the place that gets written down. tools/check-pin-reviews.sh reads the survey (--tsv) and tools/pin-reviews.tsv, and never the network, so its answer can be tested and reproduced. A behind pin passes only with a row that names the version reviewed, the newest upstream release that was read, a verdict, a date and what was read. fine: nothing in the gap is a security fix that reaches Kryptik. held: something is, and the row says why the pin stays; it is printed on every run, and --no-held, which is what a release asks, refuses it. What makes it a gate and not a list: a row fails when the pin has moved off the version it reviewed, when upstream has released past what was read, when it is older than 180 days (a vulnerability is often published long after its fix), when it has no note, and when the pin has caught up and the row was left behind, so the file shrinks when the work is done. The tests (22, offline) found a bug before anything else did. The survey is tab separated and a pin the survey could not determine has an empty "newest" column; read merges a run of tabs, every later field moved left, and all 16 undetermined pins were counted as none. The survey is now read with a separator that is not whitespace. make check-pins runs the survey and then the gate; make test-pin-reviews and tools/run-tests.sh run the suite. The reviews file ships with its contract and no rows: the rows are the review itself, and come with it. --- Makefile | 18 +++- tools/check-pin-reviews.sh | 150 +++++++++++++++++++++++++++++++ tools/pin-reviews.tsv | 28 ++++++ tools/run-tests.sh | 1 + tools/test-check-pin-reviews.sh | 154 ++++++++++++++++++++++++++++++++ 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100755 tools/check-pin-reviews.sh create mode 100644 tools/pin-reviews.tsv create mode 100755 tools/test-check-pin-reviews.sh diff --git a/Makefile b/Makefile index 0f1c199..479485e 100644 --- a/Makefile +++ b/Makefile @@ -99,7 +99,7 @@ CHROOT_ENV := KRYPTIK_ROOT="$(ROOT)" \ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" -.PHONY: test help check check-kernel-eol sources lock verify verify-provenance \ +.PHONY: test help check check-kernel-eol check-pins test-pin-reviews sources lock verify verify-provenance \ vm-disk vm-disk-boot vm-restart vm-measure cli-test update-tree-test identity-test serve-test \ test-harness test-hardening test-artifacts audit-artifacts test-boot-success \ audit-artifacts-strict manifest verify-manifest test-manifest \ @@ -152,6 +152,9 @@ help: @echo " make verify-provenance signed tags + publisher checksums for the rest" @echo " make validate-kernel check kernel fragment against pinned source" @echo " make check-kernel-eol fail if the pinned kernel is EOL or not LTS" + @echo " make check-pins survey every pin against its upstream (network), then" + @echo " fail on one that is behind without a current review in" + @echo " tools/pin-reviews.tsv. PINS_FLAGS=--no-held is what a release asks" @echo " make validate-kernel-hardened check the linux-hardened fragment" @echo " make check-kernel-hardening resolve the config against the pinned source as" @echo " stage 05 does, refuse a dropped fragment line, then run" @@ -246,6 +249,16 @@ validate-kernel: check-kernel-eol: @"$(TOOLS)"/check-kernel-eol.sh +# Two tools on purpose. The survey asks the network what upstream has released +# and judges nothing; the gate reads that survey and tools/pin-reviews.tsv and +# never the network, so its verdict can be tested and reproduced. A survey that +# could not be written is a failure here, not an empty file that passes. +PINS_SURVEY ?= $(KRYPTIK_WORK)/pin-survey.tsv +check-pins: + @mkdir -p "$(dir $(PINS_SURVEY))" + @"$(TOOLS)"/check-source-currency.sh --tsv > "$(PINS_SURVEY)" + @"$(TOOLS)"/check-pin-reviews.sh --survey "$(PINS_SURVEY)" $(PINS_FLAGS) + validate-kernel-hardened: @"$(TOOLS)"/validate-kernel-config.sh --hardened @@ -557,6 +570,9 @@ test-hardening: test-kernel-hardening: @"$(TOOLS)"/test-check-kernel-hardening.sh +test-pin-reviews: + @"$(TOOLS)"/test-check-pin-reviews.sh + test-artifacts: @"$(TOOLS)"/test-artifact-hardening.sh diff --git a/tools/check-pin-reviews.sh b/tools/check-pin-reviews.sh new file mode 100755 index 0000000..630e929 --- /dev/null +++ b/tools/check-pin-reviews.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Hold every pin that is behind its upstream to a written review. +# +# ./tools/check-source-currency.sh --tsv > survey.tsv +# ./tools/check-pin-reviews.sh --survey survey.tsv [--reviews FILE] +# [--max-age DAYS] [--strict] [--no-held] +# [--today YYYY-MM-DD] +# +# --survey FILE check-source-currency.sh's --tsv output +# --reviews FILE default tools/pin-reviews.tsv +# --max-age DAYS a review older than this has expired; default 180 +# --strict a pin the survey could not determine is a failure +# --no-held a held pin is a failure; this is what a release asks +# --today DATE the date reviews are aged against; default today (tests) +# +# "A newer version exists" is not a security verdict, which is why +# check-source-currency.sh reports and does not judge. This is where the +# judging is written down. A pin that is behind is one of three things: moved, +# reviewed as fine (someone read what upstream released after it and nothing +# there is a security fix that reaches Kryptik), or held (there is such a fix, +# and the row says why the pin stays anyway). A row with no reason is a +# failure. What makes this a gate and not a list is the other direction: a +# review covers upstream releases up to a named version, so the next upstream +# release makes the row fail until someone has read that one too. +# +# It reads a survey and never the network, so it is deterministic: the same two +# files give the same answer, in a test or a year later. +# +# Exit status: 0 when every behind pin has a current review; 1 otherwise. + +source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" + +SURVEY=""; REVIEWS="${KRYPTIK_ROOT}/tools/pin-reviews.tsv" +MAX_AGE=180; STRICT=0; NO_HELD=0; TODAY="$(date -u +%Y-%m-%d)" +while [[ $# -gt 0 ]]; do + case "$1" in + --survey) SURVEY="${2:?--survey needs a file}"; shift 2 ;; + --reviews) REVIEWS="${2:?--reviews needs a file}"; shift 2 ;; + --max-age) MAX_AGE="${2:?--max-age needs a number of days}"; shift 2 ;; + --today) TODAY="${2:?--today needs a date}"; shift 2 ;; + --strict) STRICT=1; shift ;; + --no-held) NO_HELD=1; shift ;; + -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | cut -c3-; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done +[[ -n "$SURVEY" ]] || die "--survey FILE is required (check-source-currency.sh --tsv)" +[[ -s "$SURVEY" ]] || die "the survey ${SURVEY} is missing or empty: a gate with nothing to read has not passed" +[[ -f "$REVIEWS" ]] || die "no reviews file at ${REVIEWS}" +[[ "$MAX_AGE" =~ ^[0-9]+$ ]] || die "--max-age wants a whole number of days, got ${MAX_AGE}" +is_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && date -u -d "$1" +%s >/dev/null 2>&1; } +is_date "$TODAY" || die "--today wants YYYY-MM-DD, got ${TODAY}" +today_s="$(date -u -d "$TODAY" +%s)" + +# newer A B: A sorts strictly after B, by the same ordering the survey uses. +newer() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]; } + +# --- the reviews ------------------------------------------------------------ +declare -A R_PINNED=() R_UPTO=() R_VERDICT=() R_DATE=() R_NOTE=() R_SEEN=() +declare -a MALFORMED=() +CR=$'\r' +lineno=0 +while IFS= read -r line || [[ -n "$line" ]]; do + lineno=$((lineno + 1)) + line="${line%"$CR"}" + [[ -z "${line//[[:space:]]/}" || "$line" =~ ^[[:space:]]*# ]] && continue + read -r pkg pinned upto verdict reviewed note <<<"$line" + where="${REVIEWS##*/}:${lineno}" + if [[ -z "$pkg" || -z "$pinned" || -z "$upto" || -z "$verdict" || -z "$reviewed" ]]; then + MALFORMED+=("${where}: wants package, pinned, reviewed_up_to, verdict, reviewed, note"); continue + fi + [[ "$verdict" == fine || "$verdict" == held ]] || { MALFORMED+=("${where}: ${pkg}: verdict '${verdict}' is neither fine nor held"); continue; } + is_date "$reviewed" || { MALFORMED+=("${where}: ${pkg}: reviewed '${reviewed}' is not a date"); continue; } + [[ -n "${note//[[:space:]]/}" ]] || { MALFORMED+=("${where}: ${pkg}: no note. What was read, and why is the pin ${verdict}?"); continue; } + [[ -z "${R_PINNED[$pkg]:-}" ]] || { MALFORMED+=("${where}: ${pkg}: a second row for the same package"); continue; } + R_PINNED[$pkg]="$pinned"; R_UPTO[$pkg]="$upto"; R_VERDICT[$pkg]="$verdict" + R_DATE[$pkg]="$reviewed"; R_NOTE[$pkg]="$note" +done < "$REVIEWS" + +# --- the survey against them ------------------------------------------------ +declare -a NOT_REVIEWED=() STALE=() NEW_RELEASE=() EXPIRED=() HELD=() FINE=() UNDETERMINED=() +rows=0 +# A tab is whitespace to read, and read merges a run of whitespace into one +# separator: a row with an empty 'newest' column would lose the column and +# every field after it would move left, so an UNKNOWN row would read as a +# status nobody tests for and pass in silence. The unit separator is not +# whitespace, so an empty field stays an empty field. +US=$'\037' +while IFS="$US" read -r name pinned newest status consulted; do + [[ -n "$name" ]] || continue + rows=$((rows + 1)) + R_SEEN[$name]=1 + if [[ "$status" == UNKNOWN ]]; then + UNDETERMINED+=("${name} ${pinned} (consulted ${consulted:-nothing})") + fi + if [[ "$status" != BEHIND ]]; then + [[ -z "${R_PINNED[$name]:-}" ]] || STALE+=("${name}: reviewed as behind, but the survey says ${status}; remove the row") + continue + fi + if [[ -z "${R_PINNED[$name]:-}" ]]; then + NOT_REVIEWED+=("${name} ${pinned} -> ${newest}"); continue + fi + if [[ "${R_PINNED[$name]}" != "$pinned" ]]; then + STALE+=("${name}: the review is of ${R_PINNED[$name]}, the pin is ${pinned}"); continue + fi + if newer "$newest" "${R_UPTO[$name]}"; then + NEW_RELEASE+=("${name} ${pinned}: reviewed up to ${R_UPTO[$name]}, upstream is at ${newest}"); continue + fi + age=$(( (today_s - $(date -u -d "${R_DATE[$name]}" +%s)) / 86400 )) + if [[ "$age" -gt "$MAX_AGE" ]]; then + EXPIRED+=("${name} ${pinned}: reviewed ${R_DATE[$name]}, ${age} days ago (limit ${MAX_AGE})"); continue + fi + if [[ "${R_VERDICT[$name]}" == held ]]; then + HELD+=("${name} ${pinned} (upstream ${newest}): ${R_NOTE[$name]}") + else + FINE+=("${name} ${pinned} (upstream ${newest})") + fi +done < <(tr '\t' '\037' < "$SURVEY") +[[ "$rows" -gt 0 ]] || die "the survey ${SURVEY} has no rows" +for pkg in "${!R_PINNED[@]}"; do + [[ -n "${R_SEEN[$pkg]:-}" ]] || STALE+=("${pkg}: reviewed, but the survey has no such source") +done + +# --- the report ------------------------------------------------------------- +section() { # section TITLE ITEM... + local title="$1"; shift + [[ $# -gt 0 ]] || return 0 + echo; echo "${title}" + printf '%s\n' "$@" | sort | sed 's/^/ - /' +} +log "Pins behind upstream, held to ${REVIEWS##*/} (survey: ${rows} sources, reviews aged against ${TODAY})" +section "MALFORMED rows:" "${MALFORMED[@]}" +section "BEHIND AND NOT REVIEWED (move the pin, or read what upstream released and write the row):" "${NOT_REVIEWED[@]}" +section "NEW UPSTREAM RELEASE SINCE THE REVIEW (read it, then move reviewed_up_to):" "${NEW_RELEASE[@]}" +section "EXPIRED reviews (a vulnerability can be published long after its fix; read again):" "${EXPIRED[@]}" +section "STALE rows:" "${STALE[@]}" +section "HELD: known security fixes upstream, pin kept for the reason given:" "${HELD[@]}" +section "NOT DETERMINED by the survey (not checked, which is not the same as fine):" "${UNDETERMINED[@]}" +section "reviewed as fine:" "${FINE[@]}" +echo +echo "COUNTS not-reviewed=${#NOT_REVIEWED[@]} new-release=${#NEW_RELEASE[@]} expired=${#EXPIRED[@]} stale=${#STALE[@]} malformed=${#MALFORMED[@]} held=${#HELD[@]} undetermined=${#UNDETERMINED[@]} fine=${#FINE[@]}" + +bad=$(( ${#NOT_REVIEWED[@]} + ${#NEW_RELEASE[@]} + ${#EXPIRED[@]} + ${#STALE[@]} + ${#MALFORMED[@]} )) +[[ "$NO_HELD" -eq 1 ]] && bad=$(( bad + ${#HELD[@]} )) +[[ "$STRICT" -eq 1 ]] && bad=$(( bad + ${#UNDETERMINED[@]} )) +if [[ "$bad" -gt 0 ]]; then + err "${bad} pin(s) are not covered by a current review" + exit 1 +fi +ok "every pin that is behind upstream has a current review" diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv new file mode 100644 index 0000000..1209b47 --- /dev/null +++ b/tools/pin-reviews.tsv @@ -0,0 +1,28 @@ +# Reviews of pins that are behind their upstream. +# +# Read by tools/check-pin-reviews.sh, against a survey made by +# tools/check-source-currency.sh --tsv. +# +# Columns are whitespace separated; the note runs to the end of the line. +# +# package source name, as in the survey and sources.lock +# pinned the version this review is of; a row whose pinned version +# is no longer the pin is stale, and fails +# reviewed_up_to the newest upstream release that was read. When upstream +# releases past it the row fails until that release has been +# read too, which is the whole point of the file +# verdict fine | held +# reviewed the date it was read; a review expires (180 days by +# default) because a vulnerability is often published long +# after the release that fixed it +# note what was read and what it said. Required. +# +# fine nothing released after the pin, up to reviewed_up_to, is a security +# fix that reaches Kryptik. Say what was read: NEWS, a changelog, the +# project's security page. "No CVEs" with no source is not a review. +# held there IS such a fix, and the pin stays anyway for the reason given. +# Printed on every run. check-pin-reviews.sh --no-held, which is what +# a release asks, refuses it. +# +# A pin that has caught up has no row: a row for a current pin is stale, and +# fails, so this file shrinks when the work is done instead of accumulating. diff --git a/tools/run-tests.sh b/tools/run-tests.sh index 023a3e5..3f22dc7 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -31,6 +31,7 @@ SUITES=( "test-harness|tools/test-step-errexit.sh" "test-hardening|tools/test-hardening-flags.sh" "test-kernel-hardening|tools/test-check-kernel-hardening.sh" + "test-pin-reviews|tools/test-check-pin-reviews.sh" "test-services|tools/test-services.sh" "test-boot-success|tools/test-boot-success.sh" "test-manifest|tools/test-artifact-manifest.sh" diff --git a/tools/test-check-pin-reviews.sh b/tools/test-check-pin-reviews.sh new file mode 100755 index 0000000..ace63c7 --- /dev/null +++ b/tools/test-check-pin-reviews.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Focused tests for tools/check-pin-reviews.sh. +# +# ./tools/test-check-pin-reviews.sh +# +# Offline and deterministic: the tool reads a survey file and a reviews file, +# so both are written here and the date is fixed with --today. +# +# The cases that matter are the ones where a list quietly stops being a gate: +# a review of a version that is no longer the pin, a review that upstream has +# since released past, a review too old to trust, a row with no reason, and a +# row left behind for a pin that has caught up. + +set -uo pipefail + +# See the same note in the other suites. +unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOL="${ROOT}/tools/check-pin-reviews.sh" + +PASS=0 +FAIL=0 +green() { printf '\033[32m PASS\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf '\033[31m FAIL\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } + +W="$(mktemp -d)" +trap 'rm -rf "$W"' EXIT +OUT="${W}/out" +show() { sed 's/^/ /' "$OUT"; } + +# survey NAME PINNED NEWEST STATUS ...: one row per five arguments. +survey() { + : > "${W}/survey.tsv" + while [[ $# -ge 4 ]]; do + printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "https://example.invalid/$1/" >> "${W}/survey.tsv" + shift 4 + done +} +reviews() { printf '%s\n' "$@" > "${W}/reviews.tsv"; } +run() { NO_COLOR=1 "$TOOL" --survey "${W}/survey.tsv" --reviews "${W}/reviews.tsv" --today 2026-09-19 "$@" > "$OUT" 2>&1; RC=$?; } + +# expect NAME WANT_RC REGEX...: the exit status, and every regex present. +expect() { + local name="$1" want="$2"; shift 2 + local good=1 rx + [[ "$RC" -eq "$want" ]] || good=0 + for rx in "$@"; do grep -qE -- "$rx" "$OUT" || good=0; done + if [[ "$good" -eq 1 ]]; then green "$name"; else red "${name} (exit ${RC}, wanted ${want})"; show; fi +} +# absent NAME REGEX: the regex must not appear. +absent() { if grep -qE -- "$2" "$OUT"; then red "$1"; show; else green "$1"; fi; } + +echo "-- a behind pin needs a review" +survey zlib 1.3.1 1.3.2 BEHIND bash 5.3 5.3 current +reviews "# nothing reviewed yet" +run +expect "a behind pin with no row fails" 1 'BEHIND AND NOT REVIEWED' 'zlib 1\.3\.1 -> 1\.3\.2' 'COUNTS not-reviewed=1 ' + +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read ChangeLog 1.3.2: build fixes only, no memory-safety change" +run +expect "a current review of it passes" 0 'reviewed as fine' 'zlib 1\.3\.1 \(upstream 1\.3\.2\)' 'COUNTS not-reviewed=0 .* fine=1' +absent "a current pin is not mentioned at all" 'bash' + +echo +echo "-- a review stops covering the pin" +survey zlib 1.3.1 1.3.3 BEHIND +run +expect "upstream released past the review" 1 'NEW UPSTREAM RELEASE SINCE THE REVIEW' 'reviewed up to 1\.3\.2, upstream is at 1\.3\.3' + +survey zlib 1.3.2 1.3.3 BEHIND +run +expect "the pin moved and the review is of the old one" 1 'STALE' 'the review is of 1\.3\.1, the pin is 1\.3\.2' + +survey zlib 1.3.2 1.3.2 current +run +expect "the pin caught up and the row was left behind" 1 'STALE' 'the survey says current; remove the row' + +survey zlib 1.3.1 1.3.2 BEHIND +reviews "zlib 1.3.1 1.3.2 fine 2026-01-01 read ChangeLog 1.3.2" +run +expect "a review older than the limit has expired" 1 'EXPIRED' 'reviewed 2026-01-01, 261 days ago \(limit 180\)' +run --max-age 365 +expect "a longer limit accepts it" 0 'fine=1' + +echo +echo "-- version ordering is the survey's, not the alphabet's" +survey coreutils 9.5 9.12 BEHIND +reviews "coreutils 9.5 9.9 fine 2026-09-01 read NEWS through 9.9" +run +expect "9.12 is newer than a review up to 9.9" 1 'reviewed up to 9\.9, upstream is at 9\.12' +reviews "coreutils 9.5 9.12 fine 2026-09-01 read NEWS through 9.12" +run +expect "and covered by a review up to 9.12" 0 'fine=1' + +echo +echo "-- a held pin is loud, and a release refuses it" +survey expat 2.6.2 2.8.4 BEHIND +reviews "expat 2.6.2 2.8.4 held 2026-09-19 CVE-2024-45490 fixed in 2.6.3; held until the rebuild in progress lands" +run +expect "held passes with the reason printed" 0 'HELD: known security fixes upstream' 'CVE-2024-45490 fixed in 2\.6\.3' 'held=1' +run --no-held +expect "--no-held makes it a failure" 1 'HELD' 'not covered by a current review' + +echo +echo "-- a row that says nothing is not a review" +survey zlib 1.3.1 1.3.2 BEHIND +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19" +run +expect "no note" 1 'MALFORMED' 'no note' +reviews "zlib 1.3.1 1.3.2 probably-ok 2026-09-19 looked fine" +run +expect "a verdict that is neither fine nor held" 1 'MALFORMED' "neither fine nor held" +reviews "zlib 1.3.1 1.3.2 fine last-week read it" +run +expect "a date that is not a date" 1 'MALFORMED' 'is not a date' +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read it" "zlib 1.3.1 1.3.2 held 2026-09-19 or maybe not" +run +expect "two rows for one package" 1 'MALFORMED' 'a second row for the same package' +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read it" "zlibb 1.0 1.1 fine 2026-09-19 a typo" +run +expect "a row for a source the survey does not have" 1 'STALE' 'zlibb: reviewed, but the survey has no such source' + +echo +echo "-- what the survey could not determine" +survey less 661 "" UNKNOWN zlib 1.3.2 1.3.2 current +reviews "# none" +run +expect "undetermined is reported and passes by default" 0 'NOT DETERMINED by the survey' 'less 661' 'undetermined=1' +run --strict +expect "--strict makes it a failure" 1 'not covered by a current review' + +echo +echo "-- a gate with nothing to read has not passed" +: > "${W}/survey.tsv" +run +expect "an empty survey is refused" 1 'missing or empty' +rm -f "${W}/survey.tsv" +run +expect "a missing survey is refused" 1 'missing or empty' + +echo +echo "-- the shipped reviews file" +if [[ -f "${ROOT}/tools/pin-reviews.tsv" ]]; then + survey placeholder 1 1 current + NO_COLOR=1 "$TOOL" --survey "${W}/survey.tsv" --today 2026-09-19 > "$OUT" 2>&1 + if grep -q 'MALFORMED' "$OUT"; then red "tools/pin-reviews.tsv has a malformed row"; show + else green "tools/pin-reviews.tsv is well formed"; fi +else + red "tools/pin-reviews.tsv does not exist" +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] || exit 1 From b3e3fa30d38de80977510f1aab090700fffba3b1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 13:48:19 -0700 Subject: [PATCH 02/25] The survey can read the sixteen hosts it could not, and four of those pins turn out to be behind Sixteen pins were UNKNOWN in every survey: the hosts serve an API, a feed or a front page, not a directory listing. They were also a third of what the image exposes to untrusted input: less, lynx, openssh, libinput, wlroots, wayland. Not checked is not the same as fine, and four were not fine: less 661 against 710, procps-ng 4.0.4 against 4.0.7, lvm2 2.03.39 against 2.03.42, libinput 1.30.4 against 1.32.0. Each rule was run against the real host before it was written, and each encodes a way to be confidently wrong, with a test that serves the trap: wayland and libinput number a release candidate 1.31.901, with no "rc" in it; a GitLab release list is ordered by date, so the first entry is not the newest; psmisc's release list is missing a release its tags have, and kernel-hardening-checker publishes tags and no releases; less's directory offers a beta, and the front page says which version is for general use; lynx's directory is full of dev snapshots; openssh's p1 is part of the version; curl.se/ca/ answers 200 with a meta refresh curl does not follow. wlroots is read within its pinned series, as python is: dwl is written against one series and the next is an API change. The glibc FHS patch is deferred to the glibc row, which is what decides it. The header said --only NAME; the parser has always taken --only=NAME. --- tools/check-source-currency.sh | 104 +++++++++++++++++++++++++++- tools/test-check-source-currency.sh | 67 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/tools/check-source-currency.sh b/tools/check-source-currency.sh index 26ae3ad..3793878 100755 --- a/tools/check-source-currency.sh +++ b/tools/check-source-currency.sh @@ -2,7 +2,7 @@ # Compare every pinned version against what upstream currently publishes. # # ./tools/check-source-currency.sh report everything -# ./tools/check-source-currency.sh --only NAME one package +# ./tools/check-source-currency.sh --only=NAME one package # ./tools/check-source-currency.sh --fail-on-behind # ./tools/check-source-currency.sh --strict also fail on UNKNOWN # ./tools/check-source-currency.sh --tsv machine-readable @@ -171,6 +171,25 @@ for line in sys.stdin: print(out[-1] if out else '')" } +# The values of one string key in a JSON API response, a leading "v" dropped. +# grep and sed, not a JSON parser, on purpose: the answer wanted is "every +# tag_name" and the key is anchored on its opening quote, so "author_name" +# does not match "name". A tags endpoint's objects start with their name, and +# the brace is part of the match there so a nested "name" is not taken. +api_values() { # api_values URL KEY + local open='"' + [[ "$2" == name ]] && open='\{"' + fetch "$1" | grep -oE "${open}$2\":\"[^\"]*\"" \ + | sed -E "s/.*\"$2\":\"v?([^\"]*)\"/\1/" || true +} + +# Versions made only of numbers and dots, the highest of them. Drops every +# spelling of a pre-release that has letters in it (0.8-dev, 4.0.7rc1). +numeric_newest() { grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V -u | tail -1 || true; } + +# freedesktop projects number a release candidate X.Y.9N or X.Y.90N. +drop_ninety() { grep -vE '\.9[0-9]+$' || true; } + # --- per-source strategy ---------------------------------------------------- # # Returns "|", either field possibly empty. @@ -191,6 +210,89 @@ upstream_for() { ;; esac + # --- hosts with no directory listing to read --------------------------- + # + # Sixteen pins were UNKNOWN until these were written, which is a third of + # what the image exposes to untrusted input: the compositor's libraries, + # less, lynx, openssh. Each rule below was run against the real host + # before it was written down, and each encodes a trap that produces a + # confidently wrong number rather than none: + # + # * wayland and libinput number a release candidate X.Y.9N or X.Y.90N, + # with no "rc" in it (1.25.91, 1.31.901). Dropping rc/alpha/beta keeps + # them, and reports a candidate as the newest release. + # * a GitLab release list is ordered by date, not version: libinput + # 1.30.4 sits above 1.31.3. Taking the first entry is wrong; sort. + # * psmisc's release list is missing a release its tag list has, and + # kernel-hardening-checker publishes tags and no releases at all. + # * less marks a version "released for general use" on its front page; + # a newer tarball in the directory is a beta. + # * lynx's directory is full of 2.9.3dev.N snapshots. + # * https://curl.se/ca/ answers 200 with a meta refresh, which curl -L + # does not follow; the list is on caextract.html. + local fd="https://gitlab.freedesktop.org/api/v4/projects" + case "$name" in + glibc-fhs-patch) + # Not a release of anything: it is the LFS book's patch for the + # pinned glibc and moves only when glibc does. + printf '%s|%s' "" "tools/check-source-currency.sh, the glibc row (the patch follows glibc's pin)" + return ;; + less) + consulted="https://www.greenwoodsoftware.com/less/ (released for general use)" + newest="$(fetch "https://www.greenwoodsoftware.com/less/" \ + | grep -oE 'less-[0-9]+ has been released for general use' \ + | sed -E 's/less-([0-9]+) .*/\1/' | sort -V -u | tail -1 || true)" ;; + procps-ng) + consulted="gitlab.com procps-ng/procps releases" + newest="$(api_values "https://gitlab.com/api/v4/projects/procps-ng%2Fprocps/releases?per_page=50" tag_name | numeric_newest)" ;; + psmisc) + consulted="gitlab.com psmisc/psmisc tags" + newest="$(api_values "https://gitlab.com/api/v4/projects/psmisc%2Fpsmisc/repository/tags?per_page=100" name | numeric_newest)" ;; + lvm2) + consulted="https://sourceware.org/pub/lvm2/" + newest="$(newest_in_listing "$consulted" 'LVM2\.([0-9]+(\.[0-9]+)+)\.tgz')" ;; + openssh) + consulted="https://ftp.openbsd.org/pub/OpenBSD/OpenSSH/portable/" + newest="$(newest_in_listing "$consulted" 'openssh-([0-9]+\.[0-9]+p[0-9]+)\.tar\.gz')" ;; + ca-bundle) + consulted="https://curl.se/docs/caextract.html" + newest="$(newest_in_listing "$consulted" 'cacert-([0-9]{4}-[0-9]{2}-[0-9]{2})\.pem')" ;; + wayland|libinput) + consulted="gitlab.freedesktop.org ${name}/${name} releases" + newest="$(api_values "${fd}/${name}%2F${name}/releases?per_page=50" tag_name | drop_ninety | numeric_newest)" ;; + wayland-protocols) + consulted="gitlab.freedesktop.org wayland/wayland-protocols releases" + newest="$(api_values "${fd}/wayland%2Fwayland-protocols/releases?per_page=50" tag_name | numeric_newest)" ;; + libdisplay-info) + consulted="gitlab.freedesktop.org emersion/libdisplay-info releases" + newest="$(api_values "${fd}/emersion%2Flibdisplay-info/releases?per_page=50" tag_name | numeric_newest)" ;; + wlroots) + # The pinned series only: dwl is written against one wlroots + # series, and the next one is an API change, not a drop-in. + local wseries="${V_WLROOTS%.*}" + consulted="gitlab.freedesktop.org wlroots/wlroots tags (series ${wseries})" + newest="$(api_values "${fd}/wlroots%2Fwlroots/repository/tags?per_page=100" name \ + | grep -E "^${wseries//./\\.}\.[0-9]+$" | numeric_newest || true)" ;; + seatd) + consulted="https://git.sr.ht/~kennylevinsen/seatd/refs/rss.xml" + newest="$(fetch "$consulted" | grep -oE '[0-9]+(\.[0-9]+)+' \ + | sed -E 's/<[^>]*>//g' | sort -V -u | tail -1 || true)" ;; + dwl) + consulted="codeberg.org dwl/dwl tags" + newest="$(api_values "https://codeberg.org/api/v1/repos/dwl/dwl/tags?limit=50" name | numeric_newest)" ;; + lynx) + consulted="https://invisible-mirror.net/archives/lynx/tarballs/" + newest="$(newest_in_listing "$consulted" 'lynx([0-9]+(\.[0-9]+)+)\.tar\.gz')" ;; + kernel-hardening-checker) + consulted="https://github.com/a13xp0p0v/kernel-hardening-checker/tags.atom" + newest="$(fetch "$consulted" | grep -oE 'v[0-9]+(\.[0-9]+)+' \ + | sed -E 's/v//; s/<.*//' | sort -V -u | tail -1 || true)" ;; + esac + if [[ -n "$consulted" ]]; then + printf '%s|%s' "$newest" "$consulted" + return + fi + case "$url" in # kernel.org and others put releases in per-series subdirectories, so # the file's own directory only ever offers that series. Look in the diff --git a/tools/test-check-source-currency.sh b/tools/test-check-source-currency.sh index 5f30b55..3457ba3 100755 --- a/tools/test-check-source-currency.sh +++ b/tools/test-check-source-currency.sh @@ -102,6 +102,51 @@ mkdir -p "${SERVE}/api.github.com/repos/acme/preview/releases/latest" printf '{"tag_name": "v9.9.9", "prerelease": true}\n' \ > "${SERVE}/api.github.com/repos/acme/preview/releases/latest/index.html" +# --- hosts with an API or a page instead of a listing ----------------------- +# +# json <path> <body>: 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' '<p>less-718 has been released for beta testing.</p>' \ + '<p>less-710 has been released for general use.</p>' \ + '<a href="less-718.tar.gz">less-718.tar.gz</a> <a href="less-710.tar.gz">less-710.tar.gz</a>' \ + > "${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' '<feed><title>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 From 256c6742aa262ebaafb336543ae2e24b4a5b9c1f Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 14:47:00 -0700 Subject: [PATCH 03/25] The pin reviews, and CI asks for them: 26 pins reviewed as fine, six held with what is wrong with each, and a release refused until the six are dealt with tools/pin-reviews.tsv now has a row for every pin that stays behind its upstream once the open bumps are on main. Each row names the version reviewed, the newest upstream release that was read, the date, and what was read and what it said; they come from four reports made on 2026-09-19 out of upstream NEWS files and changelogs, project security pages, the Debian tracker and NVD records, not from a search for "CVE". Held, because a fix exists and the pin cannot simply move: zlib (1.3.2 fixes one CVE and introduces a worse one that 1.3.1 does not have; the fix is unreleased), gawk (four overflows fixed only in 5.4.1, which changes the regex engine under every build script), gzip (the fix is in no release, so 1.14 fixes nothing), acl (2.4.0 does not build with the pinned tar), readline (8.2 without its official patches; 8.3 moves with bash 5.3), shadow (a use-after-free fixed in 4.17, and 4.19 and 4.20 each break the recipe). Run against the survey as it reads with the open bumps applied: 26 fine, 6 held, nothing unreviewed, and --no-held, which is what a release asks, fails on exactly those six. CI's weekly job and every push to main now run the survey and then the gate. On a pull request the verdict is printed and does not fail the check: an upstream that released this morning is not the fault of whoever opened a pull request this afternoon, and the scheduled run is what makes sure somebody reads it. The roadmap entry says what is written and what ticking it still needs. The rows describe main as it will be once the glibc, gcc and pin-batch changes are on it, so this merges after them. --- .github/workflows/ci.yml | 24 +++++++++++++++++++++++ docs/roadmap.md | 11 ++++++++--- tools/pin-reviews.tsv | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4edfd2d..17198d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,6 +453,30 @@ jobs: - name: Pinned series are supported upstream run: ./tools/check-support-status.sh --strict + # A pin that is behind its upstream needs a written review, and the + # review stops covering it when upstream releases again. 51 of 113 pins + # were behind on the day this was written and no file said which of + # those gaps held a security fix; eight did. + # + # Two tools on purpose: the survey asks the network and judges nothing, + # the gate reads the survey and tools/pin-reviews.tsv and never the + # network. On a push and on the weekly schedule the gate's verdict + # stands. On a pull request it is printed and does not fail the check: + # an upstream that released this morning is not the fault of whoever + # opened a pull request this afternoon, and the weekly run is what makes + # sure somebody reads it. + - name: Pins behind upstream are reviewed + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ./tools/check-source-currency.sh --tsv > pin-survey.tsv + echo "survey: $(wc -l < pin-survey.tsv) sources" + if [ "${{ github.event_name }}" = "pull_request" ]; then + ./tools/check-pin-reviews.sh --survey pin-survey.tsv || echo "pull request: informational" + else + ./tools/check-pin-reviews.sh --survey pin-survey.tsv + fi + - name: Pinned kernel is longterm and not EOL run: ./tools/check-kernel-eol.sh diff --git a/docs/roadmap.md b/docs/roadmap.md index 8b4b407..fc3e969 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -277,9 +277,14 @@ passes, not when its code is written. fixes and not the release branch's security backports (CVE-2025-0395, CVE-2025-4802 and others): move to a maintained glibc or carry the backports, with the unwind test still passing. Then every pin checked - against its upstream's security releases, and - `tools/check-support-status.sh` extended so CI fails when a pin falls - behind one. + against its upstream's security releases, and CI failing when a pin + falls behind one. Written: every pin that was behind has been read + against its upstream (2026-09-19), 22 moved, six are held with their + reasons in `tools/pin-reviews.tsv`, and `tools/check-pin-reviews.sh` + fails CI when a behind pin has no current review or upstream has + released past the one it has. Ticked when the rebuilt image has passed + acceptance and the six held pins are moved or patched: a release asks + the gate with `--no-held`, and it refuses them. - [ ] **An update channel.** `kryptik-update` applies a payload from a mounted disk and nothing fetches one. The net zone downloads a release by URL into a transfer area; zone 0 verifies the manifest signature, diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 1209b47..8b23f45 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -26,3 +26,45 @@ # # A pin that has caught up has no row: a row for a current pin is stale, and # fails, so this file shrinks when the work is done instead of accumulating. + +# Reviewed 2026-09-19 from upstream NEWS and changelogs, project security +# pages, the Debian security tracker and NVD records. "Survey" below is +# check-source-currency.sh's newest-upstream column on that day. + +# --- toolchain ----------------------------------------------------------------- +binutils 2.43.1 2.47 fine 2026-09-19 NEWS 2.44 to 2.47 and the Debian tracker: every CVE since 2.43 is a crafted-input bug in tools that are only fed the project's own objects, and upstream's SECURITY.txt excludes them; no wrong-code or hardening fix found. 2.44 drops gold, which the recipe enables. +gcc 14.4.0 16.2.0 fine 2026-09-19 14.4.0 is the newest 14.x. 15 changes the default C standard and 16 the default C++ one; neither is a security release. GCC Bugzilla milestones for 14.3 and 14.4 were read when the pin moved off 14.2.0. +glibc 2.40 2.44 fine 2026-09-19 The tarball is 2.40 and build/patches/glibc-2.40 carries upstream's maintained release/2.40/master branch to cdaa5d6d (2026-09-10), which is where 2.40's security fixes live. Re-read that branch's head when this row is next reviewed. +mpc 1.3.1 1.4.1 fine 2026-09-19 NEWS 1.4.0 and 1.4.1: no security or wrong-result fix. 1.4.0 is to be skipped if this ever moves. + +# --- held: a fix exists upstream and the pin stays, for the reason given ----------- +zlib 1.3.1 1.3.2 held 2026-09-19 1.3.2 fixes CVE-2026-27171 (a CPU loop in crc32_combine when a caller passes a negative length; local, low). 1.3.2 also INTRODUCES CVE-2026-85091, a heap overflow in gz_vacate that 1.3.1 does not contain (checked: the function is absent). The fix, upstream df84af25dc, is in no release. Move when one carries it. +gawk 5.3.0 5.4.1 held 2026-09-19 CVE-2026-40467, -40468, -40469 and -40553 (integer overflows in builtin.c) are fixed in 5.4.1 only; 5.3.2 does not have them. 5.4 makes MinRX the default regex engine under every build script that calls awk, glibc's and the kernel's included. Whether the overflows are reachable from data alone is not established. Moves in a build of its own, not in a batch. +gzip 1.13 1.14 held 2026-09-19 CVE-2026-41992 (out-of-bounds access in the LZH decoder after a crafted .Z file in the same gzip -d run) affects 1.14 and earlier and is fixed only in upstream git, so moving to 1.14 fixes nothing. Carry upstream's patch, or move when 1.15 exists. +acl 2.3.2 2.4.0 held 2026-09-19 2.4.0 fixes CVE-2026-54370 (a race in setfacl, getfacl and chacl) and CVE-2026-54369 (libacl follows symlinks), and does not build with the pinned tar 1.35: both define acl_*_at. Needs the tar fix the LFS book carries. Bumping libacl does not fix its callers; each has to adopt the new functions. +readline 8.2 8.3 held 2026-09-19 No CVE, but 8.2 is built without its 13 official patches, which include a use-after-free and an unterminated paste buffer. 8.3 has to move together with bash 5.3, which needs four symbols new in it. +shadow 4.16.0 4.20.2 held 2026-09-19 No CVE is fixed in the gap (CVE-2024-56433 is unfixed upstream), but 4.17.0 fixes a use-after-free in sgetgrent. 4.19 needs --disable-logind, 4.20 changes the login.defs line the recipe edits, removes PASS_MIN_DAYS and makes su - fail unless TIOCSTI is off. Target 4.19.4, with the recipe. + +# --- fine: nothing in the gap is a security fix that reaches Kryptik --------------- +bash 5.2.32 5.3 fine 2026-09-19 Patches 5.2.33 to 5.2.37 and the 5.3 NEWS: no security fix found. 5.3 needs readline 8.3. +grep 3.11 3.12 fine 2026-09-19 NEWS 3.12: a failure of grep -r on directories with over 100,000 entries; no security fix. +diffutils 3.10 3.12 fine 2026-09-19 NEWS 3.11 and 3.12: no security fix in a release. CVE-2026-53910 (diff3, disputed) is fixed only in upstream git, so a bump would not fix it. Never 3.11. +m4 1.4.19 1.4.21 fine 2026-09-19 NEWS 1.4.20 and 1.4.21: build fixes for newer glibc and compilers. +file 5.45 5.48 fine 2026-09-19 ChangeLog 5.46 to 5.48: the stack overrun it mentions is a regression that exists only in 5.46 (checked in readelf.c: 5.45 uses a BUFSIZ buffer there). Never pin 5.46. +zstd 1.5.6 1.5.7 fine 2026-09-19 Release notes 1.5.7: performance and CLI defaults; no security fix. +libffi 3.5.2 3.8.0 fine 2026-09-19 The x86-64 memory access fixes of 3.4.7 and 3.4.8 are in 3.5.2. 3.6 to 3.8 history read; no upstream security page exists, so this rests on that and an NVD search. +openssl 3.5.8 3.6.4 fine 2026-09-19 3.5.8 is the newest release of the 3.5 LTS series (supported to 2030-04-08) and came out the same day as 3.6.4. The one extra fix in 3.6.4, CVE-2026-54876, affects 3.6 and 4.0 only. 3.6 reaches end of life on 2026-11-01. +gettext 0.22.5 1.0 fine 2026-09-19 NEWS 0.23 to 1.0: no security fix. 1.0 changes how po directories are handled. +texinfo 7.1 7.3 fine 2026-09-19 NEWS 7.1.1 to 7.3: no security fix. +libtool 2.4.7 2.6.2 fine 2026-09-19 NEWS 2.5.x and 2.6.x: no security fix. +gperf 3.1 3.3 fine 2026-09-19 NEWS 3.2 and 3.3: no security fix. +pkgconf 2.3.0 3.0.7 fine 2026-09-19 NEWS to 3.0.7: no security fix. 3.0 breaks the libpkgconf ABI, moves to meson and changed quoting and sysroot handling twice. +iana-etc 20240806 20260911 fine 2026-09-19 Data only: /etc/services and /etc/protocols. +groff 1.23.0 1.24.1 fine 2026-09-19 NEWS 1.24.0 and 1.24.1: no security fix. 1.24.0 has incompatible request-syntax changes; never that one. +e2fsprogs 1.47.1 1.47.4 fine 2026-09-19 Release notes 1.47.2 to 1.47.4: no security fix. +libpipeline 1.5.7 1.5.8 fine 2026-09-19 NEWS 1.5.8: no security fix. +man-db 2.12.1 2.13.1 fine 2026-09-19 NEWS 2.13.0 and 2.13.1: no security fix. +perl 5.40.5 5.44.0 fine 2026-09-19 5.40.5 is the newest 5.40.x and has every fix through CVE-2026-13221. 5.40 is in perlpolicy's security-fix window (tools/support-policy.tsv). 5.42 and 5.44 are new series, not security releases. +libxkbcommon 1.13.2 1.14.0 fine 2026-09-19 NEWS for 1.14.0: no security fix. Upstream's own tags show 1.14.0 only as betas on the day this was read, so the survey may be early. +libdrm 2.4.129 2.4.134 fine 2026-09-19 The 27 commits between them: none is a security fix. +libinput 1.30.4 1.32.0 fine 2026-09-19 CVE-2026-35093, -35094 and -50292 are all fixed in the pinned 1.30.4, the newest 1.30.x. 1.31.3 and 1.32.0 add hardening against malicious uinput devices that was not backported; wlroots 0.19.3 has not been built against 1.32. From 9970703b524f4c7a3929b06a75d93745f5c79b85 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:16:06 -0700 Subject: [PATCH 04/25] The update channel's rules: which statement of what is current zone 0 believes, and which bytes it will stage The first piece of the update channel (docs/design/update-channel.md), and the part that is pure: no socket, no file, no signature. A pointer's text is parsed strictly, with an unknown key refused rather than skipped. A verified pointer is accepted for this image's role and never backwards: an `issued` earlier than the newest already accepted is a replay, however valid its signature. Its age says whether it is stale. A relative base is resolved against the channel address from the verified root, and only a development image may be pointed at plain http. For staging: nothing but the manifest and its signature, whole and small, until they have verified; after that only a name the signed manifest lists, at exactly the offset already held, never past the signed size. `still_needed` is the answer to a poll: what is missing, and from which byte. Versions compare as `sort -V` orders them for kryptik-update. Nothing is wired to the broker yet; nine unit tests cover the rules. --- compartments/kryptikd/src/main.rs | 1 + compartments/kryptikd/src/update.rs | 353 ++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 compartments/kryptikd/src/update.rs diff --git a/compartments/kryptikd/src/main.rs b/compartments/kryptikd/src/main.rs index 26dff93..d16ead3 100644 --- a/compartments/kryptikd/src/main.rs +++ b/compartments/kryptikd/src/main.rs @@ -28,6 +28,7 @@ mod seccomp; mod serve; mod spawn; mod time; +mod update; mod volume; mod wifi; mod zone; diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs new file mode 100644 index 0000000..6597af0 --- /dev/null +++ b/compartments/kryptikd/src/update.rs @@ -0,0 +1,353 @@ +//! The update channel's rules (docs/design/update-channel.md): what zone 0 +//! believes about a statement of what is current, and which bytes it will +//! take from the net zone for a release it has been asked to fetch. +//! +//! Nothing here verifies a signature. `kryptik-update check-pointer` and +//! `check-manifest` do that, with the code that verifies a release handed +//! over on a disk, and only text they have verified reaches these functions. +//! What is decided here is everything a signature cannot say: that a +//! statement is for this image's role, that it is not older than one already +//! accepted, how stale it is, and that a byte offered for staging is one the +//! signed manifest provides for, at the place it belongs. + +use std::cmp::Ordering; + +pub const POINTER_MAGIC: &str = "KRYPTIK-LATEST-1"; +/// The pointer and its signature, each. +pub const POINTER_MAX: usize = 8 * 1024; +/// The manifest and its signature, each. +pub const MANIFEST_MAX: u64 = 64 * 1024; +/// A pointer older than this is reported as stale: the release process +/// re-issues it on a schedule, so its age is the only sign of a withheld one. +pub const STALE_AFTER_SECS: i64 = 30 * 86400; +/// One pointer is considered per hour; the rest are refused unread. +pub const POINTER_INTERVAL_SECS: u64 = 3600; + +/// A statement of what is current, after its signature has verified. +#[derive(Debug, Clone, PartialEq)] +pub struct Pointer { + pub role: String, + pub version: String, + /// Seconds since the epoch. + pub issued: i64, + pub manifest_sha256: String, + pub base: String, +} + +fn is_version(s: &str) -> bool { + !s.is_empty() && s.len() <= 32 && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-' | b'~')) +} + +/// The pointer's text: the magic line, then each key exactly once. A key it +/// does not know is refused rather than skipped, so a newer format cannot be +/// half-understood by an older system. +pub fn parse_pointer(text: &str) -> Result { + let mut lines = text.lines(); + if lines.next() != Some(POINTER_MAGIC) { + return Err(format!("not a {POINTER_MAGIC}")); + } + let (mut role, mut version, mut issued, mut sha, mut base) = (None, None, None, None, None); + for line in lines { + let (k, v) = line.split_once(": ").ok_or_else(|| format!("not a `key: value` line: {line:?}"))?; + let slot = match k { + "role" => &mut role, + "version" => &mut version, + "issued" => &mut issued, + "manifest-sha256" => &mut sha, + "base" => &mut base, + _ => return Err(format!("unknown key {k:?}")), + }; + if slot.replace(v.to_string()).is_some() { + return Err(format!("{k} is given twice")); + } + } + let need = |o: Option, k: &str| o.ok_or_else(|| format!("no {k}")); + let (role, version, issued, sha, base) = + (need(role, "role")?, need(version, "version")?, need(issued, "issued")?, need(sha, "manifest-sha256")?, need(base, "base")?); + if !is_version(&version) { + return Err(format!("{version:?} is not a version")); + } + let issued = crate::time::parse_iso8601(&issued).ok_or_else(|| format!("issued {issued:?} is not a date"))?; + if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err("manifest-sha256 is not 64 lowercase hex digits".into()); + } + if base.is_empty() || base.len() > 512 || !base.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return Err("base must be 1 to 512 printable characters without spaces".into()); + } + Ok(Pointer { role, version, issued, manifest_sha256: sha, base }) +} + +/// Where a release's files are fetched from. An absolute base is taken as it +/// is; a relative one is resolved against the channel address from the +/// verified root, never against anything the net zone reports. Only a +/// development image may be pointed at plain http. Where the bytes come from +/// decides nothing about what they must be - the pointer carries the +/// manifest's hash - so this is about not leaking the request, not trust. +pub fn resolve_base(channel: &str, base: &str, role: &str) -> Result { + let mut url = if base.contains("://") { + base.to_string() + } else { + if base.starts_with('/') || base.split('/').any(|c| c == "..") { + return Err(format!("relative base {base:?} must stay under the channel address")); + } + format!("{}/{}", channel.trim_end_matches('/'), base) + }; + if !url.ends_with('/') { + url.push('/'); + } + match url.split_once("://").map(|(scheme, _)| scheme) { + Some("https") => Ok(url), + Some("http") if role == "development" => Ok(url), + Some("http") => Err("a production image does not fetch over plain http".into()), + _ => Err(format!("{url:?} is neither https nor http")), + } +} + +/// Versions compare the way `sort -V` orders them for the release tool: runs +/// of digits as numbers, everything else byte by byte. +pub fn version_cmp(a: &str, b: &str) -> Ordering { + let (a, b) = (a.as_bytes(), b.as_bytes()); + let (mut i, mut j) = (0, 0); + while i < a.len() && j < b.len() { + if a[i].is_ascii_digit() && b[j].is_ascii_digit() { + let run = |s: &[u8], from: usize| (from..s.len()).find(|&k| !s[k].is_ascii_digit()).unwrap_or(s.len()); + let (ie, je) = (run(a, i), run(b, j)); + let strip = |s: &[u8]| s.iter().position(|&c| c != b'0').map_or(&s[s.len()..], |p| &s[p..]).to_vec(); + let (x, y) = (strip(&a[i..ie]), strip(&b[j..je])); + match x.len().cmp(&y.len()).then_with(|| x.cmp(&y)) { + Ordering::Equal => {} + o => return o, + } + (i, j) = (ie, je); + } else { + match a[i].cmp(&b[j]) { + Ordering::Equal => {} + o => return o, + } + (i, j) = (i + 1, j + 1); + } + } + (a.len() - i).cmp(&(b.len() - j)) +} + +/// What an accepted pointer says about this machine. +#[derive(Debug, PartialEq)] +pub enum Standing { + /// It names the running release, or an older one. + Current, + Available(String), +} + +/// Whether zone 0 accepts a verified pointer. The signature said who wrote +/// it; this says whether it is for this image and whether it is a replay: +/// an `issued` earlier than the newest one already accepted is refused +/// however valid its signature. +pub fn accept_pointer(p: &Pointer, required_role: &str, running: &str, newest_issued: Option) -> Result { + if p.role != required_role { + return Err(format!("the pointer's role is '{}'; this image requires '{required_role}'", p.role)); + } + if let Some(seen) = newest_issued { + if p.issued < seen { + return Err("older than a statement this machine has already accepted: a replay".into()); + } + } + Ok(if version_cmp(&p.version, running) == Ordering::Greater { Standing::Available(p.version.clone()) } else { Standing::Current }) +} + +/// How many whole days old the newest accepted pointer is, and whether that +/// is past the bound. A clock behind the pointer reads as zero days. +pub fn staleness(now: i64, issued: i64) -> (i64, bool) { + let age = (now - issued).max(0); + (age / 86400, age > STALE_AFTER_SECS) +} + +/// One file of a release, from the verified manifest. +#[derive(Debug, Clone, PartialEq)] +pub struct Entry { + pub name: String, + pub size: u64, +} + +/// The file list `kryptik-update check-manifest` prints for a manifest it +/// has verified: `file ` per line, other lines ignored. Names +/// are held to the rule for anything that crosses the broker. +pub fn parse_file_list(text: &str) -> Result, String> { + let mut out: Vec = Vec::new(); + for line in text.lines() { + let Some(rest) = line.strip_prefix("file ") else { continue }; + let (size, name) = rest.split_once(' ').ok_or_else(|| format!("not `file `: {line:?}"))?; + let size: u64 = size.parse().map_err(|_| format!("{size:?} is not a size"))?; + crate::broker::check_transfer_name(name)?; + if name == "manifest" || name == "manifest.sig" || out.iter().any(|e| e.name == name) { + return Err(format!("the manifest lists {name:?}, which it cannot")); + } + out.push(Entry { name: name.to_string(), size }); + } + if out.is_empty() { + return Err("the manifest lists no files".into()); + } + Ok(out) +} + +pub fn total_bytes(files: &[Entry]) -> u64 { + files.iter().map(|e| e.size).sum() +} + +/// Whether `len` bytes offered for `name` at `offset` may be written, given +/// how many bytes of it are already held. `files` is `None` until the +/// manifest and its signature have verified, and until then only those two +/// are taken: whole, from byte zero, small. After that: a listed name, at +/// exactly the offset held (so a broken download resumes and nothing is +/// written twice or out of order), never past the signed size. +pub fn may_put(files: Option<&[Entry]>, name: &str, offset: u64, len: u64, held: u64) -> Result<(), String> { + if len == 0 { + return Err("nothing to put".into()); + } + let end = offset.checked_add(len).ok_or("offset and length overflow")?; + if name == "manifest" || name == "manifest.sig" { + if files.is_some() { + return Err(format!("{name} has been verified; it is not replaced")); + } + if offset != 0 || end > MANIFEST_MAX { + return Err(format!("{name} is put whole, from byte 0, in at most {MANIFEST_MAX} bytes")); + } + return Ok(()); + } + let files = files.ok_or("nothing is accepted before the manifest and its signature have verified")?; + let e = files.iter().find(|e| e.name == name).ok_or_else(|| format!("the signed manifest does not list {name:?}"))?; + if offset != held { + return Err(format!("{name}: {held} bytes are held; the next byte wanted is {held}, not {offset}")); + } + if end > e.size { + return Err(format!("{name}: the signed manifest gives it {} bytes; {end} would be past that", e.size)); + } + Ok(()) +} + +/// What is still missing and from which byte: the answer to `update-poll`. +pub fn still_needed(files: &[Entry], held: impl Fn(&str) -> u64) -> Vec<(String, u64)> { + files.iter().filter_map(|e| { let h = held(&e.name); (h < e.size).then(|| (e.name.clone(), h)) }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn pointer_text(version: &str, issued: &str) -> String { + format!("{POINTER_MAGIC}\nrole: production\nversion: {version}\nissued: {issued}\nmanifest-sha256: {SHA}\nbase: {version}/\n") + } + + #[test] + fn a_pointer_parses_and_anything_else_does_not() { + let p = parse_pointer(&pointer_text("1.0.3", "2027-03-02T14:05:00+00:00")).unwrap(); + assert_eq!((p.role.as_str(), p.version.as_str(), p.base.as_str()), ("production", "1.0.3", "1.0.3/")); + assert_eq!(p.issued, crate::time::parse_iso8601("2027-03-02T14:05:00Z").unwrap()); + let good = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + for (what, bad) in [ + ("a manifest's magic", good.replace(POINTER_MAGIC, "KRYPTIK-MANIFEST-1")), + ("a key twice", format!("{good}role: development\n")), + ("an unknown key", format!("{good}mirror: https://elsewhere/\n")), + ("no issued", good.replace("issued: 2027-03-02T14:05:00Z\n", "")), + ("a date that is not one", good.replace("2027-03-02T14:05:00Z", "yesterday")), + ("a short hash", good.replace(SHA, &SHA[..63])), + ("an uppercase hash", good.replace(SHA, &SHA.to_uppercase())), + ("a version with a space", good.replace("version: 1.0.3", "version: 1.0 3")), + ("a base with a space", good.replace("base: 1.0.3/", "base: 1.0.3/ x")), + ] { + assert!(parse_pointer(&bad).is_err(), "{what} was accepted"); + } + } + + #[test] + fn versions_order_as_the_release_tool_orders_them() { + for (a, b) in [("1.0.3", "1.0.10"), ("1.9", "1.10"), ("1.0", "1.0.1"), ("0.9.9", "1.0"), ("1.0-rc1", "1.0-rc2"), ("1.02", "1.3")] { + assert_eq!(version_cmp(a, b), Ordering::Less, "{a} < {b}"); + assert_eq!(version_cmp(b, a), Ordering::Greater, "{b} > {a}"); + } + assert_eq!(version_cmp("1.0.3", "1.0.3"), Ordering::Equal); + assert_eq!(version_cmp("1.01", "1.1"), Ordering::Equal); + } + + #[test] + fn a_pointer_is_accepted_for_this_role_and_never_backwards() { + let p = parse_pointer(&pointer_text("1.0.3", "2027-03-02T14:05:00Z")).unwrap(); + assert_eq!(accept_pointer(&p, "production", "1.0.2", None), Ok(Standing::Available("1.0.3".into()))); + assert_eq!(accept_pointer(&p, "production", "1.0.3", None), Ok(Standing::Current)); + // An older release named by a newer statement is not an update. + assert_eq!(accept_pointer(&p, "production", "1.1.0", None), Ok(Standing::Current)); + assert!(accept_pointer(&p, "development", "1.0.2", None).unwrap_err().contains("role")); + // The same statement again is fine: that is what a re-issue looks + // like to a machine that polls more often than the schedule. + assert!(accept_pointer(&p, "production", "1.0.2", Some(p.issued)).is_ok()); + assert!(accept_pointer(&p, "production", "1.0.2", Some(p.issued + 1)).unwrap_err().contains("replay")); + } + + #[test] + fn a_pointer_goes_stale_after_the_bound_and_not_before() { + assert_eq!(staleness(1000 + STALE_AFTER_SECS, 1000), (30, false)); + assert_eq!(staleness(1001 + STALE_AFTER_SECS, 1000), (30, true)); + assert_eq!(staleness(500, 1000), (0, false)); + } + + #[test] + fn a_base_resolves_against_the_verified_channel_only() { + let ch = "https://updates.example/stable"; + assert_eq!(resolve_base(ch, "1.0.3/", "production").unwrap(), "https://updates.example/stable/1.0.3/"); + assert_eq!(resolve_base(&format!("{ch}/"), "1.0.3", "production").unwrap(), "https://updates.example/stable/1.0.3/"); + assert_eq!(resolve_base(ch, "https://mirror.example/k/1.0.3/", "production").unwrap(), "https://mirror.example/k/1.0.3/"); + assert!(resolve_base(ch, "../other/1.0.3/", "production").is_err()); + assert!(resolve_base(ch, "/etc/", "production").is_err()); + assert!(resolve_base(ch, "http://mirror.example/1.0.3/", "production").is_err()); + assert!(resolve_base(ch, "http://10.0.2.2:8080/1.0.3/", "development").is_ok()); + assert!(resolve_base(ch, "file:///var/lib/", "development").is_err()); + } + + fn files() -> Vec { + parse_file_list("version: 1.0.3\nfile 1000 kryptik-root.img\nfile 40 kryptik-a.efi\nfile 40 kryptik-b.efi\nfile 9 root.json\n").unwrap() + } + + #[test] + fn the_file_list_is_the_verified_manifests_and_nothing_odd() { + assert_eq!(total_bytes(&files()), 1089); + for bad in ["file 10 ../x\n", "file 10 .hidden\n", "file ten x\n", "file 10 manifest\n", "file 1 a\nfile 2 a\n", "version: 1\n", "file 10 a b\n"] { + assert!(parse_file_list(bad).is_err(), "{bad:?} was accepted"); + } + } + + #[test] + fn nothing_large_is_taken_before_the_manifest_has_verified() { + assert!(may_put(None, "manifest", 0, 4096, 0).is_ok()); + assert!(may_put(None, "manifest.sig", 0, MANIFEST_MAX, 0).is_ok()); + assert!(may_put(None, "manifest", 0, MANIFEST_MAX + 1, 0).is_err()); + assert!(may_put(None, "manifest", 1, 10, 0).is_err()); + assert!(may_put(None, "kryptik-root.img", 0, 10, 0).unwrap_err().contains("before the manifest")); + // And once it has, the manifest is what was verified, for good. + assert!(may_put(Some(&files()), "manifest", 0, 10, 0).is_err()); + } + + #[test] + fn bytes_are_taken_only_where_the_signed_manifest_provides_for_them() { + let f = files(); + assert!(may_put(Some(&f), "kryptik-root.img", 0, 1000, 0).is_ok()); + assert!(may_put(Some(&f), "kryptik-root.img", 600, 400, 600).is_ok()); + assert!(may_put(Some(&f), "kryptik-root.img", 600, 401, 600).unwrap_err().contains("past that")); + assert!(may_put(Some(&f), "kryptik-root.img", 0, 10, 600).unwrap_err().contains("600 bytes are held")); + assert!(may_put(Some(&f), "kryptik-root.img", 700, 10, 600).is_err()); + assert!(may_put(Some(&f), "kryptik-root.img", 1000, 1, 1000).is_err()); + assert!(may_put(Some(&f), "stowaway", 0, 1, 0).unwrap_err().contains("does not list")); + assert!(may_put(Some(&f), "root.json", 0, 0, 0).is_err()); + assert!(may_put(Some(&f), "root.json", u64::MAX, 2, u64::MAX).is_err()); + } + + #[test] + fn a_poll_names_what_is_missing_and_from_which_byte() { + let held = |n: &str| match n { "kryptik-root.img" => 600, "kryptik-a.efi" => 40, _ => 0 }; + assert_eq!( + still_needed(&files(), held), + vec![("kryptik-root.img".to_string(), 600), ("kryptik-b.efi".to_string(), 0), ("root.json".to_string(), 0)] + ); + assert!(still_needed(&files(), |_| u64::MAX).is_empty()); + } +} From b17f278de7b0a40eb9d778ff4184c534be2ede26 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:16:20 -0700 Subject: [PATCH 05/25] The pin gate is sixty lines, not a hundred and fifty, and says the same things The owner asked whether what is being added is real or padded, and measured honestly this tool was padded: a join of two files with five ways to fail had grown an expiry policy, a date override for its own tests, a strict mode CI never used, six report sections and a test file longer than itself. It now does what makes it a gate and nothing else: a behind pin needs a row; the row stops covering it when the pin moves, when upstream releases past what was read, or when the pin catches up; a row needs a verdict and a note; held is printed and --no-held refuses it; an undetermined pin is reported. Review expiry, --strict, --max-age and --today are gone. The suite keeps the sixteen cases that each guard one of those behaviours, including the tab-merging bug it originally found. Run against the 32 real rows and the projected survey it gives the verdicts it gave before: six held, the rest covered, a release refused for the six. The reviews file's header, the CI step's comment and the Makefile's are cut to what a reader needs. --- .github/workflows/ci.yml | 15 +-- Makefile | 6 +- tools/check-pin-reviews.sh | 168 ++++++++----------------------- tools/pin-reviews.tsv | 31 +----- tools/test-check-pin-reviews.sh | 171 +++++++------------------------- 5 files changed, 84 insertions(+), 307 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17198d8..6134fc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -453,18 +453,9 @@ jobs: - name: Pinned series are supported upstream run: ./tools/check-support-status.sh --strict - # A pin that is behind its upstream needs a written review, and the - # review stops covering it when upstream releases again. 51 of 113 pins - # were behind on the day this was written and no file said which of - # those gaps held a security fix; eight did. - # - # Two tools on purpose: the survey asks the network and judges nothing, - # the gate reads the survey and tools/pin-reviews.tsv and never the - # network. On a push and on the weekly schedule the gate's verdict - # stands. On a pull request it is printed and does not fail the check: - # an upstream that released this morning is not the fault of whoever - # opened a pull request this afternoon, and the weekly run is what makes - # sure somebody reads it. + # Survey (network) then gate (no network). On a pull request the verdict + # is printed and does not fail the check: an upstream that released this + # morning is not the pull request's fault. The weekly run is the alarm. - name: Pins behind upstream are reviewed env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index 479485e..81b5f7f 100644 --- a/Makefile +++ b/Makefile @@ -249,10 +249,8 @@ validate-kernel: check-kernel-eol: @"$(TOOLS)"/check-kernel-eol.sh -# Two tools on purpose. The survey asks the network what upstream has released -# and judges nothing; the gate reads that survey and tools/pin-reviews.tsv and -# never the network, so its verdict can be tested and reproduced. A survey that -# could not be written is a failure here, not an empty file that passes. +# The survey asks the network and judges nothing; the gate reads the survey +# and tools/pin-reviews.tsv and never the network. PINS_SURVEY ?= $(KRYPTIK_WORK)/pin-survey.tsv check-pins: @mkdir -p "$(dir $(PINS_SURVEY))" diff --git a/tools/check-pin-reviews.sh b/tools/check-pin-reviews.sh index 630e929..b64d076 100755 --- a/tools/check-pin-reviews.sh +++ b/tools/check-pin-reviews.sh @@ -1,150 +1,60 @@ #!/usr/bin/env bash -# Hold every pin that is behind its upstream to a written review. +# Every pin that is behind its upstream needs a row in tools/pin-reviews.tsv: # -# ./tools/check-source-currency.sh --tsv > survey.tsv -# ./tools/check-pin-reviews.sh --survey survey.tsv [--reviews FILE] -# [--max-age DAYS] [--strict] [--no-held] -# [--today YYYY-MM-DD] +# package pinned reviewed_up_to fine|held date what was read, and why # -# --survey FILE check-source-currency.sh's --tsv output -# --reviews FILE default tools/pin-reviews.tsv -# --max-age DAYS a review older than this has expired; default 180 -# --strict a pin the survey could not determine is a failure -# --no-held a held pin is a failure; this is what a release asks -# --today DATE the date reviews are aged against; default today (tests) +# A row stops covering its pin when the pin moves or upstream releases past +# reviewed_up_to, so the next release has to be read too. `held` means a known +# fix is not taken, for the reason in the note; --no-held, which a release +# asks, refuses it. Reads a survey, never the network: # -# "A newer version exists" is not a security verdict, which is why -# check-source-currency.sh reports and does not judge. This is where the -# judging is written down. A pin that is behind is one of three things: moved, -# reviewed as fine (someone read what upstream released after it and nothing -# there is a security fix that reaches Kryptik), or held (there is such a fix, -# and the row says why the pin stays anyway). A row with no reason is a -# failure. What makes this a gate and not a list is the other direction: a -# review covers upstream releases up to a named version, so the next upstream -# release makes the row fail until someone has read that one too. -# -# It reads a survey and never the network, so it is deterministic: the same two -# files give the same answer, in a test or a year later. -# -# Exit status: 0 when every behind pin has a current review; 1 otherwise. +# tools/check-source-currency.sh --tsv > survey.tsv +# tools/check-pin-reviews.sh --survey survey.tsv [--reviews FILE] [--no-held] +set -uo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" - -SURVEY=""; REVIEWS="${KRYPTIK_ROOT}/tools/pin-reviews.tsv" -MAX_AGE=180; STRICT=0; NO_HELD=0; TODAY="$(date -u +%Y-%m-%d)" +SURVEY=""; REVIEWS="$(dirname "${BASH_SOURCE[0]}")/pin-reviews.tsv"; NO_HELD=0 while [[ $# -gt 0 ]]; do case "$1" in - --survey) SURVEY="${2:?--survey needs a file}"; shift 2 ;; - --reviews) REVIEWS="${2:?--reviews needs a file}"; shift 2 ;; - --max-age) MAX_AGE="${2:?--max-age needs a number of days}"; shift 2 ;; - --today) TODAY="${2:?--today needs a date}"; shift 2 ;; - --strict) STRICT=1; shift ;; + --survey) SURVEY="${2:-}"; shift 2 ;; + --reviews) REVIEWS="${2:-}"; shift 2 ;; --no-held) NO_HELD=1; shift ;; - -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | cut -c3-; exit 0 ;; - *) die "unknown argument: $1" ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -[[ -n "$SURVEY" ]] || die "--survey FILE is required (check-source-currency.sh --tsv)" -[[ -s "$SURVEY" ]] || die "the survey ${SURVEY} is missing or empty: a gate with nothing to read has not passed" -[[ -f "$REVIEWS" ]] || die "no reviews file at ${REVIEWS}" -[[ "$MAX_AGE" =~ ^[0-9]+$ ]] || die "--max-age wants a whole number of days, got ${MAX_AGE}" -is_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && date -u -d "$1" +%s >/dev/null 2>&1; } -is_date "$TODAY" || die "--today wants YYYY-MM-DD, got ${TODAY}" -today_s="$(date -u -d "$TODAY" +%s)" +[[ -s "$SURVEY" && -f "$REVIEWS" ]] || { echo "FAIL: need a non-empty --survey and a reviews file" >&2; exit 1; } -# newer A B: A sorts strictly after B, by the same ordering the survey uses. newer() { [[ "$1" != "$2" && "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]; } +bad=0 +fail() { echo " $*"; bad=$((bad + 1)); } -# --- the reviews ------------------------------------------------------------ -declare -A R_PINNED=() R_UPTO=() R_VERDICT=() R_DATE=() R_NOTE=() R_SEEN=() -declare -a MALFORMED=() -CR=$'\r' -lineno=0 -while IFS= read -r line || [[ -n "$line" ]]; do - lineno=$((lineno + 1)) - line="${line%"$CR"}" - [[ -z "${line//[[:space:]]/}" || "$line" =~ ^[[:space:]]*# ]] && continue - read -r pkg pinned upto verdict reviewed note <<<"$line" - where="${REVIEWS##*/}:${lineno}" - if [[ -z "$pkg" || -z "$pinned" || -z "$upto" || -z "$verdict" || -z "$reviewed" ]]; then - MALFORMED+=("${where}: wants package, pinned, reviewed_up_to, verdict, reviewed, note"); continue +declare -A PIN UPTO VERDICT NOTE SEEN +while read -r pkg pinned upto verdict _date note; do + [[ -z "$pkg" || "$pkg" == \#* ]] && continue + if [[ "$verdict" =~ ^(fine|held)$ && -n "$note" && -z "${PIN[$pkg]:-}" ]]; then + PIN[$pkg]="$pinned"; UPTO[$pkg]="$upto"; VERDICT[$pkg]="$verdict"; NOTE[$pkg]="$note" + else + fail "MALFORMED: ${pkg}: one row per package, verdict fine or held, and a note" fi - [[ "$verdict" == fine || "$verdict" == held ]] || { MALFORMED+=("${where}: ${pkg}: verdict '${verdict}' is neither fine nor held"); continue; } - is_date "$reviewed" || { MALFORMED+=("${where}: ${pkg}: reviewed '${reviewed}' is not a date"); continue; } - [[ -n "${note//[[:space:]]/}" ]] || { MALFORMED+=("${where}: ${pkg}: no note. What was read, and why is the pin ${verdict}?"); continue; } - [[ -z "${R_PINNED[$pkg]:-}" ]] || { MALFORMED+=("${where}: ${pkg}: a second row for the same package"); continue; } - R_PINNED[$pkg]="$pinned"; R_UPTO[$pkg]="$upto"; R_VERDICT[$pkg]="$verdict" - R_DATE[$pkg]="$reviewed"; R_NOTE[$pkg]="$note" done < "$REVIEWS" -# --- the survey against them ------------------------------------------------ -declare -a NOT_REVIEWED=() STALE=() NEW_RELEASE=() EXPIRED=() HELD=() FINE=() UNDETERMINED=() -rows=0 -# A tab is whitespace to read, and read merges a run of whitespace into one -# separator: a row with an empty 'newest' column would lose the column and -# every field after it would move left, so an UNKNOWN row would read as a -# status nobody tests for and pass in silence. The unit separator is not -# whitespace, so an empty field stays an empty field. -US=$'\037' -while IFS="$US" read -r name pinned newest status consulted; do +# Tabs become a separator that is not whitespace: read merges a run of tabs, so +# an empty "newest" column would shift every field after it. +while IFS=$'\037' read -r name pinned newest status _; do [[ -n "$name" ]] || continue - rows=$((rows + 1)) - R_SEEN[$name]=1 - if [[ "$status" == UNKNOWN ]]; then - UNDETERMINED+=("${name} ${pinned} (consulted ${consulted:-nothing})") - fi + SEEN[$name]=1 + row="${PIN[$name]:-}" if [[ "$status" != BEHIND ]]; then - [[ -z "${R_PINNED[$name]:-}" ]] || STALE+=("${name}: reviewed as behind, but the survey says ${status}; remove the row") - continue - fi - if [[ -z "${R_PINNED[$name]:-}" ]]; then - NOT_REVIEWED+=("${name} ${pinned} -> ${newest}"); continue - fi - if [[ "${R_PINNED[$name]}" != "$pinned" ]]; then - STALE+=("${name}: the review is of ${R_PINNED[$name]}, the pin is ${pinned}"); continue - fi - if newer "$newest" "${R_UPTO[$name]}"; then - NEW_RELEASE+=("${name} ${pinned}: reviewed up to ${R_UPTO[$name]}, upstream is at ${newest}"); continue - fi - age=$(( (today_s - $(date -u -d "${R_DATE[$name]}" +%s)) / 86400 )) - if [[ "$age" -gt "$MAX_AGE" ]]; then - EXPIRED+=("${name} ${pinned}: reviewed ${R_DATE[$name]}, ${age} days ago (limit ${MAX_AGE})"); continue - fi - if [[ "${R_VERDICT[$name]}" == held ]]; then - HELD+=("${name} ${pinned} (upstream ${newest}): ${R_NOTE[$name]}") - else - FINE+=("${name} ${pinned} (upstream ${newest})") + [[ "$status" == UNKNOWN ]] && echo " not determined, which is not the same as fine: ${name} ${pinned}" + [[ -n "$row" ]] && fail "STALE: ${name} is ${status} now; remove its row" + elif [[ -z "$row" ]]; then fail "NOT REVIEWED: ${name} ${pinned} -> ${newest}" + elif [[ "$row" != "$pinned" ]]; then fail "STALE: ${name}: the row reviews ${row}, the pin is ${pinned}" + elif newer "$newest" "${UPTO[$name]}"; then fail "NEW RELEASE: ${name}: reviewed up to ${UPTO[$name]}, upstream is at ${newest}" + elif [[ "${VERDICT[$name]}" == held ]]; then + echo " HELD: ${name} ${pinned}: ${NOTE[$name]}" + [[ "$NO_HELD" -eq 1 ]] && bad=$((bad + 1)) fi done < <(tr '\t' '\037' < "$SURVEY") -[[ "$rows" -gt 0 ]] || die "the survey ${SURVEY} has no rows" -for pkg in "${!R_PINNED[@]}"; do - [[ -n "${R_SEEN[$pkg]:-}" ]] || STALE+=("${pkg}: reviewed, but the survey has no such source") -done - -# --- the report ------------------------------------------------------------- -section() { # section TITLE ITEM... - local title="$1"; shift - [[ $# -gt 0 ]] || return 0 - echo; echo "${title}" - printf '%s\n' "$@" | sort | sed 's/^/ - /' -} -log "Pins behind upstream, held to ${REVIEWS##*/} (survey: ${rows} sources, reviews aged against ${TODAY})" -section "MALFORMED rows:" "${MALFORMED[@]}" -section "BEHIND AND NOT REVIEWED (move the pin, or read what upstream released and write the row):" "${NOT_REVIEWED[@]}" -section "NEW UPSTREAM RELEASE SINCE THE REVIEW (read it, then move reviewed_up_to):" "${NEW_RELEASE[@]}" -section "EXPIRED reviews (a vulnerability can be published long after its fix; read again):" "${EXPIRED[@]}" -section "STALE rows:" "${STALE[@]}" -section "HELD: known security fixes upstream, pin kept for the reason given:" "${HELD[@]}" -section "NOT DETERMINED by the survey (not checked, which is not the same as fine):" "${UNDETERMINED[@]}" -section "reviewed as fine:" "${FINE[@]}" -echo -echo "COUNTS not-reviewed=${#NOT_REVIEWED[@]} new-release=${#NEW_RELEASE[@]} expired=${#EXPIRED[@]} stale=${#STALE[@]} malformed=${#MALFORMED[@]} held=${#HELD[@]} undetermined=${#UNDETERMINED[@]} fine=${#FINE[@]}" +for pkg in "${!PIN[@]}"; do [[ -n "${SEEN[$pkg]:-}" ]] || fail "STALE: ${pkg} is not a source"; done -bad=$(( ${#NOT_REVIEWED[@]} + ${#NEW_RELEASE[@]} + ${#EXPIRED[@]} + ${#STALE[@]} + ${#MALFORMED[@]} )) -[[ "$NO_HELD" -eq 1 ]] && bad=$(( bad + ${#HELD[@]} )) -[[ "$STRICT" -eq 1 ]] && bad=$(( bad + ${#UNDETERMINED[@]} )) -if [[ "$bad" -gt 0 ]]; then - err "${bad} pin(s) are not covered by a current review" - exit 1 -fi -ok "every pin that is behind upstream has a current review" +[[ "$bad" -eq 0 ]] || { echo "FAIL: ${bad} pin(s) without a current review"; exit 1; } +echo "ok: every pin that is behind upstream has a current review" diff --git a/tools/pin-reviews.tsv b/tools/pin-reviews.tsv index 8b23f45..a56a6c8 100644 --- a/tools/pin-reviews.tsv +++ b/tools/pin-reviews.tsv @@ -1,31 +1,10 @@ -# Reviews of pins that are behind their upstream. +# Reviews of pins that are behind their upstream. Read by tools/check-pin-reviews.sh. # -# Read by tools/check-pin-reviews.sh, against a survey made by -# tools/check-source-currency.sh --tsv. +# package pinned reviewed_up_to fine|held date what was read, and why # -# Columns are whitespace separated; the note runs to the end of the line. -# -# package source name, as in the survey and sources.lock -# pinned the version this review is of; a row whose pinned version -# is no longer the pin is stale, and fails -# reviewed_up_to the newest upstream release that was read. When upstream -# releases past it the row fails until that release has been -# read too, which is the whole point of the file -# verdict fine | held -# reviewed the date it was read; a review expires (180 days by -# default) because a vulnerability is often published long -# after the release that fixed it -# note what was read and what it said. Required. -# -# fine nothing released after the pin, up to reviewed_up_to, is a security -# fix that reaches Kryptik. Say what was read: NEWS, a changelog, the -# project's security page. "No CVEs" with no source is not a review. -# held there IS such a fix, and the pin stays anyway for the reason given. -# Printed on every run. check-pin-reviews.sh --no-held, which is what -# a release asks, refuses it. -# -# A pin that has caught up has no row: a row for a current pin is stale, and -# fails, so this file shrinks when the work is done instead of accumulating. +# fine: nothing released after the pin, up to reviewed_up_to, is a security fix +# that reaches Kryptik. held: something is, and the note says why the pin stays. +# A pin that has caught up has no row; a row for it fails, so this file shrinks. # Reviewed 2026-09-19 from upstream NEWS and changelogs, project security # pages, the Debian security tracker and NVD records. "Survey" below is diff --git a/tools/test-check-pin-reviews.sh b/tools/test-check-pin-reviews.sh index ace63c7..36da73b 100755 --- a/tools/test-check-pin-reviews.sh +++ b/tools/test-check-pin-reviews.sh @@ -1,154 +1,53 @@ #!/usr/bin/env bash -# Focused tests for tools/check-pin-reviews.sh. -# -# ./tools/test-check-pin-reviews.sh -# -# Offline and deterministic: the tool reads a survey file and a reviews file, -# so both are written here and the date is fixed with --today. -# -# The cases that matter are the ones where a list quietly stops being a gate: -# a review of a version that is no longer the pin, a review that upstream has -# since released past, a review too old to trust, a row with no reason, and a -# row left behind for a pin that has caught up. - +# Tests for tools/check-pin-reviews.sh. Offline: it reads two files. set -uo pipefail - -# See the same note in the other suites. -unset KRYPTIK_SOURCES KRYPTIK_WORK KRYPTIK_LOCK KRYPTIK_OUT KRYPTIK_ROOT - ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TOOL="${ROOT}/tools/check-pin-reviews.sh" +W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT +PASS=0; FAIL=0 -PASS=0 -FAIL=0 -green() { printf '\033[32m PASS\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf '\033[31m FAIL\033[0m %s\n' "$1"; FAIL=$((FAIL + 1)); } - -W="$(mktemp -d)" -trap 'rm -rf "$W"' EXIT -OUT="${W}/out" -show() { sed 's/^/ /' "$OUT"; } - -# survey NAME PINNED NEWEST STATUS ...: one row per five arguments. -survey() { - : > "${W}/survey.tsv" - while [[ $# -ge 4 ]]; do - printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "https://example.invalid/$1/" >> "${W}/survey.tsv" - shift 4 - done -} -reviews() { printf '%s\n' "$@" > "${W}/reviews.tsv"; } -run() { NO_COLOR=1 "$TOOL" --survey "${W}/survey.tsv" --reviews "${W}/reviews.tsv" --today 2026-09-19 "$@" > "$OUT" 2>&1; RC=$?; } - -# expect NAME WANT_RC REGEX...: the exit status, and every regex present. +survey() { : > "$W/s"; while [[ $# -ge 4 ]]; do printf '%s\t%s\t%s\t%s\turl\n' "$1" "$2" "$3" "$4" >> "$W/s"; shift 4; done; } +reviews() { printf '%s\n' "$@" > "$W/r"; } +# expect NAME WANT_RC REGEX [extra tool args...] expect() { - local name="$1" want="$2"; shift 2 - local good=1 rx - [[ "$RC" -eq "$want" ]] || good=0 - for rx in "$@"; do grep -qE -- "$rx" "$OUT" || good=0; done - if [[ "$good" -eq 1 ]]; then green "$name"; else red "${name} (exit ${RC}, wanted ${want})"; show; fi + local name="$1" want="$2" rx="$3"; shift 3 + local out rc; out="$("$TOOL" --survey "$W/s" --reviews "$W/r" "$@" 2>&1)"; rc=$? + if [[ "$rc" -eq "$want" ]] && grep -qE -- "$rx" <<<"$out"; then PASS=$((PASS + 1)); echo " PASS $name" + else FAIL=$((FAIL + 1)); echo " FAIL $name (exit $rc, wanted $want)"; sed 's/^/ /' <<<"$out"; fi } -# absent NAME REGEX: the regex must not appear. -absent() { if grep -qE -- "$2" "$OUT"; then red "$1"; show; else green "$1"; fi; } +ROW="zlib 1.3.1 1.3.2 fine 2026-09-19 read the ChangeLog: build fixes only" -echo "-- a behind pin needs a review" survey zlib 1.3.1 1.3.2 BEHIND bash 5.3 5.3 current -reviews "# nothing reviewed yet" -run -expect "a behind pin with no row fails" 1 'BEHIND AND NOT REVIEWED' 'zlib 1\.3\.1 -> 1\.3\.2' 'COUNTS not-reviewed=1 ' - -reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read ChangeLog 1.3.2: build fixes only, no memory-safety change" -run -expect "a current review of it passes" 0 'reviewed as fine' 'zlib 1\.3\.1 \(upstream 1\.3\.2\)' 'COUNTS not-reviewed=0 .* fine=1' -absent "a current pin is not mentioned at all" 'bash' +reviews "# none"; expect "a behind pin with no row fails" 1 'NOT REVIEWED: zlib 1.3.1 -> 1.3.2' +reviews "$ROW"; expect "a row that covers it passes" 0 '^ok:' +survey zlib 1.3.1 1.3.3 BEHIND; expect "upstream released past the review" 1 'NEW RELEASE: zlib: reviewed up to 1.3.2, upstream is at 1.3.3' +survey zlib 1.3.2 1.3.3 BEHIND; expect "the pin moved off the version reviewed" 1 'STALE: zlib: the row reviews 1.3.1, the pin is 1.3.2' +survey zlib 1.3.2 1.3.2 current; expect "the pin caught up and the row was left" 1 'STALE: zlib is current now' -echo -echo "-- a review stops covering the pin" -survey zlib 1.3.1 1.3.3 BEHIND -run -expect "upstream released past the review" 1 'NEW UPSTREAM RELEASE SINCE THE REVIEW' 'reviewed up to 1\.3\.2, upstream is at 1\.3\.3' - -survey zlib 1.3.2 1.3.3 BEHIND -run -expect "the pin moved and the review is of the old one" 1 'STALE' 'the review is of 1\.3\.1, the pin is 1\.3\.2' - -survey zlib 1.3.2 1.3.2 current -run -expect "the pin caught up and the row was left behind" 1 'STALE' 'the survey says current; remove the row' - -survey zlib 1.3.1 1.3.2 BEHIND -reviews "zlib 1.3.1 1.3.2 fine 2026-01-01 read ChangeLog 1.3.2" -run -expect "a review older than the limit has expired" 1 'EXPIRED' 'reviewed 2026-01-01, 261 days ago \(limit 180\)' -run --max-age 365 -expect "a longer limit accepts it" 0 'fine=1' - -echo -echo "-- version ordering is the survey's, not the alphabet's" survey coreutils 9.5 9.12 BEHIND -reviews "coreutils 9.5 9.9 fine 2026-09-01 read NEWS through 9.9" -run -expect "9.12 is newer than a review up to 9.9" 1 'reviewed up to 9\.9, upstream is at 9\.12' -reviews "coreutils 9.5 9.12 fine 2026-09-01 read NEWS through 9.12" -run -expect "and covered by a review up to 9.12" 0 'fine=1' - -echo -echo "-- a held pin is loud, and a release refuses it" -survey expat 2.6.2 2.8.4 BEHIND -reviews "expat 2.6.2 2.8.4 held 2026-09-19 CVE-2024-45490 fixed in 2.6.3; held until the rebuild in progress lands" -run -expect "held passes with the reason printed" 0 'HELD: known security fixes upstream' 'CVE-2024-45490 fixed in 2\.6\.3' 'held=1' -run --no-held -expect "--no-held makes it a failure" 1 'HELD' 'not covered by a current review' +reviews "coreutils 9.5 9.9 fine 2026-09-19 read NEWS"; expect "9.12 sorts after 9.9, not before it" 1 'NEW RELEASE' +reviews "coreutils 9.5 9.12 fine 2026-09-19 read NEWS"; expect "and a review up to 9.12 covers it" 0 '^ok:' -echo -echo "-- a row that says nothing is not a review" survey zlib 1.3.1 1.3.2 BEHIND -reviews "zlib 1.3.1 1.3.2 fine 2026-09-19" -run -expect "no note" 1 'MALFORMED' 'no note' -reviews "zlib 1.3.1 1.3.2 probably-ok 2026-09-19 looked fine" -run -expect "a verdict that is neither fine nor held" 1 'MALFORMED' "neither fine nor held" -reviews "zlib 1.3.1 1.3.2 fine last-week read it" -run -expect "a date that is not a date" 1 'MALFORMED' 'is not a date' -reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read it" "zlib 1.3.1 1.3.2 held 2026-09-19 or maybe not" -run -expect "two rows for one package" 1 'MALFORMED' 'a second row for the same package' -reviews "zlib 1.3.1 1.3.2 fine 2026-09-19 read it" "zlibb 1.0 1.1 fine 2026-09-19 a typo" -run -expect "a row for a source the survey does not have" 1 'STALE' 'zlibb: reviewed, but the survey has no such source' +reviews "zlib 1.3.1 1.3.2 held 2026-09-19 1.3.2 introduces a worse bug" +expect "a held pin passes, with its reason printed" 0 'HELD: zlib 1.3.1: 1.3.2 introduces' +expect "and a release refuses it" 1 'FAIL: 1 pin' --no-held + +reviews "zlib 1.3.1 1.3.2 fine 2026-09-19"; expect "a row with no note is not a review" 1 'MALFORMED: zlib' +reviews "zlib 1.3.1 1.3.2 maybe 2026-09-19 looked"; expect "nor is an unknown verdict" 1 'MALFORMED: zlib' +reviews "$ROW" "$ROW"; expect "nor a second row for one package" 1 'MALFORMED: zlib' +reviews "$ROW" "zlibb 1 2 fine 2026-09-19 a typo"; expect "a row for no source is stale" 1 'STALE: zlibb is not a source' -echo -echo "-- what the survey could not determine" -survey less 661 "" UNKNOWN zlib 1.3.2 1.3.2 current -reviews "# none" -run -expect "undetermined is reported and passes by default" 0 'NOT DETERMINED by the survey' 'less 661' 'undetermined=1' -run --strict -expect "--strict makes it a failure" 1 'not covered by a current review' +# The bug this suite found: an empty "newest" column shifted the fields, and +# every undetermined pin was counted as none. +survey less 661 "" UNKNOWN; reviews "# none" +expect "an undetermined pin is reported, not swallowed" 0 'not determined.*less 661' -echo -echo "-- a gate with nothing to read has not passed" -: > "${W}/survey.tsv" -run -expect "an empty survey is refused" 1 'missing or empty' -rm -f "${W}/survey.tsv" -run -expect "a missing survey is refused" 1 'missing or empty' +: > "$W/s"; expect "an empty survey has not passed" 1 'need a non-empty --survey' -echo -echo "-- the shipped reviews file" -if [[ -f "${ROOT}/tools/pin-reviews.tsv" ]]; then - survey placeholder 1 1 current - NO_COLOR=1 "$TOOL" --survey "${W}/survey.tsv" --today 2026-09-19 > "$OUT" 2>&1 - if grep -q 'MALFORMED' "$OUT"; then red "tools/pin-reviews.tsv has a malformed row"; show - else green "tools/pin-reviews.tsv is well formed"; fi -else - red "tools/pin-reviews.tsv does not exist" -fi +printf 'x\t1\t1\tcurrent\turl\n' > "$W/s" +if "$TOOL" --survey "$W/s" 2>&1 | grep -q MALFORMED; then FAIL=$((FAIL + 1)); echo " FAIL tools/pin-reviews.tsv has a malformed row" +else PASS=$((PASS + 1)); echo " PASS tools/pin-reviews.tsv is well formed"; fi printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" -[[ "$FAIL" -eq 0 ]] || exit 1 +[[ "$FAIL" -eq 0 ]] From 143b18f22c001587cba990b76d7bc1c6770128a1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:22:14 -0700 Subject: [PATCH 06/25] kryptik-update checks a manifest and a pointer for the update channel, with the code that checks a payload Two read-only subcommands, which zone 0 runs before it believes anything the net zone hands it: check-manifest DIR the signature, role and version steps of `apply` on a manifest that has no payload beside it yet; prints the version, the hash of the copy it verified, and each listed file with its size check-pointer FILE SIG the signature over a statement of what is current, in a namespace of its own The manifest steps were the first half of verify_payload; they are now verify_manifest, which verify_payload calls, so `apply` and the channel cannot come to disagree about what a valid manifest is. A downgrade is refused as in `apply`: nothing that arrives over the network is a recovery. The signature check is one function with the namespace as an argument: kryptik-release for a manifest, kryptik-latest for a pointer, so neither signature can be presented as the other. Neither check needs root or this installation's disks, so the tool asks for those only for the subcommands that write. The suite that lifts verify_payload out of the tool now covers both checks with the same real ssh-keygen: the other namespace, a stranger's key, another role, an older release, a listed path that climbs, an edited pointer, a manifest presented as a pointer, and the tool itself run without root. It had been registered nowhere; it is now `make test-update-verify` and runs with the offline suites. 18 rows. --- Makefile | 9 +- tools/run-tests.sh | 1 + tools/test-update-manifest-snapshot.sh | 116 +++++++++++++++++ tools/update/kryptik-update | 173 ++++++++++++++++++------- 4 files changed, 248 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index 3fe2f28..2025dc1 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,7 @@ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" vm-disk vm-disk-boot vm-restart vm-measure cli-test update-tree-test identity-test serve-test \ test-harness test-hardening test-artifacts audit-artifacts test-boot-success \ audit-artifacts-strict manifest verify-manifest test-manifest \ - test-s6-init smoke-userspace test-services test-netzone-time test-libc-unwind \ + test-s6-init smoke-userspace test-services test-netzone-time test-update-verify test-libc-unwind \ sign-image verify-image test-image-signing test-installer test-mkdisk-guards \ install-test \ image image-boot \ @@ -185,6 +185,7 @@ help: @echo " make smoke-userspace RUN the built userland in the chroot (needs root)" @echo " make test-services validate the s6-rc service tree" @echo " make test-netzone-time the net zone's time measurement, under every shell here" + @echo " make test-update-verify what kryptik-update believes: a payload, a manifest, a pointer" @echo " make identity-test zone files, compositor colour table and zoneid audit agree" @echo " make test-libc-unwind prove the target libc can unwind (needs root)" @echo " make sign-image sign the disk image with a developer key" @@ -596,6 +597,12 @@ test-services: test-netzone-time: @"$(TOOLS)"/test-netzone-time.sh +# What kryptik-update believes, with its own functions and a real ssh-keygen: +# a payload whose manifest is swapped under it, and the two checks the update +# channel runs on a manifest and on a statement of what is current. +test-update-verify: + @"$(TOOLS)"/test-update-manifest-snapshot.sh + # boot-success.sh's decision table (commit, refuse, fall back), driven on # the host with stand-ins for the services, the ESP and the firmware. test-boot-success: diff --git a/tools/run-tests.sh b/tools/run-tests.sh index fc3d751..54bbd16 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -33,6 +33,7 @@ SUITES=( "test-kernel-hardening|tools/test-check-kernel-hardening.sh" "test-services|tools/test-services.sh" "test-netzone-time|tools/test-netzone-time.sh" + "test-update-verify|tools/test-update-manifest-snapshot.sh" "test-boot-success|tools/test-boot-success.sh" "test-manifest|tools/test-artifact-manifest.sh" "test-s6-init|tools/test-s6-init-config.sh" diff --git a/tools/test-update-manifest-snapshot.sh b/tools/test-update-manifest-snapshot.sh index 5777396..ddda506 100755 --- a/tools/test-update-manifest-snapshot.sh +++ b/tools/test-update-manifest-snapshot.sh @@ -72,6 +72,8 @@ 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" "$*"; }' @@ -147,5 +149,119 @@ 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 key -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 key -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' ' ')" + +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 key -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' ' ')" + 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-update b/tools/update/kryptik-update index 383af3b..ba52f55 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 @@ -43,16 +47,29 @@ ESP_MNT=/run/kryptik/update-esp LOCK=/run/kryptik/update.lock LOG=/var/log/kryptik/update.log -[ "$(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; } @@ -93,50 +110,7 @@ cmd_status() { # --- verification, all of it before any write ------------------------------ verify_payload() { # verify_payload DIR RECOVERY -> sets VERSION ROOT_HASH - 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" - # The manifest and its signature are read ONCE, into a directory only - # root can reach, and everything below - the signature check, the - # headers, the hash list the slot is verified against after the write - - # reads that copy. The payload directory is mutable and may be someone - # else's: verifying the file in place and then re-reading it for the - # version and the hashes left a window in which a replaced, unsigned - # manifest was accepted (reproduced with the real verifier). - SNAP="${SNAP:-$(mktemp -d /run/kryptik/update-manifest.XXXXXX 2>/dev/null || mktemp -d)}" - chmod 0700 "$SNAP" - 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)" - - # 2. Headers inside the signed bytes. - role="$(hdr "$m" role)"; VERSION="$(hdr "$m" version)"; count="$(hdr "$m" files)" - case "$role" in development|production) ;; *) die "manifest role '$role' is unknown" ;; esac - [ "$role" = "$(cat "$ROLE_FILE" 2>/dev/null || echo development)" ] \ - || die "manifest role is '$role'; this image requires '$(cat "$ROLE_FILE")'" - [ -n "$VERSION" ] || die "manifest has no version" - cur="$(running_version)" - if [ "$VERSION" = "$cur" ]; then - die "payload version $VERSION is the running version; nothing to apply" - fi - oldest="$(printf '%s\n%s\n' "$VERSION" "$cur" | sort -V | head -1)" - if [ "$oldest" = "$VERSION" ]; then - if [ "$recovery" = 1 ]; then - say "version $VERSION is OLDER than the running $cur: accepted because --recovery was given" - else - die "version $VERSION is older than the running $cur. Refusing a downgrade; -an older signed release can reintroduce a fixed defect. --recovery accepts it deliberately." - fi - fi + verify_manifest "$1" "$2" # 3. Every listed file, hash and size; and nothing unlisted. listed=0 @@ -193,6 +167,103 @@ EOF say "both kernels embed root hash $ROOT_HASH" } +# Steps 1 and 2: the signature over the manifest and the headers inside the +# signed bytes. `apply` runs them on a payload; `check-manifest` runs them on +# a manifest the update channel has been handed, before anything it lists is +# accepted. One function, so the two cannot come to disagree. +verify_manifest() { # verify_manifest DIR RECOVERY -> 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" + # The manifest and its signature are read ONCE, into a directory only + # root can reach, and everything below - the signature check, the + # headers, the hash list the slot is verified against after the write - + # reads that copy. The payload directory is mutable and may be someone + # else's: verifying the file in place and then re-reading it for the + # version and the hashes left a window in which a replaced, unsigned + # manifest was accepted (reproduced with the real verifier). + SNAP="${SNAP:-$(mktemp -d /run/kryptik/update-manifest.XXXXXX 2>/dev/null || mktemp -d)}" + chmod 0700 "$SNAP" + 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" + [ "$(head -1 "$m")" = "$MAGIC" ] || die "manifest is not a $MAGIC" + + # 1. Signature, by an enrolled key, over the manifest bytes. + verify_signed "$m" "$sig" "$NAMESPACE" manifest + + # 2. Headers inside the signed bytes. + role="$(hdr "$m" role)"; VERSION="$(hdr "$m" version)"; count="$(hdr "$m" files)" + case "$role" in development|production) ;; *) die "manifest role '$role' is unknown" ;; esac + [ "$role" = "$(cat "$ROLE_FILE" 2>/dev/null || echo development)" ] \ + || die "manifest role is '$role'; this image requires '$(cat "$ROLE_FILE")'" + [ -n "$VERSION" ] || die "manifest has no version" + cur="$(running_version)" + if [ "$VERSION" = "$cur" ]; then + die "payload version $VERSION is the running version; nothing to apply" + fi + oldest="$(printf '%s\n%s\n' "$VERSION" "$cur" | sort -V | head -1)" + if [ "$oldest" = "$VERSION" ]; then + if [ "$recovery" = 1 ]; then + say "version $VERSION is OLDER than the running $cur: accepted because --recovery was given" + else + die "version $VERSION is older than the running $cur. Refusing a downgrade; +an older signed release can reintroduce a fixed defect. --recovery accepts it deliberately." + fi + fi +} + +# 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. +verify_signed() { # verify_signed FILE SIG NAMESPACE WHAT + [ -f "$SIGNERS" ] || die "no trust anchor at $SIGNERS in this image" + principal="$(ssh-keygen -Y find-principals -s "$2" -f "$SIGNERS" 2>/dev/null | head -1 || true)" + [ -n "$principal" ] || die "the $4's signing key is not enrolled in $SIGNERS" + ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$3" -s "$2" < "$1" >/dev/null 2>&1 \ + || die "the $4 signature does NOT verify (principal $principal)" + say "signature verifies (signed by $principal)" +} + +# 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 + case "$size" in ''|*[!0-9]*) die "$rel: '$size' is not a size" ;; esac + n=$((n + 1)) + done < Date: Sat, 19 Sep 2026 17:26:11 -0700 Subject: [PATCH 07/25] What zone 0 keeps for the update channel, and what each of the three verbs does with it The stateful half of update.rs, still not wired to the broker. Under /var/lib/kryptik/update, root's alone: the newest statement accepted, when one was last looked at, the version the person asked for, the verified manifest's file list, and the staging directory `apply` will be given. latest: one statement is looked at per hour, whoever sent it; its signature is checked before a word of it is parsed, and it replaces the stored one only if it is for this image's role and not older. want: the person asks; nothing is fetched that was not asked for. poll: what is wanted, the base address from the verified statement, and what is still missing and from which byte, or idle. put: the manifest and its signature first; when both are there they are verified, held to the hash the statement announced and measured against the room there is, and a refusal throws both away. Only then is a listed file taken, in pieces of at most 1 MiB, each appended at exactly the offset held. The two signature checks are passed in as functions, so the tests stand in for kryptik-update and run against a directory of their own: a statement that does not verify, the interval, a replay, the whole staging order with a dropped connection in the middle, a manifest with another hash, another version, a size nothing could hold, and no signature. The staged directory ends up holding exactly what `apply` accepts. Thirteen tests. --- compartments/kryptikd/src/update.rs | 425 +++++++++++++++++++++++++++- 1 file changed, 424 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/update.rs b/compartments/kryptikd/src/update.rs index 6597af0..cc5a156 100644 --- a/compartments/kryptikd/src/update.rs +++ b/compartments/kryptikd/src/update.rs @@ -11,6 +11,9 @@ //! signed manifest provides for, at the place it belongs. use std::cmp::Ordering; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; pub const POINTER_MAGIC: &str = "KRYPTIK-LATEST-1"; /// The pointer and its signature, each. @@ -190,7 +193,7 @@ pub fn parse_file_list(text: &str) -> Result, String> { } pub fn total_bytes(files: &[Entry]) -> u64 { - files.iter().map(|e| e.size).sum() + files.iter().fold(0, |sum, e| sum.saturating_add(e.size)) } /// Whether `len` bytes offered for `name` at `offset` may be written, given @@ -229,6 +232,304 @@ pub fn still_needed(files: &[Entry], held: impl Fn(&str) -> u64) -> Vec<(String, files.iter().filter_map(|e| { let h = held(&e.name); (h < e.size).then(|| (e.name.clone(), h)) }).collect() } +// --- what zone 0 keeps, and what the broker's three verbs do with it ------- +// +// Under `STATE_DIR`, root's and nobody else's: +// +// pointer the newest statement accepted, as it was signed +// considered when a statement was last looked at (the rate limit) +// wanted the version the person asked for (`kryptik update fetch`) +// files `check-manifest`'s output for it, once it has verified +// incoming// the staged release: the directory `apply` is given +// +// Every function takes the directory and the two checks, so the tests run +// them against a temporary directory with checks of their own. + + +pub const STATE_DIR: &str = "/var/lib/kryptik/update"; +pub const TOOL: &str = "/usr/sbin/kryptik-update"; +pub const ROLE_FILE: &str = "/usr/share/kryptik/trust/required-role"; +pub const CONF: &str = "/etc/kryptik/update.conf"; +/// The most one `update-put` carries. A release crosses in pieces this size, +/// each one request the launcher answers between two looks at its zone, so +/// the zone's supervision is never further away than one piece. +pub const PUT_MAX: usize = 1 << 20; + +/// The two things only a signature can say, as functions so that a test can +/// stand in for `kryptik-update`. `pointer` verifies a statement and its +/// signature; `manifest` verifies the manifest and signature in a directory +/// and returns what `check-manifest` printed. +pub struct Checks<'a> { + pub pointer: &'a dyn Fn(&Path, &Path) -> Result<(), String>, + pub manifest: &'a dyn Fn(&Path) -> Result, +} + +fn run_tool(args: &[&std::ffi::OsStr]) -> Result { + let out = std::process::Command::new(TOOL) + .args(args) + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{TOOL}: {e}"))?; + if out.status.success() { + return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); + } + let err = String::from_utf8_lossy(&out.stderr); + Err(err.lines().last().unwrap_or("refused").trim_start_matches("kryptik-update: ").to_string()) +} + +/// The checks the installed system uses: `kryptik-update`, with the trust +/// anchor on the verified root and nothing from this process's environment. +pub fn tool_checks() -> Checks<'static> { + Checks { + pointer: &|p, s| run_tool(&["check-pointer".as_ref(), p.as_os_str(), s.as_os_str()]).map(|_| ()), + manifest: &|d| run_tool(&["check-manifest".as_ref(), d.as_os_str()]), + } +} + +pub fn required_role() -> String { + std::fs::read_to_string(ROLE_FILE).map(|s| s.trim().to_string()).unwrap_or_else(|_| "development".into()) +} + +pub fn running_version() -> String { + let text = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); + text.lines().find_map(|l| l.strip_prefix("VERSION_ID=")).map(|v| v.trim_matches('"').to_string()).unwrap_or_default() +} + +/// `channel =
` from the configuration on the verified root. +pub fn channel_from(conf: &str) -> Option { + conf.lines().find_map(|l| { + let (k, v) = l.split_once('=')?; + (k.trim() == "channel" && !v.trim().is_empty()).then(|| v.trim().to_string()) + }) +} + +fn private_dir(p: &Path) -> Result<(), String> { + match std::fs::DirBuilder::new().recursive(true).mode(0o700).create(p) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(format!("{}: {e}", p.display())), + } +} + +/// Written whole and renamed into place, readable by root alone. +fn put_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension("tmp"); + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&tmp) + .map_err(|e| format!("{}: {e}", tmp.display()))?; + f.write_all(bytes).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display())) +} + +fn stored_pointer(dir: &Path) -> Option { + parse_pointer(&std::fs::read_to_string(dir.join("pointer")).ok()?).ok() +} + +fn wanted(dir: &Path) -> Option { + let v = std::fs::read_to_string(dir.join("wanted")).ok()?.trim().to_string(); + is_version(&v).then_some(v) +} + +fn staging(dir: &Path, version: &str) -> PathBuf { + dir.join("incoming").join(version) +} + +fn held(stage: &Path, name: &str) -> u64 { + std::fs::symlink_metadata(stage.join(name)).ok().filter(|m| m.is_file()).map_or(0, |m| m.len()) +} + +fn verified_files(dir: &Path, version: &str) -> Option> { + let text = std::fs::read_to_string(dir.join("files")).ok()?; + (text.lines().next() == Some(&format!("version: {version}"))).then(|| parse_file_list(&text).ok()).flatten() +} + +/// `update-latest`: a statement of what is current and its signature, from +/// the net zone. One is looked at per interval, whatever becomes of it, so a +/// hostile zone cannot make zone 0 verify signatures all day. +pub fn latest(dir: &Path, checks: &Checks, now: i64, role: &str, running: &str, pointer: &[u8], sig: &[u8]) -> Result { + private_dir(dir)?; + let last: Option = std::fs::read_to_string(dir.join("considered")).ok().and_then(|s| s.trim().parse().ok()); + if last.is_some_and(|t| (now - t).unsigned_abs() < POINTER_INTERVAL_SECS) { + return Err(format!("one statement is considered every {} minutes", POINTER_INTERVAL_SECS / 60)); + } + put_file(&dir.join("considered"), now.to_string().as_bytes())?; + let text = std::str::from_utf8(pointer).map_err(|_| "the pointer is not text".to_string())?; + // What it says is judged only after who said it: a parse error must not + // tell an unsigned sender anything a signed one would not also see. + let scratch = dir.join("checking"); + let _ = std::fs::remove_dir_all(&scratch); + private_dir(&scratch)?; + let verdict = put_file(&scratch.join("latest"), pointer) + .and_then(|_| put_file(&scratch.join("latest.sig"), sig)) + .and_then(|_| (checks.pointer)(&scratch.join("latest"), &scratch.join("latest.sig"))); + let _ = std::fs::remove_dir_all(&scratch); + verdict?; + let p = parse_pointer(text)?; + let standing = accept_pointer(&p, role, running, stored_pointer(dir).map(|q| q.issued))?; + put_file(&dir.join("pointer"), pointer)?; + Ok(standing) +} + +/// `kryptik update fetch`: the person asks for the release the newest +/// accepted statement names. Nothing is fetched that was not asked for. +pub fn want(dir: &Path, running: &str) -> Result { + let p = stored_pointer(dir).ok_or("no statement of what is current has been accepted yet")?; + if version_cmp(&p.version, running) != Ordering::Greater { + return Err(format!("{} is the newest release known, and this machine runs {running}", p.version)); + } + if wanted(dir).as_deref() != Some(p.version.as_str()) { + let _ = std::fs::remove_file(dir.join("files")); + let _ = std::fs::remove_dir_all(dir.join("incoming")); + } + put_file(&dir.join("wanted"), p.version.as_bytes())?; + Ok(p.version) +} + +/// What is wanted, where from, and what of it is still missing: `None` when +/// nothing is, which the broker says as `idle`. +fn outstanding(dir: &Path, channel: &str, role: &str, running: &str) -> Option<(Pointer, String, Vec<(String, u64)>)> { + let version = wanted(dir)?; + let p = stored_pointer(dir).filter(|p| p.version == version)?; + if version_cmp(&version, running) != Ordering::Greater { + return None; + } + let base = resolve_base(channel, &p.base, role).ok()?; + let stage = staging(dir, &version); + let need = match verified_files(dir, &version) { + Some(files) => still_needed(&files, |n| held(&stage, n)), + None => ["manifest", "manifest.sig"].iter().filter(|n| held(&stage, n) == 0).map(|n| (n.to_string(), 0)).collect(), + }; + Some((p, base, need)) +} + +/// `update-poll`: the net zone asks, because nothing can call it. +pub fn poll(dir: &Path, channel: &str, role: &str, running: &str) -> String { + match outstanding(dir, channel, role, running) { + Some((p, base, need)) if !need.is_empty() => { + let list: Vec = need.iter().map(|(n, o)| format!("{n} {o}")).collect(); + format!("fetch {} {base} need {}", p.version, list.join(" ")) + } + _ => "idle".into(), + } +} + +fn free_bytes(path: &Path) -> Option { + use std::os::unix::ffi::OsStrExt; + let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + (unsafe { libc::statvfs(c.as_ptr(), &mut st) } == 0).then(|| st.f_bavail as u64 * st.f_frsize as u64) +} + +/// `update-put`: bytes for the release that is wanted, under `may_put`'s +/// rule. When the manifest and its signature are both there they are +/// verified, held to the hash the accepted pointer announced, and measured +/// against the room there is; only then is anything they list accepted. +pub fn put(dir: &Path, checks: &Checks, name: &str, offset: u64, bytes: &[u8]) -> Result { + let version = wanted(dir).ok_or("no release has been asked for")?; + let p = stored_pointer(dir).filter(|p| p.version == version).ok_or("the release asked for is not the one the newest statement names")?; + let stage = staging(dir, &version); + let files = verified_files(dir, &version); + may_put(files.as_deref(), name, offset, bytes.len() as u64, held(&stage, name))?; + private_dir(&stage)?; + let path = stage.join(name); + if files.is_none() { + let _ = std::fs::remove_file(&path); + } + let mut f = std::fs::OpenOptions::new().append(true).create(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&path) + .map_err(|e| format!("{name}: {e}"))?; + f.write_all(bytes).map_err(|e| format!("{name}: {e}"))?; + drop(f); + if let Some(files) = files { + let size = files.iter().find(|e| e.name == name).map_or(0, |e| e.size); + let have = held(&stage, name); + return Ok(if have == size { format!("{name} complete") } else { format!("{name} {have}/{size}") }); + } + if held(&stage, "manifest") == 0 || held(&stage, "manifest.sig") == 0 { + return Ok(format!("{name} complete")); + } + let refuse = |why: String| -> Result { + let _ = std::fs::remove_dir_all(&stage); + Err(why) + }; + let listing = match (checks.manifest)(&stage) { + Ok(l) => l, + Err(why) => return refuse(why), + }; + if listing.lines().next() != Some(&format!("version: {version}")) { + return refuse(format!("the manifest is not for {version}")); + } + if !listing.lines().any(|l| l.strip_prefix("sha256: ") == Some(p.manifest_sha256.as_str())) { + return refuse("the manifest is not the one the statement of what is current announced".into()); + } + let files = match parse_file_list(&listing) { + Ok(f) => f, + Err(why) => return refuse(why), + }; + let (need, free) = (total_bytes(&files), free_bytes(&stage).unwrap_or(0)); + if need > free { + return refuse(format!("the release is {need} bytes and there is room for {free}")); + } + put_file(&dir.join("files"), listing.as_bytes())?; + Ok(format!("{name} complete; the manifest verifies, {} file(s), {need} bytes", files.len())) +} + +/// `kryptik update status`, as lines for a person. +pub fn status(dir: &Path, now: i64, running: &str) -> String { + let mut out = format!("running {running}\n"); + match stored_pointer(dir) { + None => out.push_str("newest unknown: no statement of what is current has been accepted\n"), + Some(p) => { + let (days, stale) = staleness(now, p.issued); + out.push_str(&format!("newest {} (stated {days} day(s) ago)\n", p.version)); + if stale { + out.push_str(&format!( + " no statement from the release key for {days} days: either nothing has been published,\n or something is keeping it from this machine\n" + )); + } + } + } + match wanted(dir) { + None => out.push_str("staged nothing asked for\n"), + Some(v) => { + let stage = staging(dir, &v); + match verified_files(dir, &v) { + None => out.push_str(&format!("staged {v}: waiting for its manifest\n")), + Some(files) => { + let have: u64 = files.iter().map(|e| held(&stage, &e.name).min(e.size)).sum(); + let total = total_bytes(&files); + let word = if have == total { "complete; `kryptik update apply` installs it" } else { "arriving" }; + out.push_str(&format!("staged {v}: {have} of {total} bytes, {word}\n")); + } + } + } + } + out +} + +/// The staged release's directory when every byte of it has arrived: what +/// `kryptik update apply` hands to `kryptik-update apply`. +pub fn complete_stage(dir: &Path) -> Result { + let v = wanted(dir).ok_or("no release has been asked for")?; + let files = verified_files(dir, &v).ok_or_else(|| format!("{v}: its manifest has not arrived"))?; + let stage = staging(dir, &v); + match still_needed(&files, |n| held(&stage, n)).first() { + None => Ok(stage), + Some((name, at)) => Err(format!("{v}: {name} has {at} bytes so far; the release is still arriving")), + } +} + +/// Once the machine runs what was staged, the staging area has no job. +pub fn forget_if_installed(dir: &Path, running: &str) { + if wanted(dir).is_some_and(|v| version_cmp(&v, running) != Ordering::Greater) { + for f in ["wanted", "files"] { + let _ = std::fs::remove_file(dir.join(f)); + } + let _ = std::fs::remove_dir_all(dir.join("incoming")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -350,4 +651,126 @@ mod tests { ); assert!(still_needed(&files(), |_| u64::MAX).is_empty()); } + + // --- the state, against a directory of the test's own --- + + fn scratch(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("kryptik-update-test-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + d + } + + const LISTING: &str = "version: 1.0.3\nsha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\nfile 10 kryptik-root.img\nfile 4 root.json\n"; + + fn yes() -> Checks<'static> { + Checks { pointer: &|_, _| Ok(()), manifest: &|_| Ok(LISTING.to_string()) } + } + + const T0: i64 = 1_800_000_000; + const CH: &str = "https://updates.example/stable"; + + #[test] + fn a_statement_is_stored_only_when_it_verifies_is_new_and_is_due() { + let d = scratch("latest"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + let no = Checks { pointer: &|_, _| Err("the pointer signature does NOT verify".into()), manifest: &|_| Err("unused".into()) }; + assert!(latest(&d, &no, T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap_err().contains("does NOT verify")); + assert!(stored_pointer(&d).is_none(), "an unverified statement was stored"); + // Looking at that one used the interval up, whoever sent it. + assert!(latest(&d, &yes(), T0 + 60, "production", "1.0.2", p.as_bytes(), b"sig").unwrap_err().contains("every 60 minutes")); + let t1 = T0 + POINTER_INTERVAL_SECS as i64; + assert_eq!(latest(&d, &yes(), t1, "production", "1.0.2", p.as_bytes(), b"sig"), Ok(Standing::Available("1.0.3".into()))); + assert_eq!(stored_pointer(&d).unwrap().version, "1.0.3"); + assert!(!d.join("checking").exists(), "the scratch copy outlived the check"); + // Last year's statement, validly signed, an interval later: a replay. + let old = pointer_text("1.0.1", "2026-03-02T14:05:00Z"); + let t2 = t1 + POINTER_INTERVAL_SECS as i64; + assert!(latest(&d, &yes(), t2, "production", "1.0.2", old.as_bytes(), b"sig").unwrap_err().contains("replay")); + assert_eq!(stored_pointer(&d).unwrap().version, "1.0.3"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_release_is_staged_in_the_order_that_bounds_it() { + let d = scratch("stage"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + // Nothing asked for: nothing polled for, nothing taken. + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle"); + assert!(want(&d, "1.0.2").unwrap_err().contains("no statement")); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle", "fetching began before the person asked"); + assert!(put(&d, &yes(), "manifest", 0, b"m").unwrap_err().contains("no release has been asked for")); + assert_eq!(want(&d, "1.0.2").unwrap(), "1.0.3"); + assert!(want(&d, "1.0.3").unwrap_err().contains("newest release known")); + + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest 0 manifest.sig 0"); + assert!(put(&d, &yes(), "kryptik-root.img", 0, b"0123456789").unwrap_err().contains("before the manifest")); + assert_eq!(put(&d, &yes(), "manifest", 0, b"the manifest").unwrap(), "manifest complete"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest.sig 0"); + assert!(put(&d, &yes(), "manifest.sig", 0, b"its signature").unwrap().contains("the manifest verifies, 2 file(s), 14 bytes")); + + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need kryptik-root.img 0 root.json 0"); + assert!(put(&d, &yes(), "manifest", 0, b"another").unwrap_err().contains("not replaced")); + assert!(put(&d, &yes(), "stowaway", 0, b"x").unwrap_err().contains("does not list")); + assert_eq!(put(&d, &yes(), "kryptik-root.img", 0, b"01234").unwrap(), "kryptik-root.img 5/10"); + // The connection dropped; the net zone is told where to resume, and + // anything else is refused without a byte being written. + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need kryptik-root.img 5 root.json 0"); + assert!(put(&d, &yes(), "kryptik-root.img", 0, b"01234").unwrap_err().contains("5 bytes are held")); + assert!(put(&d, &yes(), "kryptik-root.img", 5, b"567890").unwrap_err().contains("past that")); + assert!(complete_stage(&d).unwrap_err().contains("still arriving")); + assert_eq!(put(&d, &yes(), "kryptik-root.img", 5, b"56789").unwrap(), "kryptik-root.img complete"); + assert_eq!(put(&d, &yes(), "root.json", 0, b"{ }").unwrap(), "root.json complete"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "idle"); + let stage = complete_stage(&d).unwrap(); + assert_eq!(std::fs::read(stage.join("kryptik-root.img")).unwrap(), b"0123456789"); + let mut names: Vec = std::fs::read_dir(&stage).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + names.sort(); + assert_eq!(names, ["kryptik-root.img", "manifest", "manifest.sig", "root.json"], "apply refuses a directory holding anything else"); + assert!(status(&d, T0, "1.0.2").contains("1.0.3: 14 of 14 bytes, complete")); + + // Once the machine runs it, the staging area is gone. + forget_if_installed(&d, "1.0.2"); + assert!(stage.exists()); + forget_if_installed(&d, "1.0.3"); + assert!(!stage.exists() && wanted(&d).is_none()); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_manifest_that_is_not_the_one_announced_is_thrown_away() { + for (tag, listing, why) in [ + ("hash", LISTING.replace("sha256: 0", "sha256: f"), "announced"), + ("version", LISTING.replace("version: 1.0.3", "version: 1.0.4"), "not for 1.0.3"), + ("room", LISTING.replace("file 10 ", "file 18446744073709551000 "), "there is room for"), + ] { + let d = scratch(tag); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + want(&d, "1.0.2").unwrap(); + let listing_for = move |_: &Path| Ok::(listing.clone()); + let checks = Checks { pointer: &|_, _| Ok(()), manifest: &listing_for }; + put(&d, &checks, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &checks, "manifest.sig", 0, b"s").unwrap_err().contains(why), "{tag}"); + assert!(!staging(&d, "1.0.3").exists(), "{tag}: the refused manifest was kept"); + assert_eq!(poll(&d, CH, "production", "1.0.2"), "fetch 1.0.3 https://updates.example/stable/1.0.3/ need manifest 0 manifest.sig 0", "{tag}"); + let _ = std::fs::remove_dir_all(&d); + } + let d = scratch("unsigned"); + let p = pointer_text("1.0.3", "2027-03-02T14:05:00Z"); + latest(&d, &yes(), T0, "production", "1.0.2", p.as_bytes(), b"sig").unwrap(); + want(&d, "1.0.2").unwrap(); + let no = Checks { pointer: &|_, _| Ok(()), manifest: &|_| Err("the manifest signature does NOT verify".into()) }; + put(&d, &no, "manifest", 0, b"m").unwrap(); + assert!(put(&d, &no, "manifest.sig", 0, b"s").unwrap_err().contains("does NOT verify")); + assert!(put(&d, &no, "kryptik-root.img", 0, b"x").unwrap_err().contains("before the manifest")); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_channel_address_is_read_from_the_configuration() { + assert_eq!(channel_from("# where releases are\nchannel = https://updates.example/stable\n").as_deref(), Some(CH)); + assert_eq!(channel_from("channel =\n"), None); + assert_eq!(channel_from("interval = 1\n"), None); + } } From a2c38e53b1694f0eb1a8128d7e50b78e134fd780 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:31:41 -0700 Subject: [PATCH 08/25] The update channel's verbs on the broker, and `kryptik update` for the person Three verbs on the zone-facing socket, taken from the zone that holds the network and from no other, like time-offset: update-latest a statement of what is current and its signature, at most 8 KiB each update-poll idle, or what is wanted, from where, and what is still missing and from which byte update-put one piece of the release, at most 1 MiB Who is asking is settled before a byte of payload is read. A release crosses in pieces, each one request answered between two looks at the zone, so supervision is never further away than one piece; the log gets a line for what ends something, not for each of some thousands of pieces. What is believed and what is stored is update.rs's decision, and every signature is kryptik-update's. For the person, through the launch service like `kryptik wifi`: `kryptik update status` (the release running, the newest known and how old that news is, what is staged), `fetch` (the asking without which the net zone is told idle) and `apply`, which hands the staged directory to kryptik-update apply: it verifies all of it again before it writes a slot, and the trial boot and the fallback are the ones that exist. Tests: the verbs' grammar and bounds and the refusal by identity as unit tests (202 pass); five rows in the boundary suite, against a real zone, for the three verbs from a zone that does not hold the network and two requests outside the grammar. The serve and cli suites pass unchanged. --- .../kryptikd/probes/boundary-checks.sh | 14 ++ compartments/kryptikd/src/broker.rs | 136 ++++++++++++++++++ compartments/kryptikd/src/serve.rs | 38 +++++ tools/desktop/kryptik-launch.c | 23 ++- tools/kryptik | 17 +++ 5 files changed, 227 insertions(+), 1 deletion(-) diff --git a/compartments/kryptikd/probes/boundary-checks.sh b/compartments/kryptikd/probes/boundary-checks.sh index 1e23667..1a6155c 100755 --- a/compartments/kryptikd/probes/boundary-checks.sh +++ b/compartments/kryptikd/probes/boundary-checks.sh @@ -244,6 +244,20 @@ MATCH="does not hold the network" check "the clock's verb is refused from a zone " MATCH="is not an offset in seconds" check "a time claim outside the grammar is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "time-offset 1e9 4 " +# The update channel (docs/design/update-channel.md): a release is brought by +# the zone that holds the network and by no other. From this zone all three +# verbs are refused by who is asking, the two that carry bytes before a byte +# of them is read, and a request outside the grammar before that. +MATCH="does not hold the network" check "a statement of what is current is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-latest 5 3 +helloabc" +MATCH="does not hold the network" check "asking whether a release is wanted is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-poll +" +MATCH="does not hold the network" check "a piece of a release is refused from a zone that does not hold the network" 0 /usr/bin/python3 -c "$BRK" "update-put kryptik-root.img 0 5 +hello" +MATCH="must be a single path component" check "a piece of a release named with a path is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "update-put ../kryptik-root.img 0 5 +hello" +MATCH="is not 1 to 1048576 bytes" check "a piece of a release larger than one piece is refused at parse time" 0 /usr/bin/python3 -c "$BRK" "update-put kryptik-root.img 0 1048577 +" MATCH="^ok text/plain 5 hello$" check "clipboard-set then clipboard-get round-trips" 0 /bin/sh -c "python3 -c '$BRK' 'clipboard-set text/plain 5 hello' >/dev/null && python3 -c '$BRK' 'clipboard-get '" diff --git a/compartments/kryptikd/src/broker.rs b/compartments/kryptikd/src/broker.rs index 6520a6a..696833c 100755 --- a/compartments/kryptikd/src/broker.rs +++ b/compartments/kryptikd/src/broker.rs @@ -181,6 +181,10 @@ const REQUEST_DEADLINE: Duration = Duration::from_secs(5); /// clipboard-get\n -> ok \n | empty\n /// time-offset \n -> ok ignored | slewed | stepped | stepped after consent\n /// (from the zone that holds the network, and no other) +/// update-latest \n -> ok current | ok available \n +/// update-poll\n -> idle | fetch need ...\n +/// update-put \n -> ok / | ok complete\n +/// (the same zone, and no other) /// anything else -> error: \n /// ``` #[derive(Debug, PartialEq)] @@ -195,6 +199,13 @@ pub enum Request { /// `time-offset `: the net zone's claim about how far /// the machine's clock is from the network's (docs/design/time.md). TimeOffset(crate::time::Claim), + /// The update channel's three verbs (docs/design/update-channel.md), from + /// the zone that holds the network and no other. `update-latest` is + /// followed by the pointer and then its signature, `update-put` by `len` + /// bytes of the named file. + UpdateLatest { plen: usize, slen: usize }, + UpdatePoll, + UpdatePut { name: String, offset: u64, len: usize }, Unknown(String), } @@ -257,6 +268,25 @@ pub fn parse_request(line: &str) -> Result { ("transfer", _) => Err("usage: transfer , with the file as one SCM_RIGHTS descriptor".into()), ("time-offset", [secs, sources]) => crate::time::parse_claim(&format!("{secs} {sources}")).map(Request::TimeOffset), ("time-offset", _) => Err("usage: time-offset ".into()), + ("update-latest", [plen, slen]) => { + let size = |w: &str, what: &str| match w.parse::() { + Ok(n) if (1..=crate::update::POINTER_MAX).contains(&n) => Ok(n), + _ => Err(format!("{what} length {w:?} is not 1 to {} bytes", crate::update::POINTER_MAX)), + }; + Ok(Request::UpdateLatest { plen: size(plen, "pointer")?, slen: size(slen, "signature")? }) + } + ("update-latest", _) => Err("usage: update-latest , then the two".into()), + ("update-poll", []) => Ok(Request::UpdatePoll), + ("update-poll", _) => Err("usage: update-poll".into()), + ("update-put", [name, offset, len]) => { + check_transfer_name(name)?; + let offset: u64 = offset.parse().map_err(|_| format!("bad offset {offset:?}"))?; + match len.parse::() { + Ok(len) if (1..=crate::update::PUT_MAX).contains(&len) => Ok(Request::UpdatePut { name: name.to_string(), offset, len }), + _ => Err(format!("length {len:?} is not 1 to {} bytes", crate::update::PUT_MAX)), + } + } + ("update-put", _) => Err("usage: update-put , then the bytes".into()), ("", _) => Err("empty request".into()), _ => Ok(Request::Unknown(verb.to_string())), } @@ -281,6 +311,44 @@ fn handle_time_offset(zone: &Zone, claim: &crate::time::Claim) -> crate::time::O ) } +/// UPDATES +/// +/// The zone that holds the network hands over a statement of what is +/// current, asks whether a release is wanted, and streams one in pieces +/// (docs/design/update-channel.md). Everything it sends is a hostile zone's +/// word: `update.rs` decides what is believed and what is stored, and +/// `kryptik-update` verifies every signature. What is decided here is only +/// who may speak: that one zone, like `time-offset`, because no other zone +/// has anywhere to have fetched a release from. +fn update_refusal(zone: &Zone) -> Option { + (zone.network != NetworkMode::Nic) + .then(|| format!("zone {:?} does not hold the network; only the zone that does may bring an update", zone.name)) +} + +/// For a zone `update_refusal` has passed, with the request's whole payload. +fn handle_update(req: &Request, payload: &[u8]) -> Result { + use crate::update as up; + let dir = Path::new(up::STATE_DIR); + let (role, running) = (up::required_role(), up::running_version()); + match req { + Request::UpdateLatest { plen, .. } => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); + let (pointer, sig) = payload.split_at(*plen); + up::latest(dir, &up::tool_checks(), now, &role, &running, pointer, sig).map(|s| match s { + up::Standing::Current => "ok current".to_string(), + up::Standing::Available(v) => format!("ok available {v}"), + }) + } + Request::UpdatePoll => { + up::forget_if_installed(dir, &running); + let conf = std::fs::read_to_string(up::CONF).unwrap_or_default(); + Ok(up::channel_from(&conf).map_or("idle".to_string(), |channel| up::poll(dir, &channel, &role, &running))) + } + Request::UpdatePut { name, offset, .. } => up::put(dir, &up::tool_checks(), name, *offset, payload).map(|r| format!("ok {r}")), + _ => Err("not an update verb".into()), + } +} + /// The same with the clock, the state directory and the floor named, which /// is what the tests do. fn time_offset_in( @@ -750,6 +818,33 @@ pub fn serve_connection(fd: RawFd, s: &Served) -> io::Result> { done => reply(fd, &format!("{}\n", done.reply())), } } + Ok(req @ (Request::UpdateLatest { .. } | Request::UpdatePoll | Request::UpdatePut { .. })) => { + let len = match &req { + Request::UpdateLatest { plen, slen } => plen + slen, + Request::UpdatePut { len, .. } => *len, + _ => 0, + }; + // Who is asking is settled before a byte of payload is read. + let outcome = match update_refusal(s.zone) { + Some(why) => Err(why), + None => match read_more(fd, &mut rest, len, started) { + Err(e) => Err(format!("payload: {e}")), + Ok(()) if rest.len() < len => Err(format!("payload short: {} of {len} bytes", rest.len())), + Ok(()) => handle_update(&req, &rest[..len]), + }, + }; + // A release is some thousands of pieces; the log gets a line for + // what ends something, not for every piece. + match &outcome { + Ok(r) if verb == "update-poll" || (verb == "update-put" && !r.contains("complete")) => {} + Ok(r) => crate::spawn::log_line(&format!("kryptikd[zone {zone}]: {verb}: {r}")), + Err(why) => crate::spawn::log_line(&format!("kryptikd[zone {zone}]: {verb}: refused: {why}")), + } + match outcome { + Ok(r) => reply(fd, &format!("{r}\n")), + Err(why) => reply(fd, &format!("error: {why}\n")), + } + } Ok(Request::ClipboardGet) => match clipboard_read(entry) { Ok(Some((mime, bytes))) => { reply(fd, &format!("ok {mime} {}\n", bytes.len())); @@ -1555,6 +1650,47 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The update channel's verbs on the wire: lengths within their bounds, + /// a name that is one path component, and nothing else. + #[test] + fn the_update_verbs_parse_within_their_bounds() { + use crate::update::{POINTER_MAX, PUT_MAX}; + assert_eq!(parse_request("update-latest 300 120"), Ok(Request::UpdateLatest { plen: 300, slen: 120 })); + assert_eq!(parse_request("update-poll"), Ok(Request::UpdatePoll)); + assert_eq!( + parse_request(&format!("update-put kryptik-root.img 1048576 {PUT_MAX}")), + Ok(Request::UpdatePut { name: "kryptik-root.img".into(), offset: 1048576, len: PUT_MAX }) + ); + assert!(parse_request("update-put manifest.sig 0 120").is_ok()); + let over_pointer = format!("update-latest {} 120", POINTER_MAX + 1); + let over_put = format!("update-put root.json 0 {}", PUT_MAX + 1); + for bad in [ + "update-latest", "update-latest 300", "update-latest 0 120", "update-latest 300 0", "update-latest -1 120", over_pointer.as_str(), + "update-poll now", + "update-put", "update-put root.json 0", "update-put root.json 0 0", "update-put root.json -1 10", "update-put root.json x 10", + "update-put ../root.json 0 10", "update-put a/b 0 10", "update-put .hidden 0 10", over_put.as_str(), + ] { + assert!(parse_request(bad).is_err(), "{bad:?} was accepted"); + } + } + + /// An update is brought by the zone that holds the network and by no + /// other: a zone with no network has nowhere to have fetched one from, + /// and a routed zone's would be the net zone's at one remove. + #[test] + fn only_the_zone_that_holds_the_network_may_bring_an_update() { + let zone_of = |mode: &str, extra: &str| { + Zone::from_str(&format!( + "[zone]\nname = \"t\"\n[network]\nmode = \"{mode}\"\n{extra}[storage]\nmode = \"ephemeral\"\nsize = \"64M\"\n[ui]\nborder_color = \"#123456\"\n" + )) + .unwrap() + }; + for mode in ["none", "routed"] { + assert!(update_refusal(&zone_of(mode, "")).is_some_and(|w| w.contains("does not hold the network")), "{mode}"); + } + assert_eq!(update_refusal(&zone_of("nic", "bridge = \"kryptik0\"\n")), None); + } + #[test] fn a_zone_with_no_identity_can_never_be_identified() { // "legacy" has no uid_base: nothing maps to it, so the broker can diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index 0956fda..d4e3621 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1056,6 +1056,44 @@ fn handle(cfg: &ServeConfig, conn: UnixStream) -> Option { } eprintln!("kryptikd serve: uid {uid} stopped zone {zone:?}"); } + // The update channel, from the person's side (update.rs). `fetch` is + // the asking without which the net zone is told `idle`; `apply` hands + // the staged directory to kryptik-update, which verifies all of it + // again before it writes a slot, and takes as long as that takes. + "update-status" | "update-fetch" | "update-apply" => { + use crate::update as up; + let dir = std::path::Path::new(up::STATE_DIR); + let running = up::running_version(); + let done = match verb { + "update-status" => { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64); + Ok(up::status(dir, now, &running)) + } + "update-fetch" => up::want(dir, &running).map(|v| format!("{v} will be fetched when the net zone next asks; `kryptik update status` shows it arriving\n")), + _ => up::complete_stage(dir).and_then(|stage| { + let out = std::process::Command::new(up::TOOL) + .arg("apply") + .arg(&stage) + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .stdin(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{}: {e}", up::TOOL))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + Err(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("kryptik-update apply failed").to_string()) + } + }), + }; + match done { + Ok(text) => { + eprintln!("kryptikd serve: uid {uid}: {verb}"); + reply(&conn, &format!("ok\n{text}")); + } + Err(e) => reply(&conn, &format!("error: {e}\n")), + } + } "wifi-list" => match crate::wifi::list(&cfg.wifi_dir) { Ok(names) => { let mut out = String::new(); diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index 602459e..f809408 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -15,6 +15,8 @@ * kryptik-launch --wifi-add SSID add one, or replace its passphrase; the passphrase * is one line on standard input, never an argument * kryptik-launch --wifi-forget SSID remove one + * kryptik-launch --update status|fetch|apply what release is current and staged; ask for + * it to be fetched; install what has arrived * * With a display, the zone's Wayland proxy (kryptik-wlproxy) is started * first if it is not already running, listening at @@ -275,7 +277,8 @@ static void usage(void) " kryptik-launch --runtime-dir\n" " kryptik-launch --clipboard-move FROM TO\n" " kryptik-launch --wifi-list | --wifi-add SSID | --wifi-forget SSID\n" - " (--wifi-add reads the passphrase from standard input)\n", stderr); + " (--wifi-add reads the passphrase from standard input)\n" + " kryptik-launch --update status|fetch|apply\n", stderr); exit(2); } @@ -362,6 +365,22 @@ static int wifi_main(int argc, char **argv) return ok ? 0 : 1; } +/* The update channel from the person's side: the daemon's update verbs + * (kryptikd's update.rs). The reply is `ok` on a line of its own and then + * text for the person, or one `error:` line. `apply` answers when + * kryptik-update has finished, which is as long as writing a slot takes. */ +static int update_main(int argc, char **argv) +{ + if (argc != 3 || (strcmp(argv[2], "status") != 0 && strcmp(argv[2], "fetch") != 0 && strcmp(argv[2], "apply") != 0)) + usage(); + char req[32]; + snprintf(req, sizeof req, "update-%s\n", argv[2]); + char *r = talk(req, -1); + int ok = strncmp(r, "ok\n", 3) == 0; + fputs(ok ? r + 3 : r, ok ? stdout : stderr); + return ok ? 0 : 1; +} + int main(int argc, char **argv) { int ask = 0, no_display = 0, pass_fd = -1, sep = -1; @@ -369,6 +388,8 @@ int main(int argc, char **argv) int i; if (argc >= 2 && strncmp(argv[1], "--wifi-", 7) == 0) return wifi_main(argc, argv); + if (argc >= 2 && strcmp(argv[1], "--update") == 0) + return update_main(argc, argv); if (argc == 4 && strcmp(argv[1], "--clipboard-move") == 0) { if (!ident_ok(argv[2]) || !ident_ok(argv[3])) usage(); diff --git a/tools/kryptik b/tools/kryptik index 79ea461..cf3b08c 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -84,6 +84,11 @@ USAGE: $PROG wifi add SSID add one, or change its passphrase (asked for on the terminal; never an argument) $PROG wifi forget SSID remove one + $PROG update status the release running, the newest one known + and how old that news is, what is staged + $PROG update fetch ask for the newest release to be fetched + $PROG update apply install what has arrived; the next boot + tries it once and falls back if it fails OPTIONS: --zones DIR override the zone directory (default: $ZONES_DIR) @@ -406,6 +411,17 @@ cmd_wifi() { esac } +# The update channel from the person's side. It exists on an installed system +# only: the state is zone 0's and the launch service is what reaches it. +cmd_update() { + case "${1:-}" in + status|fetch|apply) [[ $# -eq 1 ]] || die "$PROG update $1 takes no argument" 2 ;; + *) usage >&2; die "update needs a subcommand: status, fetch or apply" 2 ;; + esac + via_launch || die "update goes through the launch service, which is not answering here (an installed Kryptik, as a session user)" + "$LAUNCH" --update "$1" +} + # --- argument parsing ------------------------------------------------------- load_conf @@ -433,6 +449,7 @@ case "$cmd" in gc) cmd_gc ;; doctor) cmd_doctor ;; wifi) cmd_wifi "$@" ;; + update) cmd_update "$@" ;; ""|help) usage ;; # Named explicitly rather than falling into "unknown command", because # these are the things people will reasonably expect to exist. From 174995f9efca324a3367a4bcd1987d3dd7d3f7fe Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:36:26 -0700 Subject: [PATCH 09/25] The net zone fetches for the update channel: a pipe with a Range header, and nothing more tools/net/update-fetch.py is the net zone's half. `latest` brings the signed statement of what is current and its signature to zone 0; `poll` asks whether a release is wanted and streams what zone 0 says is still missing, from the byte zone 0 names, in pieces of at most 1 MiB. It decides nothing and holds nothing: the address comes from zone 0's configuration and then from the statement zone 0 verified, a piece zone 0 refuses ends the run and is not sent again, and a server that ignores Range has the bytes before the offset dropped rather than sent. netzone-init.sh runs it in the background, one at a time: the statement every half hour until zone 0 has taken one and daily after that, the question "is a release wanted?" every minute, which is one line on a local socket and is how `kryptik update fetch` is noticed. With no /etc/kryptik/update.conf there is no channel and nobody is asked; the nic zone now sees that file read-only, like the time sources. Stage 04 installs the fetcher and compiles it under the target python. `make test-update-fetch`: a real HTTP server on loopback and a stand-in for zone 0's broker. A whole release byte for byte, the manifest first, no piece over 1 MiB, a download cut at a byte resumed from that byte with and without Range, a refusal, no channel, a dead host. Eleven rows. --- Makefile | 8 +- build/stages/04-base-system.sh | 4 +- compartments/kryptikd/src/rootfs.rs | 3 + tools/net/netzone-init.sh | 42 ++++++ tools/net/update-fetch.py | 135 +++++++++++++++++ tools/run-tests.sh | 1 + tools/test-update-fetch.sh | 216 ++++++++++++++++++++++++++++ 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100755 tools/net/update-fetch.py create mode 100755 tools/test-update-fetch.sh diff --git a/Makefile b/Makefile index 2025dc1..0069145 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,7 @@ CHROOT_RUN := $(SUDO) env $(CHROOT_ENV) "$(CHROOTD)" vm-disk vm-disk-boot vm-restart vm-measure cli-test update-tree-test identity-test serve-test \ test-harness test-hardening test-artifacts audit-artifacts test-boot-success \ audit-artifacts-strict manifest verify-manifest test-manifest \ - test-s6-init smoke-userspace test-services test-netzone-time test-update-verify test-libc-unwind \ + test-s6-init smoke-userspace test-services test-netzone-time test-update-verify test-update-fetch test-libc-unwind \ sign-image verify-image test-image-signing test-installer test-mkdisk-guards \ install-test \ image image-boot \ @@ -186,6 +186,7 @@ help: @echo " make test-services validate the s6-rc service tree" @echo " make test-netzone-time the net zone's time measurement, under every shell here" @echo " make test-update-verify what kryptik-update believes: a payload, a manifest, a pointer" + @echo " make test-update-fetch the net zone's update fetcher, against a local server and broker" @echo " make identity-test zone files, compositor colour table and zoneid audit agree" @echo " make test-libc-unwind prove the target libc can unwind (needs root)" @echo " make sign-image sign the disk image with a developer key" @@ -603,6 +604,11 @@ test-netzone-time: test-update-verify: @"$(TOOLS)"/test-update-manifest-snapshot.sh +# The net zone's half of the update channel: a faithful pipe, from the offsets +# zone 0 names, in pieces zone 0 takes, that stops when zone 0 says no. +test-update-fetch: + @"$(TOOLS)"/test-update-fetch.sh + # boot-success.sh's decision table (commit, refuse, fall back), driven on # the host with stand-ins for the services, the ESP and the firmware. test-boot-success: diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index 54772b2..ece176f 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -971,6 +971,8 @@ s_netzone() { # The SNTP query the net zone measures the clock with (docs/design/time.md). install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" /usr/libexec/kryptik/sntp-offset.py python3 -m py_compile /usr/libexec/kryptik/sntp-offset.py || { echo "sntp-offset.py does not compile under the target python"; return 1; } + install -D -m 0755 "${KRYPTIK_ROOT}/tools/net/update-fetch.py" /usr/libexec/kryptik/update-fetch.py + python3 -m py_compile /usr/libexec/kryptik/update-fetch.py || { echo "update-fetch.py does not compile under the target python"; return 1; } rm -rf /usr/libexec/kryptik/__pycache__ for t in dhcpcd nft dnsmasq ip; do command -v "$t" >/dev/null 2>&1 && echo " ok $t" || { echo " MISSING $t"; return 1; } @@ -2498,7 +2500,7 @@ PACKAGES=( # kryptik-update, which refuses to start without kryptik-efiboot. "efiboot" "s_efiboot $(sha256_of "${KRYPTIK_ROOT}/tools/efi/kryptik-efiboot.c" 2>/dev/null || echo none)" "updater" "s_updater $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-update" 2>/dev/null || echo none) $(sha256_of "${KRYPTIK_ROOT}/tools/update/kryptik-recover" 2>/dev/null || echo none)" - "netzone" "s_netzone $(sha256_of "${KRYPTIK_ROOT}/tools/net/netzone-init.sh" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" 2>/dev/null || echo none)" + "netzone" "s_netzone $(sha256_of "${KRYPTIK_ROOT}/tools/net/netzone-init.sh" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/sntp-offset.py" 2>/dev/null || echo none)-$(sha256_of "${KRYPTIK_ROOT}/tools/net/update-fetch.py" 2>/dev/null || echo none)" "installer" "s_installer $(sha256_of "${KRYPTIK_ROOT}/tools/install/kryptik-install.sh" 2>/dev/null || echo none)" # The path and the binary's content hash are arguments so that both are # part of this step's fingerprint; see s_kryptikd. diff --git a/compartments/kryptikd/src/rootfs.rs b/compartments/kryptikd/src/rootfs.rs index 309e0d4..73f86c1 100644 --- a/compartments/kryptikd/src/rootfs.rs +++ b/compartments/kryptikd/src/rootfs.rs @@ -281,6 +281,9 @@ pub const ETC_RO_FILES: &[&str] = &[ // The time sources the nic zone asks (docs/design/time.md): a list of // server names, no secret, and absent on a system that keeps the default. "/etc/kryptik/time.conf", + // Where the nic zone looks for releases (docs/design/update-channel.md): + // one address, no secret, and absent on a system with no channel. + "/etc/kryptik/update.conf", ]; pub const ETC_RO_DIRS: &[&str] = &["/etc/alternatives", "/etc/ssl/certs", "/etc/pki/tls/certs"]; diff --git a/tools/net/netzone-init.sh b/tools/net/netzone-init.sh index 1862cf0..7c87bc6 100755 --- a/tools/net/netzone-init.sh +++ b/tools/net/netzone-init.sh @@ -316,6 +316,33 @@ print(s.recv(4096).decode("utf-8", "replace").strip())' "$off" "$nsrc" "$BROKER" ask_time "$@" time_ticks=0 +# --- updates --------------------------------------------------------------------- +# Zone 0 has no network and never calls this zone, so this zone asks +# (docs/design/update-channel.md): it brings the signed statement of what is +# current, asks whether a release is wanted, and streams what zone 0 says is +# missing. update-fetch.py decides nothing and holds nothing; zone 0 names +# the channel (/etc/kryptik/update.conf on the verified root), verifies +# every signature and refuses any byte it did not ask for. Without that +# file there is no channel and nothing is asked. +UPDATE_CONF=/etc/kryptik/update.conf +UPDATE_FETCH="${KRYPTIK_UPDATE_FETCH:-/usr/libexec/kryptik/update-fetch.py}" +UPDATE_BROUGHT=/run/kryptik-update-statement-brought +UPDATE_PID="" +update_run() { # update_run latest|poll: in the background, one at a time + { command -v python3 >/dev/null 2>&1 && [ -r "$UPDATE_FETCH" ] && [ -r "$UPDATE_CONF" ]; } || return 0 + [ -n "$UPDATE_PID" ] && kill -0 "$UPDATE_PID" 2>/dev/null && return 0 + ( + out="$(python3 "$UPDATE_FETCH" "$1" --broker "$BROKER" 2>&1 | tail -1)" + case "$1:$out" in + poll:idle|*:) ;; + latest:ok*) : > "$UPDATE_BROUGHT"; say "update: zone 0 on the statement of what is current: ${out}" ;; + *) say "update $1: ${out}" ;; + esac + ) & + UPDATE_PID=$! +} +update_ticks=0; statement_ticks=999999 + status_line() { a="$(uplink_addr "$@")" w="$(wifi_state)" @@ -335,6 +362,7 @@ cleanup() { say "stopping" forwarding off [ -n "$DNSPID" ] && kill "$DNSPID" 2>/dev/null + [ -n "$UPDATE_PID" ] && kill "$UPDATE_PID" 2>/dev/null for n in $WIRELESS; do p="$(wpa_pid "$n")"; [ -n "$p" ] && kill "$p" 2>/dev/null; done command -v dhcpcd >/dev/null 2>&1 && dhcpcd -x 2>/dev/null exit 0 @@ -379,6 +407,20 @@ while :; do ask_time "$@" [ "$TIME_STATE" != "$time_was" ] && changed=1 fi + # Updates: the statement once a day once zone 0 has taken one, every + # half hour until then (zone 0 looks at one an hour whatever this zone + # does); the question "is a release wanted?" every minute, which costs + # one line on a local socket and is how a person's `kryptik update + # fetch` is noticed. + update_ticks=$((update_ticks + 1)); statement_ticks=$((statement_ticks + 1)) + if [ -n "$(uplink_addr "$@")" ]; then + if [ -e "$UPDATE_BROUGHT" ]; then statement_every=8640; else statement_every=180; fi + if [ "$statement_ticks" -ge "$statement_every" ]; then + statement_ticks=0; update_run latest + elif [ "$update_ticks" -ge 6 ]; then + update_ticks=0; update_run poll + fi + fi [ "$changed" = 1 ] && status_line "$@" sleep 10 & wait $! diff --git a/tools/net/update-fetch.py b/tools/net/update-fetch.py new file mode 100755 index 0000000..b38c770 --- /dev/null +++ b/tools/net/update-fetch.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""The net zone's half of the update channel (docs/design/update-channel.md). + + update-fetch.py latest fetch the statement of what is current and its + signature, and hand both to zone 0 + update-fetch.py poll ask zone 0 whether a release is wanted and, if + one is, stream what it says is still missing + +This zone is treated as hostile, so nothing here is trusted and nothing here +decides anything: zone 0 verifies every signature, names the address the +files come from (out of the statement it verified), says which file it wants +from which byte, and refuses any piece that is not exactly that. This script +is a pipe with a Range header. It holds nothing: a release is larger than +this zone's storage, so each piece goes from the connection to the broker +and is forgotten. + +Where to look is zone 0's to say: `channel =
` in +/etc/kryptik/update.conf, on the verified root and read-only here. TLS +authenticates the host and keeps the request private; nothing about the +release's authenticity rests on it. + +Exit 0: done, or nothing to do. Exit 1: said why on standard error. +""" +import argparse +import socket +import ssl +import sys +import urllib.request + +PIECE = 1 << 20 # the most one update-put carries +SMALL = 8 * 1024 # the most a statement or its signature may be +ROUNDS = 8 # polls per run: the manifest, then the files, then idle + + +def ask(broker, header, payload=b""): + """One request to zone 0's broker, one reply line.""" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(60) + s.connect(broker) + s.sendall(header.encode() + b"\n" + payload) + s.shutdown(socket.SHUT_WR) + reply = b"" + while len(reply) < 4096: + chunk = s.recv(4096) + if not chunk: + break + reply += chunk + s.close() + return reply.decode("utf-8", "replace").strip() + + +def fetch(url, ca, offset=0): + """An open response positioned at `offset`, whether or not the server + honours a Range: one that ignores it sends the file from the start, and + the bytes before the offset are read and dropped.""" + headers = {"Range": "bytes=%d-" % offset} if offset else {} + context = ssl.create_default_context(cafile=ca) if url.startswith("https://") else None + r = urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30, context=context) + if offset and r.status != 206: + left = offset + while left: + skipped = r.read(min(left, PIECE)) + if not skipped: + raise OSError("%s ends before byte %d" % (url, offset)) + left -= len(skipped) + return r + + +def channel(conf): + with open(conf, encoding="utf-8") as f: + for line in f: + key, _, value = line.partition("=") + if key.strip() == "channel" and value.strip(): + return value.strip().rstrip("/") + "/" + raise OSError("%s names no channel" % conf) + + +def latest(args): + base = channel(args.conf) + parts = [] + for name in ("latest", "latest.sig"): + body = fetch(base + name, args.ca).read(SMALL + 1) + if not body or len(body) > SMALL: + raise OSError("%s%s is empty or larger than %d bytes" % (base, name, SMALL)) + parts.append(body) + reply = ask(args.broker, "update-latest %d %d" % (len(parts[0]), len(parts[1])), parts[0] + parts[1]) + print(reply) + return 0 if reply.startswith("ok") else 1 + + +def poll(args): + for _ in range(ROUNDS): + words = ask(args.broker, "update-poll").split() + if words[:1] != ["fetch"]: + print(" ".join(words) or "no reply") + return 0 if words == ["idle"] else 1 + # fetch need [ ...] + if len(words) < 6 or words[3] != "need" or len(words) % 2: + raise OSError("zone 0 said something this does not understand: %s" % " ".join(words)) + version, base, need = words[1], words[2], words[4:] + for name, offset in zip(need[0::2], need[1::2]): + offset = int(offset) + r = fetch(base + name, args.ca, offset) + while True: + piece = r.read(PIECE) + if not piece: + break + reply = ask(args.broker, "update-put %s %d %d" % (name, offset, len(piece)), piece) + if not reply.startswith("ok"): + # Zone 0 has the last word: what it refuses is not sent + # again, and the next poll says what it wants instead. + print("%s %s at byte %d: %s" % (version, name, offset, reply), file=sys.stderr) + return 1 + offset += len(piece) + r.close() + print("still fetching after %d rounds; the next run carries on" % ROUNDS) + return 0 + + +def main(): + ap = argparse.ArgumentParser(description="fetch for zone 0's update channel; decides nothing") + ap.add_argument("what", choices=["latest", "poll"]) + ap.add_argument("--conf", default="/etc/kryptik/update.conf") + ap.add_argument("--broker", default="/run/kryptik/broker") + ap.add_argument("--ca", default="/etc/ssl/certs/ca-certificates.crt") + args = ap.parse_args() + try: + return latest(args) if args.what == "latest" else poll(args) + except (OSError, ValueError) as e: # urllib's errors are OSErrors + print("update-fetch: %s" % e, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/run-tests.sh b/tools/run-tests.sh index 54bbd16..37fc7c4 100755 --- a/tools/run-tests.sh +++ b/tools/run-tests.sh @@ -34,6 +34,7 @@ SUITES=( "test-services|tools/test-services.sh" "test-netzone-time|tools/test-netzone-time.sh" "test-update-verify|tools/test-update-manifest-snapshot.sh" + "test-update-fetch|tools/test-update-fetch.sh" "test-boot-success|tools/test-boot-success.sh" "test-manifest|tools/test-artifact-manifest.sh" "test-s6-init|tools/test-s6-init-config.sh" diff --git a/tools/test-update-fetch.sh b/tools/test-update-fetch.sh new file mode 100755 index 0000000..fdd5f72 --- /dev/null +++ b/tools/test-update-fetch.sh @@ -0,0 +1,216 @@ +#!/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 HTTP server: Range honoured unless $T/norange exists --------------------- +cat > "$T/httpd.py" <<'EOF' +import http.server, os, sys +root, portfile, flag, log = sys.argv[1:5] +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + path = os.path.normpath(os.path.join(root, self.path.lstrip("/"))) + if not path.startswith(root) or not os.path.isfile(path): + self.send_error(404); return + data = open(path, "rb").read() + rng = self.headers.get("Range") + open(log, "a").write("%s %s\n" % (self.path, rng or "-")) + if rng and not os.path.exists(flag): + start = int(rng.split("=")[1].split("-")[0]) + body = data[start:] + self.send_response(206) + self.send_header("Content-Range", "bytes %d-%d/%d" % (start, len(data) - 1, len(data))) + else: + body = data + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) +srv = http.server.HTTPServer(("127.0.0.1", 0), H) +open(portfile, "w").write(str(srv.server_address[1])) +srv.serve_forever() +EOF + +# --- 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 "$T/httpd.py" "$T/www" "$T/port" "$T/norange" "$T/http.log" & 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 ]] From 603d35eb820496c8fd1d9891e0f2ecdb279b6ab7 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:37:05 -0700 Subject: [PATCH 10/25] The update channel's design says what was built, and the roadmap says what is left The design's status, the paragraph on how a release crosses the broker (pieces of at most 1 MiB, one request each, rather than a copy handed to a child of the launcher: no second process and no state between pieces but the staged file's length), where the channel address lives and what its absence means, and the list of files and suites. The roadmap item stays unticked: what is left is the row that needs the installed system (a release fetched over the test network, staged, applied and committed) and release tooling that publishes a signed pointer. --- docs/design/update-channel.md | 41 ++++++++++++++++++++++------------- docs/roadmap.md | 8 ++++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index 94d33ef..c4b1c3e 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -1,7 +1,11 @@ # An update channel -Status: design. Nothing here is built yet; it finishes the roadmap's "An -update channel". Builds on [boot and updates](boot-and-updates.md), whose +Status: implemented in `kryptikd` (`update.rs`, the broker's three verbs, +`kryptik update`), in `kryptik-update` (`check-manifest`, `check-pointer`) +and in the net zone (`update-fetch.py`), with the rules, the verbs' refusals +and the fetcher tested offline. Not yet exercised on the installed system: +the last row of the test table, and the release tooling that publishes a +pointer, are open. Builds on [boot and updates](boot-and-updates.md), whose verification it does not change, on [the broker](broker.md), which carries the bytes, and on [the clock](time.md), without which freshness means nothing. @@ -74,9 +78,11 @@ decides nothing about what they must be. It is signed in a namespace of its own, so a manifest's signature can never be replayed as a pointer nor a pointer's as a manifest. Which key signs it is an open decision (below). -The channel's address is on the verified root (`/etc/kryptik/update.conf`, -visible read-only in zones like the time sources), so the net zone is not -told where to look by anything it could have written. +The channel's address is zone 0's to give (`channel =
` in +`/etc/kryptik/update.conf`, visible read-only in the nic zone like the time +sources), so the net zone is not told where to look by anything it could +have written. With no such file there is no channel: the net zone asks +nobody and `update-poll` answers `idle`. Zone 0 accepts a pointer when its signature verifies against the same trust anchor releases are verified against, its role is the one this image @@ -150,10 +156,12 @@ authenticity rests on it. A `development` image may name an `http://` address, which is what the test network serves; a `production` one may not. A release is hundreds of megabytes through a socket the launcher also -supervises its zone with. The copy is handed to a child of the launcher, -which holds the connection and the staging file and nothing else, so -supervision, the zone's other verbs and its death are all noticed as -promptly as they are today. +supervises its zone with. It crosses in pieces of at most 1 MiB, each one +request that the launcher answers between two looks at its zone, under the +same five-second deadline as every other request. Supervision, the zone's +other verbs and its death are therefore never further away than one piece, +and there is no second process, no long-lived connection and no state +between pieces other than the staged file's length. ### The person, and what they see @@ -242,9 +250,12 @@ whether `status` can say "stale", changes. ## Files -`compartments/kryptikd/src/update.rs` (pointer and staging rules), -`broker.rs` (the verbs), `serve.rs` and `tools/kryptik` (`kryptik update`), -`tools/update/kryptik-update` (`check-manifest`), a fetcher shipped for the -net zone beside `tools/net/netzone-init.sh`, `rootfs.rs` -(`/etc/kryptik/update.conf` and `/etc/ssl/cert.pem` into a zone's `/etc`), -and the rows above in the suites. +`compartments/kryptikd/src/update.rs` (the pointer and staging rules, and +what zone 0 keeps under `/var/lib/kryptik/update`), `broker.rs` (the verbs), +`serve.rs`, `tools/desktop/kryptik-launch.c` and `tools/kryptik` (`kryptik +update`), `tools/update/kryptik-update` (`check-manifest`, `check-pointer`), +`tools/net/update-fetch.py` and `tools/net/netzone-init.sh` (the net zone's +half), `rootfs.rs` (`/etc/kryptik/update.conf` into the nic zone's `/etc`; +the CA bundle under `/etc/ssl/certs` was already there), and the suites: +the `update.rs` and `broker.rs` unit tests, `make test-update-verify`, `make +test-update-fetch`, and the update rows of the boundary suite. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8b4b407..647eb9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -285,7 +285,13 @@ passes, not when its code is written. by URL into a transfer area; zone 0 verifies the manifest signature, every file and the embedded root hash exactly as it does today, and refuses a downgrade. A release process that publishes the payload, its - signature and the corresponding source. + signature and the corresponding source. Written + ([the design](design/update-channel.md)): the rules and the staging in + `kryptikd`, the broker's three verbs, `kryptik update`, the two checks + in `kryptik-update` and the net zone's fetcher, each with its offline + suite. Ticked when the update suite has fetched a release over the + test network, staged, applied and committed it on the installed + system, and the release tooling publishes a signed pointer. - [ ] **The state partition is encrypted.** `/home`, `/var` and the `/etc` overlay sit on plain ext4, so a stolen laptop gives up zone 0's home, the Wi-Fi passphrases and the zone volumes' headers. LUKS2 on From b529c30860557c7cdafb7e4608d4c7ff6238140a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 17:48:11 -0700 Subject: [PATCH 11/25] The launch service's log line for an update verb names the verb and not the caller Code scanning reads a peer's uid in a log line as sensitive data, which it is not, and raised one alert on the new line. The other verbs' lines carry the same finding, dismissed as a false positive. This one does not need the argument: the launch service answers the session's user and root and nobody else, so the uid on an update line says nothing the service's own rule does not, and the line goes without it. --- compartments/kryptikd/src/serve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compartments/kryptikd/src/serve.rs b/compartments/kryptikd/src/serve.rs index d4e3621..0c65735 100644 --- a/compartments/kryptikd/src/serve.rs +++ b/compartments/kryptikd/src/serve.rs @@ -1088,7 +1088,7 @@ fn handle(cfg: &ServeConfig, conn: UnixStream) -> Option { }; match done { Ok(text) => { - eprintln!("kryptikd serve: uid {uid}: {verb}"); + eprintln!("kryptikd serve: {verb}"); reply(&conn, &format!("ok\n{text}")); } Err(e) => reply(&conn, &format!("error: {e}\n")), From 43816d69fd4eb67e36ea2fadda4be0fadb412b85 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 18:13:35 -0700 Subject: [PATCH 12/25] kryptik-update makes its own snapshot directory and never takes one from the environment Found in review. The directory the manifest is copied into was `${SNAP:-$(mktemp -d ...)}`, with nothing above it clearing SNAP, and the exit trap is `rm -rf -- "$SNAP"`. So `SNAP=/some/dir kryptik-update ...` as root changed that directory's mode, copied a manifest into it and removed it recursively on the way out, whatever it was. It needs someone who already controls root's environment, so it is a trap for the unwary rather than a boundary; but the update channel adds callers, and a script with rm -rf in its exit trap should not take the path from outside. kryptikd clears the environment when it runs the tool; the next caller might not. One line: SNAP is set empty beside the other settings. The suite is not affected, since it lifts the functions out and sets SNAP in a shell of its own; it gains a row that runs the real script with SNAP naming a directory that holds a marker, and requires the marker to survive. The row fails against the script without the line. 19 rows. --- tools/test-update-manifest-snapshot.sh | 7 +++++++ tools/update/kryptik-update | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/tools/test-update-manifest-snapshot.sh b/tools/test-update-manifest-snapshot.sh index ddda506..2b2f3ff 100755 --- a/tools/test-update-manifest-snapshot.sh +++ b/tools/test-update-manifest-snapshot.sh @@ -256,6 +256,13 @@ if [[ "$(id -u)" != 0 ]]; then [[ "$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" \ diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index ba52f55..d4bbb92 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -46,6 +46,11 @@ 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="" # 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 From 54aeda1437110fe7ea6f7f582468eeb6f30c8511 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 20:02:28 -0700 Subject: [PATCH 13/25] A pointer can be verified on a real image: the trust anchor enrols a second key, for statements only Found while reading the build for the installed-system test. The trust anchor stage 04 installs has always restricted the release key to `namespaces="kryptik-release"`. check-pointer verifies in kryptik-latest against the same file, so on a real image no pointer could ever verify: the one key enrolled is not honoured in that namespace. It failed closed, and the offline suite did not see it because its anchor was one line with no namespaces, which accepts what the installed system refuses. The anchor now has two lines. The release key, honoured for manifests and nothing else, as before; and a second development key made beside it, enrolled as kryptik-latest and honoured for statements of what is current and nothing else. That is the design's "separate freshness key", enforced by the anchor rather than by convention: the key that has to be at hand on a schedule cannot sign a release, and the key that signs releases never has to be at hand. An owner who wants one key lists the release key on the second line; the design says so. Stage 04 proves both keys both ways round on every build, and the probe fails against an anchor without the namespaces. The workflow keeps the new private half out of the acceptance artifact, like the other two. verify_signed tries every principal a key is enrolled under, so one key on two lines works as well as two keys. The suite's anchor is now shaped like the real one and gains two rows: a pointer signed by the release key and a manifest signed by the statement key are both refused, whatever namespace they sign in. 21 rows. --- .github/workflows/distro.yml | 4 +-- build/stages/04-base-system.sh | 35 +++++++++++++++++++++++-- docs/design/update-channel.md | 15 +++++++++++ tools/test-update-manifest-snapshot.sh | 36 ++++++++++++++++++++++---- tools/update/kryptik-update | 20 +++++++++----- 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/.github/workflows/distro.yml b/.github/workflows/distro.yml index b922dc4..18b24ac 100644 --- a/.github/workflows/distro.yml +++ b/.github/workflows/distro.yml @@ -316,8 +316,8 @@ jobs: # Never the private halves of the keys: the acceptance job needs the # certificate, its DER form and the variable stores, nothing that # signs. An artifact of a public repository is public. - sudo tar --zstd -cf work-after-media.tar.zst -C work --exclude='images/kryptik-root.img' --exclude='images/esp-*.img' --exclude='keys/sb/kryptik-sb.key' --exclude='keys/release/kryptik-release' . - if sudo tar --zstd -tf work-after-media.tar.zst | grep -E 'kryptik-sb\.key$|keys/release/kryptik-release$'; then echo "a private key is in the artifact"; exit 1; fi + sudo tar --zstd -cf work-after-media.tar.zst -C work --exclude='images/kryptik-root.img' --exclude='images/esp-*.img' --exclude='keys/sb/kryptik-sb.key' --exclude='keys/release/kryptik-release' --exclude='keys/release/kryptik-latest' . + if sudo tar --zstd -tf work-after-media.tar.zst | grep -E 'kryptik-sb\.key$|keys/release/kryptik-(release|latest)$'; then echo "a private key is in the artifact"; exit 1; fi sudo chown "$(id -u):$(id -g)" work-after-media.tar.zst ls -la work-after-media.tar.zst; df -h /mnt | tail -1 diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index 897e555..a220b49 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -927,9 +927,23 @@ s_release_trust() { chmod 0600 "$keydir/kryptik-release" echo "generated a new developer release signing key" fi + # A second key, for one thing: signing the update channel's statement of + # what is current (docs/design/update-channel.md). It is honoured in the + # kryptik-latest namespace and nowhere else, and the release key is + # honoured in kryptik-release and nowhere else, so the key that has to be + # at hand on a schedule can never sign a release, and the key that signs + # releases never has to be. An owner who wants one key for both lists + # the release key on the second line instead; nothing else changes. + if [[ ! -f "$keydir/kryptik-latest" ]]; then + ssh-keygen -q -t ed25519 -N "" -C "kryptik-latest (developer)" -f "$keydir/kryptik-latest" + chmod 0600 "$keydir/kryptik-latest" + echo "generated a new developer key for statements of what is current" + fi install -d -m 0755 /usr/share/kryptik/trust - printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-release.pub")" \ - > /usr/share/kryptik/trust/release-signers + { + printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-release.pub")" + printf 'kryptik-latest namespaces="kryptik-latest" %s\n' "$(cut -d' ' -f1,2 "$keydir/kryptik-latest.pub")" + } > /usr/share/kryptik/trust/release-signers chmod 0644 /usr/share/kryptik/trust/release-signers # Developer tier: the updater accepts development-role manifests. A # production image changes this file (and its key), deliberately. @@ -956,6 +970,23 @@ s_release_trust() { echo "FAIL: a foreign key verified against the anchor"; rm -rf "$t"; return 1 fi echo "ok: a foreign key is refused" + # The two keys, each in its own namespace and refused in the other's: + # what makes the statement key safe to keep where a timer can reach it. + local who ns other + for who in kryptik-release kryptik-latest; do + ns="$who"; [[ "$who" == kryptik-release ]] && other=kryptik-latest || other=kryptik-release + rm -f "$t/$who.sig" + printf 'probe of %s\n' "$who" > "$t/$who" + ssh-keygen -Y sign -f "$keydir/$who" -n "$ns" "$t/$who" < /dev/null >/dev/null 2>&1 \ + && ssh-keygen -Y verify -f /usr/share/kryptik/trust/release-signers -I "$who" -n "$ns" -s "$t/$who.sig" < "$t/$who" >/dev/null 2>&1 \ + || { echo "FAIL: the $who key does not verify in its own namespace"; rm -rf "$t"; return 1; } + rm -f "$t/$who.sig" + ssh-keygen -Y sign -f "$keydir/$who" -n "$other" "$t/$who" < /dev/null >/dev/null 2>&1 + if ssh-keygen -Y verify -f /usr/share/kryptik/trust/release-signers -I "$who" -n "$other" -s "$t/$who.sig" < "$t/$who" >/dev/null 2>&1; then + echo "FAIL: the $who key verified in the $other namespace"; rm -rf "$t"; return 1 + fi + done + echo "ok: each key verifies in its own namespace and is refused in the other's" rm -rf "$t" } diff --git a/docs/design/update-channel.md b/docs/design/update-channel.md index c4b1c3e..cac7fd0 100644 --- a/docs/design/update-channel.md +++ b/docs/design/update-channel.md @@ -237,6 +237,21 @@ the choice is about how the keys are held, which is the owner's: The design above works with any of the three; only who holds which key, and whether `status` can say "stale", changes. +**What the build does meanwhile.** The trust anchor is an OpenSSH +allowed-signers file, and each line of it names the namespaces its key is +honoured in. The release key's line has always said +`namespaces="kryptik-release"`, so that key cannot sign a pointer whatever +the updater asks for: the first option is not the default, it is a line +someone would have to widen. The development build therefore makes a second +key beside the release key and enrols it as +`kryptik-latest namespaces="kryptik-latest"`: the second option, enforced by +the anchor rather than by convention. Stage 04 proves it both ways round on +every build (each key verifies in its own namespace and is refused in the +other's), and `make test-update-verify` runs the updater against an anchor +of that shape. An owner who chooses one key lists the release key on the +second line; one who chooses no schedule changes nothing here and simply +signs a pointer only when there is a release. + ## Open points - The release process that publishes `latest`, its signature and the diff --git a/tools/test-update-manifest-snapshot.sh b/tools/test-update-manifest-snapshot.sh index 2b2f3ff..28df65e 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 @@ -143,7 +151,7 @@ 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 +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" @@ -215,14 +223,14 @@ out="$(check cmd_check_manifest "$T/climbs")" # 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 key -n kryptik-latest "$T/ptr/latest" >/dev/null 2>&1 +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 key -n kryptik-release "$T/ptr/replayed-ns" >/dev/null 2>&1 +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" \ @@ -235,6 +243,24 @@ out="$(check cmd_check_pointer "$T/ptr/stranger" "$T/ptr/stranger.sig")" && 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"* ]] \ @@ -243,7 +269,7 @@ out="$(check cmd_check_pointer "$T/ptr/edited" "$T/ptr/latest.sig")" # 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 key -n kryptik-latest "$T/ptr/manifest-as-pointer" >/dev/null 2>&1 +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" \ diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index d4bbb92..4aea226 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -220,14 +220,22 @@ an older signed release can reintroduce a fixed defect. --recovery accepts it de # 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. +# 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" - principal="$(ssh-keygen -Y find-principals -s "$2" -f "$SIGNERS" 2>/dev/null | head -1 || true)" - [ -n "$principal" ] || die "the $4's signing key is not enrolled in $SIGNERS" - ssh-keygen -Y verify -f "$SIGNERS" -I "$principal" -n "$3" -s "$2" < "$1" >/dev/null 2>&1 \ - || die "the $4 signature does NOT verify (principal $principal)" - say "signature verifies (signed by $principal)" + 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 From da6113b8ef6aeb6461ad1bc31e879b93e911f0db Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 20:08:19 -0700 Subject: [PATCH 14/25] The release tool writes and signs the statement of what is current, and stage 06 publishes one beside each payload `release-manifest.sh pointer --key K --manifest M --base B --out FILE` writes the update channel's KRYPTIK-LATEST-1 for a manifest that is already signed (its version and role, its SHA-256, the base, the date) and signs it in kryptik-latest. Re-running it with a later date for an unchanged release is how a channel shows nothing is being withheld. It is the first piece of the release tooling the roadmap asks for, and it is here now because the acceptance job is never given a private key, so a statement for the update suite has to be made where the manifest is signed. Stage 06 writes images/channel-/latest and latest.sig beside the payload, outside it because `apply` refuses a payload holding anything unlisted; verifies it through the anchor the image carries; and writes a control, the same statement signed by the release key in the manifest's namespace, which the image must refuse. Stage 04, from review: the probe's negative half requires the signature to exist before it counts a refusal, so it cannot pass because signing failed. And a rule for the keys like the one for the kernel tree: they live outside the sysroot and outside any cache of it, so a restored tree with release-trust stamped and a key missing loses the stamp and the step makes both keys and writes the anchor again. It has been rerunning on cached builds only because an earlier step happened to go stale. Suite: six rows, 46 in all. What the tool writes is what kryptik-update's own check-pointer accepts under an anchor shaped like the image's; one signed with the release key is refused; a re-issue changes only the date; no statement is written about an unsigned manifest. --- build/stages/04-base-system.sh | 14 +++++++ build/stages/06-iso.sh | 22 ++++++++++ tools/release-manifest.sh | 59 ++++++++++++++++++++++++++- tools/test-release-manifest.sh | 74 ++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index a220b49..c7b9772 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -982,6 +982,8 @@ s_release_trust() { || { echo "FAIL: the $who key does not verify in its own namespace"; rm -rf "$t"; return 1; } rm -f "$t/$who.sig" ssh-keygen -Y sign -f "$keydir/$who" -n "$other" "$t/$who" < /dev/null >/dev/null 2>&1 + # A refusal only counts when there was a signature to refuse. + [[ -s "$t/$who.sig" ]] || { echo "FAIL: could not sign the probe of $who in $other"; rm -rf "$t"; return 1; } if ssh-keygen -Y verify -f /usr/share/kryptik/trust/release-signers -I "$who" -n "$other" -s "$t/$who.sig" < "$t/$who" >/dev/null 2>&1; then echo "FAIL: the $who key verified in the $other namespace"; rm -rf "$t"; return 1 fi @@ -2585,6 +2587,18 @@ require_inside_chroot "stage 04" "system" # temporary tools and nothing built with them can claim to be unchanged. stage_depends_on "tt-" verify +# The signing keys live under ${KRYPTIK_WORK}/keys, outside the sysroot and +# outside any cache of it, on purpose. A work tree restored from such a cache +# has release-trust stamped as built and no keys; the anchor in the restored +# sysroot then names keys that no longer exist, and stage 06 would sign with +# ones the image does not trust, or find none. So, as for the kernel tree: +# no keys, no stamp. The step then makes both and writes the anchor again. +if [[ -f "${STAMPS}/${STAMP_PREFIX}release-trust" ]] && \ + [[ ! -f "${KRYPTIK_WORK}/keys/release/kryptik-release" || ! -f "${KRYPTIK_WORK}/keys/release/kryptik-latest" ]]; then + warn "release-trust is stamped as built but a signing key under ${KRYPTIK_WORK}/keys/release is gone; the step runs again." + rm -f "${STAMPS}/${STAMP_PREFIX}release-trust" +fi + unwired=0 for ((i = 0; i < ${#PACKAGES[@]}; i += 2)); do name="${PACKAGES[i]}" diff --git a/build/stages/06-iso.sh b/build/stages/06-iso.sh index 3f8767d..a0d66af 100755 --- a/build/stages/06-iso.sh +++ b/build/stages/06-iso.sh @@ -453,6 +453,28 @@ s_payload() { "${KRYPTIK_ROOT}/tools/release-manifest.sh" verify --signers "$signers" --principal kryptik-release \ --root "$out" --exact --strict "$out/manifest" ls -la "$out" + + # What a release host serves beside the payload (docs/design/update-channel.md): + # the signed statement that this release is current. Outside the payload + # directory, because `apply` refuses a payload that holds anything its + # manifest does not list. `base` is relative, so the same two files serve + # from wherever the channel is. `not-a-pointer` is the same statement + # signed by the release key in the manifest's namespace, which the image + # must refuse; the update suite serves it to prove that on the real chain. + [[ -f "$keydir/kryptik-latest" ]] || { echo "no statement key at ${keydir}; stage 04 (release-trust) makes it"; return 1; } + local chan="${IMG}/channel-${KRYPTIK_VERSION}" + rm -rf "$chan"; mkdir -p "$chan" + "${KRYPTIK_ROOT}/tools/release-manifest.sh" pointer --key "$keydir/kryptik-latest" \ + --manifest "$out/manifest" --base "${KRYPTIK_VERSION}/" --out "$chan/latest" + cp "$chan/latest" "$chan/not-a-pointer" + ssh-keygen -Y sign -f "$keydir/kryptik-release" -n kryptik-release "$chan/not-a-pointer" < /dev/null >/dev/null 2>&1 \ + || { echo "could not sign the control statement"; return 1; } + ssh-keygen -Y verify -f "$signers" -I kryptik-latest -n kryptik-latest -s "$chan/latest.sig" < "$chan/latest" >/dev/null \ + || { echo "FAIL: the image's anchor does not verify the statement this build just signed"; return 1; } + if ssh-keygen -Y verify -f "$signers" -I kryptik-release -n kryptik-latest -s "$chan/not-a-pointer.sig" < "$chan/not-a-pointer" >/dev/null 2>&1; then + echo "FAIL: the image's anchor accepts a statement signed by the release key"; return 1 + fi + ls -la "$chan" } # The release record under ${KRYPTIK_OUT}: the small things (hashes, root diff --git a/tools/release-manifest.sh b/tools/release-manifest.sh index 727afcd..4940742 100755 --- a/tools/release-manifest.sh +++ b/tools/release-manifest.sh @@ -9,6 +9,18 @@ # [--principal NAME] [--exact] # [--require-role production] # [--no-downgrade VERSION] MANIFEST +# ./tools/release-manifest.sh pointer --key PRIVKEY --manifest MANIFEST +# --base BASE --out FILE [--issued DATE] +# +# `pointer` writes the update channel's statement of what is current +# (docs/design/update-channel.md) for a manifest that is already signed, and +# signs it in a namespace of its own with a key of its own: FILE and FILE.sig +# are what a release host serves as `latest` and `latest.sig`. It says which +# release is current (the manifest's version and role), which manifest that +# is (its SHA-256, so where the files come from decides nothing), where the +# files are (BASE, absolute or relative to the channel address) and when it +# was issued. Re-running it for an unchanged release with a later date is how +# a channel shows that nothing is being withheld. # # This is the verification primitive the signed-image and recoverable-update # work needs: a record of exactly which bytes a release consists @@ -52,7 +64,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/../build/lib/common.sh" NAMESPACE="kryptik-release" MAGIC="KRYPTIK-MANIFEST-1" -usage() { sed -n '2,10p' "${BASH_SOURCE[0]}"; } +usage() { sed -n '2,13p' "${BASH_SOURCE[0]}"; } [[ "$#" -gt 0 ]] || { usage; exit 1; } MODE="$1"; shift @@ -396,10 +408,53 @@ signed manifest; do not install or boot it." ok "manifest verified: signature, role, and every listed file." } +POINTER_MAGIC="KRYPTIK-LATEST-1" +POINTER_NAMESPACE="kryptik-latest" + +do_pointer() { + local key="" manifest="" base="" out="" issued="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --key) key="${2:?--key needs a file}"; shift 2 ;; + --manifest) manifest="${2:?--manifest needs a file}"; shift 2 ;; + --base) base="${2:?--base needs an address}"; shift 2 ;; + --out) out="${2:?--out needs a file}"; shift 2 ;; + --issued) issued="${2:?--issued needs a date}"; shift 2 ;; + *) die "pointer: unknown argument: $1" ;; + esac + done + [[ -f "$key" ]] || die "pointer: --key is required and must exist" + [[ -f "$manifest" ]] || die "pointer: --manifest is required and must exist" + [[ -n "$base" && -n "$out" ]] || die "pointer: --base and --out are required" + head -1 "$manifest" | grep -qxF "$MAGIC" || die "pointer: ${manifest} is not a ${MAGIC}" + # A statement about a release nobody has signed would announce a manifest + # no machine will accept. + [[ -s "${manifest}.sig" ]] || die "pointer: ${manifest} is not signed yet (no ${manifest}.sig)" + case "$base" in *[[:space:]]*) die "pointer: --base must not contain spaces" ;; esac + local version role + version="$(awk -F': ' '$1=="version"{print $2; exit}' "$manifest")" + role="$(awk -F': ' '$1=="role"{print $2; exit}' "$manifest")" + [[ -n "$version" && -n "$role" ]] || die "pointer: the manifest has no version or no role" + issued="${issued:-$(date -u +%Y-%m-%dT%H:%M:%S+00:00)}" + { + printf '%s\n' "$POINTER_MAGIC" + printf 'role: %s\n' "$role" + printf 'version: %s\n' "$version" + printf 'issued: %s\n' "$issued" + printf 'manifest-sha256: %s\n' "$(sha256sum "$manifest" | cut -c1-64)" + printf 'base: %s\n' "$base" + } > "$out" + rm -f "${out}.sig" + ssh-keygen -Y sign -f "$key" -n "$POINTER_NAMESPACE" "$out" < /dev/null >/dev/null 2>&1 \ + || die "pointer: ssh-keygen could not sign with ${key}" + ok "pointer: ${out} names ${version} (${role}), issued ${issued}; signed as ${out}.sig" +} + case "$MODE" in create) do_create "$@" ;; sign) do_sign "$@" ;; verify) do_verify "$@" ;; + pointer) do_pointer "$@" ;; -h|--help|help) usage ;; - *) die "unknown mode '${MODE}' (expected create, sign or verify)" ;; + *) die "unknown mode '${MODE}' (expected create, sign, verify or pointer)" ;; esac diff --git a/tools/test-release-manifest.sh b/tools/test-release-manifest.sh index 6393423..44e4ba6 100755 --- a/tools/test-release-manifest.sh +++ b/tools/test-release-manifest.sh @@ -472,6 +472,80 @@ 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" --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" --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" --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 + +rm -f "${MAN}.sig" +NO_COLOR=1 bash "$TOOL" pointer --key "${W}/keys/latest" --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." From e0b2a5fd272ff7e05e90a0f003566a1afce41c6a Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 20:11:56 -0700 Subject: [PATCH 15/25] The update suite fetches a release over the network: the channel, end to end, on the installed system Not yet run: it needs a Distro run, and none is free. Written to be read. Step 3 gains two lines that need no network. The signed statement that B is current, which stage 06 now writes beside B's payload, rides on the refusals disk with its control; the real updater judges both against the image's real anchor. The statement verifies, signed by kryptik-latest; the same text signed by the release key does not. Step 8, last, from the state step 7 leaves (slot b, B committed). Nothing newer exists to fetch there, so first a rollback to slot a, and the copy step 7 applied from is removed, so the staged release is the second payload's worth on kryptik-state and not the third. Then a release host on this side of QEMU's user network, bound to loopback, serving B's statement and links to B's payload; the guest with a network for this step and no other. Zone 0 names the channel and the net zone's service is restarted, because the zone sees that file only from its next launch. In order: status names B once the net zone has brought the statement; seventy seconds later nothing has been staged, because nobody asked; the person asks; the release arrives and is complete, and the staged directory holds exactly the six files; `kryptik update apply`; slot b trial-boots and commits; the home file is intact. The release host's log must show the statement, then the manifest and its signature, before any image. Each wait is a loop in the guest that gives up before the driver would, in a subshell so that giving up is not the login shell's exit, and as a failed command, because the driver judges the exit status and the word it then expects is also in the command line the console echoes. The statement is made in stage 06 and not here because the acceptance job is never given a private key. --- tools/image/update-test.sh | 95 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 176a7d5..92bf937 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -8,9 +8,10 @@ # # A and B are two stage 06 releases of this tree (make media KRYPTIK_VERSION=... # twice); B's VERSION_ID is the observable change, reported by the guest's -# own boot report and os-release. The payload reaches the guest as files on -# a plain ext4 disk image (fetching is out of scope here and out of zone 0 -# by design). Guest-side mounts go under /run: the installed root is a +# own boot report and os-release. In steps 2 to 7 the payload reaches the +# guest as files on a plain ext4 disk image, the offline path; step 8 has the +# net zone fetch it (docs/design/update-channel.md). Guest-side mounts go +# under /run: the installed root is a # read-only verity image, so /mnt cannot take a directory, which is how the # first run of this driver failed at its first mkdir. # @@ -24,6 +25,9 @@ # a full state partition, a concurrent run; none arms a trial # 4 authenticated recovery: apply A --recovery, reboot -> slot a, version A # 5 rollback: arms b again, reboot -> slot b +# 8 (last) the same update over the network: back to slot a, then the net +# zone fetches B from a release host on this side of the user network, +# zone 0 stages it, `kryptik update apply` installs it, slot b commits # 6 interruption: apply A --recovery, kill the VM mid-write, boot: still # slot b, no trial; apply again succeeds; kill after arming: the # firmware consumes BootNext and boot-success commits a @@ -51,6 +55,14 @@ done for t in python3 mkfs.ext4 truncate ssh-keygen sfdisk; do have "$t" || die "required tool not found: $t"; done VA="$(awk -F': ' '$1=="version"{print $2}' "$PAY_A/manifest")"; VB="$(awk -F': ' '$1=="version"{print $2}' "$PAY_B/manifest")" [[ "$VA" != "$VB" ]] || die "A and B are the same version (${VA})" +# The signed statement that B is current, which stage 06 writes beside B's +# payload. It is made there and not here because this job is never given a +# private key. `not-a-pointer` is its control: the same text signed by the +# release key in the manifest's namespace. +CHAN_B="$(dirname "$PAY_B")/channel-${VB}" +for f in latest latest.sig not-a-pointer not-a-pointer.sig; do + [[ -s "$CHAN_B/$f" ]] || die "no ${CHAN_B}/${f}: stage 06's payload step writes it beside the payload" +done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/updated.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" @@ -92,6 +104,9 @@ mk_variant extra; echo "ride along" > "$BAD/extra/extra.bin" # An empty lost+found is the medium's own and is passed over (step 2 applies # a payload that is the root of an ext4 disk); one with something in it is not. mk_variant hidden; mkdir -p "$BAD/hidden/lost+found"; echo "ride along" > "$BAD/hidden/lost+found/ride" +# The statement and its control ride on the same disk: step 3 has the real +# updater judge both against the real anchor, with no network involved. +mkdir -p "$BAD/statement"; cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$CHAN_B/not-a-pointer" "$CHAN_B/not-a-pointer.sig" "$BAD/statement/" BADIMG="${VMDIR}/payload-bad.img"; payload_disk "$BADIMG" "$BAD" # The guest side, as root through su. @@ -178,12 +193,14 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'kryptik-update apply /run/upd/p/extra --recovery; echo RC=$?')" "expect:unlisted file" \ "$(ROOTSH 'kryptik-update apply /run/upd/p/hidden --recovery; echo RC=$?')" "expect:lost\\+found is not empty" \ "$(ROOTSH 'kryptik-update apply /run/upd/a; echo RC=$?')" "expect:older than the running" \ + "$(ROOTSH 'kryptik-update check-pointer /run/upd/p/statement/latest /run/upd/p/statement/latest.sig && echo STATEMENT-OK')" "expect:signed by kryptik-latest" "expect:STATEMENT-OK" \ + "$(ROOTSH 'kryptik-update check-pointer /run/upd/p/statement/not-a-pointer /run/upd/p/statement/not-a-pointer.sig; echo RC=$?')" "expect:does NOT verify" "expect:RC=1" \ "$(ROOTSH 'flock /run/kryptik/update.lock sleep 20 & sleep 1; kryptik-update apply /run/upd/a --recovery; echo RC=$?')" "expect:another update is in progress" \ "$(ROOTSH 'fallocate -l 100G /var/filler 2>/dev/null || dd if=/dev/zero of=/var/filler bs=1M 2>/dev/null; cp -a /run/upd/a /var/lib/kryptik/updates/a-full 2>&1 | tail -1; kryptik-update apply /var/lib/kryptik/updates/a-full --recovery; echo RC=$?; rm -rf /var/filler /var/lib/kryptik/updates/a-full')" "expect:RC=1" \ "$(ROOTSH 'kryptik-update status')" "expect:trial pending: none" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm -[[ "$rc" -eq 0 ]] && green "wrong key, modified image, truncated kernel, extra file, downgrade, concurrent run and full disk were all refused; no trial armed" || red "step 3 drive failed" +[[ "$rc" -eq 0 ]] && green "wrong key, modified image, truncated kernel, extra file, downgrade, concurrent run and full disk were all refused; no trial armed; the statement of what is current verifies against the image's anchor and one signed by the release key does not" || red "step 3 drive failed" # ----------------------------------------------------------------- step 4 -- step "step 4: authenticated recovery to ${VA} with --recovery" @@ -312,6 +329,76 @@ txt | grep -q 'boot-success: trial slot b did NOT boot' && green "boot-success n starts="$(txt | grep -c 'BdsDxe: starting Boot')"; ups="$(txt | grep -c 'KRYPTIK_SMOKE: END')"; panics="$(txt | grep -c 'Kernel panic')" if [[ "$starts" -ge 3 && "$ups" -ge 2 && "$panics" -ge 1 ]]; then green "three boots in one session: the corrupt trial (panicked), the fallback and the retried trial (both reached userspace)"; else red "expected three boots: firmware starts=${starts}, userspace ends=${ups}, panics=${panics}"; fi +# ----------------------------------------------------------------- step 8 -- +step "step 8: ${VB} once more, fetched by the net zone and staged by zone 0" +# Step 7 leaves slot b running ${VB}, committed, and there is nothing newer to +# fetch. So first back to slot a by rollback (what step 5 proves, the other +# way round); from ${VA} the channel has a release to offer. The copy step 7 +# applied from goes first: the staged release is then the second payload's +# worth on kryptik-state, not the third, which is what the disk was sized +# for (--payloads 2 above). +start_vm update-p8 +drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'rm -rf /var/lib/kryptik/updates/b2; kryptik-update rollback && echo RB8-OK')" "expect:armed: the next boot tries slot a" "expect:RB8-OK" \ + "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ + "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'cat /run/kryptik/boot-identity | head -1; cat /var/lib/kryptik/boot/last-result; echo P8A-OK')" "expect:slot=a" "expect:commit a" "expect:P8A-OK" \ + "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" +rc=$?; stop_vm +[[ "$rc" -eq 0 ]] && green "back on slot a (${VA}) by rollback, with room for one staged release" || red "step 8: the rollback to slot a failed" +stop_unless_ok "$rc" "step 8 rollback" + +# The release host: B's statement and B's payload, served from this side of +# QEMU's user network. Bound to loopback, which is what the guest reaches as +# 10.0.2.2; never to an address another machine on the runner's network could +# ask. http is what a development image may be pointed at and a production +# one may not. The files are links: nothing here copies three gigabytes. +SERVE="${VMDIR}/channel"; rm -rf "$SERVE"; mkdir -p "$SERVE/${VB}" +cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$SERVE/" +for f in "$PAY_B"/*; do [[ -f "$f" ]] && ln -s "$(readlink -f "$f")" "$SERVE/${VB}/$(basename "$f")"; done +CHAN_PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" +python3 -m http.server "$CHAN_PORT" --bind 127.0.0.1 --directory "$SERVE" > "${VMDIR}/channel-httpd.log" 2>&1 & +CHAN_PID=$! +sleep 1 +kill -0 "$CHAN_PID" 2>/dev/null || die "the release host did not start: $(cat "${VMDIR}/channel-httpd.log")" + +# The guest, with a network for this step and no other. Zone 0 names the +# channel; the net zone sees that file only from its next launch, so its +# service is restarted. Then, in order: the net zone brings the statement +# and zone 0 accepts it (status names ${VB}); nothing is fetched until the +# person asks; the person asks; the release arrives and is complete; apply +# is the offline path's apply, and the trial boot and the commit are the +# ones steps 2 and 7 judge. Each wait is a loop in the guest that gives up +# before the driver would. +# In a subshell, so that giving up is not the login shell's exit; and giving +# up is a failed command, because the driver's `run:` judges the exit status +# and the word it then expects is also in the command line the console echoes. +wait_status() { printf '(i=0; until kryptik update status | grep -q "%s"; do i=$((i+1)); [ $i -lt 72 ] || exit 1; sleep 5; done) && echo %s || { kryptik update status; false; }' "$1" "$2"; } +start_vm update-p8b --net user +drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ + "$(ROOTSH "mkdir -p /etc/kryptik && printf 'channel = http://10.0.2.2:${CHAN_PORT}/\\n' > /etc/kryptik/update.conf && s6-svc -r /run/service/net-zone && echo CONF-OK")" "expect:CONF-OK" \ + "run:kryptik update status | grep -q 'nothing asked for'" \ + "run:$(wait_status "newest ${VB} " STATED-OK)" "expect:STATED-OK" \ + "$(ROOTSH 'sleep 70; echo STAGED-UNASKED=$(ls /var/lib/kryptik/update/incoming 2>/dev/null | wc -l)')" "expect:STAGED-UNASKED=0" \ + "run:kryptik update fetch" "expect:${VB} will be fetched" \ + "run:$(wait_status "${VB}: .* bytes, " ARRIVING-OK)" "expect:ARRIVING-OK" \ + "run:$(wait_status "bytes, complete" COMPLETE-OK)" "expect:COMPLETE-OK" \ + "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr '\\n' ' '; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img manifest manifest.sig root.json LISTED" \ + "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ + "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ + "login:${TUSER}:${TPASS}" \ + "$(ROOTSH 'cat /run/kryptik/boot-identity | head -1; cat /var/lib/kryptik/boot/last-result; echo P8C-OK')" "expect:slot=b" "expect:commit b" "expect:P8C-OK" \ + "run:test \"\$(cat /home/${TUSER}/marker)\" = before-update" \ + "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" +rc=$?; stop_vm +kill "$CHAN_PID" 2>/dev/null +[[ "$rc" -eq 0 ]] && green "the net zone brought the statement, nothing was fetched until it was asked for, ${VB} arrived whole, and it was applied, trial-booted and committed; data intact" || red "step 8 drive failed" +txt | grep -q "version_id=${VB}" && green "guest reports version ${VB} after the fetched update" || red "guest did not report ${VB}" +# What the release host was asked for: the statement, then the manifest and +# its signature before anything large. +first="$(grep -o 'GET /[^ ]*' "${VMDIR}/channel-httpd.log" | sed 's|GET /||' | awk '!seen[$0]++' | head -5 | tr '\n' ' ')" +if [[ "$first" == "latest latest.sig ${VB}/manifest ${VB}/manifest.sig "* ]]; then green "the release host was asked for the statement, then the manifest and its signature, before any image"; else red "the release host was asked in another order: ${first}"; fi + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" echo "Limits: the interruptions are QEMU process kills with cache=writeback and explicit fsyncs;" echo "they exercise the recovery logic, not a storage controller's power-loss behaviour." From 0e422cada5eb6dcef30ef154a45854a0d5cc4bba Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 22:42:13 -0700 Subject: [PATCH 16/25] The update suite's network step, after review: a proven restart, a wait that fails on a stall, a release host that cannot outlive the suite From the review of the step, by the suite's author. The net zone is restarted the way the guest checks already do it and have proven: down, a pause, up, then a NEW "netzone: READY" line in the catch-all log, and only then is `status` read. `s6-svc -r` is proven nowhere, and before the new line the old zone could still answer. The wait for the release is no longer a clock. Gigabytes through QEMU's user network on a nested-KVM runner may take longer than any one driver step allows, so each wait fails when the staged line has not changed for 100 seconds, succeeds at "complete", and also succeeds at the end of its six minutes if bytes were still arriving, for the next wait to take over; four of them, then "complete" is required. A stall fails in under two minutes and a slow link costs waits, not the run. The release host is tools/image/release-host.py, shared with the fetch suite, which had its own copy: static files on loopback and a port the kernel picks, Range honoured with a 206 as the design asks of a release host (python's http.server ignores it, and a resumed fetch would re-read gigabytes), streamed rather than read whole, a name that leaves the root refused, one log line per request. It is killed from an EXIT trap, so no way out of the suite leaves it behind. And two commands given to the guest as root had single quotes in them, which the driver wraps its `su -c` argument in; they use double quotes now, and the restart command ends in a subshell rather than an exit, or the driver's own marker after it would never print. Checked offline: the fetch suite on the shared host, 11 rows; a 3 GiB sparse file streamed whole and from a Range near its end; the arrival wait against stand-ins for complete, stalled and still arriving; both suites parse and pass shellcheck. The step itself still needs a run. --- tools/image/release-host.py | 69 +++++++++++++++++++++++++++++++++++++ tools/image/update-test.sh | 43 ++++++++++++++++++----- tools/test-update-fetch.sh | 33 +++--------------- 3 files changed, 107 insertions(+), 38 deletions(-) create mode 100755 tools/image/release-host.py diff --git a/tools/image/release-host.py b/tools/image/release-host.py new file mode 100755 index 0000000..0ad7492 --- /dev/null +++ b/tools/image/release-host.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""A release host for the suites: static files over HTTP, on loopback only. + + release-host.py ROOT PORTFILE LOG [NORANGE] + +Serves ROOT on 127.0.0.1 and a port the kernel picks, written to PORTFILE +once the socket is listening. Honours `Range: bytes=N-` with a 206, which is +what the update channel asks a release host for (docs/design/update-channel.md) +and what python's own http.server does not do; while the file NORANGE exists +it ignores Range and sends the whole file, which is the server the fetcher +must also survive. Files are streamed, never read whole: a root image is +gigabytes. Every request is one line in LOG: the path, then the Range header +or `-`. + +Loopback only, on purpose: under QEMU's user network the guest reaches this +as 10.0.2.2, and nothing else on the runner's network can ask it anything. +""" +import http.server +import os +import shutil +import sys + +root, portfile, log = (os.path.realpath(sys.argv[1]), sys.argv[2], sys.argv[3]) +norange = sys.argv[4] if len(sys.argv) > 4 else None + + +class Host(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + # Links inside ROOT may point anywhere (the suites link to a payload + # rather than copy it); the requested NAME may not leave ROOT. + path = os.path.normpath(os.path.join(root, self.path.split("?", 1)[0].lstrip("/"))) + if not (path == root or path.startswith(root + os.sep)) or not os.path.isfile(path): + self.send_error(404) + return + rng = self.headers.get("Range") + with open(log, "a") as f: + f.write("%s %s\n" % (self.path, rng or "-")) + size = os.path.getsize(path) + start = 0 + if rng and rng.startswith("bytes=") and not (norange and os.path.exists(norange)): + try: + start = int(rng[6:].split("-", 1)[0]) + except ValueError: + start = 0 + if start >= size: + self.send_error(416) + return + self.send_response(206) + self.send_header("Content-Range", "bytes %d-%d/%d" % (start, size - 1, size)) + else: + self.send_response(200) + self.send_header("Content-Length", str(size - start)) + self.end_headers() + with open(path, "rb") as f: + f.seek(start) + try: + shutil.copyfileobj(f, self.wfile, 1 << 20) + except (BrokenPipeError, ConnectionResetError): + pass # the client went away mid-file: that is a test, not a fault + + +server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Host) +with open(portfile + ".tmp", "w") as f: + f.write(str(server.server_address[1])) +os.replace(portfile + ".tmp", portfile) +server.serve_forever() diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index 92bf937..a8d8b8c 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -356,11 +356,18 @@ stop_unless_ok "$rc" "step 8 rollback" SERVE="${VMDIR}/channel"; rm -rf "$SERVE"; mkdir -p "$SERVE/${VB}" cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$SERVE/" for f in "$PAY_B"/*; do [[ -f "$f" ]] && ln -s "$(readlink -f "$f")" "$SERVE/${VB}/$(basename "$f")"; done -CHAN_PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" -python3 -m http.server "$CHAN_PORT" --bind 127.0.0.1 --directory "$SERVE" > "${VMDIR}/channel-httpd.log" 2>&1 & +# release-host.py honours Range with a 206, as the design asks of a release +# host, so a fetch that is cut resumes from its byte instead of re-reading +# gigabytes through the user network; it streams, binds loopback and a port +# the kernel picks, and logs one line per request. It must not outlive this +# suite whichever way the suite ends, hence the trap. +CHAN_LOG="${VMDIR}/channel-requests.log"; : > "$CHAN_LOG"; rm -f "${VMDIR}/channel.port" +python3 "${SELF}/release-host.py" "$SERVE" "${VMDIR}/channel.port" "$CHAN_LOG" > "${VMDIR}/channel-host.err" 2>&1 & CHAN_PID=$! -sleep 1 -kill -0 "$CHAN_PID" 2>/dev/null || die "the release host did not start: $(cat "${VMDIR}/channel-httpd.log")" +trap '[[ -n "${CHAN_PID:-}" ]] && kill "$CHAN_PID" 2>/dev/null' EXIT +for _ in $(seq 50); do [[ -s "${VMDIR}/channel.port" ]] && break; sleep 0.1; done +CHAN_PORT="$(cat "${VMDIR}/channel.port" 2>/dev/null)" +[[ -n "$CHAN_PORT" ]] || die "the release host did not start: $(cat "${VMDIR}/channel-host.err")" # The guest, with a network for this step and no other. Zone 0 names the # channel; the net zone sees that file only from its next launch, so its @@ -370,20 +377,38 @@ kill -0 "$CHAN_PID" 2>/dev/null || die "the release host did not start: $(cat "$ # is the offline path's apply, and the trial boot and the commit are the # ones steps 2 and 7 judge. Each wait is a loop in the guest that gives up # before the driver would. +# The net zone is restarted the way build/guest-tests/zones-check.sh does it, +# the one restart the suites have proven: down, a pause, up, then a NEW +# "netzone: READY" line in the catch-all log. Before that line, `status` +# would be read while the old zone is still there. +# (No single quote may appear in a command given to ROOTSH: the driver +# wraps it in them for `su -c`. And it must not `exit`, or the driver's own +# marker after it is never printed; hence the subshell.) +RESTART_NET='before=$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null); before=${before:-0}; s6-svc -d /run/service/net-zone; sleep 3; s6-svc -u /run/service/net-zone; (i=0; until [ "$(grep -hc "netzone: READY" /run/uncaught-logs/current 2>/dev/null || true)" -gt "$before" ]; do i=$((i+1)); [ $i -lt 90 ] || exit 1; sleep 1; done) && echo NET-RESTARTED || echo NET-NOT-READY' +# The release is gigabytes through the user network on a nested-KVM runner, +# so the wait for it is not a clock: it fails when the staged line has not +# changed for 100 seconds (a stall; the net zone asks once a minute, so less +# would call the quiet before the first piece a stall), it succeeds at +# "complete", and at the end of its six minutes it succeeds too if bytes +# were still arriving, for the next wait to take over. A slow link costs +# waits, not the run. +wait_arrival() { printf '%s' '(prev=; same=0; i=0; while [ $i -lt 72 ]; do s="$(kryptik update status | sed -n "s/^staged *//p")"; case "$s" in *"bytes, complete"*) echo ARRIVED-WHOLE; exit 0 ;; esac; if [ "$s" = "$prev" ]; then same=$((same+1)); else same=0; prev="$s"; fi; [ $same -lt 20 ] || { echo "STALLED at: $s"; exit 1; }; i=$((i+1)); sleep 5; done; echo "still arriving: $s")'; } # In a subshell, so that giving up is not the login shell's exit; and giving # up is a failed command, because the driver's `run:` judges the exit status # and the word it then expects is also in the command line the console echoes. wait_status() { printf '(i=0; until kryptik update status | grep -q "%s"; do i=$((i+1)); [ $i -lt 72 ] || exit 1; sleep 5; done) && echo %s || { kryptik update status; false; }' "$1" "$2"; } start_vm update-p8b --net user drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ - "$(ROOTSH "mkdir -p /etc/kryptik && printf 'channel = http://10.0.2.2:${CHAN_PORT}/\\n' > /etc/kryptik/update.conf && s6-svc -r /run/service/net-zone && echo CONF-OK")" "expect:CONF-OK" \ + "$(ROOTSH "mkdir -p /etc/kryptik && printf \"channel = http://10.0.2.2:${CHAN_PORT}/\\n\" > /etc/kryptik/update.conf && echo CONF-OK")" "expect:CONF-OK" \ + "$(ROOTSH "$RESTART_NET")" "expect:NET-RESTARTED" \ "run:kryptik update status | grep -q 'nothing asked for'" \ "run:$(wait_status "newest ${VB} " STATED-OK)" "expect:STATED-OK" \ "$(ROOTSH 'sleep 70; echo STAGED-UNASKED=$(ls /var/lib/kryptik/update/incoming 2>/dev/null | wc -l)')" "expect:STAGED-UNASKED=0" \ "run:kryptik update fetch" "expect:${VB} will be fetched" \ "run:$(wait_status "${VB}: .* bytes, " ARRIVING-OK)" "expect:ARRIVING-OK" \ - "run:$(wait_status "bytes, complete" COMPLETE-OK)" "expect:COMPLETE-OK" \ - "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr '\\n' ' '; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img manifest manifest.sig root.json LISTED" \ + "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" "run:$(wait_arrival)" \ + "run:kryptik update status | grep -q 'bytes, complete'" \ + "$(ROOTSH "ls /var/lib/kryptik/update/incoming/${VB} | sort | tr \"\\n\" \" \"; echo LISTED")" "expect:kryptik-a.efi kryptik-b.efi kryptik-root.img manifest manifest.sig root.json LISTED" \ "run:kryptik update apply" "expect:armed: the next boot tries slot b" \ "$(ROOTSH 'reboot')" "expect:Linux version" "expect:KRYPTIK_SMOKE: END" \ "login:${TUSER}:${TPASS}" \ @@ -391,12 +416,12 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "run:test \"\$(cat /home/${TUSER}/marker)\" = before-update" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm -kill "$CHAN_PID" 2>/dev/null +kill "$CHAN_PID" 2>/dev/null; CHAN_PID="" [[ "$rc" -eq 0 ]] && green "the net zone brought the statement, nothing was fetched until it was asked for, ${VB} arrived whole, and it was applied, trial-booted and committed; data intact" || red "step 8 drive failed" txt | grep -q "version_id=${VB}" && green "guest reports version ${VB} after the fetched update" || red "guest did not report ${VB}" # What the release host was asked for: the statement, then the manifest and # its signature before anything large. -first="$(grep -o 'GET /[^ ]*' "${VMDIR}/channel-httpd.log" | sed 's|GET /||' | awk '!seen[$0]++' | head -5 | tr '\n' ' ')" +first="$(awk '{sub("^/", "", $1); if (!seen[$1]++) print $1}' "$CHAN_LOG" | head -5 | tr '\n' ' ')" if [[ "$first" == "latest latest.sig ${VB}/manifest ${VB}/manifest.sig "* ]]; then green "the release host was asked for the statement, then the manifest and its signature, before any image"; else red "the release host was asked in another order: ${first}"; fi printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" diff --git a/tools/test-update-fetch.sh b/tools/test-update-fetch.sh index fdd5f72..fc6361f 100755 --- a/tools/test-update-fetch.sh +++ b/tools/test-update-fetch.sh @@ -39,34 +39,9 @@ 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 HTTP server: Range honoured unless $T/norange exists --------------------- -cat > "$T/httpd.py" <<'EOF' -import http.server, os, sys -root, portfile, flag, log = sys.argv[1:5] -class H(http.server.BaseHTTPRequestHandler): - def log_message(self, *a): pass - def do_GET(self): - path = os.path.normpath(os.path.join(root, self.path.lstrip("/"))) - if not path.startswith(root) or not os.path.isfile(path): - self.send_error(404); return - data = open(path, "rb").read() - rng = self.headers.get("Range") - open(log, "a").write("%s %s\n" % (self.path, rng or "-")) - if rng and not os.path.exists(flag): - start = int(rng.split("=")[1].split("-")[0]) - body = data[start:] - self.send_response(206) - self.send_header("Content-Range", "bytes %d-%d/%d" % (start, len(data) - 1, len(data))) - else: - body = data - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) -srv = http.server.HTTPServer(("127.0.0.1", 0), H) -open(portfile, "w").write(str(srv.server_address[1])) -srv.serve_forever() -EOF +# --- 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' @@ -121,7 +96,7 @@ while True: c.sendall((reply + "\n").encode()); c.close() EOF -python3 "$T/httpd.py" "$T/www" "$T/port" "$T/norange" "$T/http.log" & PIDS+=($!) +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; } From bcea985b905434c8878e804cb66afdf0127267a2 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sat, 19 Sep 2026 22:50:18 -0700 Subject: [PATCH 17/25] The broker's fuzz seeds gain the update channel's three verbs The seed file came in with the parser tests, which said the new verbs were worth a line each once they existed. Six lines: the poll, a statement inside and just past its size limit, a piece of the manifest, a piece of an image at an offset, and a name that climbs. The test's zone holds no network, so every one of them is refused by who is asking before a byte of payload is read, and nothing the fuzz sends can reach the staging area. 203 unit tests. --- compartments/kryptikd/fuzz-corpus/broker-requests | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compartments/kryptikd/fuzz-corpus/broker-requests b/compartments/kryptikd/fuzz-corpus/broker-requests index 373de5d..7d55c35 100644 --- a/compartments/kryptikd/fuzz-corpus/broker-requests +++ b/compartments/kryptikd/fuzz-corpus/broker-requests @@ -16,3 +16,9 @@ time-offset +3599.999999 16 time-offset 1e9 4 time-offset nan 4 steal +update-poll +update-latest 300 120 +update-latest 8193 1 +update-put manifest 0 64 +update-put kryptik-root.img 1048576 1048576 +update-put ../kryptik-root.img 0 5 From 2176597ac657aeac416f73479149991921a063c1 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 00:55:13 -0700 Subject: [PATCH 18/25] Three code scanning alerts fixed at the source: no shell in the EFI tool, every append checked in the launcher, no passwords in test transcripts kryptik-efiboot ran its two helper programs through popen, which means through a shell, with a device name pasted into the command line. The name comes from devices.sh on the verified root, so it was not exploitable, but a root tool that builds command lines out of strings is one edit from trusting the wrong one. run_read now forks and execs an argument array with no shell; stderr goes to /dev/null as before. Exercised lifted out of the tool: an argument full of $(...), backticks, ; and | comes back literally and nothing runs; only the first line is returned; a program that is not there or prints nothing is an error. kryptik-launch sized its request correctly since the earlier fix, but still added snprintf's return value to an offset unchecked, which is the pattern that turns a short buffer into a write past its end. Every piece is now checked to have fitted before the next is placed. The 2000-character runtime directory that overflowed before is clean under AddressSanitizer. vm-drive.py wrote everything it sent to the guest into its transcript, passwords included, and transcripts are uploaded with every acceptance report. They are the test image's throwaway passwords, but there is no reason for them to be there: login and su now send the password with secret=True and the transcript says "(a password)". --- tools/desktop/kryptik-launch.c | 17 ++++++++++++--- tools/efi/kryptik-efiboot.c | 39 +++++++++++++++++++++++++++------- tools/image/vm-drive.py | 11 ++++++---- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/tools/desktop/kryptik-launch.c b/tools/desktop/kryptik-launch.c index f6917b8..3c60bd1 100644 --- a/tools/desktop/kryptik-launch.c +++ b/tools/desktop/kryptik-launch.c @@ -456,13 +456,24 @@ int main(int argc, char **argv) char *req = malloc(cap); if (!req) die("out of memory"); - int len = snprintf(req, cap, "run %s%s%s%s\n", zone, wl ? " wayland=" : "", wl ? wl : "", pass_fd >= 0 ? " pass=fd" : ""); + /* snprintf returns what it WANTED to write. Adding that to an offset + * without looking is how a short buffer becomes a write past its end, + * so every piece is checked to have fitted before the next is placed. */ + size_t len = 0; + #define PUT(...) do { \ + int n_ = snprintf(req + len, cap - len, __VA_ARGS__); \ + if (n_ < 0 || (size_t)n_ >= cap - len) \ + die("request too long"); \ + len += (size_t)n_; \ + } while (0) + PUT("run %s%s%s%s\n", zone, wl ? " wayland=" : "", wl ? wl : "", pass_fd >= 0 ? " pass=fd" : ""); for (i = 0; i < ncmd; i++) { if (strchr(cmd[i], '\n')) die("argument %d contains a newline", i); - len += snprintf(req + len, cap - (size_t)len, "arg %s\n", cmd[i]); + PUT("arg %s\n", cmd[i]); } - len += snprintf(req + len, cap - (size_t)len, "end\n"); + PUT("end\n"); + #undef PUT char *r = talk(req, pass_fd); if (pass_fd >= 0) diff --git a/tools/efi/kryptik-efiboot.c b/tools/efi/kryptik-efiboot.c index 1659af2..e6ed0c3 100644 --- a/tools/efi/kryptik-efiboot.c +++ b/tools/efi/kryptik-efiboot.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #define EFIVARS "/sys/firmware/efi/efivars/" @@ -90,21 +91,43 @@ static int delete_var(const char *name) { /* --- the ESP partition, by label ------------------------------------------ */ struct part { char dev[128]; char uuid[40]; uint64_t start, size; uint32_t number; }; -static int run_read(const char *cmd, char *out, size_t cap) { - FILE *p = popen(cmd, "r"); if (!p) return -1; - if (!fgets(out, (int)cap, p)) { pclose(p); return -1; } - pclose(p); +/* The first line a program prints. No shell: the arguments are an array, so + * nothing in them is ever parsed as a command. The device name handed to + * blkid comes from devices.sh on the verified root, but a root tool that + * builds a command line out of any string is one edit away from trusting + * the wrong one. */ +static int run_read(char *const argv[], char *out, size_t cap) { + int fd[2]; + if (pipe(fd)) return -1; + pid_t pid = fork(); + if (pid < 0) { close(fd[0]); close(fd[1]); return -1; } + if (pid == 0) { + int nul = open("/dev/null", O_WRONLY); + dup2(fd[1], 1); + if (nul >= 0) dup2(nul, 2); + close(fd[0]); close(fd[1]); + execvp(argv[0], argv); + _exit(127); + } + close(fd[1]); + FILE *p = fdopen(fd[0], "r"); + int got = p && fgets(out, (int)cap, p) != NULL; + if (p) fclose(p); else close(fd[0]); + int st; + while (waitpid(pid, &st, 0) < 0 && errno == EINTR) {} + if (!got) return -1; out[strcspn(out, "\n")] = 0; return 0; } static int find_esp(struct part *p) { char dev[128]; - if (run_read("/usr/libexec/kryptik/devices.sh part kryptik-esp 2>/dev/null", dev, sizeof dev) || !dev[0]) return -1; + char *find[] = { "/usr/libexec/kryptik/devices.sh", "part", "kryptik-esp", NULL }; + if (run_read(find, dev, sizeof dev) || !dev[0]) return -1; snprintf(p->dev, sizeof p->dev, "%s", dev); - char cmd[256], out[128]; - snprintf(cmd, sizeof cmd, "blkid -s PARTUUID -o value %s 2>/dev/null", dev); - if (run_read(cmd, out, sizeof out) || strlen(out) != 36) return -1; + char out[128]; + char *uuid[] = { "blkid", "-s", "PARTUUID", "-o", "value", dev, NULL }; + if (run_read(uuid, out, sizeof out) || strlen(out) != 36) return -1; snprintf(p->uuid, sizeof p->uuid, "%s", out); const char *base = strrchr(dev, '/'); base = base ? base + 1 : dev; char path[256]; diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index 73a4695..b0a4433 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -87,11 +87,14 @@ def expect(self, regex, timeout=None): raise RuntimeError(f"timeout ({timeout}s) waiting for {regex!r}; last output:\n{tail}") self._read() - def send(self, text, enter=True): + def send(self, text, enter=True, secret=False): data = text.encode() + (b"\r" if enter else b"") self.s.sendall(data) if self.log: - self.log.write(b"\n<<< " + text.encode() + b"\n"); self.log.flush() + # A password goes to the guest and not into the transcript, which + # is uploaded with every acceptance report. + shown = b"(a password)" if secret else text.encode() + self.log.write(b"\n<<< " + shown + b"\n"); self.log.flush() def drain(self, seconds): end = time.time() + seconds @@ -142,7 +145,7 @@ def login(self, user, password): self.knock(r"login: ?$", self.timeout) self.send(user) self.expect(r"Password: ?", 60) - self.send(password) + self.send(password, secret=True) # a fresh shell prompt: bash prints "user@host:dir$ " or "$ " self.expect(r"[$#] ?$", 60) # make the prompt unambiguous for run() @@ -184,7 +187,7 @@ def su(self, password, cmd): tag = f"KRC{self.marker}" self.send(f"su - root -c '{cmd}; echo {tag}=$?'") self.expect(r"Password: ?", 60) - self.send(password) + self.send(password, secret=True) # Wait for the exit marker, but keep what the command printed: the # steps that follow expect lines of that output ("running slot: a", # "ZT END"), and a plain expect() would have consumed them with the From 9d5492480d4f3181519488d3044c8704d9977993 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 01:04:17 -0700 Subject: [PATCH 19/25] A password has its own way to the guest, so no path leads from one to the transcript The first version passed secret=True to send(), which still wrote to the log on its other branch, and the scanner rightly could not tell the two apart: it raised the same alert three lines lower. send_secret() writes the password to the socket and a fixed string to the transcript, so the separation is in the structure and not in a flag. --- tools/image/vm-drive.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index b0a4433..292daa7 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -87,14 +87,19 @@ def expect(self, regex, timeout=None): raise RuntimeError(f"timeout ({timeout}s) waiting for {regex!r}; last output:\n{tail}") self._read() - def send(self, text, enter=True, secret=False): + def send(self, text, enter=True): data = text.encode() + (b"\r" if enter else b"") self.s.sendall(data) if self.log: - # A password goes to the guest and not into the transcript, which - # is uploaded with every acceptance report. - shown = b"(a password)" if secret else text.encode() - self.log.write(b"\n<<< " + shown + b"\n"); self.log.flush() + self.log.write(b"\n<<< " + text.encode() + b"\n"); self.log.flush() + + def send_secret(self, text): + # To the guest and never to the transcript, which is uploaded with every + # acceptance report. A method of its own, so that no path leads from a + # password to the log. + self.s.sendall(text.encode() + b"\r") + if self.log: + self.log.write(b"\n<<< (a password)\n"); self.log.flush() def drain(self, seconds): end = time.time() + seconds @@ -145,7 +150,7 @@ def login(self, user, password): self.knock(r"login: ?$", self.timeout) self.send(user) self.expect(r"Password: ?", 60) - self.send(password, secret=True) + self.send_secret(password) # a fresh shell prompt: bash prints "user@host:dir$ " or "$ " self.expect(r"[$#] ?$", 60) # make the prompt unambiguous for run() @@ -187,7 +192,7 @@ def su(self, password, cmd): tag = f"KRC{self.marker}" self.send(f"su - root -c '{cmd}; echo {tag}=$?'") self.expect(r"Password: ?", 60) - self.send(password, secret=True) + self.send_secret(password) # Wait for the exit marker, but keep what the command printed: the # steps that follow expect lines of that output ("running slot: a", # "ZT END"), and a plain expect() would have consumed them with the From a8ffcfb8317038325a48a749e5f2332bbb870be0 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 01:10:16 -0700 Subject: [PATCH 20/25] The build checks that the device helper is executable, now that a tool runs it directly kryptik-efiboot used to reach devices.sh through a shell and now execs it, so the file's own exec bit is what it depends on. The updater and efiboot already have the x beside them in the boot check; the helper did not. --- build/stages/04-base-system.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index cd037d3..a40c04c 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -1594,7 +1594,7 @@ s_boot_check() { chk "sysctl fragments" /usr/lib/kryptik/sysctl.d chk "zone definitions" /usr/lib/kryptik/zones/work.toml chk "zone policies" /usr/lib/kryptik/zones/policy/work.seccomp - chk "device helper" /usr/libexec/kryptik/devices.sh + chk "device helper" /usr/libexec/kryptik/devices.sh x chk "boot scripts" /usr/libexec/kryptik/sysinit.sh x chk "test control helper" /usr/libexec/kryptik/testctl.sh chk "boot-success" /usr/libexec/kryptik/boot-success.sh x From 37203ee750eaa6ab293320b7c5f2a9807cc67844 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 01:39:38 -0700 Subject: [PATCH 21/25] util-linux 2.42.3 builds against glibc 2.40, and asks the kernel to block symlinks and not magic links The from-nothing build (run 35496552035, 9329c91) stopped in stage 04 at util-linux: libmount/src/hook_idmap.c:335, 'RESOLVE_NO_SYMLINKS' undeclared. Everything before it had passed on the new toolchain, the compiler check included. It is upstream's, and visible only where the C library's does not bring in, which is glibc before 2.43. hook_idmap.c uses the constant and includes nothing that defines it. Looking for where the other files get it found the worse half: include/fileutils.h defines a fallback of 0x02, and in the kernel's ABI 0x02 is RESOLVE_NO_MAGICLINKS; RESOLVE_NO_SYMLINKS is 0x04. context.c preprocesses to `mnt_context_is_restricted(cxt) ? 0x02 : 0` on such a libc, so the restricted-mount hardening 2.42 added blocks the wrong thing. Kryptik's mount is not setuid, so that mode is not reachable here; it is fixed anyway, because a carried patch should not leave a known-wrong constant beside the line it touches. build/patches/util-linux-2.42.3 holds one patch: fileutils.h includes the kernel header where configure found it and its fallback is 0x04, and hook_idmap.c includes fileutils.h as context.c and hook_mount.c do. The row becomes s_util_linux, which applies the set through apply_repo_patches with the same configure flags as before. Reproduced and checked on a glibc 2.39 host: the released tarball fails at the identical line; the patch applies to it with no fuzz through the project's own function, which refuses a tampered copy; the whole package then builds; and context.c preprocesses to 0x04 where it had 0x02. --- ...S-is-0x04-and-hook_idmap-includes-it.patch | 43 +++++++++++++++++++ build/patches/util-linux-2.42.3/README.md | 27 ++++++++++++ build/patches/util-linux-2.42.3/SHA256SUMS | 1 + build/stages/04-base-system.sh | 14 +++++- 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch create mode 100644 build/patches/util-linux-2.42.3/README.md create mode 100644 build/patches/util-linux-2.42.3/SHA256SUMS diff --git a/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch b/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch new file mode 100644 index 0000000..130fbda --- /dev/null +++ b/build/patches/util-linux-2.42.3/0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch @@ -0,0 +1,43 @@ +libmount: RESOLVE_NO_SYMLINKS is 0x04, and hook_idmap.c includes its definition + +Carried by Kryptik against util-linux 2.42.3; not upstream at the time of +writing. Two defects in the restricted-mount hardening, both visible only +where the C library's does not bring in (glibc +before 2.43): + + * libmount/src/hook_idmap.c uses RESOLVE_NO_SYMLINKS and includes nothing + that defines it, so it does not compile. + * include/fileutils.h defines a fallback for it, 0x02. In the kernel's ABI + 0x02 is RESOLVE_NO_MAGICLINKS; RESOLVE_NO_SYMLINKS is 0x04. context.c and + hook_mount.c compile with the fallback, so a restricted mount asks the + kernel to block magic links and not symlinks. + +fileutils.h now includes where configure found it, and its +fallback is the kernel's value; hook_idmap.c includes fileutils.h as the +other two do. + +--- a/include/fileutils.h ++++ b/include/fileutils.h +@@ -65,8 +65,11 @@ + extern int ul_openat_resolve(int dirfd, const char *path, int flags, + mode_t mode, unsigned long long resolve); + ++#ifdef HAVE_LINUX_OPENAT2_H ++# include ++#endif + #ifndef RESOLVE_NO_SYMLINKS +-# define RESOLVE_NO_SYMLINKS 0x02 ++# define RESOLVE_NO_SYMLINKS 0x04 + #endif + #ifndef RESOLVE_BENEATH + # define RESOLVE_BENEATH 0x08 +--- a/libmount/src/hook_idmap.c ++++ b/libmount/src/hook_idmap.c +@@ -26,6 +26,7 @@ + #include "namespace.h" + + #include "mountP.h" ++#include "fileutils.h" + + #ifdef HAVE_LINUX_NSFS_H + # include diff --git a/build/patches/util-linux-2.42.3/README.md b/build/patches/util-linux-2.42.3/README.md new file mode 100644 index 0000000..355b187 --- /dev/null +++ b/build/patches/util-linux-2.42.3/README.md @@ -0,0 +1,27 @@ +# util-linux 2.42.3: the restricted-mount flag, where libc does not define it + +Applied by `s_util_linux` in stage 04 through `apply_repo_patches`; +`SHA256SUMS` is verified before anything is applied. + +util-linux 2.42 makes a restricted mount refuse symlinks in its paths by +passing `RESOLVE_NO_SYMLINKS` to `openat2()`. On a C library whose `` +does not bring `` in (glibc before 2.43; Kryptik is on 2.40) +that has two defects: + +- `libmount/src/hook_idmap.c` uses the constant and includes nothing that + defines it. It does not compile: this is what stopped the build. +- `include/fileutils.h` defines a fallback of `0x02`. In the kernel's ABI + `0x02` is `RESOLVE_NO_MAGICLINKS`; `RESOLVE_NO_SYMLINKS` is `0x04`. + `context.c` compiled with the fallback, so a restricted mount asked the + kernel to block the wrong thing. + +The patch makes `fileutils.h` include the kernel header where configure found +it, corrects the fallback, and has `hook_idmap.c` include `fileutils.h` as +`context.c` and `hook_mount.c` do. Checked on a glibc 2.39 host: the released +tarball fails at `hook_idmap.c:335`; patched, it builds, and `context.c` +preprocesses to `0x04` where it had `0x02`. + +Kryptik's `mount` is not setuid, so its restricted mode is not reachable here; +the value is corrected because a carried patch should not leave a known-wrong +constant beside the line it fixes. Not upstream when this was written. Delete +this directory when a util-linux release carries the fix. diff --git a/build/patches/util-linux-2.42.3/SHA256SUMS b/build/patches/util-linux-2.42.3/SHA256SUMS new file mode 100644 index 0000000..08782e2 --- /dev/null +++ b/build/patches/util-linux-2.42.3/SHA256SUMS @@ -0,0 +1 @@ +fc9be07ae0bb2e7656d12e17cf64d21ce1982037516d777fe8b60348a096c8bb 0001-libmount-RESOLVE_NO_SYMLINKS-is-0x04-and-hook_idmap-includes-it.patch diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index cd037d3..c5dcb55 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -163,6 +163,18 @@ native_build() { # --- packages that need more than ./configure ------------------------------ +# util-linux with the patch set in build/patches (see its README): 2.42.3 does +# not compile against a glibc older than 2.43, and gets one flag wrong there. +s_util_linux() { + local src; src="$(unpack "util-linux-${V_UTIL_LINUX}.tar.xz" "util-linux-${V_UTIL_LINUX}")" + cd "$src" + apply_repo_patches "util-linux-${V_UTIL_LINUX}" + ./configure --prefix=/usr --libdir=/usr/lib --runstatedir=/run --disable-chfn-chsh --disable-login --disable-nologin --disable-su --disable-setpriv --disable-runuser --disable-pylibmount --disable-liblastlog2 --disable-static --without-python + make + make install +} + + # Locale generation, using the localedef already installed by stage 01/02. # # Split out from the glibc rebuild and placed FIRST because of a dependency @@ -2308,7 +2320,7 @@ PACKAGES=( "zlib" "s_zlib" "python" "s_python" "texinfo" "native_build texinfo-${V_TEXINFO}.tar.xz texinfo-${V_TEXINFO}" - "util-linux" "native_build util-linux-${V_UTIL_LINUX}.tar.xz util-linux-${V_UTIL_LINUX} --libdir=/usr/lib --runstatedir=/run --disable-chfn-chsh --disable-login --disable-nologin --disable-su --disable-setpriv --disable-runuser --disable-pylibmount --disable-liblastlog2 --disable-static --without-python" + "util-linux" "s_util_linux" "glibc" "s_glibc" "bzip2" "s_bzip2" "xz" "s_xz_native" From 7dbd0a521fed23546a937daf715fb455282da087 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 01:47:06 -0700 Subject: [PATCH 22/25] The suites' release host holds a requested name to its root before it touches the filesystem with it Code scanning raised three path alerts on tools/image/release-host.py: the requested path reached isfile, getsize and open. The check that the normalised name stays under the root was there, but it shared one condition with the isfile call, so the name was used before it had been judged. The check now stands alone and first, and only a name that passed it reaches any filesystem call. Behaviour is the same, probed both ways: a file and a link inside the root are served (the suites link to a payload rather than copy it), /../ and /sub/../../ outside it are 404, as are the root itself and a directory. The fetch suite passes on it, 11 rows. It binds loopback only and exists for the suites; the alerts were still right about the order. --- tools/image/release-host.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/image/release-host.py b/tools/image/release-host.py index 0ad7492..fffe409 100755 --- a/tools/image/release-host.py +++ b/tools/image/release-host.py @@ -30,9 +30,14 @@ def log_message(self, *args): def do_GET(self): # Links inside ROOT may point anywhere (the suites link to a payload - # rather than copy it); the requested NAME may not leave ROOT. + # rather than copy it); the requested NAME may not leave ROOT. The + # name is normalised and held to ROOT before anything touches the + # filesystem with it. path = os.path.normpath(os.path.join(root, self.path.split("?", 1)[0].lstrip("/"))) - if not (path == root or path.startswith(root + os.sep)) or not os.path.isfile(path): + if not path.startswith(root + os.sep): + self.send_error(404) + return + if not os.path.isfile(path): self.send_error(404) return rng = self.headers.get("Range") From 6fdccf875f8b3bb82e445c8242a810ec144b068b Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 12:06:17 -0700 Subject: [PATCH 23/25] The six installed-system suites share one copy of their helpers Each suite carried its own verdict counters, test accounts and password hashes, control-disk preseed, serial driver wrapper, start_vm, stop_vm, ROOTSH and partition-offset helper: the same twenty lines six times, with three spellings of part_start. They are in tools/image/suite-lib.sh now. A suite that differs says how: update-test sets DRIVE_TIMEOUT, zones-test keeps its per-call timeout and passes --net user itself. No behaviour changes; 90 lines out, 24 in, plus the 35-line shared file. --- tools/image/gui-test.sh | 12 +++--------- tools/image/install-test.sh | 14 +++----------- tools/image/integrity-test.sh | 19 ++++++------------- tools/image/state-test.sh | 23 +++-------------------- tools/image/suite-lib.sh | 33 +++++++++++++++++++++++++++++++++ tools/image/update-test.sh | 26 +++++--------------------- tools/image/zones-test.sh | 20 ++++---------------- 7 files changed, 57 insertions(+), 90 deletions(-) create mode 100755 tools/image/suite-lib.sh diff --git a/tools/image/gui-test.sh b/tools/image/gui-test.sh index 79d13bf..5e9b1a7 100755 --- a/tools/image/gui-test.sh +++ b/tools/image/gui-test.sh @@ -37,15 +37,9 @@ VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/gui.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/gui-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" SHOT="${VMDIR}/gui-untrusted.ppm" SHOT_FS="${VMDIR}/gui-untrusted-fullscreen.ppm" @@ -56,7 +50,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-gui.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name gui-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index 8d67cef..c0c2965 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -47,19 +47,12 @@ case "$DISK" in /dev/*|/sys/*|/proc/*) die "refusing to use ${DISK} as a target [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} exists and is not a regular file" for t in python3 sfdisk blkid truncate; do have "$t" || die "required tool not found: $t"; done -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" want() { if grep -qE "$2" "$1"; then green "$3"; else red "$3"; fi; } deny() { if grep -qE "$2" "$1"; then red "$3"; else green "$3"; fi; } -step() { printf '\n==> %s\n' "$*"; } txt_of() { tr -d '\r' < "$1"; } -# The test account and root password the preseed creates. The hashes are -# what lands on disk; the plaintext exists only in this harness. -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -hash_of() { openssl passwd -6 "$1"; } -TUSER_HASH="$(hash_of "$TPASS")"; ROOT_HASH="$(hash_of "$RPASS")" # ----------------------------------------------------------------- step 1 -- step "step 1: install from the medium onto a blank ${SIZE} disk" @@ -68,7 +61,7 @@ if [[ -z "$SIZE" ]]; then SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" rm -f "$DISK"; truncate -s "$SIZE" "$DISK" CTL="${VMDIR}/testctl-install.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null || die "control disk" + "${PRESEED[@]}" > /dev/null || die "control disk" "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars "$VARS" --mode smoke --timeout "$TIMEOUT" --name install-p1 qrc=$? P1="${VMDIR}/install-p1.txt"; txt_of "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" > "$P1" @@ -102,7 +95,6 @@ cp "/usr/share/OVMF/OVMF_VARS_4M.fd" "$VARSF" SERVE="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name install-p2)" SER="$(sed -n 's/^serial=//p' <<<"$SERVE")"; PIDF="$(sed -n 's/^pid=//p' <<<"$SERVE")"; LOG2="$(sed -n 's/^log=//p' <<<"$SERVE")" [[ -S "$SER" ]] || die "no serial socket from run-ovmf: ${SERVE}" -DRV="${SELF}/vm-drive.py" REC="${VMDIR}/install-p2.json" python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "expect:KRYPTIK_SMOKE: END" \ diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index 328ba5f..ff82df2 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -46,20 +46,13 @@ DISK="${DISK:-${VMDIR}/integrity.img}" ENROLLED="${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" [[ -f "$ENROLLED" ]] || die "no enrolled variable store; run tools/image/ovmf-vars.sh" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/integrity-vars.fd"; cp "$ENROLLED" "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" txt_latest() { tr -d '\r' < "$LATEST"; } # Partition offsets on the disk file, from its GPT, so the host can edit the # ESP with mtools and flip bytes in slot a without mounting anything. -part_start() { sfdisk -d "$DISK" 2>/dev/null | awk -v n="$1" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } # ----------------------------------------------------------------- step 1 -- step "step 1: install, then boot alone with the developer key enrolled (Secure Boot on)" @@ -68,7 +61,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-integrity.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars enrolled --mode smoke --timeout "$TIMEOUT" --name integ-install > /dev/null txt_latest | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed from the medium under Secure Boot" || { red "install failed"; exit 1; } txt_latest | grep -q 'KRYPTIK_SMOKE: secureboot=1' && green "the medium itself booted with Secure Boot enforced" || red "medium did not report secureboot=1" @@ -101,7 +94,7 @@ grep -q 'SIGNED=loaded' <<<"$T1" && green "the module signed by the build loads" # ----------------------------------------------------------------- step 2 -- step "step 2: an untrusted boot artifact is refused by the firmware" -ESP_OFF=$(( $(part_start 1) * 512 )) +ESP_OFF=$(( $(part_start "$DISK" 1) * 512 )) ESPIMG="${VMDIR}/integrity-esp.img" # lift the ESP out, keep a pristine copy, swap in a foreign-signed kernel dd if="$DISK" of="$ESPIMG" bs=1M iflag=skip_bytes,count_bytes skip="$ESP_OFF" count=$((512*1024*1024)) status=none @@ -131,7 +124,7 @@ rm -rf "$TMPK" # ----------------------------------------------------------------- step 3 -- step "step 3: a tampered root is refused by dm-verity before userspace" -A_OFF=$(( $(part_start 2) * 512 )) +A_OFF=$(( $(part_start "$DISK" 2) * 512 )) # Flip a byte in the ext4 superblock (byte 1024 of the image, the volume # name field at +0x78): the first thing a root mount reads, so dm-verity # sees a block whose hash does not match before any userspace exists. A @@ -177,7 +170,7 @@ step "step 5: offline tampering of the state partition does not reach privileged # a trust anchor of their own for updates, a zone definition the launch # daemon will honour, a kernel tunable applied at boot. Plant all three from # the host, boot, and measure each from inside the guest. -S_OFF=$(( $(part_start 4) * 512 )) +S_OFF=$(( $(part_start "$DISK" 4) * 512 )) TMPK="$(mktemp -d)"; MNT="$TMPK/state"; mkdir -p "$MNT" ssh-keygen -q -t ed25519 -N "" -f "$TMPK/attacker" >/dev/null if mount -o loop,offset="$S_OFF" "$DISK" "$MNT" 2>/dev/null; then diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index 4170f62..7220186 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -51,27 +51,10 @@ DISK="${DISK:-${VMDIR}/state.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" CLONE="${VMDIR}/state-clone.img" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/state-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" -start_vm() { # start_vm NAME [extra args] -> SER PIDF LOG - local name="$1"; shift - local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}" -} -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } -drive() { python3 "$DRV" --serial "$SER" --timeout 300 "$@"; } -txt() { tr -d '\r' < "$LOG"; } -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -part_start() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } # A boot that must come up degraded: smoke mode, transcript only, and the # guest cannot power itself off (no user to log in as), so it is killed at @@ -109,7 +92,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB")" || die "could not siz rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-state.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name state-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } start_vm state-p1 diff --git a/tools/image/suite-lib.sh b/tools/image/suite-lib.sh new file mode 100755 index 0000000..d5d70d4 --- /dev/null +++ b/tools/image/suite-lib.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# What the installed-system suites share: the verdict, the accounts the +# preseed creates, and the plumbing around run-ovmf.sh and vm-drive.py. +# Sourced after common.sh with SELF set. start_vm reads DISK and VARSF; drive +# reads DRIVE_TIMEOUT. +# shellcheck disable=SC2034 # read by the suite that sources this + +PASS=0; FAIL=0 +green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } +red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } +step() { printf '\n==> %s\n' "$*"; } + +# The plaintext exists only in the harness; the hashes are what lands on disk. +TUSER=tester; TPASS=tester-pw; RPASS=root-pw +TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" +PRESEED=( "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" ) + +DRV="${SELF}/vm-drive.py" +LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" + +start_vm() { # start_vm NAME [run-ovmf args] -> SER QMP PIDF LOG + local name="$1"; shift + local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" + SER="$(sed -n 's/^serial=//p' <<<"$out")"; QMP="$(sed -n 's/^qmp=//p' <<<"$out")" + PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" + [[ -S "$SER" ]] || die "no serial socket: ${out}" +} +stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } +drive() { python3 "$DRV" --serial "$SER" --timeout "${DRIVE_TIMEOUT:-300}" "$@"; } +txt() { tr -d '\r' < "$LOG"; } +ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } # a command as root, through su +# Where partition N of a disk file starts, in sectors, from its GPT. +part_start() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } diff --git a/tools/image/update-test.sh b/tools/image/update-test.sh index a8d8b8c..15c3966 100755 --- a/tools/image/update-test.sh +++ b/tools/image/update-test.sh @@ -67,13 +67,8 @@ VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/updated.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/updated-vars.fd" [[ "$VARS" == "enrolled" ]] && cp "${KRYPTIK_WORK}/keys/sb/vars/enrolled.fd" "$VARSF" || cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" @@ -109,18 +104,7 @@ mk_variant hidden; mkdir -p "$BAD/hidden/lost+found"; echo "ride along" > "$BAD/ mkdir -p "$BAD/statement"; cp "$CHAN_B/latest" "$CHAN_B/latest.sig" "$CHAN_B/not-a-pointer" "$CHAN_B/not-a-pointer.sig" "$BAD/statement/" BADIMG="${VMDIR}/payload-bad.img"; payload_disk "$BADIMG" "$BAD" -# The guest side, as root through su. -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -start_vm() { # start_vm NAME [extra run-ovmf args] -> sets SER PIDF LOG - local name="$1"; shift - local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")"; QMP="$(sed -n 's/^qmp=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}" -} -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } -drive() { python3 "$DRV" --serial "$SER" --timeout 420 "$@"; } -txt() { tr -d '\r' < "$LOG"; } -part_start_disk() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } +DRIVE_TIMEOUT=420 # Each step starts from the state the one before it leaves. When the copy in # step 4 failed for want of disk space, steps 5 to 7 went on to roll back a @@ -145,7 +129,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB_A" --payloads 2)" || die rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-update.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB_A" --disk "$DISK" --testctl "$CTL" --vars "$VARS" --mode smoke --timeout "$TIMEOUT" --name update-install > /dev/null tr -d '\r' < "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "A installed" || { red "A did not install"; exit 1; } @@ -302,7 +286,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "B armed from slot a" || red "step 7 arming failed" stop_unless_ok "$rc" "step 7 arming" -B_OFF=$(( $(part_start_disk "$DISK" 3) * 512 )) +B_OFF=$(( $(part_start "$DISK" 3) * 512 )) # The ext4 superblock's volume name: the first block a root mount reads, so # the trial boot meets the corruption at once (a byte deep in the data area # can sit in a block nothing reads at boot, and the trial would succeed). diff --git a/tools/image/zones-test.sh b/tools/image/zones-test.sh index 3a6efc0..f706af0 100755 --- a/tools/image/zones-test.sh +++ b/tools/image/zones-test.sh @@ -46,22 +46,10 @@ VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/zones.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" -PASS=0; FAIL=0 -green() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); } -red() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); } -step() { printf '\n==> %s\n' "$*"; } -TUSER=tester; TPASS=tester-pw; RPASS=root-pw -TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -DRV="${SELF}/vm-drive.py" +# shellcheck source=tools/image/suite-lib.sh +source "${SELF}/suite-lib.sh" VARSF="${VMDIR}/zones-vars.fd"; cp /usr/share/OVMF/OVMF_VARS_4M.fd "$VARSF" -LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" -ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } -start_vm() { local name="$1"; shift; local out; out="$("${SELF}/run-ovmf.sh" --no-media --disk "$DISK" --vars-file "$VARSF" --mode serve --allow-reboot --net user --name "$name" "$@")" - SER="$(sed -n 's/^serial=//p' <<<"$out")"; PIDF="$(sed -n 's/^pid=//p' <<<"$out")"; LOG="$(sed -n 's/^log=//p' <<<"$out")" - [[ -S "$SER" ]] || die "no serial socket: ${out}"; } -stop_vm() { sleep 1; [[ -f "$PIDF" ]] && kill "$(cat "$PIDF")" 2>/dev/null; sleep 1; } drive() { python3 "$DRV" --serial "$SER" --timeout "$1" "${@:2}"; } -txt() { tr -d '\r' < "$LOG"; } # ----------------------------------------------------------------- step 1 -- step "step 1: install and boot alone with a NIC" @@ -70,7 +58,7 @@ DISK_SIZE="$("${SELF}/test-disk-size.sh" --medium "$USB" --extra-mib 2048)" || d rm -f "$DISK"; truncate -s "$DISK_SIZE" "$DISK" CTL="${VMDIR}/testctl-zones.img" "${SELF}/mk-testctl.sh" --out "$CTL" install_target=/dev/vda smoke_poweroff=1 install_wait=5 \ - "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" > /dev/null + "${PRESEED[@]}" > /dev/null "${SELF}/run-ovmf.sh" --usb "$USB" --disk "$DISK" --testctl "$CTL" --vars clean --mode smoke --timeout "$TIMEOUT" --name zones-install > /dev/null tr -d '\r' < "$LATEST" | grep -q 'KRYPTIK_INSTALL: rc=0' && green "installed" || { red "install failed"; exit 1; } @@ -79,7 +67,7 @@ step "step 2: the guest-side zone, network and storage checks (as root)" # 3 GB, not the 2 GB default: the ephemeral-size-bound check fills untrusted's # 2G tmpfs to its limit, and those pages are RAM. In a 2 GB guest the fill # ran the machine out of memory before ENOSPC could be reached. -start_vm zones-p2 --mem 3072 +start_vm zones-p2 --net user --mem 3072 drive 900 "expect:KRYPTIK_SMOKE: END" "seen:kryptik-firstboot: created user '${TUSER}'" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'bash /usr/lib/kryptik/guest-tests/zones-check.sh 2>&1 | tee /var/log/kryptik/zones-check.log; echo ZCHECK-DONE')" \ "expect:ZT END" "expect:ZCHECK-DONE" From 1e17453c92f8d2ec24ca3cc21b6725cc14663ee3 Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 12:15:26 -0700 Subject: [PATCH 24/25] The state partition is LUKS2, asked for on the console at every boot /home, /var and the /etc overlay sat on plain ext4, so a stolen disk gave up zone 0's home, the Wi-Fi passphrases and the zone volumes' headers. The installer asks twice for a passphrase before its first write (one line of standard input when unattended), formats partition 4 as LUKS2 with aes-xts-plain64 and argon2id, and puts the ext4 inside it. sysinit asks on the console, three times at most, and a failed unlock, a missing header or a plain filesystem in the partition's place is the degraded state that already exists. The passphrase reaches cryptsetup on a descriptor through the shell's builtin printf: never an argument, never a file. Two things the design did not foresee. cryptsetup's own prompt discards pending input after printing, so sysinit turns echo off first and reads the line itself. And the early getty reads the same terminal from the first moment, so kryptik-console now waits for sysinit to finish, and gives up waiting for it to start after 30 s so a broken service database still ends in a console. kryptik-recover backs up and restores the header; kryptik state passphrase changes the passphrase. vm-drive.py answers the prompt wherever it appears and run-ovmf.sh's smoke mode attaches it, so no suite gained a step; the install suite proves the header, the absent superblock and that a file in /home cannot be read from the raw partition, the state suite three wrong passphrases and a destroyed header, and the integrity suite plants its /etc entries through a mapping opened on the host. vm-drive.py loses a grab method nothing called and a record path that read an attribute that never existed. --- build/service-scripts/installer-run.sh | 9 ++++-- build/service-scripts/sysinit.sh | 41 ++++++++++++++++++++++---- build/stages/04-base-system.sh | 7 +++++ docs/BOOT_INSTALL_RECOVER.md | 20 +++++++++---- docs/design/state-encryption.md | 24 ++++++++++++++- docs/roadmap.md | 4 ++- tools/image/install-test.sh | 16 ++++++++-- tools/image/integrity-test.sh | 13 ++++---- tools/image/run-ovmf.sh | 12 ++++++-- tools/image/state-test.sh | 12 ++++++-- tools/image/suite-lib.sh | 19 +++++++++++- tools/image/vm-drive.py | 32 ++++++++++---------- tools/install/kryptik-install.sh | 40 +++++++++++++++++++++---- tools/kryptik | 13 ++++++++ tools/update/kryptik-recover | 25 ++++++++++++++-- 15 files changed, 232 insertions(+), 55 deletions(-) diff --git a/build/service-scripts/installer-run.sh b/build/service-scripts/installer-run.sh index afb90a6..75ef91b 100755 --- a/build/service-scripts/installer-run.sh +++ b/build/service-scripts/installer-run.sh @@ -67,8 +67,11 @@ fi # `... | sed` followed by rc=$? reads sed's status, which is how a missing # partitioner was once reported as rc=0. logf=/run/kryptik-install.log +# The state passphrase goes in on standard input (printf is a builtin), the +# one place it is ever written down being the control disk. +sp="$(testctl_get state_passphrase)" # shellcheck disable=SC2086 # preseed_args is deliberately word-split -/usr/sbin/kryptik-install --target "$target" --yes $preseed_args > "$logf" 2>&1 +printf '%s\n' "$sp" | /usr/sbin/kryptik-install --target "$target" --yes $preseed_args > "$logf" 2>&1 rc=$? sed 's/^/KRYPTIK_INSTALL: /' "$logf" say "rc=${rc}" @@ -93,12 +96,14 @@ if [ "$rc" -eq 0 ]; then say "verify: could not mount the ESP read-only" fi st="$(blkid -t PARTLABEL=kryptik-state -o device 2>/dev/null | grep "^${target}" | head -1)" - if [ -n "$st" ] && mount -o ro "$st" /run/verify 2>/dev/null; then + if [ -n "$st" ] && printf '%s' "$sp" | cryptsetup open --readonly --type luks2 --key-file=- "$st" kryptik-verify-state 2>/dev/null \ + && mount -o ro /dev/mapper/kryptik-verify-state /run/verify 2>/dev/null; then say "verify: state_marker=$([ -e /run/verify/.kryptik-state ] && echo yes || echo no)" say "verify: install_json=$([ -r /run/verify/lib/kryptik/install.json ] && echo yes || echo no)" say "verify: preseed=$([ -r /run/verify/lib/kryptik/firstboot.preseed ] && echo present || echo none)" umount /run/verify fi + cryptsetup close kryptik-verify-state 2>/dev/null || true slot_a="$(blkid -t PARTLABEL=kryptik-a -o device 2>/dev/null | grep "^${target}" | head -1)" if [ -n "$slot_a" ]; then say "verify: slot_a_sha256=$(head -c "$(cat /etc/kryptik/root-image-bytes 2>/dev/null || echo 0)" "$slot_a" | sha256sum | cut -c1-64)" diff --git a/build/service-scripts/sysinit.sh b/build/service-scripts/sysinit.sh index 8ced4b0..792db2a 100755 --- a/build/service-scripts/sysinit.sh +++ b/build/service-scripts/sysinit.sh @@ -1,6 +1,12 @@ #!/bin/sh -e # Idempotent on purpose: s6-rc may run this again after a runlevel change. +# The console is this script's while it runs, because it may ask for the state +# passphrase there and two readers on one terminal lose keystrokes: +# kryptik-console holds the getty back until this has finished, however it ends. +echo running > /run/kryptik-sysinit +trap 'echo finished > /run/kryptik-sysinit' EXIT + [ -r /etc/hostname ] && hostname "$(cat /etc/hostname)" || true # The names under /etc the overlay's upper layer may carry: the account @@ -38,6 +44,25 @@ prune_etc_upper() { # prune_etc_upper UPPER QUARANTINE return 0 } +# Ask for the state passphrase on the console, three times at most. Echo goes +# off before the prompt is printed and stty never discards input, so an answer +# that arrives the moment the prompt appears is not lost. printf is a builtin: +# the passphrase reaches cryptsetup on a descriptor, never as an argument. +unlock_state() { # unlock_state DEVICE -> /dev/mapper/kryptik-state + try=1 + while [ "$try" -le 3 ] && [ ! -b /dev/mapper/kryptik-state ]; do + stty -echo < /dev/console 2>/dev/null || true + printf 'sysinit: passphrase for the state partition (try %s of 3): ' "$try" > /dev/console + IFS= read -r pass < /dev/console || pass="" + stty echo < /dev/console 2>/dev/null || true + echo > /dev/console + printf '%s' "$pass" | cryptsetup open --type luks2 --key-file=- "$1" kryptik-state 2>/dev/null || true + try=$((try + 1)) + done + pass="" + [ -b /dev/mapper/kryptik-state ] +} + # The kernel mounts devtmpfs itself (CONFIG_DEVTMPFS_MOUNT=y); these are the # rest, each guarded because stage 2 init may already have done it. mountpoint -q /proc || mount -t proc proc /proc -o nosuid,noexec,nodev @@ -140,9 +165,15 @@ if ! mountpoint -q /var; then state_dev="$(kryptik_part kryptik-state)" if [ ! -b "$state_dev" ]; then STATE=degraded; STATE_REASON="${state_dev} is not a block device" - elif mount -t ext4 -o nosuid,nodev,noatime "$state_dev" "$state_mnt" 2>/run/kryptik/state-mount.err; then + elif ! cryptsetup isLuks --type luks2 "$state_dev" 2>/dev/null; then + # Never mounted as found: a plain filesystem put in the encrypted + # one's place would otherwise be believed without a question. + STATE=degraded; STATE_REASON="${state_dev} carries no LUKS2 header" + elif ! unlock_state "$state_dev"; then + STATE=degraded; STATE_REASON="${state_dev} was not unlocked in three tries; reboot to try again" + elif mount -t ext4 -o nosuid,nodev,noatime /dev/mapper/kryptik-state "$state_mnt" 2>/run/kryptik/state-mount.err; then STATE=persistent - echo "sysinit: state partition ${state_dev} mounted (disk ${root_disk})" + echo "sysinit: state partition ${state_dev} unlocked and mounted (disk ${root_disk})" else STATE=degraded; STATE_REASON="mount of ${state_dev} failed: $(tr '\n' ' ' < /run/kryptik/state-mount.err)" fi @@ -212,9 +243,9 @@ rmdir "$state_mnt" 2>/dev/null || true # the release trust anchor (kryptik-update) /usr/share/kryptik/trust # all of which sit on the verified root. What remains under /etc is what # must be mutable: accounts and passwords, hostname, the local user's -# session hooks. Their protection is the state partition's, and that -# partition is not encrypted in this developer tier - a stated limitation, -# not tamper protection. +# session hooks. Their protection is the state partition's: encrypted, so an +# offline reader learns nothing, and not authenticated, so an offline writer +# can still damage it. That is why the list above stays. if ! mountpoint -q /etc; then mkdir -p /var/lib/kryptik/etc/upper /var/lib/kryptik/etc/work if mount -t overlay overlay \ diff --git a/build/stages/04-base-system.sh b/build/stages/04-base-system.sh index c1a46cf..33ae005 100755 --- a/build/stages/04-base-system.sh +++ b/build/stages/04-base-system.sh @@ -1083,6 +1083,13 @@ s_console() { dev="$1" +# sysinit may be asking for the state passphrase on this console. It gets 30 s +# to start (a broken service database must still end in a console); once it +# has, the console is its own until it ends. +n=0 +until [ -e /run/kryptik-sysinit ] || [ "$n" -ge 150 ]; do sleep 0.2; n=$((n + 1)); done +while [ "$(cat /run/kryptik-sysinit 2>/dev/null)" = running ]; do sleep 0.2; done + if [ -z "$dev" ]; then # /sys/class/tty/console/active lists the kernel-preferred console last. # With both video and serial consoles that is "tty0 ttyS0", so taking the diff --git a/docs/BOOT_INSTALL_RECOVER.md b/docs/BOOT_INSTALL_RECOVER.md index 3ecf82d..4543bb4 100644 --- a/docs/BOOT_INSTALL_RECOVER.md +++ b/docs/BOOT_INSTALL_RECOVER.md @@ -82,7 +82,11 @@ disk that could be installed and never updated is refused. It writes, in order: partition 1 `kryptik-esp` (the medium's ESP, with the slot A kernel as the boot file), 2 `kryptik-a` (the verified root image, read back and hashed against the medium's record), 3 `kryptik-b` (empty; the first update fills -it), 4 `kryptik-state` (ext4: users, zone volumes, updates). It ends with +it), 4 `kryptik-state` (LUKS2 with ext4 inside: users, zone volumes, +updates). Before its first write it asks twice for the passphrase of the +state partition, which the system then asks for at every boot. There is no +escrow: without the passphrase, or without the partition's header, the +state is lost. It ends with `KRYPTIK_INSTALL: rc=0`. Then: ```sh @@ -99,7 +103,9 @@ installed system ignores it. ## 3. First boot and daily use -On the first boot the system runs `kryptik-firstboot` on the first console: +Every boot asks for the state passphrase on the console, three times at +most, before anything else starts; root changes it with `kryptik state +passphrase`. On the first boot the system runs `kryptik-firstboot` on the first console: it asks for a user name and password, and for root's password (root can still not log in at a terminal; the password is for `su` from the user's session). With a preseed on the control disk it creates that user instead. If the @@ -132,11 +138,11 @@ when the zone's policy allows the direction and you answer yes to the question the chrome shows. **Degraded boot.** If the system cannot find exactly one `kryptik-state` -partition on its own disk, or cannot mount it, it boots degraded: it says +partition on its own disk, or cannot unlock or mount it, it boots degraded: it says so on the console, creates no account, starts no desktop and refuses updates. Nothing on the disk is written in that state. Fix the cause -(a cloned disk attached, a relabelled partition, a damaged filesystem) and -boot again. +(a cloned disk attached, a relabelled partition, a damaged filesystem or +header) and boot again; after three wrong passphrases, just boot again. ## 4. Update @@ -179,6 +185,10 @@ kryptik-recover --disk /dev/sdY --commit-slot a # the other slot is intact: kryptik-recover --disk /dev/sdY --restore-slot a # the slot's root is damaged: rewrite it from this medium ``` +`--backup-state-header FILE` and `--restore-state-header FILE` save and put +back the state partition's LUKS2 header. Keep a backup somewhere that is not +this disk: a damaged header with no backup is a lost state partition. + `--restore-slot` writes the medium's own root image and kernel into the slot, exactly as the installer does, then commits it. The state partition is not touched: users and zone volumes survive. The result is the medium's diff --git a/docs/design/state-encryption.md b/docs/design/state-encryption.md index 2cf445e..3ac1a5e 100644 --- a/docs/design/state-encryption.md +++ b/docs/design/state-encryption.md @@ -1,6 +1,6 @@ # The state partition, encrypted -Status: design. Nothing here is built. It is the "state partition is +Status: built, waiting for its first acceptance run. It is the "state partition is encrypted" item of [version 1.0](../roadmap.md#version-10), written down before the code because three of its choices were a person's to make. They were decided on 2026-09-20, each as recommended (marked **Decided**). Builds on @@ -50,6 +50,28 @@ machine, and verified. - **The passphrase can be changed** (`kryptik state passphrase`, zone 0, root): `cryptsetup luksChangeKey` on a descriptor, the old one asked first. +## Where the code departs from the text above + +- **The prompt is `sysinit`'s own, not cryptsetup's.** cryptsetup prints its + prompt and then changes the terminal with a call that discards pending + input, so an answer sent the instant the prompt appears can be lost. + `sysinit` turns echo off first (`stty`, which discards nothing), prints + the prompt, reads one line and hands it to cryptsetup on a descriptor. +- **The console is `sysinit`'s while it runs.** The early getty is + supervised from the first moment and would read the same terminal, so + `kryptik-console` waits until `sysinit` has finished, however it ends, and + gives up waiting for it to start after 30 s. +- **A plain filesystem in the partition's place is refused, not mounted.** + Otherwise swapping the encrypted partition for an unencrypted one would + be believed without a question. +- **The suites did not gain a step each.** `vm-drive.py` answers the prompt + wherever it appears, from `KRYPTIK_STATE_PASSPHRASE`, and `run-ovmf.sh`'s + smoke mode attaches the driver too, so an undriven boot of an installed + disk is answered the same way. One unlock path, the one a person uses. +- Not yet checked by a suite: `kryptik state passphrase` and the two header + commands of `kryptik-recover` (the state suite damages and restores the + header from the host). + ## What it does and does not give Confidentiality against an offline reader: yes. Authentication: **no**. XTS diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fd19bf..1533c9e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -305,7 +305,9 @@ passes, not when its code is written. the Wi-Fi passphrases and the zone volumes' headers. LUKS2 on `kryptik-state`, unlocked at boot by a passphrase (and later a TPM, see version 2), created by the installer, with the state test's degraded - paths still honest. + paths still honest. Written + ([the design](design/state-encryption.md)). Ticked when the install, + state and integrity suites pass with it on the installed system. - [ ] **kryptikd is built from pinned source by a pinned compiler.** Today the runner's rustc compiles it and the result is copied in (ADR-010's unresolved cost). A pinned rustc in the build (its published binary, diff --git a/tools/image/install-test.sh b/tools/image/install-test.sh index c0c2965..490b50d 100755 --- a/tools/image/install-test.sh +++ b/tools/image/install-test.sh @@ -71,7 +71,7 @@ want "$P1" 'KRYPTIK_INSTALL: rc=0' "the installer exited 0 want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-esp=/dev/vda1 type=vfat' "partition 1 is the ESP" want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-a=/dev/vda2' "partition 2 is kryptik-a" want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-b=/dev/vda3' "partition 3 is kryptik-b" -want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=ext4' "partition 4 is the state partition" +want "$P1" 'KRYPTIK_INSTALL: verify: kryptik-state=/dev/vda4 type=crypto_LUKS' "partition 4 is the state partition, and it is LUKS" want "$P1" 'KRYPTIK_INSTALL: verify: esp_files=.*EFI/BOOT/BOOTX64.EFI' "the ESP has the removable-media boot file" want "$P1" 'KRYPTIK_INSTALL: verify: install_json=yes' "install.json was written" want "$P1" 'KRYPTIK_INSTALL: verify: preseed=present' "the first-boot preseed was written" @@ -104,7 +104,8 @@ python3 "$DRV" --serial "$SER" --timeout 300 --record "$REC" \ "grab:mounts:awk '\$2==\"/\"||\$2==\"/var\"||\$2==\"/etc\"||\$2==\"/home\" {print \$2, \$1, \$3, \$4}' /proc/mounts" \ "grab:secureboot:od -An -tu1 -j4 -N1 /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c 2>/dev/null || echo none" \ "grab:bootresult:cat /var/lib/kryptik/boot/last-result" \ - "run:touch /home/${TUSER}/persisted-p2 && sync" \ + "run:echo KRYPTIK-CLEAR-MARKER-7f3a91 > /home/${TUSER}/persisted-p2 && sync" \ + "$(ROOTSH "grep -rqa state[-]pw /proc/[0-9]*/cmdline /run /etc 2>/dev/null && echo PW-LEAK || echo PW-NOLEAK")" "expect:PW-NOLEAK" \ "su:${RPASS}:reboot" \ "expect:Linux version" \ "expect:KRYPTIK_SMOKE: END" \ @@ -121,7 +122,16 @@ echo " transcript: ${LOG2}" [[ "$drc" -eq 0 ]] && green "first boot, login, reboot, second login and clean poweroff all happened" || red "the serial drive failed (see above)" want "$P2" 'KRYPTIK_SMOKE: root_source=/dev/dm-0 ext4 ro' "installed root is the verity device" want "$P2" 'KRYPTIK_SMOKE: boot_identity=slot=a media=' "booted slot a" -want "$P2" 'KRYPTIK_SMOKE: var_source=/dev/vda4 ext4' "state partition mounted on /var" +want "$P2" 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state ext4' "the unlocked state partition is mounted on /var" +want "$P2" 'passphrase for the state partition \(try 1 of 3\)' "sysinit asked for the state passphrase on the console" +deny "$P2" "$KRYPTIK_STATE_PASSPHRASE" "the passphrase is nowhere in the transcript" +# From the host: partition 4 is a LUKS header and ciphertext. +S4=$(( $(part_start "$DISK" 4) * 512 )) +magic() { dd if="$DISK" bs=1 skip="$1" count="$2" status=none | od -An -tx1 | tr -d ' \n'; } +[[ "$(magic "$S4" 6)" == 4c554b53babe ]] && green "partition 4 starts with a LUKS header" || red "partition 4 does not start with a LUKS header" +[[ "$(magic $(( S4 + 1080 )) 2)" != 53ef ]] && green "no ext4 superblock in the clear" || red "an ext4 superblock is readable on partition 4" +if tail -c +$(( S4 + 1 )) "$DISK" | LC_ALL=C grep -aq 'KRYPTIK-CLEAR-MARKER-7f3a91'; then red "a file written under /home is readable from the raw partition" +else green "a file written under /home is not readable from the raw partition"; fi want "$P2" 'KRYPTIK_SMOKE: etc_source=overlay' "/etc is an overlay" want "$P2" 'KRYPTIK_SMOKE: root_writable=no' "the verified root is not writable" want "$P2" 'boot-success: slot a up' "boot-success recorded slot a" diff --git a/tools/image/integrity-test.sh b/tools/image/integrity-test.sh index ff82df2..55573c4 100755 --- a/tools/image/integrity-test.sh +++ b/tools/image/integrity-test.sh @@ -39,7 +39,7 @@ while [[ "$#" -gt 0 ]]; do esac done [[ -f "$USB" ]] || die "--usb IMG is required" -for t in python3 sbsign sbverify openssl mcopy mdel mdir sfdisk; do have "$t" || die "required tool not found: $t"; done +for t in python3 sbsign sbverify openssl mcopy mdel mdir sfdisk cryptsetup losetup; do have "$t" || die "required tool not found: $t"; done VMDIR="${KRYPTIK_WORK}/vm"; mkdir -p "$VMDIR" DISK="${DISK:-${VMDIR}/integrity.img}" [[ -e "$DISK" && ! -f "$DISK" ]] && die "refusing: ${DISK} is not a regular file" @@ -170,10 +170,9 @@ step "step 5: offline tampering of the state partition does not reach privileged # a trust anchor of their own for updates, a zone definition the launch # daemon will honour, a kernel tunable applied at boot. Plant all three from # the host, boot, and measure each from inside the guest. -S_OFF=$(( $(part_start "$DISK" 4) * 512 )) TMPK="$(mktemp -d)"; MNT="$TMPK/state"; mkdir -p "$MNT" ssh-keygen -q -t ed25519 -N "" -f "$TMPK/attacker" >/dev/null -if mount -o loop,offset="$S_OFF" "$DISK" "$MNT" 2>/dev/null; then +if open_state "$DISK" "$MNT" 2>/dev/null; then up="$MNT/lib/kryptik/etc/upper" mkdir -p "$up/kryptik/trust" "$up/kryptik/zones" "$up/sysctl.d" printf 'kryptik-release namespaces="kryptik-release" %s\n' "$(cut -d' ' -f1,2 "$TMPK/attacker.pub")" > "$up/kryptik/trust/release-signers" @@ -202,7 +201,7 @@ EOF printf 'ACTION=="add", RUN+="/var/lib/kryptik/evil.sh"\n' > "$up/udev/rules.d/99-evil.rules" printf '#!/bin/sh\ntouch /var/lib/kryptik/evil-ran\n' > "$MNT/lib/kryptik/evil.sh"; chmod 0755 "$MNT/lib/kryptik/evil.sh" printf 'planted:1310720:65536\n' > "$up/subuid" - sync; umount "$MNT" + close_state "$MNT" green "planted a trust anchor, a zone definition, a sysctl fragment, a preload library and a udev rule under the state's /etc upper layer, and one allowed change" else red "could not mount the state partition from the host (loop/offset); step 5 not performed" @@ -257,13 +256,13 @@ if [[ -n "${EXTRA[*]:-}" ]]; then grep -q 'not enrolled' <<<"$T5" && green "an update signed by the planted anchor's key is refused (the anchor is read from the verified root)" || red "an attacker-signed update was not refused" fi grep -q 'KRYPTIK_SMOKE: sysctl kernel.kptr_restrict=2' <<<"$T5" && green "the planted sysctl fragment was not applied" || red "the planted sysctl was applied" -grep -q 'KRYPTIK_SMOKE: var_source=/dev/vda4' <<<"$T5" && green "state stayed persistent through the tamper (this is a repairable machine, not a bricked one)" || red "state not persistent in step 5" +grep -q 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state' <<<"$T5" && green "state stayed persistent through the tamper (this is a repairable machine, not a bricked one)" || red "state not persistent in step 5" # undo the planting so later runs start clean -if mount -o loop,offset="$S_OFF" "$DISK" "$MNT" 2>/dev/null; then +if open_state "$DISK" "$MNT" 2>/dev/null; then rm -rf "$MNT/lib/kryptik/etc/upper/kryptik/trust" "$MNT/lib/kryptik/etc/upper/kryptik/zones" "$MNT/lib/kryptik/etc/upper/sysctl.d" \ "$MNT/lib/kryptik/etc/upper/ld.so.preload" "$MNT/lib/kryptik/etc/upper/udev" "$MNT/lib/kryptik/etc/upper/subuid" \ "$MNT/lib/kryptik/etc/quarantine" "$MNT/lib/kryptik/evil.sh" "$MNT/lib/kryptik/evil-ran" - sync; umount "$MNT" + close_state "$MNT" fi rm -rf "$TMPK" diff --git a/tools/image/run-ovmf.sh b/tools/image/run-ovmf.sh index b0524d8..79fc191 100755 --- a/tools/image/run-ovmf.sh +++ b/tools/image/run-ovmf.sh @@ -179,9 +179,17 @@ smoke) { printf '%q ' "$QEMU" "${ARGS[@]}"; echo; } > "${LOG}.cmd" ln -sfn "$LOG" "${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" echo "serial log: ${LOG}" + # The console is a socket the driver watches, not a file: an installed + # disk asks for its state passphrase there and the driver answers it. + # wait=on holds the guest until the driver is connected, so it sees all. + SER="${VMDIR}/${RUN_ID}.serial" set +e; trap - ERR - timeout --foreground "$TIMEOUT" "$QEMU" "${ARGS[@]}" -serial "file:${LOG}" -monitor none < /dev/null > "${LOG}.qemu" 2>&1 - rc=$? + "$QEMU" "${ARGS[@]}" -chardev "socket,id=ser0,path=${SER},server=on,wait=on,logfile=${LOG}" -serial chardev:ser0 \ + -monitor none < /dev/null > "${LOG}.qemu" 2>&1 & + qpid=$! + for _ in $(seq 1 50); do [[ -S "$SER" ]] && break; sleep 0.2; done + python3 "${SELF}/vm-drive.py" --serial "$SER" --timeout "$TIMEOUT" wait-exit > /dev/null + if kill -0 "$qpid" 2>/dev/null; then kill "$qpid"; wait "$qpid"; rc=124; else wait "$qpid"; rc=$?; fi set -e [[ "$rc" -eq 124 ]] && warn "QEMU hit the ${TIMEOUT}s timeout" [[ "$rc" -ne 0 && "$rc" -ne 124 ]] && { warn "QEMU exited ${rc}:"; sed 's/^/ /' "${LOG}.qemu" | tail -5; } diff --git a/tools/image/state-test.sh b/tools/image/state-test.sh index 7220186..0dffa44 100755 --- a/tools/image/state-test.sh +++ b/tools/image/state-test.sh @@ -118,7 +118,7 @@ drive "expect:KRYPTIK_SMOKE: END" "login:${TUSER}:${TPASS}" \ "$(ROOTSH 'poweroff')" "expect:Power down" "wait-exit" rc=$?; stop_vm [[ "$rc" -eq 0 ]] && green "boots with a clone attached; login and the file work" || red "step 2 drive failed" -txt | grep -q 'KRYPTIK_SMOKE: var_source=/dev/vda4 ext4' && green "/var is this disk's partition (vda4), not the clone's" || red "/var is not vda4" +txt | grep -q 'KRYPTIK_SMOKE: var_source=/dev/mapper/kryptik-state ext4' && green "/var is the unlocked state partition" || red "/var is not the unlocked state partition" txt | grep -q 'state_dev=/dev/vda4' && green "boot identity names /dev/vda4" || red "boot identity does not name vda4" txt | grep -q 'sysinit: kryptik-state on other disks ignored: /dev/vdb4' && green "the clone's state partition was seen and ignored" || red "the clone's partition was not reported as ignored" txt | grep -q 'STATE DEGRADED' && red "degraded with a clone attached (ambiguity wrongly detected)" || green "not degraded: the clone is not this installation" @@ -143,10 +143,10 @@ normal_boot state-p3b step "step 4: a corrupt state partition" S4_OFF=$(( $(part_start "$DISK" 4) * 512 )) SAVE="${VMDIR}/state-super.bin" -# the ext4 superblock and group descriptors: the first 64 KiB of the partition +# both copies of the LUKS2 header: the first 64 KiB of the partition dd if="$DISK" of="$SAVE" bs=1 skip="$S4_OFF" count=65536 status=none dd if=/dev/zero of="$DISK" bs=1 seek="$S4_OFF" count=65536 conv=notrunc status=none -degraded_boot state-p4 'mount of /dev/vda4 failed' +degraded_boot state-p4 '/dev/vda4 carries no LUKS2 header' dd if="$SAVE" of="$DISK" bs=1 seek="$S4_OFF" conv=notrunc status=none normal_boot state-p4b @@ -182,5 +182,11 @@ else echo " note: no softdog line; the reset came from an emulated hardware txt | grep -q 'Kernel panic' && red "state-p6: kernel panic" || green "state-p6: no panic" txt | grep -q 'STATE DEGRADED' && red "state-p6: degraded after the reset" || green "state-p6: state is intact after the reset" +# ----------------------------------------------------------------- step 7 -- +step "step 7: three wrong passphrases, then the right one" +KRYPTIK_STATE_PASSPHRASE=not-the-passphrase degraded_boot state-p7 '/dev/vda4 was not unlocked in three tries' +[[ "$(tr -d '\r' < "$LATEST" | grep -c 'passphrase for the state partition')" -eq 3 ]] && green "state-p7: asked three times and no more" || red "state-p7: not asked exactly three times" +normal_boot state-p7b + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/tools/image/suite-lib.sh b/tools/image/suite-lib.sh index d5d70d4..8c3c9d4 100755 --- a/tools/image/suite-lib.sh +++ b/tools/image/suite-lib.sh @@ -13,7 +13,12 @@ step() { printf '\n==> %s\n' "$*"; } # The plaintext exists only in the harness; the hashes are what lands on disk. TUSER=tester; TPASS=tester-pw; RPASS=root-pw TUSER_HASH="$(openssl passwd -6 "$TPASS")"; ROOT_HASH="$(openssl passwd -6 "$RPASS")" -PRESEED=( "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" ) +# The state passphrase: the installer takes it from the control disk, and +# vm-drive.py answers sysinit with it at every boot of an installed disk, +# driven or not, which is why it is exported. +export KRYPTIK_STATE_PASSPHRASE=state-pw +PRESEED=( "preseed_user=${TUSER}" "preseed_password_hash=${TUSER_HASH}" "preseed_root_hash=${ROOT_HASH}" + "state_passphrase=${KRYPTIK_STATE_PASSPHRASE}" ) DRV="${SELF}/vm-drive.py" LATEST="${KRYPTIK_WORK}/logs/ovmf-serial.latest.log" @@ -31,3 +36,15 @@ txt() { tr -d '\r' < "$LOG"; } ROOTSH() { printf 'su:%s:%s' "$RPASS" "$1"; } # a command as root, through su # Where partition N of a disk file starts, in sectors, from its GPT. part_start() { sfdisk -d "$1" 2>/dev/null | awk -v n="$2" -F'[ ,]+' '$1 ~ n"$" {for(i=1;i<=NF;i++) if($i=="start=") print $(i+1)}'; } +# The state partition of a disk file, from the host: opened with the suites' +# passphrase and mounted at MNT, then put away again. +open_state() { # open_state DISK MNT + STATE_LOOP="$(losetup --find --show --offset $(( $(part_start "$1" 4) * 512 )) "$1")" || return 1 + printf '%s' "$KRYPTIK_STATE_PASSPHRASE" | cryptsetup open --type luks2 --key-file=- "$STATE_LOOP" kryptik-suite-state \ + && mount /dev/mapper/kryptik-suite-state "$2" && return 0 + close_state "$2"; return 1 +} +close_state() { # close_state MNT + sync; umount "$1" 2>/dev/null + cryptsetup close kryptik-suite-state 2>/dev/null; losetup -d "$STATE_LOOP" 2>/dev/null +} diff --git a/tools/image/vm-drive.py b/tools/image/vm-drive.py index 292daa7..6a775c7 100755 --- a/tools/image/vm-drive.py +++ b/tools/image/vm-drive.py @@ -22,11 +22,17 @@ (qcodes, e.g. key:y key:ret key:alt+e) wait-exit wait for the serial socket to close (guest gone) +An installed disk asks for its state passphrase on this console at every boot. +With KRYPTIK_STATE_PASSPHRASE set, the driver answers wherever that is asked, +as a person would; no step names it. + Exit status 0 when every step succeeded; the failing step is named otherwise. The whole transcript goes to --log. stdlib only; no pexpect. """ import json, os, re, socket, sys, time +UNLOCK = re.compile(rb"passphrase for the state partition \(try \d of 3\): ") + class Drive: def __init__(self, path, log, timeout): self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -37,8 +43,9 @@ def __init__(self, path, log, timeout): self.log = open(log, "ab") if log else None self.timeout = timeout self.closed = False - self.records = {} self.marker = 0 + self.passphrase = os.environ.get("KRYPTIK_STATE_PASSPHRASE") + self.answered = 0 # how far into the transcript the prompts are answered def _read(self): try: @@ -52,6 +59,12 @@ def _read(self): self.all += d if self.log: self.log.write(d); self.log.flush() + if self.passphrase: + # From the last answer, or just before this read: a prompt may + # straddle two reads, and none is answered twice. + for m in UNLOCK.finditer(self.all, max(self.answered, len(self.all) - len(d) - 80)): + self.answered = m.end() + self.send_secret(self.passphrase) return True def seen(self, regex, timeout=None): @@ -158,32 +171,17 @@ def login(self, user, password): self.expect(r"READY-\d+", 30) self.expect(r"KDRV\$ ?$", 30) - def run(self, cmd, require_zero=True, record=None): + def run(self, cmd, require_zero=True): self.marker += 1 tag = f"KRC{self.marker}" self.send(f"{cmd}; echo {tag}=$?") m = self.expect(rf"{tag}=(\d+)", self.timeout) rc = int(m.group(1)) - # output between the echoed command and the tag is what we captured; - # keep whatever preceded the match for records - if record is not None: - self.records[record] = self.last_output.decode("utf-8", "replace") if hasattr(self, "last_output") else "" self.expect(r"KDRV\$ ?$", 30) if require_zero and rc != 0: raise RuntimeError(f"command failed ({rc}): {cmd}") return rc - def grab(self, name, cmd): - self.marker += 1 - tag = f"KRC{self.marker}" - self.send(f"echo BEGIN-{tag}; {cmd}; echo END-{tag}=$?") - self.expect(rf"BEGIN-{tag}\r?\n", self.timeout) - m = self.expect(rf"END-{tag}=(\d+)", self.timeout) - # everything consumed up to the END marker is in the discarded prefix; - # re-search the log-less buffer: simpler to capture during expect - self.expect(r"KDRV\$ ?$", 30) - return int(m.group(1)) - def su(self, password, cmd): # A login shell for root: the image strips the sbin directories from # an ordinary user's PATH, and a plain `su -c` inherits that PATH, so diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index 36fb606..d5ea9f3 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -13,7 +13,8 @@ # 2 kryptik-a the medium's verity root image, byte for byte, # read back and hashed against the medium's record # 3 kryptik-b empty (the first update fills it) -# 4 kryptik-state ext4, with install.json and the first-boot preseed +# 4 kryptik-state LUKS2 (docs/design/state-encryption.md), ext4 inside +# it, with install.json and the first-boot preseed # # It refuses to touch: # - the device the running root is on, through any dm/loop stack @@ -55,7 +56,7 @@ done # --- every external tool, checked before the first write ------------------- missing="" -for tool in sfdisk partx blockdev blkid mkfs.ext4 dd sha256sum mount umount sync awk sed \ +for tool in sfdisk partx blockdev blkid cryptsetup stty mkfs.ext4 dd sha256sum mount umount sync awk sed \ readlink lsblk head cmp cp mkdir stat tr; do command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}" done @@ -143,11 +144,14 @@ fi media="$(sed -n 's/^media=//p' /run/kryptik/boot-identity 2>/dev/null)" [ -n "$media" ] || die "this is not an install medium (no kryptik.media= on the signed command line)" mkdir -p "$MNT_BASE/media" "$MNT_BASE/esp" "$MNT_BASE/state" "$MNT_BASE/tesp" +MAPPING=kryptik-install-state cleanup() { + [ -t 0 ] && stty echo 2>/dev/null || true for m in "$MNT_BASE/tesp" "$MNT_BASE/state" "$MNT_BASE/esp" "$MNT_BASE/media"; do mountpoint -q "$m" 2>/dev/null && umount "$m" 2>/dev/null || true rmdir "$m" 2>/dev/null || true done + [ -b "/dev/mapper/$MAPPING" ] && cryptsetup close "$MAPPING" 2>/dev/null || true rmdir "$MNT_BASE" 2>/dev/null || true } trap cleanup EXIT INT TERM @@ -232,6 +236,24 @@ if [ "$ASSUME_YES" -ne 1 ]; then [ "$answer" = "ERASE" ] || die "not confirmed; nothing was written" fi +# The state passphrase, before the first write: from the terminal twice, or +# one line of standard input when that is not a terminal (the unattended +# path). It reaches cryptsetup on a descriptor (printf is a builtin), never +# on a command line and never in a file. +if [ -t 0 ]; then + stty -echo + printf '%s: a passphrase for the state partition, asked at every boot: ' "$PROG" + IFS= read -r STATE_PASS || STATE_PASS="" + printf '\n%s: again: ' "$PROG" + IFS= read -r again || again="" + stty echo; echo + [ "$STATE_PASS" = "$again" ] || die "the two passphrases differ; nothing was written" + again="" +else + IFS= read -r STATE_PASS || STATE_PASS="" +fi +[ -n "$STATE_PASS" ] || die "no state passphrase given; nothing was written" + # --- partition ------------------------------------------------------------- say "partitioning (sfdisk, GPT: kryptik-esp, kryptik-a, kryptik-b, kryptik-state)" P1="$(part_dev "$TARGET_REAL" 1)"; P2="$(part_dev "$TARGET_REAL" 2)" @@ -268,8 +290,13 @@ got="$(dd if="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha say "kryptik-a verifies (${got})" say "clearing kryptik-b" dd if=/dev/zero of="$P3" bs=1M count=4 conv=fsync status=none || die "clearing kryptik-b failed" -say "creating kryptik-state (ext4)" -mkfs.ext4 -q -F -L kryptik-state "$P4" || die "mkfs.ext4 on ${P4} failed" +say "creating kryptik-state (LUKS2, ext4 inside it)" +printf '%s' "$STATE_PASS" | cryptsetup -q luksFormat --type luks2 --cipher aes-xts-plain64 \ + --key-size 512 --pbkdf argon2id --key-file=- "$P4" || die "luksFormat on ${P4} failed" +printf '%s' "$STATE_PASS" | cryptsetup open --type luks2 --key-file=- "$P4" "$MAPPING" \ + || die "could not open the new state partition" +STATE_PASS="" +mkfs.ext4 -q -F -L kryptik-state "/dev/mapper/$MAPPING" || die "mkfs.ext4 inside ${P4} failed" # --- the target ESP: slot A is the committed boot file ---------------------- mount -o rw "$P1" "$MNT_BASE/tesp" || die "could not mount the new ESP" @@ -284,7 +311,7 @@ sync umount "$MNT_BASE/tesp" || die "could not unmount the new ESP" # --- the state partition: what installed this, and the first-boot preseed -- -mount -o rw "$P4" "$MNT_BASE/state" || die "could not mount kryptik-state" +mount -o rw "/dev/mapper/$MAPPING" "$MNT_BASE/state" || die "could not mount kryptik-state" mkdir -p "$MNT_BASE/state/lib/kryptik" cat > "$MNT_BASE/state/lib/kryptik/install.json" </dev/null || true sync say "installed ${VERSION} to ${TARGET_REAL}: boot it from firmware with the medium removed." say " slot a: ${P2} slot b: ${P3} (empty) state: ${P4} esp: ${P1}" +say "The state partition opens with that passphrase and nothing else: there is no" +say "escrow. Keep a copy of its header (kryptik-recover --backup-state-header)." diff --git a/tools/kryptik b/tools/kryptik index cf3b08c..03720b3 100755 --- a/tools/kryptik +++ b/tools/kryptik @@ -89,6 +89,8 @@ USAGE: $PROG update fetch ask for the newest release to be fetched $PROG update apply install what has arrived; the next boot tries it once and falls back if it fails + $PROG state passphrase change the passphrase the state partition + asks for at boot (root; the old one first) OPTIONS: --zones DIR override the zone directory (default: $ZONES_DIR) @@ -422,6 +424,16 @@ cmd_update() { "$LAUNCH" --update "$1" } +# The state partition's passphrase. cryptsetup asks on the terminal, the old +# one first and the new one twice; nothing of it passes through here. +cmd_state() { + [[ "${1:-}" == passphrase && $# -eq 1 ]] || { usage >&2; die "state needs a subcommand: passphrase" 2; } + [[ $EUID -eq 0 ]] || die "the state passphrase is root's to change" + local dev; dev="$(sed -n 's/^state_dev=//p' /run/kryptik/boot-identity 2>/dev/null)" + [[ -b "$dev" ]] || die "this system has no state partition in use" + exec cryptsetup luksChangeKey "$dev" +} + # --- argument parsing ------------------------------------------------------- load_conf @@ -450,6 +462,7 @@ case "$cmd" in doctor) cmd_doctor ;; wifi) cmd_wifi "$@" ;; update) cmd_update "$@" ;; + state) cmd_state "$@" ;; ""|help) usage ;; # Named explicitly rather than falling into "unknown command", because # these are the things people will reasonably expect to exist. diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 9f72d37..7cd0118 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" @@ -129,4 +148,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" From c281f2f25750baf6e2325bb0624a94c3a6d51c0c Mon Sep 17 00:00:00 2001 From: DevomB Date: Sun, 20 Sep 2026 12:17:28 -0700 Subject: [PATCH 25/25] A kernel is judged before it gets its name on the ESP, and a read-back reads the disk kryptik-update copied the new kernel to the ESP, renamed it to kryptik-.efi and only then compared its hash with the manifest's. The payload directory may change after it is verified (the manifest is snapshotted for that reason), so a swapped kernel failed the comparison with its final name already on the ESP, where rollback and kryptik-recover --commit-slot accept a kernel by its existence. The staged file is hashed now, removed on a mismatch, and renamed only when it is the manifest's. The three writers of a root image (the updater, the installer, recovery) wrote with conv=fsync and read straight back, which on any machine with memory to spare reads the page cache. blockdev --flushbufs sits between the two now, so 'verifies after write' is about the disk. --- tools/install/kryptik-install.sh | 1 + tools/update/kryptik-recover | 1 + tools/update/kryptik-update | 11 ++++++++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/install/kryptik-install.sh b/tools/install/kryptik-install.sh index d5ea9f3..e60ce0b 100755 --- a/tools/install/kryptik-install.sh +++ b/tools/install/kryptik-install.sh @@ -284,6 +284,7 @@ if [ "$ROOT_OFF" -gt 0 ]; then else dd if="$ROOT_SRC" of="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" conv=fsync status=none || die "writing the root image failed" fi +blockdev --flushbufs "$P2" # so the read-back is of the disk, not of the page cache say "reading kryptik-a back" got="$(dd if="$P2" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha256sum | cut -c1-64)" [ "$got" = "$ROOT_SHA" ] || die "kryptik-a does not verify: wrote ${got}, the medium says ${ROOT_SHA}" diff --git a/tools/update/kryptik-recover b/tools/update/kryptik-recover index 7cd0118..e94e731 100755 --- a/tools/update/kryptik-recover +++ b/tools/update/kryptik-recover @@ -126,6 +126,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" diff --git a/tools/update/kryptik-update b/tools/update/kryptik-update index 4aea226..2c64c32 100755 --- a/tools/update/kryptik-update +++ b/tools/update/kryptik-update @@ -312,6 +312,7 @@ cmd_apply() { # --- writes begin: the inactive slot only ------------------------------- say "writing kryptik-$target ($ROOT_BYTES bytes)" dd if="$dir/kryptik-root.img" of="$tdev" bs=4M conv=fsync status=none || die "writing slot $target failed" + blockdev --flushbufs "$tdev" # so the read-back is of the device, not of the page cache got="$(dd if="$tdev" bs=4M iflag=count_bytes count="$ROOT_BYTES" status=none | sha256sum | cut -c1-64)" [ "$got" = "$H_ROOT" ] || die "slot $target read back as $got; the manifest says $H_ROOT" say "slot $target verifies after write" @@ -320,10 +321,14 @@ cmd_apply() { mkdir -p "$ESP_MNT/EFI/kryptik" "$ESP_MNT/kryptik" cp "$dir/kryptik-$target.efi" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" || die "staging the kernel on the ESP failed" sync -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" - mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" + # Judged before it gets the name rollback and recovery look for: the + # payload directory may have changed since it was verified. want_k="$H_KA"; [ "$target" = b ] && want_k="$H_KB" - [ "$(sha256sum "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" | cut -c1-64)" = "$want_k" ] \ - || die "the kernel on the ESP does not hash to the manifest's kryptik-$target.efi" + if [ "$(sha256sum "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" | cut -c1-64)" != "$want_k" ]; then + rm -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" + die "the kernel staged on the ESP does not hash to the manifest's kryptik-$target.efi" + fi + mv -f "$ESP_MNT/EFI/kryptik/kryptik-$target.efi.new" "$ESP_MNT/EFI/kryptik/kryptik-$target.efi" printf '%s\n' "$VERSION" > "$ESP_MNT/kryptik/version-$target.new" mv -f "$ESP_MNT/kryptik/version-$target.new" "$ESP_MNT/kryptik/version-$target" sync