From c7e9aa620cdd5f3ff4dcdfaceabe32c3e7800e40 Mon Sep 17 00:00:00 2001 From: Tomaz Zaman Date: Mon, 10 Aug 2026 23:08:42 +0200 Subject: [PATCH 29/62] mono: sign the OTA channel + anti-rollback (review C1/S3/S4/W2/W4/W6) The update channel was unsigned and its only integrity check (sha256) shipped in the same latest.json as the download URL - authenticity theater. Now: - Signing (C1): releases are signed with an offline usign key; the public half is baked to /etc/mono-release.pub. The checker verifies the signature over the hash list against that key BEFORE flashing, then the image hash against the signed list, and fails closed if key/sig/usign are absent. The sha in latest.json is now untrusted routing only. Auto-mode stays off by default. (Private key is release-host-local, never committed.) - Anti-rollback (S3): string-equality version check replaced with a real newer-than comparison; non-forward moves are refused (unit-tested). - Trust deps (S4): usign + ca-bundle are hard deps of the checker, not transitive luck from luci-ssl et al. - Board keys (W4): latest.json is generated from image metadata (profiles.json), not filename string-surgery that fails silently. - RAM guard (W6): auto-flash refuses when MemAvailable is too low to hold the image in the sysupgrade ramdisk. - Minor-series lock (W2): documented that only patch releases are followed unattended; minor/major bumps are manual. Deferred (not code, tracked on the roadmap): CI boot-gate + staged rollout (C2) and A/B slots (C3, declined - mitigated by auto-mode default-off). Co-Authored-By: Claude Fable 5 --- package/mono/mono-update-check/Makefile | 13 ++- .../mono-update-check/files/mono-release.pub | 2 + .../mono-update-check/files/mono-update-check | 87 +++++++++++++++---- scripts/mono-update.sh | 72 ++++++++++----- 4 files changed, 132 insertions(+), 42 deletions(-) create mode 100644 package/mono/mono-update-check/files/mono-release.pub diff --git a/package/mono/mono-update-check/Makefile b/package/mono/mono-update-check/Makefile index aff87ba19b..0c9085a9b5 100644 --- a/package/mono/mono-update-check/Makefile +++ b/package/mono/mono-update-check/Makefile @@ -11,15 +11,19 @@ define Package/mono-update-check SECTION:=utils CATEGORY:=Utilities TITLE:=Mono Gateway update checker - DEPENDS:=@TARGET_layerscape + # usign: verify the release signature. ca-bundle: validate the server + # TLS cert on the https fetch (both are the update trust chain, so they + # are hard deps, not left to transitive luck from other packages). + DEPENDS:=@TARGET_layerscape +usign +ca-bundle endef define Package/mono-update-check/description Daily check of a mono firmware server (latest.json) against the image's baked release tag. Disabled until uci sets the server URL: uci set mono-update.check.url='https://.../openwrt' - mode 'notify' (default) logs availability; mode 'auto' downloads, - verifies sha256 and applies sysupgrade unattended. + mode 'notify' (default) logs availability; mode 'auto' verifies the + release signature (usign) against the baked public key, then the image + hash against the signed hash list, then applies sysupgrade unattended. endef define Build/Compile @@ -48,6 +52,9 @@ define Package/mono-update-check/install $(INSTALL_BIN) ./files/mono-update-check $(1)/usr/sbin/ $(INSTALL_DIR) $(1)/etc/uci-defaults $(INSTALL_BIN) ./files/90-mono-update-cron $(1)/etc/uci-defaults/ + # Public half of the offline release-signing key; the trust anchor + # for auto-mode. Private key never leaves the release host. + $(INSTALL_DATA) ./files/mono-release.pub $(1)/etc/mono-release.pub endef $(eval $(call BuildPackage,mono-update-check)) diff --git a/package/mono/mono-update-check/files/mono-release.pub b/package/mono/mono-update-check/files/mono-release.pub new file mode 100644 index 0000000000..91abe5323d --- /dev/null +++ b/package/mono/mono-update-check/files/mono-release.pub @@ -0,0 +1,2 @@ +untrusted comment: Mono Gateway release key +RWTLrdUqYTbEZ/ND1cKOe+0bX1YitJST2Ri/HbHOGaVZXBJLpCXCxEJ9 diff --git a/package/mono/mono-update-check/files/mono-update-check b/package/mono/mono-update-check/files/mono-update-check index b2237ec16e..8146ed689b 100644 --- a/package/mono/mono-update-check/files/mono-update-check +++ b/package/mono/mono-update-check/files/mono-update-check @@ -1,35 +1,64 @@ #!/bin/sh # Compare the image's baked release tag against the server's latest.json. -# mode=notify: log and drop a state file. mode=auto: download, verify -# sha256, sysupgrade (config is preserved by the platform upgrade flow). +# notify (default): log availability + drop a state file. No download. +# auto: verify the release SIGNATURE against the baked public key, then +# the image hash against the signed hash list, then sysupgrade. +# Trust anchor is /etc/mono-release.pub, NOT anything in latest.json - the +# sha in the JSON is untrusted routing data. . /lib/functions.sh URL=$(uci -q get mono-update.check.url) MODE=$(uci -q get mono-update.check.mode) STATE=/tmp/mono-update-available +PUBKEY=/etc/mono-release.pub [ -n "$URL" ] || exit 0 CURRENT=$(cat /etc/mono_release 2>/dev/null || echo dev) BOARD=$(board_name) BOARD=${BOARD%-sdboot} + +# mono-vMAJOR.MINOR.PATCH[-rN] -> "MAJOR MINOR PATCH N" (0 when absent) +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 $1 strictly newer than $2? Unparseable current (e.g. "dev") => yes. +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 "r=\$$i; c=\$$((i + 4))" + [ "$r" -gt "$c" ] && return 0 + [ "$r" -lt "$c" ] && return 1 + i=$((i + 1)) + done + return 1 +} + JSON=$(wget -q -T 30 -O - "$URL/latest.json") || { logger -t mono-update "cannot fetch $URL/latest.json" exit 0 } - TAG=$(echo "$JSON" | jsonfilter -e '@.tag') SYSUPGRADE_URL=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sysupgrade") -SHA=$(echo "$JSON" | jsonfilter -e "@.devices['$BOARD'].sha256") [ -n "$SYSUPGRADE_URL" ] || { logger -t mono-update "no image for board $BOARD in latest.json" exit 0 } - [ -n "$TAG" ] || exit 0 -if [ "$TAG" = "$CURRENT" ]; then - rm -f "$STATE" + +if ! is_newer "$TAG" "$CURRENT"; then + rm -f "$STATE" # up to date or a refused downgrade + [ "$TAG" = "$CURRENT" ] || logger -t mono-update "ignoring non-forward $TAG (running $CURRENT)" exit 0 fi @@ -38,17 +67,37 @@ echo "$TAG $SYSUPGRADE_URL" > "$STATE" [ "$MODE" = "auto" ] || exit 0 -FILE=/tmp/mono-sysupgrade.bin -logger -t mono-update "auto mode: downloading $TAG" -wget -q -T 300 -O "$FILE" "$SYSUPGRADE_URL" || { - logger -t mono-update "download failed" - rm -f "$FILE" - exit 1 -} -echo "$SHA $FILE" | sha256sum -c -s || { - logger -t mono-update "sha256 mismatch, refusing to flash" - rm -f "$FILE" +# --- auto mode: signature-gated, RAM-guarded flash --------------------- +[ -f "$PUBKEY" ] || { logger -t mono-update "no $PUBKEY, refusing auto-flash"; exit 1; } +command -v usign >/dev/null 2>&1 || { logger -t mono-update "usign missing, refusing"; exit 1; } + +# The whole image lives in a ramdisk during sysupgrade; don't start if RAM +# is tight (protects lean variants; the DK has 8 GB and passes trivially). +AVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo) +[ -n "$AVAIL" ] && [ "$AVAIL" -lt 786432 ] && { + logger -t mono-update "MemAvailable ${AVAIL}KB too low, skipping auto-flash" exit 1 } -logger -t mono-update "applying $TAG via sysupgrade" -exec sysupgrade "$FILE" + +DIR=${SYSUPGRADE_URL%/*} +IMG_NAME=${SYSUPGRADE_URL##*/} +TMP=/tmp/mono-update +rm -rf "$TMP"; mkdir -p "$TMP" + +fetch() { wget -q -T 300 -O "$2" "$1"; } +fail() { logger -t mono-update "$1"; rm -rf "$TMP"; exit 1; } + +fetch "$DIR/sha256sums" "$TMP/sha256sums" || fail "cannot fetch sha256sums" +fetch "$DIR/sha256sums.sig" "$TMP/sha256sums.sig" || fail "cannot fetch signature" +usign -V -m "$TMP/sha256sums" -x "$TMP/sha256sums.sig" -p "$PUBKEY" \ + || fail "signature verification FAILED - 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" + +fetch "$SYSUPGRADE_URL" "$TMP/image.bin" || fail "image download failed" +GOT=$(sha256sum "$TMP/image.bin" | cut -d' ' -f1) +[ "$GOT" = "$WANT" ] || fail "image hash mismatch (signed list) - refusing" + +logger -t mono-update "verified $TAG, applying via sysupgrade" +exec sysupgrade "$TMP/image.bin" diff --git a/scripts/mono-update.sh b/scripts/mono-update.sh index c4e233c011..547b150d26 100755 --- a/scripts/mono-update.sh +++ b/scripts/mono-update.sh @@ -35,6 +35,9 @@ done git fetch --quiet origin 'refs/tags/v*:refs/tags/v*' +# Deliberately tracks only the current minor series (v25.12.x patch +# releases). A new minor or major (kernel/vendor bump, new ASK base) is a +# manual, eyes-open migration - it is never followed unattended. BASE=$(git tag --merged "$BRANCH" 'v[0-9]*' | sort -V | tail -1) SERIES=${BASE%.*} LATEST=$(git tag -l "${SERIES}.*" | sort -V | tail -1) @@ -105,25 +108,53 @@ cp "$BINDIR"/openwrt-layerscape-armv8_64b-mono_*-emmc.img.gz \ git format-patch --quiet -o "$OUT/patches" "$LATEST..$BRANCH" (cd "$OUT" && sha256sum *.img.gz *.bin > sha256sums) -# One entry per built mono device, keyed by board name (device profile -# name with the first underscore as the vendor comma, matching the -# SUPPORTED_DEVICES convention). -{ - printf '{\n\t"tag": "%s",\n\t"date": "%s",\n\t"devices": {' \ - "$RELTAG" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - sep="" - for f in "$OUT"/*-sysupgrade.bin; do - dev=$(basename "$f") - dev=${dev#openwrt-layerscape-armv8_64b-} - dev=${dev%-ext4-sysupgrade.bin} - board=$(echo "$dev" | sed 's/_/,/') - sha=$(sha256sum "$f" | cut -d' ' -f1) - printf '%s\n\t\t"%s": { "sysupgrade": "%s/%s/%s", "sha256": "%s" }' \ - "$sep" "$board" "$URLBASE" "$RELTAG" "$(basename "$f")" "$sha" - sep="," - done - printf '\n\t}\n}\n' -} > releases/latest.json +# Sign the hash list. This - not the sha in latest.json - is the trust +# anchor devices verify against the baked public key before flashing. +USIGN=staging_dir/host/bin/usign +if [ -n "${MONO_SIGN_KEY:-}" ] && [ -x "$USIGN" ]; then + "$USIGN" -S -m "$OUT/sha256sums" -s "$MONO_SIGN_KEY" -x "$OUT/sha256sums.sig" + echo "mono-update: signed sha256sums" +else + echo "mono-update: WARNING: unsigned release (set MONO_SIGN_KEY); auto-update devices will refuse it" >&2 +fi + +# A real flashing tool, shipped with the release (not a two-step dd in prose). +cat > "$OUT/flash-mono-gateway.sh" <<'FLASH' +#!/bin/sh +# Flash a Mono Gateway eMMC image from recovery Linux, leaving the boot +# firmware (4 KiB-32 MiB) intact. Usage: flash-mono-gateway.sh [dev] +set -e +IMG="$1"; DEV="${2:-/dev/mmcblk0}" +[ -f "$IMG" ] || { echo "usage: $0 <...-emmc.img.gz> [/dev/mmcblkN]"; exit 1; } +case "$IMG" in *.gz) feed(){ gunzip -c "$IMG"; };; *) feed(){ cat "$IMG"; };; esac +echo "GPT (first 4 KiB)..."; feed | dd of="$DEV" bs=512 count=8 conv=fsync +echo "System (from 32 MiB)..."; feed | dd of="$DEV" bs=1M skip=32 seek=32 conv=fsync +sync; echo "Done - set DIP to eMMC and reboot." +FLASH +chmod +x "$OUT/flash-mono-gateway.sh" + +# latest.json: board keys come from the image metadata (profiles.json), +# not filename string-surgery, so a device that finds no image for its +# board fails visibly rather than from a silent naming drift. +python3 - "$RELTAG" "$URLBASE" "$OUT" "$BINDIR/profiles.json" \ + > releases/latest.json <<'PY' +import json, sys, os, hashlib, datetime +reltag, urlbase, out, profiles = sys.argv[1:5] +prof = json.load(open(profiles)).get("profiles", {}) +devices = {} +for name, p in prof.items(): + boards = p.get("supported_devices") or [] + img = next((im["name"] for im in p.get("images", []) + if im.get("name", "").endswith("sysupgrade.bin") + and os.path.exists(os.path.join(out, im["name"]))), None) + if not boards or not img: + continue + h = hashlib.sha256(open(os.path.join(out, img), "rb").read()).hexdigest() + devices[boards[0]] = {"sysupgrade": f"{urlbase}/{reltag}/{img}", "sha256": h} +json.dump({"tag": reltag, + "date": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "devices": devices}, sys.stdout, indent=2) +PY if [ -n "${MONO_PUBLISH_DEST:-}" ]; then echo "mono-update: publishing to $MONO_PUBLISH_DEST" @@ -146,7 +177,8 @@ if git remote get-url mono >/dev/null 2>&1; then gh release create "$RELTAG" --repo "$REPO" \ --title "$RELTAG" \ --notes "Automated release: OpenWrt $LATEST base, branch $(git rev-parse --short "$BRANCH")." \ - "$OUT"/*-sysupgrade.bin "$OUT"/*-emmc.img.gz "$OUT/sha256sums" \ + "$OUT"/*-sysupgrade.bin "$OUT"/*-emmc.img.gz \ + "$OUT/sha256sums" "$OUT"/sha256sums.sig "$OUT/flash-mono-gateway.sh" \ || echo "mono-update: WARNING: GitHub release failed" >&2 fi fi -- 2.47.3