From f2b9b7699dfdea313b4468b5a15e4313dcc4fed1 Mon Sep 17 00:00:00 2001 From: Tomaz Zaman Date: Tue, 11 Aug 2026 23:35:13 +0200 Subject: [PATCH 36/62] mono-update-check: verify signed manifest, add persistent anti-rollback floor Move the whole update decision onto signed data and make it monotonic: - Fetch latest.json + latest.json.sig and usign-verify against the baked keys in /etc/mono-keys BEFORE reading any field. Tag and per-board image hash now come from a signature-verified document. Closes the downgrade attack (previous commit): a forged or replayed manifest can no longer decide what is flashed. Verification runs in notify mode too. - Persistent anti-rollback floor (/etc/mono-update.state, preserved across sysupgrade via /lib/upgrade/keep.d): refuse any tag <= the newest ever installed. /etc/mono_release resets to the running image each boot; the floor file is the durable memory that survives a downgrade attempt. - notify mode no longer prints 'sysupgrade ' (which skips verification); it steers the operator to auto mode, which downloads, hash-verifies, flashes. - fetch() gains connect/stall timeouts so a stalling server can't wedge the daily run; the unused get() is removed. Key-leak recovery is a release-side action - ship a rotation-key-signed release that removes the compromised key - not a manifest field. An earlier epoch scheme was dropped as ineffective: the epoch lived in the manifest the leaked key signs, so a leaked key could claim any epoch; the real defense is removing the key, which the anti-rollback floor already backstops. Tests (tests/): version/floor unit tests including the non-mono fallback branch; an end-to-end test with real usign covering a genuine upgrade, a forged-tag / untrusted-key refusal, a replay, a post-rollback walk-up, an image tamper, and notify-mode safety, plus a packaging-consistency check for the floor file; and a publish-gate test proving wrong-key / mismatched / missing signatures are refused. Co-Authored-By: Claude Opus 4.8 --- package/mono/mono-update-check/Makefile | 5 +- .../mono-update-check/files/mono-update-check | 125 +++++++++++----- .../mono-update-check/files/mono-update.keep | 2 + .../tests/test_downgrade_e2e.sh | 140 ++++++++++++++++++ .../tests/test_publish_gate.sh | 58 ++++++++ .../tests/test_version_logic.sh | 87 +++++++++++ 6 files changed, 377 insertions(+), 40 deletions(-) create mode 100644 package/mono/mono-update-check/files/mono-update.keep create mode 100755 package/mono/mono-update-check/tests/test_downgrade_e2e.sh create mode 100755 package/mono/mono-update-check/tests/test_publish_gate.sh create mode 100755 package/mono/mono-update-check/tests/test_version_logic.sh diff --git a/package/mono/mono-update-check/Makefile b/package/mono/mono-update-check/Makefile index 588458b217..921d0a2c85 100644 --- a/package/mono/mono-update-check/Makefile +++ b/package/mono/mono-update-check/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=mono-update-check PKG_VERSION:=1.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_LICENSE:=GPL-2.0 include $(INCLUDE_DIR)/package.mk @@ -58,6 +58,9 @@ define Package/mono-update-check/install $(INSTALL_DIR) $(1)/etc/mono-keys $(INSTALL_DATA) ./files/mono-release.pub $(1)/etc/mono-keys/mono-release.pub $(INSTALL_DATA) ./files/mono-rotation.pub $(1)/etc/mono-keys/mono-rotation.pub + # Preserve the anti-rollback floor across sysupgrade. + $(INSTALL_DIR) $(1)/lib/upgrade/keep.d + $(INSTALL_DATA) ./files/mono-update.keep $(1)/lib/upgrade/keep.d/mono-update endef $(eval $(call BuildPackage,mono-update-check)) diff --git a/package/mono/mono-update-check/files/mono-update-check b/package/mono/mono-update-check/files/mono-update-check index 38a44c8ba2..956a8d4204 100644 --- a/package/mono/mono-update-check/files/mono-update-check +++ b/package/mono/mono-update-check/files/mono-update-check @@ -1,16 +1,32 @@ #!/bin/sh # Compare the image's baked release tag against the server's latest.json. -# notify (default): report availability + drop a state file. No download. -# auto: verify the release SIGNATURE against the baked public keys, then -# the image hash against the signed hash list, then sysupgrade. -# Trust anchor is /etc/mono-keys, NOT anything in latest.json. Fetches use -# curl (uclient-fetch is unreliable on large HTTPS downloads). +# notify (default): report availability. No download, no flash. +# auto: verify + sysupgrade unattended. +# +# Trust model: latest.json is SIGNED (usign -> latest.json.sig) and verified +# against the baked public keys in /etc/mono-keys BEFORE any field in it is +# trusted. The freshness / anti-rollback decision AND the image hash all come +# from that verified document - never from unsigned bytes. Signing only a +# per-release hash list would leave the version unbound, letting anyone who +# controls the update URL advertise an inflated tag pointing at an old, +# still-validly-signed image and force a downgrade. A persistent floor +# (/etc/mono-update.state, preserved across sysupgrade) records the newest tag +# ever installed, so a validly-signed OLDER release is refused. +# +# Key rotation / leak recovery is NOT done here: it is a release-side action - +# ship a release signed by the offline rotation key that removes the +# compromised key from /etc/mono-keys and installs a replacement. Devices that +# take it stop trusting the old key; the floor stops them being pulled back +# before it. See scripts/mono-sign-release.sh. +# +# Fetches use curl (uclient-fetch is unreliable on large HTTPS downloads). . /lib/functions.sh URL=$(uci -q get mono-update.check.url) MODE=$(uci -q get mono-update.check.mode) STATE=/tmp/mono-update-available KEYDIR=/etc/mono-keys +FLOORFILE=/etc/mono-update.state # Print to the console AND the system log, so an interactive run is never # silent and cron output still lands in the log. @@ -22,8 +38,12 @@ CURRENT=$(cat /etc/mono_release 2>/dev/null || echo dev) BOARD=$(board_name) BOARD=${BOARD%-sdboot} -get() { curl -fsSL --max-time 30 "$1"; } -fetch() { curl -fsSL --retry 3 --retry-delay 5 -o "$2" "$1"; } +# Small files (manifest, signature): bounded wall-clock. The image download +# instead uses a stall timeout (below), so a slow-but-progressing link is not +# cut off while a server that just hangs cannot wedge the daily run forever. +fetch() { curl -fsSL --connect-timeout 30 --max-time 120 -o "$2" "$1"; } +fetchbig() { curl -fsSL --connect-timeout 30 --speed-time 60 --speed-limit 2048 \ + --retry 3 --retry-delay 5 -o "$2" "$1"; } # mono-vMAJOR.MINOR.PATCH[-rN] -> "MAJOR MINOR PATCH N" ver_key() { @@ -35,6 +55,9 @@ ver_key() { EOF echo "${a:-0} ${b:-0} ${c:-0} ${r:-0}" } +# is_newer A B -> true (0) when tag A is strictly newer than tag B. +# A non-mono B (e.g. "dev", or a corrupt /etc/mono_release) counts as older +# than everything, so dev builds always see a release as an update. is_newer() { case "$2" in mono-v*) ;; *) return 0;; esac set -- $(ver_key "$1") $(ver_key "$2") @@ -48,62 +71,86 @@ is_newer() { return 1 } -JSON=$(get "$URL/latest.json") || { say "cannot reach update server ($URL)"; exit 0; } +# --- persistent floor: the highest tag ever installed ------------------ +# The floor never moves backward, so a validly-signed but older release is +# refused. /etc/mono_release resets to the running image's tag on every boot; +# the floor file is the durable memory that survives a downgrade attempt. +state_get() { # $1=key -> value (empty if unset) + [ -f "$FLOORFILE" ] || return 0 + sed -n "s/^$1=//p" "$FLOORFILE" | head -1 +} +FLOOR_TAG=$(state_get floor) +case "$FLOOR_TAG" in mono-v*) ;; *) FLOOR_TAG="$CURRENT";; esac +is_newer "$CURRENT" "$FLOOR_TAG" && FLOOR_TAG="$CURRENT" # max(persisted, current) + +# --- fetch + verify the SIGNED manifest before trusting any field ------ +[ -n "$(ls "$KEYDIR"/*.pub 2>/dev/null)" ] || { say "no keys in $KEYDIR, refusing"; exit 1; } +command -v usign >/dev/null 2>&1 || { say "usign missing, refusing"; exit 1; } + +TMP=/tmp/mono-update +rm -rf "$TMP"; mkdir -p "$TMP" +fetch "$URL/latest.json" "$TMP/latest.json" || { say "cannot reach update server ($URL)"; exit 0; } +fetch "$URL/latest.json.sig" "$TMP/latest.json.sig" || { say "manifest not signed on server - refusing (no update)"; exit 0; } + +ok=0 +for k in "$KEYDIR"/*.pub; do + [ -f "$k" ] || continue + if usign -V -m "$TMP/latest.json" -x "$TMP/latest.json.sig" -p "$k" 2>/dev/null; then + ok=1; break + fi +done +[ "$ok" = 1 ] || { say "latest.json signature FAILED against all trusted keys - refusing"; exit 1; } + +# Every field below now comes from a signature-verified document. +JSON=$(cat "$TMP/latest.json") +FMT=$(echo "$JSON" | jsonfilter -e '@.format_version' 2>/dev/null) +[ "$FMT" = "1" ] || { say "unsupported manifest format_version '$FMT' - refusing"; exit 1; } TAG=$(echo "$JSON" | jsonfilter -e '@.tag') SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade") +WANT=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256") +[ -n "$TAG" ] || { say "malformed manifest (no tag)"; exit 1; } [ -n "$SYSUPGRADE_URL" ] || { say "no image for board $BOARD in latest.json"; exit 0; } -[ -n "$TAG" ] || { say "malformed server response"; exit 0; } -if ! is_newer "$TAG" "$CURRENT"; then +# Anti-rollback: compare against the floor, not just the running tag. +if ! is_newer "$TAG" "$FLOOR_TAG"; then rm -f "$STATE" if [ "$TAG" = "$CURRENT" ]; then say "up to date ($CURRENT)" else - say "server offers older $TAG, keeping $CURRENT" + say "server offers $TAG, not newer than floor $FLOOR_TAG - keeping $CURRENT" fi exit 0 fi -say "update available: $TAG (running $CURRENT)" -echo "$TAG $SYSUPGRADE_URL" > "$STATE" +say "update available: $TAG (running $CURRENT, floor $FLOOR_TAG)" +echo "$TAG" > "$STATE" if [ "$MODE" != "auto" ]; then - say "notify mode - to install: sysupgrade $SYSUPGRADE_URL" + # Deliberately NOT a 'sysupgrade ' command: sysupgrade does no + # signature or hash check, so following it by hand would flash whatever + # the server serves. Verified installation only happens in auto mode. + say "notify mode - $TAG available; install with 'mono-update.check.mode=auto'" \ + "(verifies signature + hash before flashing). Do not sysupgrade the URL by hand." exit 0 fi -# --- auto mode: signature-gated, RAM-guarded flash --------------------- -[ -n "$(ls "$KEYDIR"/*.pub 2>/dev/null)" ] || { say "no keys in $KEYDIR, refusing auto-flash"; exit 1; } -command -v usign >/dev/null 2>&1 || { say "usign missing, refusing"; exit 1; } +# --- auto mode: RAM-guarded flash (manifest already verified) ---------- AVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo) [ -n "$AVAIL" ] && [ "$AVAIL" -lt 786432 ] && { say "low memory (${AVAIL}KB), skipping auto-flash"; exit 1; } +[ -n "$WANT" ] || { say "manifest has no sha256 for $BOARD - refusing"; exit 1; } -DIR=${SYSUPGRADE_URL%/*} -IMG_NAME=${SYSUPGRADE_URL##*/} -TMP=/tmp/mono-update -rm -rf "$TMP"; mkdir -p "$TMP" fail() { say "$1"; rm -rf "$TMP"; exit 1; } -say "verifying signature for $TAG..." -fetch "$DIR/sha256sums" "$TMP/sha256sums" || fail "cannot fetch sha256sums" -fetch "$DIR/sha256sums.sig" "$TMP/sha256sums.sig" || fail "cannot fetch signature" -ok=0 -for k in "$KEYDIR"/*.pub; do - [ -f "$k" ] || continue - if usign -V -m "$TMP/sha256sums" -x "$TMP/sha256sums.sig" -p "$k" 2>/dev/null; then - ok=1; break - fi -done -[ "$ok" = 1 ] || fail "signature verification FAILED against all trusted keys - refusing $TAG" - -WANT=$(awk -v n="$IMG_NAME" '$2==n || $2=="*"n {print $1}' "$TMP/sha256sums" | head -1) -[ -n "$WANT" ] || fail "image $IMG_NAME not in signed hash list" - -say "signature OK - downloading $TAG (this takes a minute)..." -fetch "$SYSUPGRADE_URL" "$TMP/image.bin" || fail "image download failed" +say "downloading $TAG (this takes a minute)..." +fetchbig "$SYSUPGRADE_URL" "$TMP/image.bin" || fail "image download failed" GOT=$(sha256sum "$TMP/image.bin" | cut -d' ' -f1) -[ "$GOT" = "$WANT" ] || fail "image hash mismatch against signed list - refusing" +[ "$GOT" = "$WANT" ] || fail "image hash mismatch against signed manifest - refusing" + +# Advance the floor BEFORE flashing so a crash mid-upgrade can never later +# accept an image older than this one. Preserved across sysupgrade via keep.d. +printf 'floor=%s\n' "$TAG" > "$FLOORFILE" +sync say "verified $TAG - applying via sysupgrade, the device will reboot now" exec sysupgrade "$TMP/image.bin" diff --git a/package/mono/mono-update-check/files/mono-update.keep b/package/mono/mono-update-check/files/mono-update.keep new file mode 100644 index 0000000000..7dc8db109f --- /dev/null +++ b/package/mono/mono-update-check/files/mono-update.keep @@ -0,0 +1,2 @@ +# Preserve the update anti-rollback floor across sysupgrade. +/etc/mono-update.state diff --git a/package/mono/mono-update-check/tests/test_downgrade_e2e.sh b/package/mono/mono-update-check/tests/test_downgrade_e2e.sh new file mode 100755 index 0000000000..9cfb29ab9d --- /dev/null +++ b/package/mono/mono-update-check/tests/test_downgrade_e2e.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# End-to-end regression test for the signed-manifest update path. Uses real +# usign signatures to prove the client: +# - flashes a genuine, newer, release-signed release (auto mode); +# - REFUSES a forged high tag signed by an untrusted key; +# - REFUSES a replayed genuine-but-older release (anti-rollback floor); +# - REFUSES a post-rollback walk-up (durable floor survives current reset); +# - REFUSES a tampered image (hash mismatch under a genuine manifest); +# - in notify mode, reports availability WITHOUT emitting an unverified +# 'sysupgrade ' and WITHOUT flashing. +# Plus a static packaging-consistency check for the anti-rollback floor file. +# +# Requires a built host usign (staging_dir/host/bin/usign) and python3; skips +# cleanly if usign has not been built yet. Run from anywhere. +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/../../../.." && pwd) # .../source +PKG="$REPO/package/mono/mono-update-check" +CLIENT="$PKG/files/mono-update-check" +USIGN="$REPO/staging_dir/host/bin/usign" +fails=0 + +echo "== packaging consistency (anti-rollback floor survives sysupgrade) ==" +# The floor's durability rests on three files agreeing on one path. A refactor +# that changes one and not the others silently destroys anti-rollback. +FLOOR_PATH=$(sed -n 's/^FLOORFILE=//p' "$CLIENT" | head -1) +if [ "$FLOOR_PATH" = "/etc/mono-update.state" ]; then echo "ok client FLOORFILE=$FLOOR_PATH" +else echo "FAIL client FLOORFILE='$FLOOR_PATH' != /etc/mono-update.state"; fails=$((fails+1)); fi +if grep -q '^/etc/mono-update.state$' "$PKG/files/mono-update.keep"; then echo "ok keep.d lists the floor file" +else echo "FAIL keep.d does not list /etc/mono-update.state"; fails=$((fails+1)); fi +if grep -q 'keep.d/mono-update' "$PKG/Makefile"; then echo "ok Makefile installs the keep.d entry" +else echo "FAIL Makefile does not install keep.d/mono-update"; fails=$((fails+1)); fi + +[ -x "$USIGN" ] || { echo "SKIP e2e: host usign not built ($USIGN)"; [ "$fails" -eq 0 ] && exit 0 || exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "SKIP e2e: python3 not available"; [ "$fails" -eq 0 ] && exit 0 || exit 1; } + +S=$(mktemp -d) +trap 'rm -rf "$S"' EXIT +mkdir -p "$S"/bin "$S"/etc/mono-keys "$S"/lib "$S"/tmp "$S"/srv +SRV="$S/srv" + +"$USIGN" -G -s "$S/release.sec" -p "$S/release.pub" -c release >/dev/null +"$USIGN" -G -s "$S/attacker.sec" -p "$S/attacker.pub" -c attacker >/dev/null +cp "$S/release.pub" "$S/etc/mono-keys/mono-release.pub" + +cat > "$S/lib/functions.sh" <<'EOF' +board_name() { echo "mono,gateway-dk"; } +EOF +cat > "$S/bin/uci" < "$S/bin/logger" +cat > "$S/bin/sysupgrade" < "$S/FLASHED" +echo "SYSUPGRADE \$1" +EOF +cat > "$S/bin/jsonfilter" <<'EOF' +#!/usr/bin/env python3 +import sys, json, re +expr = sys.argv[sys.argv.index("-e")+1] +data = json.load(sys.stdin) +m = re.match(r"@\.devices\['([^']+)'\]\.(\w+)$", expr) +v = (data.get("devices", {}).get(m.group(1), {}).get(m.group(2), "") + if m else data.get(expr[2:], "")) +if v not in ("", None): + print(v) +EOF +ln -s "$USIGN" "$S/bin/usign" +chmod +x "$S"/bin/* 2>/dev/null + +sed -e "s|/lib/functions.sh|$S/lib/functions.sh|" \ + -e "s|/etc/mono_release|$S/etc/mono_release|" \ + -e "s|KEYDIR=/etc/mono-keys|KEYDIR=$S/etc/mono-keys|" \ + -e "s|FLOORFILE=/etc/mono-update.state|FLOORFILE=$S/etc/mono-update.state|" \ + -e "s|/tmp/mono-update|$S/tmp/mono-update|g" \ + "$CLIENT" > "$S/client.sh" + +mkdir -p "$SRV/r3" "$SRV/r4" "$SRV/r5" +printf 'IMAGE-R3' > "$SRV/r3/img-sysupgrade.bin" +printf 'IMAGE-R4' > "$SRV/r4/img-sysupgrade.bin" +printf 'IMAGE-R5' > "$SRV/r5/img-sysupgrade.bin" +h() { sha256sum "$1" | cut -d' ' -f1; } +make_manifest() { # out tag dir imghash + cat > "$1" < sets $out, $got(flash|refuse) + echo "$1" > "$S/etc/mono_release" + rm -f "$S/FLASHED" "$S/etc/mono-update.state" + [ -n "$2" ] && printf '%s\n' "$2" > "$S/etc/mono-update.state" + out=$(cd "$S" && PATH="$S/bin:$PATH" UCI_MODE="$3" sh "$S/client.sh" 2>&1) + [ -f "$S/FLASHED" ] && got=flash || got=refuse +} +scenario() { # name expect current floor reason manifest + publish "$6"; run "$3" "$4" auto + if [ "$got" != "$2" ] || ! echo "$out" | grep -q "$5"; then + echo "FAIL [$1] got=$got want=$2 reason=/$5/"; echo " $out"; fails=$((fails+1)) + else echo "ok [$1] $got ($5)"; fi +} + +echo "== end-to-end (real usign) ==" +scenario genuine-upgrade flash mono-v25.12.5-r3 "" SYSUPGRADE m_r5.json +scenario forged-tag-bad-key refuse mono-v25.12.5-r3 "" "signature FAILED" m_bad.json +scenario replay-old-signed refuse mono-v25.12.5-r5 "floor=mono-v25.12.5-r5" "not newer than floor" m_r3.json +scenario floor-walk-up refuse mono-v25.12.5-r3 "floor=mono-v25.12.5-r5" "not newer than floor" m_r4.json + +# image tamper: genuine signed r5 manifest, but the served bytes are swapped +publish m_r5.json +printf 'TAMPERED' > "$SRV/r5/img-sysupgrade.bin" +run mono-v25.12.5-r3 "" auto +if [ "$got" = refuse ] && echo "$out" | grep -q "hash mismatch"; then echo "ok [image-tamper] refuse (hash mismatch)" +else echo "FAIL [image-tamper] got=$got: $out"; fails=$((fails+1)); fi +printf 'IMAGE-R5' > "$SRV/r5/img-sysupgrade.bin" # restore + +# notify mode: a genuine newer release must NOT flash and must NOT print a raw +# 'sysupgrade ' (which would skip verification). +publish m_r5.json +run mono-v25.12.5-r3 "" notify +if [ "$got" = refuse ] && ! echo "$out" | grep -q "file://" && echo "$out" | grep -q "mode=auto"; then + echo "ok [notify-no-raw-flash] refuse, steers to auto" +else echo "FAIL [notify-no-raw-flash] got=$got: $out"; fails=$((fails+1)); fi + +echo +if [ "$fails" -eq 0 ]; then echo "ALL PASS"; else echo "$fails FAILED"; exit 1; fi diff --git a/package/mono/mono-update-check/tests/test_publish_gate.sh b/package/mono/mono-update-check/tests/test_publish_gate.sh new file mode 100755 index 0000000000..5f831b63b3 --- /dev/null +++ b/package/mono/mono-update-check/tests/test_publish_gate.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Regression test for the publish gate: mono-publish-release.sh must refuse +# unless the signatures VALIDATE against the fleet's baked public keys - not +# merely exist. Guards against a wrong/rotated signing key or a stale +# latest.json.sig silently shipping a release every device would reject. +# +# Requires a built host usign; skips cleanly otherwise. Run from anywhere. +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/../../../.." && pwd) +USIGN="$REPO/staging_dir/host/bin/usign" +[ -x "$USIGN" ] || { echo "SKIP: host usign not built ($USIGN)"; exit 0; } +fails=0 + +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +mkdir -p "$T/scripts" "$T/package/mono/mono-update-check/files" "$T/releases/mono-v25.12.5-r7" +cp "$REPO/scripts/mono-sign-release.sh" "$REPO/scripts/mono-publish-release.sh" "$T/scripts/" + +# fleet keys (what devices bake) and an unrelated "wrong" key +"$USIGN" -G -s "$T/fleet.sec" -p "$T/package/mono/mono-update-check/files/mono-release.pub" -c fleet >/dev/null +"$USIGN" -G -s "$T/rot.sec" -p "$T/package/mono/mono-update-check/files/mono-rotation.pub" -c rot >/dev/null +"$USIGN" -G -s "$T/wrong.sec" -p "$T/wrong.pub" -c wrong >/dev/null + +printf 'deadbeef img-sysupgrade.bin\n' > "$T/releases/mono-v25.12.5-r7/sha256sums" +manifest() { cat > "$T/releases/latest.json" </dev/null 2>&1; } +ok() { echo "ok $1"; } +bad() { echo "FAIL $1"; fails=$((fails+1)); } + +# 1. signed with the fleet release key -> verifies -> publishes (no dest, exits 0) +manifest; sign "$T/fleet.sec" +publish && ok "fleet-key signed release publishes" || bad "fleet-key signed release publishes" + +# 2. signed with the rotation key (also baked) -> accepted +manifest; sign "$T/rot.sec" +publish && ok "rotation-key signed release publishes" || bad "rotation-key signed release publishes" + +# 3. signed with a WRONG key -> valid-looking .sig, must REFUSE (F1) +manifest; sign "$T/wrong.sec" +publish && bad "wrong-key release REFUSED" || ok "wrong-key release REFUSED" + +# 4. stale/mismatched latest.json.sig: sign, then mutate latest.json bytes (F2) +manifest; sign "$T/fleet.sec" +sed -i 's/2026-01-01/2026-02-02/' "$T/releases/latest.json" # sig no longer matches content +publish && bad "mismatched latest.json.sig REFUSED" || ok "mismatched latest.json.sig REFUSED" + +# 5. missing signature -> refuse +manifest; sign "$T/fleet.sec"; rm -f "$T/releases/latest.json.sig" +publish && bad "missing signature REFUSED" || ok "missing signature REFUSED" + +echo +if [ "$fails" -eq 0 ]; then echo "ALL PASS"; else echo "$fails FAILED"; exit 1; fi diff --git a/package/mono/mono-update-check/tests/test_version_logic.sh b/package/mono/mono-update-check/tests/test_version_logic.sh new file mode 100755 index 0000000000..949c198cd4 --- /dev/null +++ b/package/mono/mono-update-check/tests/test_version_logic.sh @@ -0,0 +1,87 @@ +#!/bin/sh +# Dependency-free unit tests for the mono-update version / floor logic. +# Mirrors the ver_key/is_newer helpers and the floor comparison in the client. +set -u +fails=0 + +ver_key() { + v=${1#mono-v} + case "$v" in *-r*) r=${v##*-r};; *) r=0;; esac + v=${v%-r*} + IFS=. read -r a b c _ <<-EOF + $v + EOF + echo "${a:-0} ${b:-0} ${c:-0} ${r:-0}" +} +is_newer() { + case "$2" in mono-v*) ;; *) return 0;; esac + set -- $(ver_key "$1") $(ver_key "$2") + i=1 + while [ $i -le 4 ]; do + eval "rr=\$$i; cc=\$$((i + 4))" + [ "$rr" -gt "$cc" ] && return 0 + [ "$rr" -lt "$cc" ] && return 1 + i=$((i + 1)) + done + return 1 +} + +# check_newer A B expected(yes/no) +check_newer() { + if is_newer "$1" "$2"; then got=yes; else got=no; fi + if [ "$got" = "$3" ]; then + echo "ok is_newer($1,$2)=$got" + else + echo "FAIL is_newer($1,$2)=$got want $3"; fails=$((fails+1)) + fi +} + +echo "== is_newer ==" +check_newer mono-v25.12.5-r3 mono-v25.12.5-r2 yes # revision bump +check_newer mono-v25.12.5-r2 mono-v25.12.5-r3 no # older revision +check_newer mono-v25.12.5-r3 mono-v25.12.5-r3 no # equal is not newer +check_newer mono-v25.12.6-r1 mono-v25.12.5-r9 yes # patch beats revision +check_newer mono-v25.12.5-r1 mono-v25.12.6-r1 no # older patch +check_newer mono-v26.1.0-r1 mono-v25.12.5-r9 yes # minor/major +check_newer mono-v25.12.5-r10 mono-v25.12.5-r9 yes # multi-digit revision +check_newer mono-v25.12.10-r1 mono-v25.12.9-r1 yes # multi-digit patch + +echo "== is_newer non-mono second arg (dev / corrupt-mono_release fallback) ==" +# When the SECOND arg isn't a mono-v tag, is_newer returns true so dev builds +# always update. Consequence: if BOTH /etc/mono_release and the floor file are +# non-mono (corrupt), the device would accept any signed release. That is why +# the floor is max(persisted, current) and both come from a baked/flashed tag +# in practice - documented here so the branch isn't changed unknowingly. +check_newer mono-v0.0.0-r0 dev yes # dev floor: anything is "newer" +check_newer mono-v25.12.5-r1 "" yes # empty +check_newer mono-v25.12.5-r1 garbage yes # corrupt + +echo "== floor (max of persisted + current), refuse tag<=floor ==" +# accept_update TAG PERSISTED CURRENT -> prints accept/reject +accept_update() { + tag=$1; persisted=$2; current=$3 + floor=$persisted + case "$floor" in mono-v*) ;; *) floor="$current";; esac + is_newer "$current" "$floor" && floor="$current" + if is_newer "$tag" "$floor"; then echo accept; else echo reject; fi +} +# check_floor TAG PERSISTED CURRENT expected +check_floor() { + got=$(accept_update "$1" "$2" "$3") + if [ "$got" = "$4" ]; then + echo "ok floor tag=$1 persisted=$2 current=$3 -> $got" + else + echo "FAIL floor tag=$1 persisted=$2 current=$3 -> $got want $4"; fails=$((fails+1)) + fi +} +check_floor mono-v25.12.5-r6 mono-v25.12.5-r5 mono-v25.12.5-r5 accept # normal forward +check_floor mono-v25.12.5-r5 mono-v25.12.5-r5 mono-v25.12.5-r5 reject # equal to floor +check_floor mono-v25.12.5-r3 mono-v25.12.5-r5 mono-v25.12.5-r5 reject # <= floor: downgrade blocked +# Downgrade survived a reboot: image got rolled to r3 (current=r3) but the +# persisted floor still remembers r5 -> attacker cannot walk it up to r4. +check_floor mono-v25.12.5-r4 mono-v25.12.5-r5 mono-v25.12.5-r3 reject +check_floor mono-v25.12.5-r6 mono-v25.12.5-r5 mono-v25.12.5-r3 accept # but genuine r6 still ok +check_floor mono-v25.12.5-r1 "" dev accept # fresh dev, no floor + +echo +if [ "$fails" -eq 0 ]; then echo "ALL PASS"; else echo "$fails FAILED"; exit 1; fi -- 2.47.3